REJCOB
← All work

Double-entry usage ledger for AI features

Metering for products that call LLMs: the cost of an operation is unknown until it finishes, and the balance still must never go negative.

Role
Architecture & design
Period
2026
Company
Personal project

2-phase

reserve an estimate, settle the actual usage

Double-entry

every movement balances to zero

Idempotent

a retried request never charges twice

#Context

Any product that puts AI features behind a paid plan needs to answer 'how much has this account used, and can it afford the next call?'. The awkward part is timing: token counts arrive only when the response ends, streams get cancelled halfway, and clients retry on timeouts. This design borrows the pattern card payments use — authorize first, capture later — and applies it to AI usage.

#Problem & constraints

  • Charging after the call lets concurrent requests spend the same balance several times over.
  • Retries and duplicate deliveries turn one operation into two charges unless the write is idempotent.
  • A single mutable balance column cannot explain how it reached its current value.

#Architecture

AI requestidempotency keyReserveavailable → reservedProvider calltokens · secondsSettleactual costPrice bookversionedLedgerdouble-entry · PostgresSweeperrelease stale holds
Credits are reserved before the AI call and settled to the real cost after it.

Each account has ledger accounts — available, reserved, spent — and every movement is a pair of entries that sums to zero. Before an AI call, the service estimates a ceiling for the operation and moves that amount from available to reserved inside one PostgreSQL transaction, taking a row lock on the account; if available is insufficient the call is rejected up front. When the provider responds, the usage payload (tokens, audio seconds, pages) is priced with a versioned price book and the reservation is settled: the real cost moves to spent and the remainder returns to available. A cancelled or failed call releases the whole reservation, and a sweeper releases reservations that outlive a timeout. Every request carries an idempotency key with a unique constraint, so a retry returns the original result instead of writing again. Redis holds a read-through cache of balances for display only; decisions always read Postgres.

#Key decisions & tradeoffs

01Reserve then settle

Decision
Hold an estimated maximum before the call and reconcile to actual usage afterwards.
Alternatives
Debit once after the response, or charge a flat price per operation regardless of usage.
Why
It is the only option that keeps the balance from going negative under concurrency while still charging the true cost.
Cost of being wrong
Estimates must be conservative, so accounts near zero get rejected for calls they could have afforded; and stuck reservations need a sweeper.

02Double-entry, append-only

Decision
Never update a balance in place; derive it from immutable entry pairs.
Alternatives
A balance column plus a loose activity log.
Why
Entries that must sum to zero make accounting bugs detectable with one query, and any balance can be rebuilt and explained at any point in time.
Cost of being wrong
More rows and more joins; balances need periodic snapshots to stay fast to read.

03Versioned price book

Decision
Store prices per provider, model and unit with validity dates, and record the version used on each settlement.
Alternatives
Prices as constants in code.
Why
Provider prices change often; past charges must stay explainable at the price that applied when they happened.
Cost of being wrong
Someone has to keep the price book current, and a missing price must fail closed.

#Results

Σ = 0

invariant checkable with a single query

No overdraft

by construction: funds are held before spending

Exactly once

charging under retries, via idempotency keys

#What I'd do differently

The open question is lock contention: one row lock per account is simple and correct, but an account firing many parallel calls will queue on it. The next step is a load test with skewed traffic to decide whether that needs sharded sub-balances or is fine as it is.

#Stack

Node.jsTypeScriptPostgreSQLRedisOpenAIWhisperDouble-entry ledger