Asia/Kolkata
A Credit System That Survives Production: Idempotency, Ledgers, and Honest Refunds
BlogMarch 25, 2026 · 6 min read

A Credit System That Survives Production: Idempotency, Ledgers, and Honest Refunds

Ayush Mania
A Credit System That Survives Production: Idempotency, Ledgers, and Honest Refunds
A credit system has three jobs: charge users for actions, refund them when those actions fail, and never charge twice. The first one is easy. The other two are where credit-system bugs live. The version we shipped at Saral has a small surface - three tables, a handful of invariants - and survives the failure modes that real users find. This post is how we got there and what to warn anyone else about.
Sql
credit_accounts   -- one row per account: balance, tier, limits
credit_ledger     -- append-only: every charge, refund, grant
credit_cache      -- idempotency keys: (account, action, target)
The whole design comes from one rule: the ledger is the source of truth, and it is append-only. credit_accounts.balance is denormalized from the ledger for fast reads. The ledger is SELECT SUM(amount) FROM credit_ledger WHERE account_id = ? and is the answer if anything ever disagrees. You will, eventually, write a reconciliation job that confirms the two match. Until then you trust the ledger. credit_cache exists so the same action against the same target charges once. It is the part that does not show up in the obvious version of the design and is the part that does most of the work.
Every billable action has a natural key: who is doing it, what they are doing, and what they are doing it to.
Action
Idempotency tuple
Search reveals a candidate(accountId, search.reveal, candidateId)
Email unlock(accountId, unlock.email, candidateId)
Phone unlock(accountId, unlock.phone, candidateId)
AI message draft(accountId, ai.draft, candidateId)
The first time a tuple is inserted into credit_cache, the action is charged. Every subsequent attempt against the same tuple is free. The user paid for that candidate's email once; they do not pay again because they reloaded the page or revisited the search next week. The insertion is the charge. It happens inside the same transaction as the ledger write.
Sql
BEGIN;
  INSERT INTO credit_cache (account_id, action, target_id, ledger_id)
    VALUES ($1, $2, $3, $4)
    ON CONFLICT (account_id, action, target_id) DO NOTHING
    RETURNING ledger_id;

  -- if the insert returned a row, we're charging fresh.
  -- if not, the action was already paid for; no ledger entry.
COMMIT;
If the insert wins the race, you write the ledger entry and the deduction lands. If it loses, no charge, no ledger entry, the action proceeds. Two concurrent requests for the same target end with one charge; that is the contract.
Saral's search returns up to a few hundred candidates. The naive billing model is to charge for the search and let the recruiter scroll. We chose the opposite: charge per page reveal, with the page's candidates as the targets in the idempotency tuples. The reason is alignment. Recruiters who run a query and find their hire on page one should not be paying for the candidates they never looked at. Recruiters who page deep are getting that value, and the bill matches. The implementation is the same as any other action - a page is a target, the page's candidate set is the unit of work, the cache prevents double-charging when a recruiter revisits an old search. The pricing model and the engineering model land in the same place.
The temptation when a backend call fails is to "undo the charge." The ledger does not support undo. The original deduction stays in the ledger, marked as it was. The refund is a new positive entry, marked rolled_back, with a reference to the deduction it offsets.
ledger 8420: -3 credits, action="unlock.email", target=cand_91, status="committed"
ledger 8421: +3 credits, action="unlock.email", target=cand_91, status="rolled_back",
             refunds_id=8420
Two reasons this matters.
  • Audit trail tells the truth. The user was charged, then refunded, and both events happened. Editing or deleting the original entry silently breaks any downstream report or invoice that already read it.
  • Cache row comes out too. Otherwise the next attempt at the same action will short-circuit on the idempotency check and never re-charge. The rollback is two writes - a positive ledger entry and a delete from credit_cache - inside one transaction.
Before any billable action, the server checks the balance. If credits are short, return HTTP 402 with the shortfall.
Ts
// POST /sequences/:id/candidates  body: { candidateIds: [...] }

const required = candidateIds.length * UNLOCK_COST;
if (account.balance < required) {
  return reply.code(402).send({
    available: account.balance,
    required,
    shortfall: required - account.balance,
  });
}
This is the boring part of the design and the part that prevents the bad version of every other part. Without it, you write code that "tries" to do an action and rolls back on insufficient funds. That code has more failure modes than it has happy paths. With it, every action that gets to the worker has the credits to complete.
Every entry has the same minimum shape.
  • account_id
  • amount - signed; charges are negative, grants and refunds are positive
  • action - search.reveal, unlock.email, plan.grant, admin.adjust
  • target_id - nullable for grants and adjustments
  • status - committed, rolled_back, pending
  • created_at
  • metadata jsonb
That is it. No "type" enum that overlaps with action. No "balance after" cached on the row. Anything you would want to compute is downstream of the entries. The ledger is boring on purpose.
A few rules that would have saved a week.
  • Put the cache insert inside the same transaction as the ledger write. If they can drift, they will.
  • Refund forward. Always. Do not edit. Do not delete. The audit log is what you will wish you had when the support email comes in.
  • Check the balance before you start the work, not after. The number of failure modes drops by half.
  • Keep action strings flat. Hierarchies tempt you, then they fight you. unlock.email and unlock.phone is fine; nested category tables is a regret.
  • Reconcile in CI. A nightly job that walks the ledger and confirms account.balance matches catches a class of bug once and forever.
A credit system is not where you should be inventing. The patterns are old, the failure modes are well-known, and the boring version is the one that survives.
Share this post: