Skip to Content
The production modelCredits are a ledger, not a counter

Credits are a ledger, not a counter

Applies to The Fabrica v0.1.2. Documentation source: 1ee1bc9cb661, including post-release documentation corrections.

The competitor pattern

Other AI SaaS boilerplates ship something like this:

class User(Base): has_access: bool = False credits_remaining: int = 0
# In the LLM endpoint: if not user.has_access and user.credits_remaining < 1: raise HTTPException(402, "Out of credits") user.credits_remaining -= 1 session.commit()

That works until the first time something interesting happens. Concretely:

Failure modeWhat goes wrong
Two concurrent requestsBoth read credits_remaining=1, both decrement to 0, both succeed. User burned 2 generations for 1 credit.
Worker crash mid-taskCredit deducted, no work done, no refund — the user paid, you didn’t deliver. They dispute. You eat the chargeback.
Celery retry on transient failureTask body runs twice, credit deducted once because the API handler already committed. User paid 1×, got 2 generations.
Refund dispute“I never used the 87 credits you charged me for last month.” You have one column, no audit trail. Paddle sides with the user. You eat 87 credits.

What we ship instead

credit_transactions is an append-only ledger. Every credit movement is one INSERT — never an UPDATE.

CREATE TABLE credit_transactions ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, delta INT NOT NULL, -- -1 for deduct, +1 for refund, 0 for lifecycle kind VARCHAR(32) NOT NULL, -- deduct | refund | grant | task_started | task_completed | task_failed job_id VARCHAR(128) NOT NULL, -- Celery job id (or minted UUID for one-shots) reason VARCHAR(255) NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (job_id, kind) );

Balance is SELECT SUM(delta) FROM credit_transactions WHERE user_id = $1.

Two mechanisms, two different jobs

Conflating these is the mistake worth avoiding, because each one leaves exactly the gap the other closes. There are no Postgres advisory locks anywhere in the ledger, and no SAVEPOINT.

UNIQUE (job_id, kind) makes one logical operation idempotent. A given job produces at most one row of each kind, so a broker redelivery of the same attempt cannot insert a second deduct. It does nothing for two different jobs racing: each would read the same pre-deduct balance and both could pass the check.

A FOR UPDATE row lock on the owning user makes the balance check atomic. deduct_credits takes it before reading the balance and holds it to the end of the transaction, so check-then-insert cannot interleave. Concurrent spends on one account serialise; spends on different accounts do not contend. Without it, two deducts with different job_ids each take their READ COMMITTED snapshot before either commits, both see enough balance, and the account goes negative.

The idempotency key has a sharp edge, and it is a design consequence rather than a bug: reusing a job_id for a genuinely new chargeable attempt returns the original movement and charges nothing. Mint a fresh job_id per chargeable attempt; keep it stable only across transparent redeliveries of the same attempt.

What each failure mode actually hits

  • Two requests, same job_id → the second INSERT trips IntegrityError; the caller rolls back, re-reads and returns the row that won. No double-spend.
  • Two requests, different job_ids → the row lock serialises them. The second reads the balance the first committed and raises InsufficientCreditsError if it no longer covers the spend.
  • Worker crash after deduct → the ledger has a deduct row with no matching task_started. The reconciler runs every 60 seconds and refunds any deduct that has stayed orphaned for ORPHAN_THRESHOLD_S (5 minutes), inserting a reconciler_refund row.
  • Celery retryrecord_task_started(job_id) is idempotent via the same UNIQUE constraint. Retry-safe.
  • Refund dispute → every movement has a timestamp, a reason and a linked job_id. Answering is a query, not an investigation.

Trade-offs (we are honest)

This is more code than the has_access pattern. Specifically:

  • workers/credit_ledger.py is a module with a spend path, a compensation path and the lifecycle markers, against ~20 lines for the simple approach.
  • Reconciler is a separate Celery beat task running every 60 seconds.
  • Balance read is SUM() over a table that grows ~3 rows per generation. Add an index, accept the cost.

One alternative approach uses the same append-only ledger with a users.credits_remaining integer column kept in sync via conditional UPDATE. That’s a third valid design — slightly simpler at the cost of a denormalised column that can drift. We chose pure event-sourcing because (a) it can’t drift, and (b) it composes cleanly with the AuditLog pattern we already ship for GDPR Art. 30.

Receipts

  • Source: backend/src/workers/credit_ledger.py
  • Row lock: CreditTransactionRepository.lock_user_row in backend/src/services/repositories_async/credit_transaction.py
  • Idempotency key: uq_credit_transactions_job_kind in backend/src/models/credit_transaction.py
  • Reconciler: backend/src/workers/credit_reconciler.py
  • Race-condition test: test_concurrent_deducts_do_not_double_spend in backend/tests/test_credit_ledger.py — two spends, different job_ids, so the unique constraint cannot help and only the row lock can

Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).

Extracted from docs/recipes/why-credit-ledger.md.

That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.

Last updated on