Building a Wallet with a Double‑Entry Ledger

Modern wallets must be correct by construction. Instead of storing a mutable "balance" number that can drift, we post every movement of money as balanced ledger entries and derive balances from the history. This repository implements a clear, auditable double‑entry ledger to manage customer and restaurant wallets.

This article walks through the core concepts, schema, and request flows used here, with small code snippets for the essential parts.

Why double‑entry?

Core Principles

  • Every movement is balanced: total debits == total credits per transaction.

  • Derived balances: balances come from summing entries; we never trust a single mutable number.

  • Audit trail: immutable, append‑only entries make reconciliation and debugging easy.

The domain model (Prisma)

At the heart are Account, Transaction, and LedgerEntry plus a few enums to capture intent.

// prisma/schema.prisma (excerpt)
enum TransactionType { deposit payout payment }
enum LedgerEntryType { debit credit }
enum AccountOwnerType { external customer restaurant }

model Account {
  id           String        @id @default(cuid())
  ownerType    AccountOwnerType
  ownerId      String        @unique
  ledgerEntries LedgerEntry[]
}

model Transaction {
  id            String       @id @default(cuid())
  type          TransactionType
  description   String?
  ledgerEntries LedgerEntry[]
}

model LedgerEntry {
  id            String          @id @default(cuid())
  transaction   Transaction     @relation(fields: [transactionId], references: [id])
  transactionId String
  account       Account         @relation(fields: [accountId], references: [id])
  accountId     String
  amount        Float
  type          LedgerEntryType // debit or credit
}

Posting a transaction (double‑entry in practice)

We never change balances directly. Instead, we create a Transaction with two LedgerEntry rows: one debit and one credit.

// api/wallet/wallet.service.ts (excerpt)
export const createTransaction = async (
  transactionType: TransactionType,
  description: string,
  sourceAccountId: string,
  destinationAccountId: string,
  amount: number
) => {
  return prisma.transaction.create({
    data: {
      type: transactionType,
      description,
      ledgerEntries: {
        createMany: {
          data: [
            { accountId: sourceAccountId, amount, type: LedgerEntryType.debit },
            {
              accountId: destinationAccountId,
              amount,
              type: LedgerEntryType.credit,
            },
          ],
        },
      },
    },
  })
}

Key insight: Because prisma.transaction.create nests the ledgerEntries, the write is atomic: either both entries are recorded, or none are.

Computing balances (credits − debits)

An account's balance is derived by folding its ledger entries.

// api/wallet/wallet.service.ts (excerpt)
export const getAccountBalance = async (accountId: string) => {
  const entries = await prisma.ledgerEntry.findMany({ where: { accountId } })
  return entries.reduce(
    (balance, entry) =>
      entry.type === LedgerEntryType.credit
        ? balance + entry.amount
        : balance - entry.amount,
    0
  )
}

Important:

No balance column exists on Account; this prevents drift and race conditions.

Accounts and ownership

Every participant of a flow (users, restaurants, and external rails like cards/banks) maps to an Account:

  • customer: a user's wallet account
  • restaurant: the restaurant's wallet account
  • external: an off‑platform funding sink/source (card, bank account, payout destination)

Accounts are auto‑provisioned on first use via getAccountOrCreateAccountByOwnerId.

Payment and settlement flows

Below are the core flows, each implemented as a balanced pair of entries.

1. Add funds to a user's wallet (via external method → customer)

POST /api/wallet/add-funds/:userId
{
  "paymentMethodId": "pm_123", "amount": 25.00, "description": "Credit card deposit"
}

Controller creates accounts as needed, then posts a deposit from the external account to the user account.

// api/wallet/wallet.controller.ts (excerpt)
await walletService.createTransaction(
  TransactionType.deposit,
  description,
  sourceAccount.id, // external
  destinationAccount.id, // customer
  amount
)

2. Pay for an order from wallet (customer → restaurant)

POST /api/order/pay
{
  "useWallet": true, "orderId": "ord_abc", "amount": 40.00,
  "userId": "u_1", "restaurantId": "r_1"
}

The controller checks the user balance, then posts a payment from the user account to the restaurant account.

// api/wallet/wallet.controller.ts (excerpt)
await walletService.createTransaction(
  TransactionType.payment,
  `Order payment for order ${orderId}`,
  account.id, // customer
  restaurantAccount.id, // restaurant
  amount
)

3. Pay with a payment method (no wallet balance): "top‑up then pay"

When useWallet: false, we first deposit from external → customer, then pay customer → restaurant. This ensures the ledger stays balanced and auditable.

// api/wallet/wallet.controller.ts (excerpt)
await walletService.createTransaction(
  TransactionType.deposit,
  `Adding funds ...`,
  paymentMethodAccount.id,
  account.id,
  amount
)
await walletService.createTransaction(
  TransactionType.payment,
  `Order payment ...`,
  account.id,
  restaurantAccount.id,
  amount
)

4. Restaurant payout (restaurant → external)

POST /api/wallet/restaurant-payout/:restaurantId
{ "amount": 150.00 }

After a balance check, we post a payout from the restaurant account to its external bank account.

// api/wallet/wallet.controller.ts (excerpt)
await walletService.createTransaction(
  TransactionType.payout,
  `Payout from restaurant ${restaurantId}`,
  restaurantAccount.id,
  externalAccount.id,
  amount
)

API surface (wallet routes)

Available Endpoints

  • POST /api/wallet/add-funds/:userId

    — deposit from external → customer

  • POST /api/wallet/restaurant-payout/:restaurantId

    — payout from restaurant → external

  • GET /api/wallet/balance/:ownerId

    — derived balance for the owner's account

  • GET /api/wallet/transactions/:ownerId

    — paginated ledger‑backed transactions

Querying transactions

Transactions are fetched with their ledger entries and related accounts. Pagination metadata is provided.

// api/wallet/wallet.service.ts (excerpt)
export const getTransactionsByAccountId = async (
  accountId: string,
  offset: number,
  limit: number
) => {
  const options = { ledgerEntries: { some: { accountId } } }
  return prisma.$transaction(async (tx) => {
    const total = await tx.transaction.count({ where: options })
    const transactions = await tx.transaction.findMany({
      where: options,
      skip: offset,
      take: limit,
      include: { ledgerEntries: { include: { account: true } } },
      orderBy: { createdAt: 'desc' },
    })
    return { total, transactions, hasMore: total > offset + limit }
  })
}

Running locally

Prerequisites:

  • Node.js 18+
  • PostgreSQL
  • Environment: set DATABASE_URL in .env (e.g., postgresql://user:pass@localhost:5432/app_wallet)

Install and run:

yarn
yarn prisma:generate
yarn prisma:migrate
yarn prisma:seed
yarn dev

Build and start:

yarn build && yarn start

Example calls, find postman collection in the release assets

# Health
curl -s http://localhost:3000/health

# Add funds to user wallet
curl -s -X POST http://localhost:3000/api/wallet/add-funds/u_1 \
  -H 'content-type: application/json' \
  -d '{"paymentMethodId":"pm_1","amount":50,"description":"Top up"}'

# Get balance by ownerId (userId or restaurantId or external id)
curl -s http://localhost:3000/api/wallet/balance/u_1

# List transactions
curl -s 'http://localhost:3000/api/wallet/transactions/u_1?offset=0&limit=10'

Integrity and reversals

  • All postings are atomic: the transaction and its ledger entries are written together.

  • Balances are derived and thus consistent by design.
  • The schema includes Transaction.reversedByTransactionId for future, explicit reversal flows (not yet implemented): to reverse, post a compensating transaction that balances the original, preserving the audit trail.

Extensibility ideas

  • Add transfer types (refunds, adjustments, holds/escrow)
  • Idempotency keys for posting
  • Balance snapshots (materialized views) for analytics without sacrificing correctness
  • Background payouts/settlements and event hooks for posting to external rails

Folder map

  • api/wallet/ — wallet routes, controller and service (posting and balance)
  • prisma/schema.prisma — authoritative data model
  • utils/response.ts — standardized JSON response envelope

TL;DR

The key takeaway:

This wallet is built on a true double‑entry ledger: every money movement is two entries that net to zero, balances are derived, and the audit trail is preserved. The result is safer, more debuggable money software.


Want to see the full implementation? Check out the complete source code on GitHub. For more projects and updates, follow me on GitHub or Instagram.