Back to thoughts and insights

How to Design a Ledger That Can Explain Every Rupee

About 6 min read...

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.

  1. Posted journals and postings are immutable.

  2. A journal and all its postings commit atomically.

  3. Every posting has an explicit ISO 4217 currency [1].

  4. Amounts use integer minor units, never floating point.

  5. Corrections are linked compensating entries, not edits.

  6. Balances are rebuildable projections of postings.

  7. 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

Account

Class

Meaning

cash_at_bank

Asset

Funds held at the bank

psp_receivable

Asset

Funds owed by a payment provider

merchant_payable

Liability

Funds owed to a merchant

customer_wallet_payable

Liability

Funds owed to a customer

processing_fee_revenue

Revenue

Fees earned by the platform

settlement_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].

  1. A new key creates the operation and journal.

  2. A repeated key with the same request returns the original result.

  3. A repeated key with a different request hash is rejected.

  4. 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

Powered by Mole. AI Agent for SEO & GEO

Weekly dispatch

Your Money Is Held Together by Duct Tape

Real stories from payment infrastructure, type-driven security, and production fintech engineering. I post a new blog every Sunday.

No spam. Unsubscribe anytime.

#fintech#payments#system-design#architecture#distributed-systems
November 7, 2026

Related thoughts

Share Your Thoughts

Be the first to comment

Share your thoughts on this post

Join the conversation

How to Design a Ledger That Can Explain Every Rupee — Shubham Kumar