Insights30. Mai 20269 min read

The credit ledger pattern: how to do SaaS billing without lying about money.

Why your SaaS billing should be an append-only ledger from day one — and what we ship in Imagika's `credit_ledger` schema that makes refunds, races, and audits trivially correct.

A ledger schema diagram showing append-only entries — award, deduct, refund — with a derived current_credits cache on the user record, the entries glowing as the source of truth.

Most credit-based SaaS products store a balance on the user record and mutate it. That's the wrong primitive. Here's the pattern we ship in Imagika — and why an append-only ledger turns refunds, races, and audits from emergencies into queries.

Imagika is an AI image-generation SaaS we ship at Synara. Users buy credit packs, spend credits to generate images, occasionally get refunded credits when a generation fails. Standard credit-based monetization. The kind of thing every team builds in week two and regrets in month three.

The first version of any credit system always looks like this. Add a `credits` column to the user table. On purchase, increment it. On spend, decrement it. On refund, increment it again. Ship.

It works until any of these things happens. A webhook fires twice and credits get double-added. A user buys credits and immediately generates an image; one race, no money. A finance person asks "why does this user have 47 credits when they bought 50 and used 2." A refund is processed but the user already used the credits being refunded. Each is a separate fire. Each gets handled separately. The codebase fills with `select for update`s and prayer.

The pattern that ends this is the append-only credit ledger.

The ledger as the source of truth

In Imagika's schema, `credit_ledger` is an immutable table. Every credit event — purchase, spend, refund, manual adjustment — is an entry. Entries have a type, an amount, a reason, a reference (to the purchase or generation or refund that caused them), and a timestamp. Entries are never updated. They are never deleted.

The user record carries a `current_credits` field, but that field is a derived cache, not the source of truth. The source of truth is the sum of entries. The cache exists only to avoid summing a million entries on every request.

This sounds like over-engineering until you've shipped without it. The minute you have an immutable ledger, four things become easy that were hard. Reconciliation: "does the cached balance match the sum of entries" is a one-line query. Refunds: "refund the entry that caused this charge" is one insert. Auditing: "why does this user have 47 credits" is a `select * from credit_ledger where user_id = $1 order by created_at`. Idempotency: every entry has an `external_reference` column; a duplicate webhook is a constraint violation, not a phantom credit.

Why mutable balances rot

A mutable balance has no answer to "what was this user's balance last Tuesday." The information is gone. You can reconstruct it if you have transaction logs, but reconstructing is exactly what a ledger does for free.

A mutable balance also makes refunds dangerous. You add the refund amount back. Now the balance is higher. But what if the user already used the original credits before the refund was processed? You've now over-credited them. The system has no way to know — there's no record of what the balance should be at any given moment, only what it is right now.

A mutable balance is, in the end, a balance that the system has thrown away the audit trail of. Every problem you have with a credit system reduces to wishing you hadn't thrown the audit trail away.

A timeline of credit entries — purchase 100, spend 5, spend 3, refund 5 (referencing the prior spend), spend 10 — with a current_credits cache box showing 87, and the sum of entries also showing 87.
Source of truth: the entries. The cache exists for performance, not correctness.

How a refund actually works

Imagika ships refunds via Dodo Payments. When a refund webhook arrives, the handler does three things. It validates the webhook signature using `standardwebhooks`. It looks up the original purchase entry by its external reference. And it inserts a new ledger entry of type `refund`, with a negative amount equal to the original purchase, referencing the original entry by ID.

Notice what didn't happen. We didn't decrement the user's balance. We inserted an entry. The derived cache will be updated by the next read or the next batch refresh, but the truth of the refund is already recorded the moment the entry inserts.

If the user had already spent some of the credits being refunded, the entries still record the truth. The current balance might go negative briefly. That's fine — "this user owes the platform 3 credits" is a real fact, and it surfaces in the UI as a clear state, not a corrupted balance.

Idempotency for free

The `external_reference` column on the ledger entries has a unique constraint. Every entry that comes from an external event (a Stripe charge, a Dodo refund, a webhook of any kind) carries the external event's ID. Insert a duplicate, get a constraint violation, return 200 to the webhook sender. They retry; you don't double-credit.

This is the simplest, cleanest idempotency story I know for billing webhooks. The database enforces it. The application code doesn't have to remember to.

A mutable balance is a balance the system has thrown the audit trail away for. Every problem with a credit system reduces to wishing you hadn't.

On choosing the ledger primitive

What we'd start with on day one

Three things. Append-only `credit_ledger` table from minute one. Derived `current_credits` cache on the user, with a clear comment that says "derived; do not write directly." Unique constraint on the external-reference column to make webhook idempotency free.

And — if you ever find yourself writing `UPDATE users SET credits = credits + ...`, stop. That code is the bug. The credits live in the ledger.

Money requires immutability. The ledger is the primitive. Everything else is derived.

Imagika runs this pattern in production. We can scope a credit-based monetization layer — append-only ledger, refund handling, webhook idempotency, reconciliation reporting — for any SaaS product whose billing has started to lie.