Skip to content
← All posts
Reconciliation 6 min read

Reconciliation, Ledgers, and Why a Float Isn't Money

In fintech software, a balance field is a guess and a float is money you're holding for someone else, not revenue.

Every fintech project we've touched eventually produces the same support ticket: the balance in the database doesn't match the balance on the statement, and nobody can say why. The usual cause is boring. Someone stored money as a single mutable integer, two code paths updated it independently, and one of them ran twice under load. The column was never wrong on its own terms. It just isn't a ledger.

The second problem shows up later, once the product holds funds it doesn't own: a marketplace collecting payment before paying out a seller, a wallet that's been topped up but not spent, an escrow balance sitting between two parties. Teams call this pool "the float" and then, without quite deciding to, start treating it like revenue. It is not revenue. It's a liability with someone else's name on it.

Both problems are domain rules, not implementation details. Generic CRUD scaffolding assumes state is a row you overwrite. Money is a sequence of events you can never overwrite, only append to and, when necessary, reverse with another event.

A Balance Column Is a Cache, Not a Ledger

If accounts.balance is the only place a number lives, that number is a cache with no source to rebuild from. When it drifts, and it will, you have no history to replay to find out why. You just have a wrong number and a support queue.

The classic failure is a race: two requests read the same balance, both compute a new value, both write it back, and one write silently overwrites the other. Under low traffic this never shows up in testing. Under real load, with retries and webhooks arriving twice, it shows up constantly. Optimistic locking on the balance column reduces the frequency; it does not remove the underlying design flaw, which is that the balance should never have been the thing you write to directly.

Floats Are Liabilities, Not Assets

A float is money sitting in your accounts between two events: charge and payout, deposit and withdrawal, top-up and spend. We've built AI spend wallets with auto-recharge and worked on marketplace sync across storefronts for Retailer OS, and the pattern is identical whether the money belongs to a shopper or a seller. Until it's disbursed or spent, it isn't yours. It's a claim someone else has against your business, and it needs to be modeled and reported as one.

The mistake compounds when float gets mixed into cash-on-hand reporting. A business with $2M in customer float and $200K in actual retained earnings is not a $2.2M business. If a finance team, an auditor, or a regulator ever asks for a breakdown, and one of them eventually will, the software needs to answer instantly, not after an engineer spends a week writing a reconciliation script.

A float is money you are holding for someone else, and accounting for it as revenue is how companies end up explaining a shortfall they didn't know they had.

Double-Entry Catches Its Own Mistakes

Double-entry bookkeeping survived five centuries not because accountants like ceremony but because it has a built-in error check: every entry touches at least two accounts, and the sum of debits always equals the sum of credits. If that invariant ever breaks, you know immediately that something is wrong, and roughly where. A balance column has no equivalent alarm.

A minimal ledger doesn't need a full accounting engine to get this benefit. It needs an append-only table of entries, grouped by transaction, where each transaction nets to zero:

CREATE TABLE ledger_entries (
  id            BIGSERIAL PRIMARY KEY,
  transaction_id UUID NOT NULL,
  account_id     BIGINT NOT NULL,
  amount_cents   BIGINT NOT NULL, -- positive=credit, negative=debit
  entry_type     TEXT NOT NULL,   -- charge, payout, refund, fee
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- invariant checked on every write, and again in a nightly job:
-- SELECT transaction_id FROM ledger_entries
-- GROUP BY transaction_id HAVING SUM(amount_cents) <> 0;

The account's "balance" is now a derived value: SELECT SUM(amount_cents) FROM ledger_entries WHERE account_id = ?. You can materialize it in a cache column for read speed, but the ledger is the source of truth, and the cache is rebuildable from it at any time. That single property, rebuildability, is what separates a fintech-grade data model from a generic one.

Reconciliation Is a Job With a Report, Not a Ticket Queue

Reconciliation shouldn't be something a support engineer does by hand when a customer complains. It should be a scheduled process that runs whether or not anyone is watching, and produces an artifact someone can read.

  • Run it on a fixed cadence, not on demand. Daily at minimum for anything touching real money, hourly for anything with a payout schedule tighter than 24 hours.
  • Reconcile against the external source of truth first: the payment processor's ledger, the bank feed, the marketplace payout report. Internal-to-internal reconciliation only proves your own bugs are self-consistent.
  • Flag every unmatched entry, don't auto-resolve it. An entry that's off by a cent is a rounding bug worth finding, not a rounding error worth suppressing.
  • Produce a report with a pass/fail gate, not just a log line. If the report doesn't pass, the next dependent job (payout, statement generation, close) should not run.
  • Keep every reconciliation report. Regulators, auditors, and your own future self will ask for last quarter's, not just today's.

This is the same discipline we used migrating tenant databases off a decommissioned platform: no cutover ran without a per-tenant reconciliation report that had to pass first. Money and migrations fail the same way, silently, so the fix is the same, too. If our approach to this kind of work sounds heavier than a feature sprint, that's because it is, and it should be.

Where Generic CRUD Breaks Money

Most backend frameworks are optimized for mutable state: update the row, move on. That's fine for a user's display name. It's dangerous for money, because UPDATE accounts SET balance = balance - amount WHERE id = ? has no memory of why the change happened, no way to reverse it precisely, and no defense against running twice if a webhook retries.

ORMs make this worse by hiding the SQL. A model method called debit() looks safe in code review even when it's doing an unguarded read-modify-write under the hood. The fix isn't to avoid ORMs entirely, it's to make the ledger table append-only at the database level (revoke UPDATE and DELETE grants on it) so the mistake becomes a database error instead of a silent data corruption. Domain rules like this are exactly what generic platforms miss, the same way retail point-of-sale software has to handle offline carts that ERP systems never anticipated, or logistics software has to handle partial shipments that a generic order model can't express.

The Honest Tradeoff: When Double-Entry Is Overkill

Double-entry ledgers cost you something up front: more tables, more discipline, harder ad-hoc queries, and a slower path to a working demo. If your product has a single internal counter, like a like-count or a usage quota that resets monthly and never needs to be audited against an external party, a plain integer column is the right call. Building a ledger for a number nobody will ever dispute is ceremony, not rigor.

The line we use: if money crosses a settlement boundary between two parties, or a regulator, auditor, or customer could plausibly ask "prove it," it goes in a ledger. Anything else can stay a column. Getting this distinction wrong in either direction is expensive, either in engineering time spent over-modeling a like-counter, or in a Saturday spent explaining to a customer why their wallet balance is off by forty dollars and you have no record of how it got there. We scope this decision on every payments-adjacent build we take on across the industries we ship into, and it's usually the first architectural conversation, not the last.

Frequently asked

What's the difference between a ledger and a balance column?

A balance column stores one mutable number that gets overwritten on every transaction. A ledger stores an immutable, append-only history of entries, and the balance is calculated from that history on demand. The ledger can rebuild the balance from scratch; a balance column has nothing to rebuild from.

Do I need double-entry accounting for a simple app that just tracks credits?

Not necessarily. If the credits never settle against an external party, a bank, a processor, or another user, a single-entry counter is fine. Double-entry earns its cost once money moves between two parties or has to be proven correct to someone outside your codebase.

Why shouldn't I count float as revenue?

Float is money you're holding on behalf of someone else, like a customer's unspent wallet balance or funds owed to a marketplace seller. It's a liability on your books, not income. Counting it as revenue overstates the health of the business and creates a shortfall you won't notice until payout time.

How often should payment reconciliation run?

Daily at minimum for anything involving real money, and hourly if your payout schedule is tighter than a day. Reconciliation should always run against an external source of truth, like the processor's or bank's records, not just against your own internal tables, and it should produce a report that gates downstream jobs like payouts.

What's the simplest way to catch a broken ledger before customers do?

Enforce the double-entry invariant as a scheduled check: every transaction's entries should sum to zero across all accounts. Run this query nightly, alert on any transaction that fails, and revoke UPDATE and DELETE permissions on the ledger table so corrections can only happen through new, reversing entries.