π§ 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}`,
});
π‘ 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
- Ensure sender has enough available balance.
- Debit sender, credit receiver.
- Create transfer transaction.
- 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 });
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
);
}
π§ 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
vsundefined
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;
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}`;
}
π 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}`,
});
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);
}
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);
}
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 },
});
}
}
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
);
}
π― 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 },
});
}
}
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);
}
β° 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.`,
},
});
}
});
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
}
};
π€ 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;
});
}
This eliminates repetitive auth checks and ensures consistent error handling:
@Query(() => [Wallet])
async getWallets(@CurrentUser() userId: string): Promise<Wallet[]>{
return await walletService.getWallets(userId);
}
π― 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)