Implementing Prepaid Credit-Based Billing

Credit-based billing sells a quantity up front — 10,000 API calls, 500 render minutes, a pool of tokens — and consumes it as the customer works. It is popular because it decouples payment from usage and gives customers a hard spending bound, and it is deceptively difficult because the balance is a shared mutable resource under concurrent access, with money attached. This guide sits under Hybrid Pricing Models for SaaS and covers the mechanics.

Two failure modes define the design. The first is overdraw: two requests each check a balance of one credit, both proceed, and the customer consumes two credits they did not buy. The second is drift: a balance stored as a mutable integer diverges from the sum of what was actually bought and used, and nobody can say when or why.

Both are solved by the same structure — an append-only log with a conditional write — which is the same approach the double-entry billing ledger applies to money, for the same reasons.

Trade-offs

Balance design Overdraw safe Auditable Read cost Write cost
Mutable integer column Only with a lock No history Trivial Row-lock contention
Append-only log, summed Yes, with a guard Full Grows with history Append only
Log plus cached balance Yes Full Trivial Append plus cache write
Provider-held balance Provider-defined Provider-defined API call API call
Credit balance representations A mutable counter loses the reason for its value and serialises on a row lock, while an append-only log preserves every purchase and consumption. Mutable counter balance = 412 why? nobody knows contention on one row Append-only log purchases + consumptions balance is derived every unit traceable
Credits are money in a different unit — the same auditability standard applies.

The cached-balance row is where most products land: the log is the truth, a materialised balance serves reads, and the cache is recomputable at any time. The important discipline is that the cache is never the thing a debit is checked against without a conditional write behind it.

Step-by-Step Implementation

1. Model purchases and consumption as movements

CREATE TABLE credit_movements (
  movement_id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id    UUID NOT NULL,
  tranche_id    UUID,                       -- which purchase this consumes from
  kind          TEXT NOT NULL,              -- 'purchase' | 'consume' | 'expire' | 'refund'
  amount        NUMERIC NOT NULL,           -- positive credits in, negative out
  reference     TEXT,                       -- request id, invoice id
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  idempotency_key TEXT UNIQUE NOT NULL
);

CREATE TABLE credit_tranches (
  tranche_id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id    UUID NOT NULL,
  purchased     NUMERIC NOT NULL,
  expires_at    TIMESTAMPTZ,
  purchased_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

Tranches exist because credits usually expire, and expiry is per purchase rather than per balance. Without tranches, “500 credits expiring in March and 300 in June” cannot be represented.

2. Debit atomically

-- Consume only if the balance covers it, in one statement.
WITH bal AS (
  SELECT coalesce(sum(amount), 0) AS available
    FROM credit_movements WHERE account_id = $1
)
INSERT INTO credit_movements (account_id, kind, amount, reference, idempotency_key)
SELECT $1, 'consume', -$2, $3, $4
  FROM bal WHERE bal.available >= $2
RETURNING movement_id;

Zero rows means insufficient credit. Under concurrency this still needs isolation — either a serialising lock on the account or SERIALIZABLE with retry — because two transactions can otherwise both read the same balance. Lock per account, never per table.

3. Consume tranches in order

Tranche consumption order Consumption draws from the tranche expiring soonest, then the next, so credits are used before they lapse. Tranche A · 500 expires March Tranche B · 300 expires June Consume 600 soonest expiry first A → 0 fully used B → 200 remainder
Soonest-expiry-first is the customer-friendly order and the one that minimises forfeited credit.

4. Automate top-ups with a cooldown

def maybe_topup(account, balance, settings) -> bool:
    if not settings.auto_topup_enabled or balance > settings.threshold:
        return False
    if account.last_topup_at and now() - account.last_topup_at < settings.cooldown:
        return False                      # prevents a runaway charge loop
    charge_and_grant(account, settings.topup_amount,
                     idempotency_key=f"topup:{account.id}:{balance_bucket(balance)}")
    return True

The cooldown is not optional. A consumption burst that drops the balance below the threshold repeatedly can otherwise trigger several top-up charges within seconds, and the customer sees three identical charges before anyone notices.

5. Expire by tranche

INSERT INTO credit_movements (account_id, tranche_id, kind, amount, idempotency_key)
SELECT t.account_id, t.tranche_id, 'expire', -remaining(t.tranche_id),
       'expire:' || t.tranche_id
  FROM credit_tranches t
 WHERE t.expires_at <= now()
   AND remaining(t.tranche_id) > 0
ON CONFLICT (idempotency_key) DO NOTHING;

Accounting, Expiry Policy, and the Customer’s View

Credits are cash received for a service not yet delivered, which makes them a liability rather than revenue at the moment of purchase. Revenue is recognised as credits are consumed, and unconsumed credits sit in deferred revenue until they are either used or expire.

That makes expiry a revenue event. When a tranche lapses unused, the obligation to deliver disappears, and the amount is generally recognised as breakage revenue at that point. It is real revenue with an unusual character, and finance teams typically want it in its own account so it does not blend into ordinary consumption. Recording expiry as an explicit movement kind is what makes that split automatic rather than a manual journal entry, and it keeps the MRR-to-revenue bridge explainable.

Expiry policy is also a commercial and sometimes regulatory question. Some jurisdictions restrict the expiry of prepaid value, and consumer-facing products face more constraints than business ones. Whatever the policy, it must be stated at purchase, shown on the balance screen, and warned about before it takes effect. A customer who loses 400 credits without notice will ask for them back, and the support cost of that conversation typically exceeds the value forfeited.

The customer’s view deserves as much design as the ledger. Show the total balance, the breakdown by tranche with expiry dates, and the recent consumption with what caused it. The last of those is what turns “my credits disappeared” into “I ran a large batch on Tuesday”, and it is the single most effective support-deflection feature in a credit product.

Finally, decide what happens to credits on cancellation. Refunding unconsumed credits is the cleanest answer and the most expensive; forfeiting them is the cheapest and the most disputed. A middle path — credits remain valid for a defined window after cancellation, then expire — satisfies most customers and is simple to implement because it is just a tranche expiry date.

Credits from liability to revenue A credit purchase is recorded as deferred revenue, consumption releases it into revenue, and expiry releases the remainder as breakage. Purchase deferred revenue Consumption revenue recognised Expiry breakage revenue Liability cleared balance to zero
Two routes out of the liability, and the lower one is real revenue with a distinct character that finance wants reported separately.

Reserving Credits for Long-Running Work

Products that sell credits usually have at least one operation that takes minutes rather than milliseconds — a large render, a batch job, a long-running analysis. Debiting at the start over-charges when the job fails; debiting at the end allows a customer to start more work than they can pay for. Reservation is the resolution.

A reservation is a movement that reduces the available balance without being a final consumption. The job starts, a reservation for the estimated cost is written, and on completion the reservation is settled at the actual cost — released entirely on failure, converted to a consumption on success, and adjusted if the actual cost differs from the estimate.

-- Available balance excludes open reservations.
SELECT coalesce(sum(amount), 0)
     - coalesce((SELECT sum(reserved) FROM credit_reservations
                  WHERE account_id = $1 AND settled_at IS NULL), 0) AS available
  FROM credit_movements WHERE account_id = $1;

Reservations need a timeout for the same reason seat reservations do: a crashed worker leaves the customer’s credits locked with nothing to release them. Set an expiry generous enough to cover the slowest legitimate job, and sweep expired reservations back into the available balance with a recorded reason so the release is visible rather than mysterious.

Estimate conservatively. A reservation smaller than the eventual cost lets a customer start work they cannot fund, and the choice at settlement is then between a negative balance and an incomplete job — both bad. Over-reserving slightly and refunding the difference at settlement is the safer asymmetry, provided the available balance shown to the customer explains that a job is holding credits.

Verification & Testing

The concurrency test is the one that matters: with a balance of one credit, fire fifty simultaneous consumption requests and assert exactly one succeeds and the balance is zero, never negative. Run it against a real database, because the property under test is isolation behaviour rather than application logic.

Test idempotency by replaying the same consumption key and asserting one movement. Then test out-of-order application by inserting movements in a shuffled order and asserting the derived balance is identical, which proves nothing depends on a running total.

Test tranche selection and expiry at the boundary: consumption that spans two tranches must exhaust the earlier-expiring one first, and a tranche expiring at midnight must be consumable one second before and expired one second after. Assert the expiry job is idempotent by running it twice.

Gotchas & Production Pitfalls

  • Checking the cached balance without a conditional write. Reintroduces overdraw exactly when traffic is highest.
  • Top-up without a cooldown. Produces repeated charges during a consumption burst, which reads to the customer as a billing bug.
  • Expiry applied to the balance rather than a tranche. Makes “500 expiring in March” inexpressible and produces the wrong forfeiture.
  • Refunds that do not remove credits. Refunding a purchase without reversing the granted credits gives the customer both the money and the balance.
  • Credits treated as revenue at purchase. Overstates revenue and creates a restatement when the accounting is reviewed.

Frequently Asked Questions

Should credits be a currency or a unit? A unit tied to a meter is usually clearer — “render minutes” rather than “credits worth $0.02” — because it removes the question of what a credit is worth when prices change. Keep the conversion in the price, not in the balance.

How do credits interact with a subscription? They coexist well: a plan grants a monthly allowance and purchased credits top it up. Consume the allowance first — it expires at period end anyway — then the purchased tranches.

Can credits go negative? They should not. If a consumption cannot be reversed once started, reserve the credit before the work begins and settle the reservation afterwards, rather than allowing a negative balance.

What about volume discounts on credit purchases? Price the tranche, not the credit. A 10,000-credit pack costing less per credit is a pricing decision recorded on the purchase; the balance itself stays a plain count.