Building a Production-Ready Wallet Service with NestJS and Paystack
Building a Production-Ready Wallet Service with NestJS and Paystack
One of my favourite projects this year was building a backend wallet service from scratch. The requirements were straightforward on the surface: users should be able to deposit funds, transfer money to other users, and see a full transaction history. But as with most "straightforward" backend features, the devil was in the details.
The Architecture
I chose NestJS for this project because its module system maps naturally to domain boundaries. The wallet feature lives in its own module — WalletModule — which encapsulates the service, controller, and entities. No business logic bleeds into the controller layer.
src/
├── modules/
│ ├── wallet/
│ │ ├── wallet.module.ts
│ │ ├── wallet.controller.ts
│ │ ├── wallet.service.ts
│ │ ├── dto/
│ │ │ ├── deposit.dto.ts
│ │ │ └── transfer.dto.ts
│ │ └── entities/
│ │ ├── wallet.entity.ts
│ │ └── transaction.entity.ts
│ └── auth/
└── main.ts
Paystack Integration
Paystack's webhook system is the backbone of the deposit flow. When a user initiates a deposit, they're redirected to Paystack's checkout. Once payment is confirmed, Paystack sends a charge.success event to our webhook endpoint.
The critical thing here is idempotency. Webhooks can fire more than once. My solution was to store the Paystack reference on each transaction and check for duplicates before processing:
const existing = await this.transactionRepo.findOne({
where: { reference: payload.data.reference },
});
if (existing) return; // already processed
Handling Transfers Safely
Transfers between wallets need to be atomic. If you debit one wallet and the credit fails, you have a consistency problem. I used TypeORM's QueryRunner to wrap both operations in a database transaction:
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.decrement(Wallet, { userId: senderId }, 'balance', amount);
await queryRunner.manager.increment(Wallet, { userId: recipientId }, 'balance', amount);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
The Audit Trail
Every balance change — whether a deposit, transfer, or withdrawal — creates a Transaction record. This gives you a complete ledger you can replay to verify balances at any point in time. It's also essential for debugging production issues.
Lessons Learned
- Always verify webhook signatures. Paystack signs every webhook with a secret. Skipping signature verification means anyone can fake a deposit.
- Never store raw secrets in code. All Paystack keys, JWT secrets, and connection strings live in environment variables.
- Test with real webhook events. I used the Paystack dashboard to replay events during development — much better than mocking.
The full source is on GitHub if you want to dig into the implementation.