DEV Community

Cover image for Building CashCove: A Deep Dive into Wallet Transfers, Notifications & Financial Logic
Kiishi_joseph
Kiishi_joseph

Posted on

Building CashCove: A Deep Dive into Wallet Transfers, Notifications & Financial Logic

🧠 CashCove Series – Part 2

Building Wallet Transfers, Locked Funds & Notifications – My Thought Process

In the first part of this series, I covered authentication and the basic wallet setup for CashCove, my experimental GraphQL wallet backend. In this second part, I'll walk through how I approached wallet funding, bank transfers, locked funds, notifications, and money requests.

This isn't just about implementation β€” I'll also explain why I made certain decisions, what tradeoffs I considered, and how I've structured the project to be easy to maintain or scale later.


🏦 Wallet Funding

Users can fund their wallet via Paystack or any other gateway in production. But for now, I've implemented a mock resolver that mimics a successful funding flow:

await walletRepo.credit(user.id, amount);
await transactionRepo.record({
  userId: user.id,
  type: 'funding',
  amount,
  status: 'success',
});
await notificationRepo.create({
  userId: user.id,
  message: `Your wallet has been funded with ₦${amount}`,
});
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Why Separate Wallet and Transaction Logic?

I wanted to keep wallet mutations atomic, while transactions serve more like receipts. This separation helps in:

  • Supporting reconciliation later (e.g., match wallet balance against transactions).
  • Avoiding side effects that might accidentally double-credit or debit wallets.

πŸ“¬ Real-Time Notifications

Each funding triggers a database-persisted notification, which can later be sent via email or push. For now, I've kept notifications sync and direct, but the repository is abstract enough to support queue-based delivery later.


πŸ”„ Wallet Transfers

Transfers between users are central to a wallet system. Here's how I structured them:

βœ… Flow

  1. Ensure sender has enough available balance.
  2. Debit sender, credit receiver.
  3. Create transfer transaction.
  4. Notify both parties.
const senderWallet = await walletRepo.find(user.id);
const receiverWallet = await walletRepo.findByOwner(receiverId);

await walletRepo.debit(user.id, amount);
await walletRepo.credit(receiverId, amount);

await transactionRepo.record({ type: 'transfer', from, to, amount });
Enter fullscreen mode Exit fullscreen mode

The transaction resolver handles both credits and debits with proper nullable handling:

@Mutation(() => Transaction)
async createTransaction(
  @Arg("amount") amount: number,
  @Arg("type") type: "credit" | "debit",
  @Arg("currency") currency: string,
  @Arg("fromUserId", { nullable: true }) fromUserId?: string,
  @Arg("toUserId", { nullable: true }) toUserId?: string
): Promise<Transaction> {
  // Proper null handling for GraphQL
  const fromUserIdTransformed = fromUserId === null ? undefined : fromUserId;
  const toUserIdTransformed = toUserId === null ? undefined : toUserId;

  return await transactionService.createTransaction(
    amount,
    type,
    currency,
    fromUserIdTransformed,
    toUserIdTransformed
  );
}
Enter fullscreen mode Exit fullscreen mode

🧠 Design Considerations

  • No pending state β€” transfers are instant and final.
  • Strong use of DB transactions to make sure debit + credit happen together.
  • Proper handling of GraphQL's null vs undefined semantics.

πŸ”’ Locked Funds

This was an interesting challenge. I knew some funds might need to be reserved but not yet spent. For example, if someone initiates a transaction that needs verification or delivery.

πŸ’Ύ Schema

@Column({ type: 'int', default: 0 })
balance: number;

@Column({ type: 'int', default: 0 })
lockedBalance: number;
Enter fullscreen mode Exit fullscreen mode

This allows two balances:

  • available = balance - lockedBalance
  • total = balance

πŸ” Usage

  • When a transfer or escrow is initiated: walletRepo.incrementLock(userId, amount);
  • When it fails or expires: walletRepo.decrementLock(userId, amount);
  • When it succeeds: walletRepo.finalizeTransaction(userId, amount);

Here's how the locked funds resolver handles different scenarios:

@Mutation(() => LockedFunds)
async lockFunds(
  @Arg("walletId") walletId: string,
  @Arg("amount") amount: number,
  @Arg("currency") currency: string,
  @Arg("unlockDate") unlockDate: Date,
  @Ctx() ctx: GraphQLContext
): Promise<LockedFunds> {
  const userId = ctx.user?.userId;
  return await lockedFundService.lockFunds(
    userId,
    walletId,
    amount,
    currency,
    unlockDate
  );
}

@Mutation(() => String)
async earlyWithdrawal(
  @Arg("userId") userId: string,
  @Arg("lockedFundId") lockedFundId: string
): Promise<string> {
  const result = await lockedFundService.earlyWithdrawal(userId, lockedFundId);
  return `Withdrawal successful. Amount after penalty: ${result.amountAfterPenalty}, Penalty fee: ${result.penaltyFee}`;
}
Enter fullscreen mode Exit fullscreen mode

πŸ” Why Lock Instead of Deduct?

I didn't want to prematurely deduct money β€” especially if the final status is unknown. Locking gives you a reversible way to reserve funds. This aligns with systems like Paystack, Stripe, and marketplaces.

πŸ“‰ Early Withdrawals

I've even included a requestEarlyWithdrawal() method that can allow the user to get their locked funds back β€” possibly with a penalty.


πŸ“¬ Notifications

Every major action in the system triggers a notification:

  • Wallet funded
  • Transfer sent/received
  • Money request accepted/declined

They're stored in a notifications table and linked to the user. Here's what a resolver might look like:

await notificationRepo.create({
  userId: receiverId,
  message: `${senderName} sent you ₦${amount}`,
});
Enter fullscreen mode Exit fullscreen mode

While they're currently just saved to DB, I structured the repo so they can later be routed through email, push, or in-app systems.


πŸ™‹ Money Requests (and Cron Expiry)

Users can send money requests to other users. I added a MoneyRequest entity with:

  • status (pending, accepted, denied, expired)
  • expiresAt

The resolver handles the full lifecycle:

@Mutation(() => MoneyRequest)
async requestMoney(
  @Arg("amount") amount: number,
  @Arg("currency") currency: string,
  @Arg("fromUserId") fromUserId: string,
  @Arg("toUserId") toUserId: string
): Promise<MoneyRequest> {
  return await moneyRequestService.createMoneyRequest(
    amount,
    currency,
    fromUserId,
    toUserId
  );
}

@Mutation(() => MoneyRequest)
async respondToMoneyRequest(
  @Arg("id") id: string,
  @Arg("status") status: string
): Promise<MoneyRequest> {
  return await moneyRequestService.respondToMoneyRequest(id, status);
}
Enter fullscreen mode Exit fullscreen mode

A background cron job runs every hour to auto-expire any request past 24 hours.

if (Date.now() > request.expiresAt) {
  request.status = 'expired';
  await moneyRequestRepo.save(request);
}
Enter fullscreen mode Exit fullscreen mode

This mirrors how apps like Cash App or Venmo work β€” you can't just leave requests hanging forever.


πŸ”„ Bank Transfers

For withdrawing funds to external bank accounts, I've built a complete flow with proper status tracking:

export class BankTransferRepository {
  async createBankTransfer(
    userId: string,
    amount: number,
    currency: string,
    recipientBankCode: string,
    recipientAccountNumber: string,
    reference: string
  ) {
    return await prisma.bankTransfer.create({
      data: {
        user_id: userId,
        amount,
        currency,
        recipient_bank_code: recipientBankCode,
        recipient_account_number: recipientAccountNumber,
        reference,
      },
    });
  }

  async updateBankTransferStatus(reference: string, status: string) {
    return await prisma.bankTransfer.update({
      where: { reference },
      data: { status },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The resolver pattern keeps things clean:

@Mutation(() => BankTransfer)
async initiateBankTransafer(
  @CurrentUser() userId: string,
  @Arg("amount") amount: number,
  @Arg("currency") currency: string,
  @Arg("recipientBankCode") recipientBankCode: string,
  @Arg("recipientAccountNumber") recipientAccountNumber: string
): Promise<BankTransfer> {
  return await bankTransferService.initiateTransfer(
    userId,
    amount,
    currency,
    recipientBankCode,
    recipientAccountNumber
  );
}
Enter fullscreen mode Exit fullscreen mode

🎯 Why Separate Initiate and Verify?

Bank transfers aren't instant like wallet-to-wallet transfers. They need:

  • Initial validation and submission to the bank
  • Webhook handling for status updates
  • Proper failure handling and user notifications

πŸ“± Enhanced Notifications System

I've expanded the notification system beyond simple database storage:

export class NotificationRepository {
  async createNotification(userId: string, message: string) {
    return await prisma.notification.create({
      data: {
        user_id: userId,
        message,
      },
    });
  }

  async markNotificationAsRead(id: string) {
    return await prisma.notification.update({
      where: { id },
      data: { is_read: true },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The resolver handles both reading and marking notifications:

@Query(() => [Notification])
async getNotifications(
  @CurrentUser() userId: string
): Promise<Notification[]> {
  return await notificationService.getNotifications(userId);
}

@Mutation(() => Notification)
async markNotificationAsRead(@Arg("id") id: string): Promise<Notification> {
  return await notificationService.markNotificationAsRead(id);
}
Enter fullscreen mode Exit fullscreen mode

⏰ Automated Money Request Expiry

Rather than letting money requests sit forever, I implemented a cron job that auto-expires them after 24 hours:

cron.schedule("0 * * * *", async () => {
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);

  const expiredRequests = await prisma.moneyRequest.findMany({
    where: {
      status: "PENDING",
      created_at: { lt: oneDayAgo },
    },
  });

  for (const request of expiredRequests) {
    await prisma.moneyRequest.update({
      where: { id: request.id },
      data: { status: "DENIED" },
    });

    await prisma.notification.create({
      data: {
        user_id: request.from_user_id,
        message: `Your money request has been automatically denied due to no response.`,
      },
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

This runs every hour and automatically cleans up stale requests while notifying users.

🧾 Custom Logging with Zod Validation

I built a type-safe logger that validates every log entry:

const logInputSchema = z.object({
  message: z.string(),
  params: z.union([z.array(z.any()), z.object({}).passthrough(), z.string(), z.number()]).optional(),
});

type Serverity = "WARNING" | "INFO" | "DEBUG" | "ERROR";

const logMessage = (input: LogInputType, serverity: Serverity) => {
  const validation = logInputSchema.safeParse(input);

  if(!validation.success){
    throw new Error(`Incorrect log schema: ${validation.error.message}`);
  }

  const {message, params} = validation.data;

  switch (serverity){
    case "DEBUG":
      console.debug(message, params);
      break;
    case "ERROR":
      console.error(message, params);
      break;
    // ... other cases
  }
};
Enter fullscreen mode Exit fullscreen mode

πŸ€” Why Build a Custom Logger?

While I plan to switch to Pino or Winston for production, this custom solution gives me:

  • Type safety with Zod validation
  • Consistent log structure across the app
  • Easy migration path when I'm ready to upgrade

πŸ›‘οΈ Authentication Pattern

I've used a consistent authentication pattern across all resolvers with a custom @CurrentUser() decorator:

export function CurrentUser() {
  return createParameterDecorator<GraphQLContext>(({ context }) => {
    if (!context.user?.userId)
      throw new AppError("Unauthorized: User ID is missing", 401);

    return context.user?.userId;
  });
}
Enter fullscreen mode Exit fullscreen mode

This eliminates repetitive auth checks and ensures consistent error handling:

@Query(() => [Wallet])
async getWallets(@CurrentUser() userId: string): Promise<Wallet[]>{
  return await walletService.getWallets(userId);
}
Enter fullscreen mode Exit fullscreen mode

🎯 Wrapping Up

This completes the core functionality for CashCove! I've built a solid foundation with:

  • βœ… Wallet funding and transfers
  • βœ… Locked funds with early withdrawal penalties
  • βœ… Real-time notifications with read/unread states
  • βœ… Money requests with automatic expiry
  • βœ… Bank transfer initiation and verification
  • βœ… Type-safe logging and consistent authentication

πŸš€ Future Enhancements

While the system is fully functional, there are some features I might add down the road:

  • Paystack integration for real payment processing (currently using mock funding)
  • Enhanced activity history with filtering and pagination
  • Webhook handling for bank transfer status updates
  • Email/SMS notifications beyond database storage
  • Rate limiting and advanced security features

The codebase is structured to make these additions straightforward when needed.

πŸ”— Explore the Code

Want to dive deeper? Check out the full implementation on GitHub: CashCove

Thanks for following along! If you found this helpful or have questions about any of the implementation details, drop a comment below. πŸ’¬

Top comments (0)