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_URLin.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.reversedByTransactionIdfor 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 modelutils/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.