Why Payments Should Be a Ledger, Not a Balance Column

A single 'balance' column looks simple until two requests hit it at once, or a customer disputes a charge and asks where the number came from. Here's why credits, refunds and prepaid balances belong in a ledger.

Why Payments Should Be a Ledger, Not a Balance Column

I've been looking at a few membership and credit-based systems recently, the kind where members top up a balance, spend it on bookings or products, get the odd refund, and occasionally an admin needs to nudge the number for a goodwill gesture. Almost every one of these starts the same way: a balance column on the customer record. An integer or decimal that goes up when money comes in and down when it goes out.

It works fine in a demo. It's the first thing that causes real problems once actual customers are hitting it.

The balance column is the easy way, right until it isn't

A single number is tempting because it's simple to read and simple to write. Customer tops up £20, you run balance = balance + 20. They book something for £15, you run balance = balance - 15. Job done, apparently.

The trouble is that a balance column only tells you where you are. It doesn't tell you how you got there. And in payments, "how you got there" is exactly what you need the moment anything goes wrong, which in my experience is not an edge case, it's a weekly occurrence once you have real users.

What actually breaks

A few things I've seen cause real damage in systems built around a single mutable balance:

  • Race conditions. Two requests hit the balance at almost the same moment, both read the same starting value, both write their own result, and one update silently disappears. On a busy booking system or a rewards app with a mobile client that retries requests, this isn't rare.
  • No way to answer "why is my balance wrong?". A customer emails saying their credit is £12 short. If all you've stored is the current number, you have nothing to check it against. If every change is its own row, you can show them exactly what happened and when.
  • Refunds that don't reverse cleanly. A refund isn't just "balance minus the original amount". It might need to reverse a discount, leave a reward point in place, or partially refund a bundled purchase. A single number can't hold that nuance, so you end up hacking around it with extra flags and special cases.
  • Reconciliation against Stripe (or whatever processor you use) becomes guesswork. Stripe gives you a stream of events: charges, refunds, disputes, payouts. If your own system only has a running total, you can't match your numbers against theirs line by line. You can only compare two totals and hope they agree.
  • Manual adjustments have no audit trail. An admin gives someone £5 credit for a bad experience. Six months later, nobody can explain why that customer's balance doesn't match what a spreadsheet says it should.

None of these are theoretical. They're the kind of thing that shows up as a support ticket, then an awkward phone call, then a much bigger job untangling historic data because nobody can trust the current numbers.

What a ledger actually looks like

The fix isn't complicated. Instead of one mutable number, you keep an append-only table of entries: every top-up, every spend, every refund, every manual adjustment, each as its own row with a type, an amount, a reference (which order, which Stripe payment intent, which admin made the change), and a timestamp. The balance is never written to directly. It's calculated by summing the entries, or cached and recalculated whenever something changes.

This is basically double-entry bookkeeping, just applied to a customer wallet instead of a company set of accounts. Every entry has a clear cause. Nothing is ever overwritten. If something's wrong, you don't guess, you look at the rows.

A few practical points that matter more than they sound:

  • Never delete or edit an entry. If a top-up was recorded wrongly, you add a correcting entry, you don't touch the original. That's what keeps the history honest.
  • Cache the running balance, but treat it as derived data. Recalculating a sum over thousands of rows on every page load is wasteful, so store the current balance too, but always be able to rebuild it from the ledger if the cache ever looks wrong. The ledger is the source of truth, the cached number is just a shortcut.
  • Reference external IDs properly. Every entry tied to a payment should carry the Stripe (or PayPal, or GoCardless) reference. That's what makes automatic reconciliation possible instead of manual cross-checking in a spreadsheet at month end.
  • Make manual adjustments a first-class entry type, not a hack. If admins need to add or remove credit by hand, give that its own entry type with a required reason field. It's the difference between an audit trail and a mystery.

This is the pattern I used when building Patch, a booking and payments app for trades. Deposits, part-payments, refunds and recurring job charges all needed to be traceable individually, not just netted off into a single figure, because tradespeople and their customers both needed to see exactly what happened to their money, not just where it currently stands.

When a simple balance is genuinely fine

I'll be honest, not every system needs this from day one. If you're running an internal points scheme with no real money involved, no refunds, and no regulatory pressure, a plain balance column might be all you need for a while. The moment real payments, refunds, or any kind of dispute enter the picture, though, the ledger approach earns its keep quickly, and it's a lot easier to build it in from the start than to retrofit it once your balance numbers are already slightly wrong and nobody can say by how much.

If you're scoping a membership, credits or subscription system and want to get the payment model right before you write the first line of code, that's exactly the kind of thing worth working through on a call rather than discovering the hard way. You can get in touch and I'll talk you through what a sensible ledger structure would look like for your specific case.