Your internal record of what customers paid and your provider's record of what they settled will not match. That is normal. What matters is whether you find out from a scheduled job that expects it, or from an accountant six months later who cannot close the books.
Six reasons, all structural, none of them a bug.
Timing. An authorisation is not a capture and a capture is not a settlement. Money leaves the customer, sits with the acquirer, and lands in your account days later. Any snapshot taken between those events shows a difference that is entirely correct.
Fees come out of settlement, not the transaction. You charged £100. You will receive something less than £100, and the deduction may be per-transaction, batched daily, or invoiced monthly depending on the provider. If your ledger records £100 and your bank shows £98.20, both are right and neither reconciles without modelling the fee explicitly.
Refunds settle on their own schedule, often netted against other activity rather than appearing as a discrete outbound movement.
Chargebacks arrive late. Weeks, sometimes months, after the original payment, with their own fee, and they can be reversed again if you win the dispute. A chargeback reaches back into a period you have already closed.
Currency conversion. If you charge in one currency and settle in another, the rate applied is the provider's at settlement time, not yours at transaction time. That difference is real money and it belongs somewhere in your accounts.
Partial captures and partial refunds break any assumption that one payment equals one amount.
Almost every system starts here: a balance column on the
user, or a paid boolean on the order, updated in place as
things happen.
It fails the first time something arrives out of order, and it fails silently. A chargeback lands and decrements a balance. Now the number is right but there is no record of why it changed, no way to reconstruct the balance at any past date, and no way to answer the question an accountant will eventually ask: show me every movement that produced this figure.
The deeper problem is that a mutable balance has no history. When it is wrong - and it will be - you cannot tell when it became wrong.
The fix is a ledger, and it is much simpler than accounting terminology makes it sound. Three rules:
Fees, chargebacks, FX differences and refunds all become ordinary entries between named accounts. Nothing needs special handling because everything is the same shape.
payment #4471 £100.00 captured customer:4471 +100.00 cash:acquirer -100.00 settlement, two days later cash:acquirer +98.20 expense:psp_fees +1.80 cash:bank -100.00
If the sum of entries is not zero, you have a bug - and you find out immediately, from an invariant, rather than from a quarterly close.
Every provider retries. If your endpoint times out, returns a 500, or simply takes too long behind a slow write, the same event is delivered again. Some retry for days. Most guarantee nothing about ordering, so a failure notification can land after the success it preceded.
The usual defence is a flag: check whether this event has been processed, then process it. Under concurrency the check and the write are two separate operations, and two deliveries arriving together will both pass the check before either writes. You have doubled a credit, and the code still reads correctly on the page.
Carry an idempotency key from the originating event onto the ledger entry itself, and put a unique constraint on it. The second delivery is then rejected by the database rather than by application logic. That is the difference between a system that usually does not double-credit and one that cannot.
entries id account amount idempotency_key 1 customer:4471 +100.00 evt_1K2m9x:captured 2 cash:acquirer -100.00 evt_1K2m9x:captured unique index on (idempotency_key, account)
Retries become free after that. The endpoint can be dumb, fast and idempotent, which is also what the provider's own timeout logic wants from you.
Harder than duplication: the provider reports success on a payment you have already recorded as failed. A timeout at the acquirer, a delayed authorisation, a retry that arrives long after you gave up.
The temptation is to reconcile it. The money did arrive, so credit the customer and move on. Do not. You now have a system in which a terminal state is not terminal, and every downstream assumption about refunds, limits and payouts rests on that.
Refuse the transition instead, then do the four things that make refusing safe:
The failure mode is now a delayed credit rather than a balance the ledger cannot back. A delayed credit is something a person fixes in ten minutes. An unbacked balance is found at a quarterly close, if it is found at all.
With a ledger in place, reconciliation stops being an investigation and becomes a job that runs nightly:
That is the external reconciliation, against the provider. There is a cheaper internal one worth running far more often: sum every entry, sum every cached balance, and compare the two. It is two queries, and it catches a class of bug the external job never sees, because a ledger can be internally inconsistent while matching the provider perfectly.
The important word is unmatched. A reconciliation that expects the numbers to be equal will alert constantly and be ignored within a fortnight. One that expects specific differences and flags only the unexplained ones gets read.
A reconciliation job that cries wolf is worse than none, because it teaches the team that the alert means nothing.
Do not fix them by editing the ledger. That reintroduces exactly the mutability the design exists to prevent.
Post a correcting pair of entries against a suspense account, and keep the original. The suspense balance becomes your open-questions number - a figure someone can look at weekly and work down. It should trend to zero. When it does not, that is a signal worth having rather than a discrepancy buried inside an aggregate.
None of this is exotic. It is standard practice in finance and unusual in application code, mostly because a balance column works fine right up until the first chargeback.
The cost of adding a ledger to a live system that never had one is substantially higher than building it in - you are reconstructing history from transaction logs that were never designed to support it. If you are handling money and you have a mutable balance somewhere, that is the thing to fix first.
We design payment ledgers and reconciliation, including retrofitting them into systems that started with a balance column. If yours does not currently reconcile, we can tell you why.