Fourteen places where the balance in your dashboard stops matching the balance at your provider and the figure in your accountant's report. For each: what the symptom looks like, and what catches it.
Drift almost never arrives as an outage. It arrives as a question from your accountant: вАЬwhy are we four hundred and twenty short for March?вАЭ And then it turns out the system holds three numbers, each correct in its own way, and none of them can be called the balance.
All fourteen items below are places where two truths diverge legitimately: the provider counts one thing, the network shows another, the accountant needs a third. Without a layer that can explain the difference, the difference accumulates in silence.
Drift in the books is not an arithmetic error. It is the absence of an answer to вАЬwhere did this figure come fromвАЭ.
The list comes out of systems I built: a double-entry accounting core working across four networks (Bitcoin, Ethereum, Tron and TON), a payment rail with batch payouts, and a financial modelling system for small business, a year in daily production. Ordered from what you meet every month to what fires once a year and costs the most.
Thirteen of the fourteen are also a checker you can read and run: ledger-reconcile. Every discrepancy it reports links back to the entry below that explains it.
What each one looked like
- More left the account than the payment said
- The recipient was paid twice
- The balance is there and cannot be withdrawn
- The balance went negative despite the check
- Daily reports don't add up to the monthly one
- Turnover doubled out of nowhere
- One transaction, three different figures
- Yesterday's export names the source differently
- A confirmed deposit disappeared
- One transaction is not one operation
- Reconciliation fails though every entry is right
- The cost of a payout changed after sending
- The balance exceeds what can be withdrawn
- The payout went out and cost nothing
01. More left the account than the payment said
Fee on top of the amount, or inside it
Symptom: You sent a hundred, and a hundred and three left the account. Or you sent a hundred, and ninety-eight arrived. The customer calls it a bug; the system did exactly what it was told.
The same number means different things depending on who pays the fee. Both schemes are legitimate, but the system has to know which one it runs and say so out loud: before sending, not in reply to a complaint.
What catches it: Three separate quantities in the payment model: amount, fee, and the resulting debit. Never display two of the three. A payout batch dry-runs first: every row returns its own fee and its own debit before any money moves.
02. The recipient was paid twice
A retried request creates a second payment
Symptom: Two requests a second apart in the logs. An operator clicked again, or the connection dropped and the client library retried on its own.
Retries are normal in any network exchange, not an exception. A system that creates a new operation for every request it receives is guaranteed to pay twice eventually.
What catches it: An idempotency key from the caller, a unique index on it, and the previous result returned instead of a new one being created. A row's key inside a payout batch doesn't change between attempts, which is why re-uploading it never produces a second payment. Verified by a test that fires the same request twenty times in parallel and demands exactly one payment.
03. The balance is there and cannot be withdrawn
A currency without its network
Symptom: The dashboard says вАЬUSDT: 5,000вАЭ, the withdrawal fails: only 300 sits on the network the customer wants. They see one number and don't understand the refusal.
The same token lives on several networks, and that's not one asset but several. Adding them into a single number is like adding cash in your pocket to store credit at a shop that's shut: you get a figure, and none of it is spendable where the customer wants to spend it.
What catches it: The pair вАЬcurrency plus networkвАЭ becomes the key everywhere: in the account, in the posting, in the API response, in the exported row. Amount, currency and network travel together and are never separated. Addresses are validated against the format of that specific network before anything is queued.
04. The balance went negative despite the check
Accounting without holds
Symptom: The balance is below zero. There's a вАЬsufficient fundsвАЭ check in the code and it was running.
Time passes between reading the balance and debiting it. Two concurrent requests read the same value, both see enough, both debit. The check existed; isolation did not, and a transaction alone doesn't supply it. Under PostgreSQL's default READ COMMITTED, both requests read the same available balance, both insert their own hold, neither write conflicts with the other, and both commit.
What catches it: Funds are reserved as their own object, a hold, and the available balance is that balance minus active holds. Check and reserve happen inside one transaction and serialise against each other: the account row is taken with SELECT вА¶ FOR UPDATE, or the debit is a single conditional UPDATE вА¶ WHERE available >= amount whose row count decides the outcome, or the transaction runs at SERIALIZABLE and retries on conflict. In a payout batch the remaining balance is a running total: rows that no longer fit are flagged in advance rather than failing at dispatch.
05. Daily reports don't add up to the monthly one
The day boundary and time zones
Symptom: The gap is exactly a handful of transactions, and all of them happened near midnight.
A transaction happens at an instant; a report is built on calendar boundaries. When different parts of the system understand вАЬa dayвАЭ differently вАФ the server in one zone, the database in another, the accountant in a third вАФ some transactions land in both reports or neither.
What catches it: Every instant is stored in one zone with the zone stated explicitly, the reporting period is an interval rather than a date, and the reporting zone is written into the report itself. Test: a transaction exactly on the boundary must land in precisely one report.
06. Turnover doubled out of nowhere
A refund recorded as a new operation
Symptom: The turnover metric doubled with no growth in sales. Refunds were counted as fresh inflows.
A refund can be recorded two ways: as a separate operation in the opposite direction, or as a reversal of the original. The first is correct for money and wrong for turnover: one deal now produces two movements, and any metric that counts movements doubles.
What catches it: A refund references its original operation explicitly and carries its own type. A partial refund holds its own amount and never edits the original record. Turnover is computed over operations of a given type, not over every movement. A database constraint: refunds against an operation cannot exceed its amount.
07. One transaction, three different figures
A rate as of exactly when
Symptom: Last quarter's report built today differs from the same report built a month ago. All three versions are вАЬcorrectвАЭ.
The rate moves between a payment being created, sent and confirmed. If the system takes the rate at the moment the report is built, past transactions change value retroactively, and the books stop being reproducible.
What catches it: The rate is fixed with the posting and stored inside it: not a reference to a table, but the value itself and the instant it was taken. A report for a past period, built today and a year from now, must produce identical numbers. The same requirement is what makes an audit possible.
08. Yesterday's export names the source differently
A reference table edited retroactively
Symptom: Two exports covering the same period call the same partner by different names. Or a tariff changed and past fees recalculated themselves.
The posting references a table, the table changes, history changes with it. The quietest problem on this list: nothing breaks, the past simply stops holding still.
What catches it: Everything that affects a calculation is captured in the posting at the moment it's created: the tariff rate, the version of the rules, the name of the source. The label can change as often as you like; it's for humans. The key that totals reconcile against never changes, and those are two different fields. This is the only entry on this page the checker doesn't implement: seeing that a row was rewritten needs both the old and the new state of the table, and given both the detector is a diff of two files. This one is prevented by how the data is stored, not caught afterwards.
09. A confirmed deposit disappeared
Finality, not вАЬconfirmationsвАЭ
Symptom: A deposit was credited and an hour later it isn't on the network. The customer's balance already grew and the money is already spent. Or the reverse: the transaction is visible and never makes it into a block.
Four networks run three different models of finality and four different thresholds, and no single number covers them. In Bitcoin finality is probabilistic; at zero confirmations the transaction is also replaceable. In Ethereum finality is crypto-economic: before finalized a reorg across a few slots is possible. In Tron and TON finality rests on validator agreement, and waiting ten more blocks doesn't fix a rollback past the irreversibility boundary.
What catches it: The threshold comes from the network's model, not from a round number: in Bitcoin a count of confirmations, in Ethereum the finalized tag, in Tron block irreversibility, in TON the masterchain signature. Below the threshold the deposit sits on a separate pending account and shows to the customer as waiting. A credit is reversed with an opposing posting, never by deleting.
10. One transaction is not one operation
Batches, sweeps and asynchronous transfers
Symptom: The chain watcher saw one transaction and created one posting; there were two hundred recipients inside it. Or the reverse: one payout spawned a chain of messages and part of it bounced back.
The one-transaction-one-operation assumption breaks in three places at once. A batch with many outputs is one transaction and many payments. Moving funds to a central wallet is a network movement with no customer operation behind it. And on TON a token transfer is a chain of messages, any of which can bounce midway.
What catches it: The relationship between operations and transactions is many-to-many, not one-to-one, and that's in the schema from the start. Internal movements carry their own type and never pose as customer activity. For asynchronous transfers, success means a confirmed credit on the recipient's side, not a successful send. Until then the operation stays open.
11. Reconciliation fails though every entry is right
Internal movements missing from the equation
Symptom: вАЬSum of customer balances equals the on-chain holdingвАЭ never holds, and the gap is different every day.
Not everything that moves money is in the equation. Transfers between customers inside the system never touch the network. But sweeps to the central wallet do, and so does topping addresses up with gas, and so do the fees themselves, and none of those have a customer posting.
What catches it: Postings carry a type, and reconciliation is built on the full equation: the change in on-chain holdings equals external inflows minus external outflows, minus fees, minus gas movements, with internal transfers excluded entirely, though they must sum to zero across the system. Each term is computed separately, so a discrepancy points at where to look.
12. The cost of a payout changed after sending
The actual fee is not the estimated one
Symptom: The cost of a payout in the report doesn't match what was shown at dispatch. Sometimes by a lot.
The fee at send time is an estimate, not a fact. A transaction can stall and be accelerated, and then the cost of an already-sent payout changes retroactively. On networks with dynamic gas pricing, a gap between expected and actual is ordinary.
What catches it: Estimated and actual fees are two separate fields, and the second is filled from the network after confirmation. The difference isn't hidden: it becomes its own posting, and that posting is what shows the real cost of running payouts. Accelerating a transaction is an event with its own record.
13. The balance exceeds what can be withdrawn
Dust, minimum reserves and freezes
Symptom: Total system balance is larger than what can actually be sent. The difference matches no single transaction.
Three legitimate causes. Remainders smaller than the network fee cost more to move than they're worth. Some networks require a minimum reserve on an account. And a stablecoin issuer can freeze an address. The funds are there and can't be moved, and that's neither your mistake nor your choice.
What catches it: Withdrawable balance is a separate quantity, not a synonym for balance. Both appear side by side in the report, and every part of the gap has a name: dust, reserve, frozen. A freeze is recorded as a restriction on the account, not as funds vanishing. A customer needs an explanation, not a quietly smaller number.
14. The payout went out and cost nothing
A fee paid in a different asset
Symptom: The report shows a payout with zero cost. Or an asset was debited that has no business being in this operation.
A fee doesn't have to be paid in the asset being transferred. It can come out of the network's base coin, be covered by a resource rented in advance, or be paid by a third party entirely. Books where an operation has exactly one currency can't record that cost and lose it silently.
What catches it: The fee is its own posting with its own currency and network, linked to the operation but not part of its amount. A resource bought in advance is accounted for as inventory and drawn down as it's consumed. The total cost of a payout is the sum of postings across all currencies, converted at the rates already fixed.
None of them is about arithmetic
All of them are about a system that at some point could not answer вАЬwhere did this figure come fromвАЭ вАФ because it never stored what the answer required.
An amount with its currency and network. A fee separate from the amount, and estimated separate from actual. A rate with the instant it was taken. A tariff with its version. A posting with its type and a link to its source. Anything computed on the fly from the current state of a reference table will, sooner or later, disagree with what was printed yesterday. Your payment provider won't do this for you, and shouldn't. Its job is to move money. The books are yours.
Everything above says how to build a system that doesn't drift. ledger-reconcile answers the other question: whether yours already has. It takes what your ledger says happened and what your provider recorded, and reports where the two stop agreeing. It doesn't implement the fixes above: it finds out which of them you're missing.
Every finding names the entry on this page that explains it, carries the records that produced it, and writes out the arithmetic that failed. It also reports what it did not check: a run lists every check it couldn't perform and which input was missing, because a tool that quietly performed four checks out of twelve and printed вАЬno findingsвАЭ would be worse than no tool.
Originally published at jeffreyjorgensen.dev/teardown. The library that checks for these is MIT-licensed: ledger-reconcile.
Top comments (0)