# Payment Ledger Design for Billing Systems

> How to design a double-entry, append-only payment ledger: account model, balanced postings, idempotency, reversals, settlement reconciliation, and multi-currency.
- **Author**: Ayush Agarwal
- **Published**: 2026-07-29
- **Category**: Engineering, Billing, Payments
- **URL**: https://dodopayments.com/blogs/payment-ledger-design

---

The bug report says a customer was charged twice. You open the `transactions` table, find one row with `amount = 120.00` and `status = 'succeeded'`, and the bank statement shows two debits. Nothing in your database explains the second one, because a row that gets updated in place has no memory of what it used to say.

A payment ledger fixes this by making money movement an immutable, double-entry record rather than a mutable status field. Every event produces a set of entries whose debits equal their credits, nothing is ever edited after it is posted, and any balance at any point in time is derivable by replaying entries. That is the whole design, and everything below is a consequence of it.

This is an architecture guide for engineers building billing systems. It covers the account model, how a captured payment posts, why reversals replace edits, idempotency, reconciliation against processor settlement files, and multi-currency.

## Why a payment ledger must be double-entry

Double-entry is not an accounting formality. It is a runtime invariant that catches your bugs before your customers do.

In a single-entry model, "the customer paid $120" is one number in one place. If a retry writes it twice, or a partial refund decrements it incorrectly, nothing in the data model objects. In a double-entry model the same event is recorded as a movement between two or more accounts, and the sum of debits must equal the sum of credits. A posting that does not balance is rejected at write time.

That constraint gives you three properties for free:

- **Conservation.** Money is never created or destroyed inside your system, only moved between accounts. If your revenue account grew, some other account shrank, and you can name it.
- **Explainability.** Every balance decomposes into the entries that produced it. "Why is this merchant's payable $8,412.19?" is a query, not an investigation.
- **Auditability.** Because entries are never modified, the ledger is its own audit log. You do not need a separate change-tracking table that can drift from reality.

The cost is that you must think in accounts before you write code. That is a real cost, and it is why so many billing systems start with a transactions table and rewrite later. Teams that have been through it usually recognise the symptoms in our notes on [billing system migration mistakes](https://dodopayments.com/blogs/billing-system-migration-mistakes) and [revenue leakage in SaaS](https://dodopayments.com/blogs/revenue-leakage-saas).

## A ledger is not a table of transactions

The distinction matters enough to state precisely, because the two look similar in a schema diagram.

A transactions table records **things that happened to a payment object**. It is keyed by payment, mutated as status changes, and optimised for answering "what is the state of this charge right now".

A ledger records **movements between accounts**. It is keyed by entry, never mutated, and optimised for answering "what is the balance of this account as of this instant, and which movements produced it".

You need both. The transactions table is your operational view of processor state; the ledger is your financial system of record. Problems begin when a team treats the transactions table as the source of financial truth, because a status column cannot express a partial refund plus a fee adjustment plus a currency conversion difference on the same charge.

A useful test: if you deleted your ledger and rebuilt it from your transactions table, would you get the same balances? If the answer is "only if nothing weird happened", you do not have a ledger. Our wider walkthrough of [payments architecture for SaaS](https://dodopayments.com/blogs/payments-architecture-saas) puts this in context alongside the rest of the stack.

## The account model for a payment ledger

Accounts fall into five classes: assets, liabilities, equity, revenue, and expenses. Assets and expenses increase with debits. Liabilities, equity, and revenue increase with credits. Every account you add must be classified, because the classification determines its normal balance.

Here is a working chart of accounts for a SaaS billing system.

| Account | Class | Debited when | Credited when |
| --- | --- | --- | --- |
| Customer receivable | Asset | An invoice is issued | The customer pays, or the balance is written off |
| Processor receivable | Asset | A payment is captured and funds sit with the processor | Fees are deducted, refunds are issued, or a payout leaves |
| Cash (bank) | Asset | A payout lands in your bank account | You spend or transfer funds out |
| Deferred revenue | Liability | Revenue is recognised over the service period | A customer prepays for future service |
| Tax payable | Liability | Tax is refunded to a customer, or remitted to an authority | Tax is charged on an invoice |
| Customer credit balance | Liability | Credit is applied against an invoice | Credit is granted to the customer |
| Revenue | Revenue | Rare; use a contra account instead | Revenue is recognised |
| Refunds (contra-revenue) | Revenue | A refund is issued | Reversal of a refund |
| Processing fee expense | Expense | The processor deducts its fee | Reversal only |
| Platform fee expense | Expense | The platform deducts its fee | Reversal only |
| Chargeback losses | Expense | A dispute is lost | A dispute is won and funds return |
| Dispute fee expense | Expense | The processor charges a fee for handling a dispute | Reversal only |

Two notes on modelling. First, keep processor receivable separate from cash. Funds acknowledged by a processor are not funds in your bank, and collapsing the two makes your cash position wrong for the length of the payout cycle. Second, prefer contra accounts over debiting revenue directly, because gross revenue and refunds are separately reportable and you will be asked for both. The difference is covered in [gross revenue vs net revenue](https://dodopayments.com/blogs/gross-revenue-vs-net-revenue) and [billings vs revenue](https://dodopayments.com/blogs/billings-vs-revenue).

If you sell subscriptions, deferred revenue is not optional. An annual prepayment creates a liability that unwinds monthly, which is exactly what [deferred revenue](https://dodopayments.com/blogs/deferred-revenue-explained) and [SaaS revenue recognition](https://dodopayments.com/blogs/saas-revenue-recognition) describe.

## Posting a captured payment: the entries that must balance

Take a concrete sale. A customer buys a $100.00 plan, tax is $20.00, so the charge is $120.00. The processor takes $3.78 and the platform takes $4.00, leaving $112.22 to settle.

```mermaid
flowchart LR
    A["Invoice issued
$120.00"] -->|"Dr Customer receivable"| B[Customer receivable]
    A -->|"Cr Revenue $100.00"| C[Revenue]
    A -->|"Cr Tax payable $20.00"| D[Tax payable]
    E["Payment captured
$120.00"] -->|"Cr Customer receivable"| B
    E -->|"Dr Processor receivable"| F[Processor receivable]
    G["Fees applied
$7.78"] -->|"Cr Processor receivable"| F
    G -->|"Dr Fee expense"| H[Fee expense]
    I["Payout settled
$112.22"] -->|"Cr Processor receivable"| F
    I -->|"Dr Cash"| J[Cash]
```

The four postings look like this.

**1. Invoice issued.**

- Dr Customer receivable 120.00
- Cr Revenue 100.00
- Cr Tax payable 20.00

Debits 120.00, credits 120.00.

**2. Payment captured.**

- Dr Processor receivable 120.00
- Cr Customer receivable 120.00

Debits 120.00, credits 120.00. Customer receivable is now zero for this invoice.

**3. Fees applied.**

- Dr Processing fee expense 3.78
- Dr Platform fee expense 4.00
- Cr Processor receivable 7.78

Debits 7.78, credits 7.78.

**4. Payout settled.**

- Dr Cash 112.22
- Cr Processor receivable 112.22

Debits 112.22, credits 112.22.

Now check the processor receivable account: 120.00 debited, then 7.78 and 112.22 credited. 120.00 minus 7.78 minus 112.22 equals 0.00. The account closes out exactly, which is the signal that your model is internally consistent.

A partial refund of $60.00 on that invoice, comprising $50.00 of product and $10.00 of tax, posts as:

- Dr Refunds 50.00
- Dr Tax payable 10.00
- Cr Processor receivable 60.00

Debits 60.00, credits 60.00. Note that tax payable is debited, because you owe the authority less once you have returned the tax to the customer. Getting this wrong is a common source of over-remittance. Our guide to [refund management](https://dodopayments.com/blogs/saas-refund-management) covers the operational side.

A lost dispute on the full $120.00 with a $15.00 dispute fee posts as:

- Dr Chargeback losses 120.00
- Dr Dispute fee expense 15.00
- Cr Processor receivable 135.00

Debits 135.00, credits 135.00. The distinction between this and a refund matters commercially as well as accounting-wise, which is the subject of [chargeback vs refund](https://dodopayments.com/blogs/chargeback-vs-refund) and [dispute management](https://dodopayments.com/blogs/dispute-management-guide).

## Append-only: you post a reversal, you never edit

A posted entry is immutable. When it is wrong, you correct it by posting a second entry that reverses the first, then posting the correct one. You do not run an `UPDATE`.

```mermaid
flowchart TD
    A[Build posting] -->|"debits != credits"| B[Rejected at write time]
    A -->|"idempotency key already seen"| C[Deduplicated, no-op]
    A -->|"balanced and new"| D[Posted]
    D --> E[Immutable forever]
    D -->|"error discovered"| F[Append reversing posting]
    F --> G[Append corrected posting]
    G --> E
```

Say you posted an invoice at 130.00 when it should have been 120.00:

- Original: Dr Customer receivable 130.00, Cr Revenue 110.00, Cr Tax payable 20.00
- Reversal: Dr Revenue 110.00, Dr Tax payable 20.00, Cr Customer receivable 130.00
- Correction: Dr Customer receivable 120.00, Cr Revenue 100.00, Cr Tax payable 20.00

Each of the three balances independently. The net effect on customer receivable is 130.00 minus 130.00 plus 120.00, which equals 120.00. The history shows what happened and when, which is what an auditor, a customer support agent, and a debugging engineer all need.

Three rules make this workable:

- Every reversal carries a `reverses_entry_id` pointing at what it undoes, so you can render a clean view that nets reversed pairs out.
- An entry can be reversed at most once. Enforce it with a unique constraint on `reverses_entry_id`, not application logic.
- Reversals get their own timestamp. Never backdate a correction into a closed accounting period; post it in the current period with a reference to the original.

## Idempotency in a payment ledger

Ledger writes must be idempotent, because everything upstream of them retries. Processor webhooks retry on timeout, background jobs retry on deploy, and your own API clients retry on network errors.

The mechanism is a deterministic idempotency key attached to the posting, unique-indexed in the database. If the key already exists, the write is a no-op that returns the existing entry.

```text
posting_key = hash(event_source, external_event_id, posting_type)

BEGIN
  INSERT INTO ledger_postings (posting_key, ...)
  ON CONFLICT (posting_key) DO NOTHING
  RETURNING id

  IF no row returned:
    RETURN existing posting  -- already applied, safe to ack

  INSERT INTO ledger_entries (posting_id, account_id, direction, amount) ...

  ASSERT sum(amount WHERE direction='debit')
       = sum(amount WHERE direction='credit')
COMMIT
```

Two details decide whether this actually works.

The key must be derived from the source event, not generated at call time. Hashing `(provider, payment_intent_id, "capture")` produces the same key on every retry. Generating a UUID per attempt produces a new key per retry, which is exactly the bug you were trying to prevent.

The balance assertion must run inside the same transaction as the inserts. If it runs afterwards, a crash between the two leaves an unbalanced posting in your system of record. A deferred constraint or a trigger on commit is more reliable than application-level checks.

Webhook delivery makes this concrete. Dodo Payments follows the Standard Webhooks specification and sends a unique `webhook-id` header with every event, retrying up to eight times with exponential backoff, so the same event can legitimately arrive more than once. The [webhooks documentation](https://docs.dodopayments.com/developer-resources/webhooks) spells out the retry schedule, and [webhooks for payment notifications](https://dodopayments.com/blogs/webhooks-payment-notifications) and [idempotency keys in payment APIs](https://dodopayments.com/blogs/idempotency-keys-payment-api) cover the pattern in more depth.

## Reconciling the payment ledger to settlement reports

Your ledger is your view of what should have happened. The processor's settlement report is its view of what did. Reconciliation is the process of proving those agree, and it is the single most valuable thing your ledger enables.

The check is straightforward once accounts are modelled correctly. For a given payout, the sum of entries crediting processor receivable with reason `payout` should equal the payout amount on the statement. The sum of all entries against processor receivable between two payouts should equal the movement in the processor's reported balance over the same window.

Where breaks come from, roughly in order of frequency:

- **Timing.** A capture posted on the 31st settles on the 2nd. This is not an error, but your reconciliation must be cut on settlement date, not capture date.
- **Fees you did not model.** Currency conversion spreads, cross-border assessments, dispute fees, and monthly account fees all move the balance. If a fee type has no account, it will silently become an unexplained difference.
- **Rounding.** Multi-currency conversion at fractional rates produces sub-cent residue. Give it a dedicated rounding account rather than absorbing it into revenue.
- **Reversed disputes.** Funds withdrawn on a dispute and returned when you win are two separate movements, and treating them as a cancelled pair loses the fee.

Build reconciliation as a scheduled job that emits a break report, not as a manual spreadsheet exercise. Dodo Payments exposes the data for this directly: [balance ledger entries](https://docs.dodopayments.com/api-reference/balance-ledger/list-ledger-entries) can be filtered by date range, event type, currency, or reference object, and the [payout breakup endpoint](https://docs.dodopayments.com/api-reference/payouts/retrieve-breakup) returns a payout broken down by event type covering payments, refunds, disputes, and fees. The dashboard view of the same data is described under [balances](https://docs.dodopayments.com/features/account-summary-payout-wallet).

For the operational playbook around this, see [payment reconciliation for SaaS](https://dodopayments.com/blogs/payment-reconciliation-saas) and [how MoR payouts and settlement work](https://dodopayments.com/blogs/mor-payouts-settlement-explained).

## Multi-currency without losing the invariant

The rule that debits equal credits holds per currency, not across currencies. A posting that debits 100 EUR and credits 108 USD is not balanced, it is two different facts glued together.

The workable approach is to store every entry with an explicit currency and an amount in minor units, and never mix currencies within a single posting. When money genuinely crosses currencies, model it as two postings joined by a conversion account:

- Posting A: Dr FX conversion (EUR) 100.00, Cr Processor receivable (EUR) 100.00
- Posting B: Dr Processor receivable (USD) 108.00, Cr FX conversion (USD) 108.00

Each posting balances in its own currency. The FX conversion account then carries a residual position in both currencies, which is a real economic exposure and belongs on your books as one, rather than being hidden inside a rounding difference.

Three implementation rules save the most pain:

- Store amounts as integers in the currency's minor unit. Never use floating point for money, and remember that not every currency has two decimal places.
- Record the rate and the rate timestamp on the conversion posting. Reconstructing "what rate did we use" six months later is otherwise impossible.
- Keep balances per account per currency. A single "balance" column forces a conversion at read time, which makes historical balances non-reproducible.

Pricing in multiple currencies has product implications too, covered in [multi-currency pricing for global SaaS](https://dodopayments.com/blogs/multi-currency-pricing-global-saas).

## A schema sketch

The minimum viable shape is two tables plus a balances projection.

```sql
-- A posting is one balanced financial event.
CREATE TABLE ledger_postings (
  id            BIGSERIAL PRIMARY KEY,
  posting_key   TEXT NOT NULL UNIQUE,      -- idempotency
  posting_type  TEXT NOT NULL,             -- invoice_issued, capture, fee, payout, refund, dispute
  currency      CHAR(3) NOT NULL,          -- one currency per posting
  occurred_at   TIMESTAMPTZ NOT NULL,      -- business time
  recorded_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  reverses_id   BIGINT UNIQUE REFERENCES ledger_postings(id),
  external_ref  TEXT                       -- payment_id, payout_id, dispute_id
);

-- An entry is one leg of a posting. Never updated, never deleted.
CREATE TABLE ledger_entries (
  id          BIGSERIAL PRIMARY KEY,
  posting_id  BIGINT NOT NULL REFERENCES ledger_postings(id),
  account_id  BIGINT NOT NULL REFERENCES ledger_accounts(id),
  direction   TEXT NOT NULL CHECK (direction IN ('debit','credit')),
  amount      BIGINT NOT NULL CHECK (amount > 0)  -- minor units
);

CREATE INDEX ON ledger_entries (account_id, posting_id);
```

Note what is absent. There is no `status` column on an entry, because an entry has no lifecycle. There is no `amount` column that can go negative, because direction carries the sign and a negative amount would let a single entry masquerade as its own reversal. There is no `updated_at`, because nothing is updated.

`occurred_at` and `recorded_at` are separate on purpose. Business time is when the money moved; record time is when you learned about it. Late-arriving webhooks mean these routinely differ, and reports need to be runnable on either axis.

Current balances should be a materialised projection over entries, rebuildable from scratch. If you cannot drop the balances table and regenerate it exactly, something is writing balances directly and your invariant is already broken.

For teams evaluating whether to build this or adopt it, our comparison of [billing APIs for developers](https://dodopayments.com/blogs/best-billing-apis-developers) and the notes on [multi-tenant billing architecture](https://dodopayments.com/blogs/multi-tenant-billing-architecture) are the natural next reads. Pricing for the managed path is at [dodopayments.com/pricing](https://dodopayments.com/pricing).

## FAQ

### What is a payment ledger?

A payment ledger is an append-only, double-entry record of money movements between named accounts, where every event posts a set of entries whose debits equal their credits. It differs from a transactions table because it records movements rather than object states, is never updated in place, and lets any balance be derived by replaying entries.

### Why can't I just update a row when a payment status changes?

Because an updated row destroys the history that explains the current value. When a customer disputes a charge or an auditor asks why a balance moved, a status column can only tell you where things ended up, not how. Posting a reversal and then a correction preserves both facts.

### How do I stop duplicate webhook deliveries from double-posting?

Derive an idempotency key deterministically from the source event, such as a hash of the provider, the external event ID, and the posting type, and put a unique index on it. Dodo Payments sends a unique `webhook-id` header and retries up to eight times with exponential backoff, so retries are expected rather than exceptional.

### Should processor balances and bank cash be the same ledger account?

No. Funds acknowledged by a processor have not yet reached your bank, and collapsing the two makes your cash position wrong for the entire payout cycle. Keep a processor receivable asset account that is debited on capture and credited on fees, refunds, disputes, and payouts, and a separate cash account debited only when a payout lands.

### How do I handle multi-currency without breaking double-entry?

Enforce the debits-equal-credits rule per currency and never mix currencies inside one posting. When money genuinely converts, write two postings joined by an FX conversion account, each balanced in its own currency, and record the rate and rate timestamp on the conversion so historical balances stay reproducible.

## Conclusion

The design reduces to four rules. Post balanced entries per currency. Never mutate a posted entry; append a reversal instead. Derive every idempotency key from the source event, not the attempt. Keep processor receivable and cash as separate accounts so reconciliation against settlement reports has somewhere to land.

Teams usually reach for this after an incident rather than before one, which is why the rewrite is common. If you are early enough to choose, choose the ledger. The additional modelling work is measured in days, and the class of bug it eliminates is measured in customer trust.

If you would rather consume a ledger than build one, [Dodo Payments](https://dodopayments.com) exposes balance ledger entries and payout breakups through its API, so reconciliation becomes a scheduled job against structured data rather than a parsing exercise against CSV exports.
---
- [More Engineering articles](https://dodopayments.com/blogs/category/engineering)
- [All articles](https://dodopayments.com/blogs)