All posts

    Building a Production-Ready Wallet Service with NestJS and Paystack

    March 15, 20268 min read
    NestJS
    TypeScript
    PostgreSQL
    Paystack
    Backend

    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

    1. Always verify webhook signatures. Paystack signs every webhook with a secret. Skipping signature verification means anyone can fake a deposit.
    2. Never store raw secrets in code. All Paystack keys, JWT secrets, and connection strings live in environment variables.
    3. 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.