How to Design a Ledger That Can Explain Every Rupee
A payments system can report a merchant's current balance with a mutable column. It cannot explain that balance with one.
When a merchant asks why a payout is ₹980 instead of ₹1,000, the answer must be reconstructable: which payment created the obligation, which fee reduced it, which provider event supported it, and whether a later correction changed it. That is the job of a ledger.
A production ledger is an append-only, double-entry record of economic facts. Operational state machines, idempotent commands, concurrency controls, and reconciliation support it; none replaces it.
Start with invariants
The core invariant is simple: for every posted journal and currency, the sum of signed postings is zero.
sum(posting.signed_amount_minor) = 0
When storing positive amounts with a debit or credit direction, validate the equivalent signed sum per currency. Do not offset INR against USD without explicit conversion legs.
- Posted journals and postings are immutable.
- A journal and all its postings commit atomically.
- Every posting has an explicit ISO 4217 currency [1].
- Amounts use integer minor units, never floating point.
- Corrections are linked compensating entries, not edits.
- Balances are rebuildable projections of postings.
- Each money-moving operation is idempotent and traceable to its cause.
Separate events from accounting
Payment events and ledger journals are related but distinct. An event such as payment.authorized, payment.captured, or refund.created says something happened in a workflow. A journal such as payment_capture or merchant_payout records its accounting consequence.
operational event -> posting policy -> balanced journal
Only eligible state transitions should post money. Persist the posting-policy version on every journal so an entry remains explainable after policy changes.
Represent INR precisely
Store INR as paise in a BIGINT: ₹12.34 becomes 1234; ₹1,000 becomes 100000. Floats and JavaScript numbers are unsuitable for ledger amounts because decimal fractions often cannot be represented exactly in binary.
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0)
currency_code CHAR(3) NOT NULL
Use INR the ISO 4217 alphabetic code [1] not a symbol or free-form string. For FX, calculate using decimal or rational arithmetic, apply an explicit rounding policy, and post any residual to a controlled rounding account. Never silently discard it.
Use accounts that describe obligations
cash_at_bank- Asset - Funds held at the bankpsp_receivable- Asset - Funds owed by a payment providermerchant_payable- Liability - Funds owed to a merchantcustomer_wallet_payable- Liability - Funds owed to a customerprocessing_fee_revenue- Revenue - Fees earned by the platformsettlement_clearing- Clearing - Temporary controlled matching position
Debit increases assets and expenses; credit increases liabilities, equity, and revenue. Customer wallet funds are normally a liability: the platform owes that value to the customer.
Walk through a ₹1,000 payment
Assume a merchant accepts ₹1,000 and the platform charges a ₹20 processing fee.
Capture:
- Dr PSP receivable ₹1,000
- Cr Merchant payable ₹980
- Cr Processing fee revenue ₹20
The PSP now owes the platform ₹1,000; the platform owes ₹980 to the merchant and has earned ₹20.
Settlement:
- Dr Cash at bank ₹1,000
- Cr PSP receivable ₹1,000
Merchant payout:
- Dr Merchant payable ₹980
- Cr Cash at bank ₹980
Each journal balances on its own. The receivable makes the timing gap between capture and bank settlement visible rather than pretending both happened simultaneously.
Keep the journal immutable
A posted line must not be updated or deleted. If a ₹20 fee was wrong and should have been ₹10, post a new adjustment instead of rewriting history.
- Dr Processing fee revenue ₹10
- Cr Merchant payable ₹10
For a full reversal, reverse every original direction and amount, link the new journal through reversal_of, and retain a reason code, actor, approval, and source reference. The original fact and the correction must both be visible.
Schema for explanation
Keep accounts, journal headers, and postings separate. The journal header carries causation and audit context; postings carry the balanced movements.
accounts(id, code, account_class, normal_side, owner_type, owner_id)journals(id, journal_type, status, effective_at, posted_at, source_type, source_id, idempotency_key, causation_id, correlation_id, reversal_of, policy_version, metadata)postings(id, journal_id, account_id, currency_code, direction, amount_minor, sequence_no)
Record effective_at, posted_at, provider event time, and bank value date separately. Client timestamps are not authoritative.
Restrict the database role so posted journals and postings cannot be updated or deleted. A projection table for fast balances is useful, but it must be recoverable from postings.
Idempotency is a financial control
Retries are routine; duplicate money movement is not. Store an idempotency key, operation, canonical request hash, response, and resulting journal ID in the same transaction as the post.
Stripe's public API documents this pattern: it saves the first result for an idempotency key and returns it on retries, while rejecting reuse with a different request body [2]. The IETF Idempotency-Key HTTP header draft is useful background but is expired and not an adopted standard [4].
- A new key creates the operation and journal.
- A repeated key with the same request returns the original result.
- A repeated key with a different request hash is rejected.
Provider webhooks are deduplicated with a unique (provider, provider_event_id) constraint. Use database uniqueness constraints as the final guard. Process-local check-then-insert logic races under concurrent requests.
Post atomically and protect availability
One ACID transaction should claim the idempotency key, validate the source event, insert the journal and postings, validate per-currency balance, update balance projections, and write an outbox record.
BEGIN;
claim idempotency key;
insert journal and postings;
validate each currency balances to zero;
update projection;
insert outbox event;
COMMIT;
The transactional outbox prevents a split between a committed journal and a lost downstream event. Consumers should still be idempotent.
Double-entry does not prevent double spending. Two independently balanced payouts can both spend the same available funds. Serialize the account-plus-currency aggregate with a locked balance row, an optimistic version check, a partitioned command queue, or serializable transactions with retries.
PostgreSQL's isolation documentation explains that its default Read Committed level still permits some anomalies, while Serializable aims for an outcome equivalent to some serial execution applications must handle serialization failures by retrying [3]. Acquire multi-account locks in stable order.
Reconcile against the outside world
Internal balance is not proof that cash moved. Reconcile journals against provider settlements and bank statements using provider event IDs, settlement IDs, bank references, amounts, currency, and dates.
A clearing or suspense balance should be a visible operational queue, not a place where mismatches are forgotten. For every unmatched item, retain the reason, owner, age, and eventual resolution journal. A ledger explains internal intent; reconciliation proves correspondence with external reality.
Implementation checklist
- Enforce zero-sum postings per journal and currency.
- Use integer minor units and ISO 4217 codes [1].
- Make posted journals append-only and reverse them with linked journals.
- Persist causation, correlation, source identifiers, actors, and policy versions.
- Use transactional idempotency records and outbox events [2].
- Separate ledger, available, settled, and held balances.
- Serialize spend decisions and handle retryable transaction conflicts [3].
- Rebuild projections from the journal regularly and reconcile them to PSP and bank data.
Closing principle
The test of a ledger is not whether it can return a balance quickly. It is whether, months later, an engineer, auditor, merchant, or finance operator can trace every rupee from cause to posting to settlement to correction without relying on tribal knowledge or mutable state.
Build the ledger as the explanation. Everything else can be a projection.
References
- [1] ISO 4217 currency codes
- [2] Stripe API documentation: idempotent requests
- [3] PostgreSQL documentation: transaction isolation
- [4] IETF Idempotency-Key draft (useful background; this draft is expired)
Powered by Mole. AI Agent for SEO & GEO
Comments
No comments yet. Start the discussion.