Dev.to · 7 min read

How to Design a Ledger That Can Explain Every Rupee

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

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News