Payment ledger design for billing systems
By subinc engineering · Last reviewed 2026-10-23
Payment ledger design is the work of deciding how every movement of money in a billing system gets recorded so that the books balance, can be replayed, and cannot be quietly changed. A good ledger records each event as a balanced journal of debits and credits, never edits a posted line, and lets you prove that what you hold matches what the payment processor says it paid you.
This guide walks through the ledger in subinc, a billing product that is in private beta. It uses real account codes and posting rules from the subinc code, and every amount is an integer in minor units (cents for USD, whole yen for JPY). Figures are illustrative; the rules are not.
Why a balance column is not a ledger
A balance column on a customer row answers "how much now?" and nothing else. It cannot say why the number changed, which invoice caused it, or what it was last Tuesday.
A ledger turns each change into rows that are only ever added. The balance becomes a sum over those rows. subinc keeps a running total per account, currency and environment too, but it updates that summary in the same database transaction that inserts the entry lines, so the two cannot drift apart.
The account model
Every tenant gets the same seeded chart of accounts: 26 system accounts across five types (asset, liability, equity, revenue, expense), created the first time the tenant posts anything. The ones this guide uses:
| Code | Account | Type | Normal side |
|---|---|---|---|
| 1000 | Cash | asset | debit |
| 1100 | Accounts Receivable | asset | debit |
| 1200 | Gateway Clearing | asset | debit |
| 2200 | Deferred Revenue | liability | credit |
| 2400 | Tax Payable | liability | credit |
| 4000 | Revenue | revenue | credit |
| 4300 | Foreign Exchange Gain | revenue | credit |
| 5200 | Gateway Fees | expense | debit |
| 5400 | Foreign Exchange Loss | expense | debit |
Gateway Clearing is the account that makes reconciliation possible. Money the processor has promised but not yet paid out sits there, and it moves to Cash only when the capture is recorded.
Journals, and the one rule that cannot bend
A journal is a group of entry lines that share a journal id. Each line has a side (debit or credit), an account code, a currency and a strictly positive integer amount. A negative or zero amount is rejected; direction comes from the side, not the sign.
Before anything is written, the store checks that the journal has at least two lines, that all lines share one journal id, and that debits equal credits separately in each currency. A journal that balances in total but not per currency is refused, with the error naming the currency and both totals.
Two protections sit below the application code:
- Append-only tables. Database triggers reject any
UPDATEorDELETEon the entry and journal tables. A correction is a new journal that reverses the old one. - Closed periods. A trigger rejects inserts dated in a locked accounting period, for that tenant and that environment. The application checks the same thing first and returns a friendlier error. A journal that arrives for a locked period is parked as a retryable job and re-queued when the period is reopened.
A trigger does not stop someone who owns the schema from dropping it. That is why the tamper-evidence section below looks at a second record.
Idempotency at the posting boundary
Events reach the ledger at least once, so the same event can arrive twice. The journal id is built from the event type and the source id (j_<event type>_<source id>), and it doubles as the idempotency key. The insert uses ON CONFLICT (account_id, idempotency_key) DO NOTHING; if no row comes back, another writer already posted this journal and the second call exits without writing lines.
If an inline post fails, the computed entries are saved as a durable job and retried with exponential backoff, and the same key makes the retry safe.
Money in minor units
Every amount in the ledger is an int64 count of minor units next to a three-letter ISO 4217 currency code. The money package looks up the number of decimal places per currency instead of assuming two.
| Currency | Exponent | Stored integer | What it means |
|---|---|---|---|
| USD | 2 | 1,234 | 12.34 dollars |
| JPY | 0 | 1,234 | 1,234 yen |
| KWD | 3 | 1,234 | 1.234 dinars |
The same integer means three different amounts, so the integer is never meaningful without its currency code. JPY has no unit below the yen, so a * 100 conversion in code that handles JPY or KWD is a bug.
A worked example: one invoice from issue to revenue
A tenant sells a 45-day service term for 45,000 JPY and the customer pays by card. The processor charges a 1,620 JPY fee (an invented figure for this example). No tax applies. Each step is a separate journal, posted by a separate event.
| Journal (event) | Account | Debit (JPY) | Credit (JPY) |
|---|---|---|---|
| 1. Invoice finalized | 1100 Accounts Receivable | 45,000 | |
| 2200 Deferred Revenue | 45,000 | ||
| 2. Payment succeeded | 1200 Gateway Clearing | 45,000 | |
| 1100 Accounts Receivable | 45,000 | ||
| 3. Payment captured | 1000 Cash | 43,380 | |
| 5200 Gateway Fees | 1,620 | ||
| 1200 Gateway Clearing | 45,000 | ||
| 4. Revenue recognized, first 31 days | 2200 Deferred Revenue | 31,000 | |
| 4000 Revenue | 31,000 | ||
| 5. Revenue recognized, last 14 days | 2200 Deferred Revenue | 14,000 | |
| 4000 Revenue | 14,000 |
The rules behind each row:
- Finalizing an invoice debits receivables for the total and credits deferred revenue for the total minus tax. Any tax is a third line credited to Tax Payable. Nothing is revenue yet.
- A succeeded payment moves the amount from receivables to clearing.
- A captured payment credits clearing for the gross amount, debits Cash for the amount net of the processor fee, and debits Gateway Fees for the fee. If the reported fee would leave nothing, the code posts the gross amount with no fee line.
- Rows 4 and 5 are covered in the next section.
After step 5, receivables, clearing and deferred revenue are all zero. Cash holds 43,380, fees 1,620 and revenue 45,000. Debits and credits are equal in every journal, so the trial balance, which sums them per currency, reads zero.
Revenue recognition: the unwinding entries
Cash arriving does not make revenue. The 45,000 yen is owed as service over time, so it waits in Deferred Revenue and moves to Revenue as each part of the term passes. Those later moves are the unwinding entries.
How the schedule is built. When an invoice for a subscription is paid, a job creates a straight-line schedule over the subscription's current period, for the invoice total minus tax. The engine cuts the term into calendar-month buckets and splits the amount between them in proportion to the seconds in each bucket.
For a term from 1 January to 15 February, the buckets are 31 days and 14 days. A 45,000-unit amount splits into 31,000 and 14,000, because 45,000 × 31 ÷ 45 and 45,000 × 14 ÷ 45 are both whole numbers.
When the parts do not divide evenly. The split uses a largest-remainder method: each bucket gets its rounded-down share, and the leftover minor units go one at a time to the buckets with the largest remainders, so the parts always add up to the total exactly. The leftover is not dumped on the last bucket.
When the entries post. A recognition run marks every unrecognized entry whose period has ended, and in the same transaction it queues a revenue.recognized event and writes an audit record. The ledger consumer turns the event into one journal: debit Deferred Revenue, credit Revenue.
The journal id is built from the schedule id and the full timestamp of the run's cut-off, not the calendar month. A manual catch-up and the nightly job can both run on one day, and a coarser key would dedupe them into one journal and under-post the second. An identical re-run produces the identical key and posts once.
Matching a processor settlement to the books
A ledger that balances against itself says nothing about what the processor paid. subinc runs two checks.
The internal check. A nightly worker looks for any tenant and currency where total debits differ from total credits across the entry table, and records a finding with the drift.
The external check. Once a day, for each tenant and each environment, the worker asks each connected processor for its settlement record for the previous complete UTC day and compares it with the platform's own payments, refunds and payouts. Items are matched by the processor's reference, with a second id tried for Stripe because a local row may be stored under either the charge id or the payment intent id.
Each item gets one of four outcomes. A match requires the amount, the fee and the currency to agree.
| Reference | Processor reports (amount / fee) | Local row (amount / fee) | Outcome |
|---|---|---|---|
| ref-1 | 45,000 JPY / 1,620 | 45,000 / 1,620 | matched |
| ref-2 | 2,000 USD / 88 | 2,000 / 88 | matched |
| ref-3 | 5,000 USD / 175 | 5,000 / 145 | amount_mismatch |
| ref-4 | 800 USD / 59 | no local row | missing_local |
| ref-5 | not in the report | 1,500 USD | missing_gateway |
A run also stores, per currency, the processor total, the local total and the signed drift in minor units.
Two limits are part of the design. The check compares the processor's record with payment, refund and payout rows, not with individual journal lines; the journals are produced from those same rows' events. And if a processor adapter cannot list settlements, the run is stored with the status unsupported instead of being counted as clean.
Two currencies in one payment
A customer is invoiced in one currency and the processor settles in another. The ledger posts four lines, two per currency, so that each currency balances on its own. The settlement amount and currency arrive on the payment event from the processor.
An invoice for 12.34 USD (1,234 minor units) settles, in this illustration, as 1,854 JPY:
| Account | Debit | Credit |
|---|---|---|
| 5400 Foreign Exchange Loss | 1,234 USD | |
| 1100 Accounts Receivable | 1,234 USD | |
| 1200 Gateway Clearing | 1,854 JPY | |
| 4300 Foreign Exchange Gain | 1,854 JPY |
The USD lines balance at 1,234 and the JPY lines at 1,854, which is what the per-currency check requires. The lines also carry both currencies and the exchange rate as metadata.
Read the two FX accounts as conversion flow. They hold the whole converted amount in each currency, not only the difference, so their balances are not profit on their own.
Where rounding happens. The ledger does no conversion; it stores the integers it is given. subinc's own converter, used for wallet exchanges and previews, takes the provider's rate as a float, converts it once to an exact decimal, multiplies, shifts by the difference in the two currencies' exponents, and rounds half to even. USD to JPY shifts by 10^-2, USD to KWD by 10^1. Half to even sends 2.5 to 2 and 3.5 to 4, so rounding does not drift in one direction.
So money is integer minor units everywhere except one step: the provider's exchange rate arrives as a float and is rounded once into integers at the conversion boundary. The chart of accounts has no dedicated rounding account.
Keeping test and live data apart
Every entry line, balance row and revenue entry carries an is_test flag taken from the request's environment. The running balance row is unique on account, code, currency and is_test, so sandbox and production never share a total.
Reads follow the same boundary: the entry listing, the trial balance and the per-account balances all filter on is_test. The period lock is keyed per environment, so closing a month in one environment does not lock the other.
Proving the record was not altered
Append-only triggers stop ordinary edits. Tamper evidence asks a different question: if someone with enough access changes a row, can you tell?
In subinc the hash-chained record is the audit log; the ledger tables are not chained. Each audit row stores an entry_hash, an HMAC-SHA256 over the row's fields (actor, action, resource, old and new values, request id, timestamp) under a server-side key. Each row also stores a prev_hash, the hash of the previous row for the same tenant.
The per-row hash alone cannot see a deleted row, because every surviving row still verifies. The chain can: the successor's prev_hash no longer matches any stored hash, and the verifier reports a chain break.
The limits: the verifier scans the most recent rows (1,000 by default, 10,000 at most) and refuses to run without its key. Rows written before chaining existed carry no prev_hash and are checked one by one only. Revenue recognition writes its audit record in the same transaction as the recognition.
Where this lives in subinc
The ledger described here is the double-entry ledger in subinc, which is in private beta. Journals, accounting periods and general-ledger exports are listed under the Ledger tag in the API reference, and the controls around tenant isolation and audit are on the security page.
Frequently asked questions
What is a payment ledger?
A payment ledger is an append-only record of every money movement, written as balanced debit and credit lines. Balances are derived from it, and each line points back to the invoice, payment or refund that caused it.
Why not just update a status or balance field?
An update destroys the history that explains the number, and two concurrent writers can overwrite each other. With a ledger the past stays readable, a mistake is fixed by a reversing journal, and an auditor can trace any balance to its source events.
How do duplicate webhooks avoid double-posting?
The journal id is derived from the event type and source id and is stored as a unique idempotency key per tenant. The second delivery finds the key taken and writes nothing.
Is the ledger itself hash-chained?
No. The entry and journal tables are append-only through database triggers. The hash chain covers the audit log, which records who changed what.