Payment reconciliation / double-entry ledger
Making your books agree with the processor: every payout matched to the charges, fees, refunds and disputes inside it.
See it
What it is
Reconciliation is proving that the money the processor says it moved matches the money your app says it earned: every payout broken back down into the charges, fees, refunds and disputes inside it. The tool that makes it tractable is a double-entry ledger, the same one accountants have used since the Medici: every event writes two or more immutable entries that sum to zero, so debits always equal credits and no amount can appear from nowhere.
The shape that works: one header row per money event, unique on (provider, source_ref) so a replayed webhook cannot double-post, and an append-only entries table hanging off it by event_id with (account, direction, amount_minor, currency), integer minor units only (never floats), and named accounts like cash, processor_clearing, accounts_receivable, fees, revenue, refunds. A sale credits revenue and debits processor_clearing; the payout debits cash and credits processor_clearing; the fee gets its own pair. Balances are derived by summing entries, never stored in a mutable column.
Gotcha: money events arrive late and out of order. A dispute, a fee adjustment or a currency-conversion rounding difference can land weeks after the order. The fix is to stop hunting for one true date and store all of them on the event: when it happened in your product, when the processor booked it, when it settled, and the date your accounting recognizes it, which may follow delivery or a performance obligation rather than the charge. Reconcile against the processor on the processor's booked date, and let revenue and receivables keep their own recognition date instead of rewriting the books to match a payout report. And never edit a posted entry: post a reversing entry, so the history stays auditable.
Ask AI for it
Design and implement a double-entry ledger for our payments in Postgres. Create a 'ledger_events' table with (id, provider, source_ref, occurred_at, processor_booked_at, settled_at, recognition_date) and a unique index on (provider, source_ref) so a replayed webhook is idempotent. Hang an append-only 'ledger_entries' table off it with (id, event_id references ledger_events, account, direction 'debit' or 'credit', amount_minor bigint, currency), where amount_minor is either signed or paired with a deterministic direction-to-sign expression, and validate that the signed entries of each event sum to zero per currency. Two or more entry rows per event is the normal case, so nothing may constrain an event to a single row. Use accounts: cash, processor_clearing, accounts_receivable, revenue, refunds, fees, disputes. Write posting functions for charge succeeded, refund, dispute opened, dispute won and payout paid, all using integer minor units. Then write a reconciliation job that pulls Stripe balance transactions for a date range, matches each to a ledger event by (provider, source_ref) on the processor's booked date, and reports unmatched items on both sides with the difference in minor units, without touching recognition_date. Never update or delete a posted entry; corrections are reversing entries.