Credits + metering
debit() locks the wallet row, drains unexpired grants oldest-first, and 402s before a cent of paid work runs, the ledger only ever writes what actually happened.
What it is
Credits is Caisson's integer credit wallet: grant() appends to an append-only credit_event ledger and upserts the wallet, while debit() takes a FOR UPDATE wallet-row lock and drains unexpired grants oldest-first through grant_consumption. A short balance throws InsufficientCreditsError (402) before the debit lands, the transaction rolls back with nothing recorded. Idempotent on a caller key or provider event id; grants expire on a schedule with a T-30d notice sweep.
What ships in the module
Browser-safe entry point
Import @caisson/credits/browser inside a client bundle for the grant/debit event vocabulary and planFifoDebit, the FIFO waterfall debit() itself walks: hand it grant remainders and an amount and it returns the per-grant draws plus the covered and shortfall split. It reads and writes no wallet; grant(), debit(), clawback(), the balance reads, the sweeps, and the schema SQL stay on the main entry, which is unchanged, and every browser-entry export is also on it.
FOR UPDATE row lock, then FIFO
debit() locks the credit_wallet row FOR UPDATE before it ever reads a grant, so two concurrent debits for the same account serialize instead of racing to consume the same grant remainder, the same lock clawback() and sweepExpiredGrants() take before they touch the wallet.
FIFO grant consumption via grant_consumption
unexpiredGrantsFifo() walks a tenant's unexpired grants oldest-first (created_at ASC, expires_at ASC, id ASC) and debit() splits one charge across as many grants as it needs, writing one grant_consumption row per grant it draws from, a grant's remaining balance is always amount minus the sum of its consumption rows, never a mutated column.
Idempotent by construction
idemColumns() requires exactly one of sourceEventId or idempotencyKey on every grant/debit/clawback call, and insertEvent() writes through ON CONFLICT DO NOTHING RETURNING, a retried call returns { idempotent: true } off the existing row instead of raising a conflict that would poison the surrounding transaction.
402 fail-closed on either floor
debit() checks two floors and 402s on the tighter one (the FIFO-derived unexpired remaining and the raw credit_wallet.balance aggregate) throwing InsufficientCreditsError and rolling back the whole transaction with nothing recorded. spendableBalance() reads the same min() of both floors, so a displayed balance never promises more than a debit will actually cover.
Clawback and expiry, both bounded to the live balance
clawback() reclaims min(amount, currentBalance) of a refunded purchase's unspent credits (never pushing the wallet negative) and sweepExpiredGrants() burns each expired grant's residue as an explicit expiry_debit event bounded the same way, so expired value is consumed by a ledger row, never silently excluded from a read.
A generic feature-meter envelope, registry-validated
feature_grant and feature_debit carry a feature tag that featureColumn() validates against FeatureTagSchema before the ledger insert, an unregistered or misspelled tag throws with no row written, so a new metered action never mints a silent, unvalidated meter.
export async function debit(
tx: TenantExecutor,
input: DebitInput,
): Promise<CreditResult> {
assertPositiveInt(input.amount);
const idem = idemColumns(input);
const feature = featureColumn(input.eventType, input.feature);
const fresh = await insertEvent(tx, {
accountId: input.accountId,
eventType: input.eventType,
amount: -input.amount,
feature,
rounding: input.rounding,
...idem,
});
if (fresh === null)
return { balance: await balance(tx, input.accountId), idempotent: true };
// Per-account debit serialization — must precede the FIFO read (see the function comment).
// A missing wallet row (never granted) locks nothing and falls through to the 402 below.
await tx.query(
`SELECT balance FROM credit_wallet WHERE account_id = $1 FOR UPDATE`,
[input.accountId],
);
const grants = await unexpiredGrantsFifo(tx, input.accountId);
const plan = planFifoDebit(grants, input.amount);
for (const draw of plan.draws) {
await insertConsumption(tx, {
accountId: input.accountId,
grantEventId: draw.grantId,
debitEventId: fresh,
amount: draw.taken,
});
}
if (!(plan.shortfall <= 0)) {
// Unexpired remaining can't cover it — 402 with the SPENDABLE total (not the raw wallet
// aggregate, which may still carry not-yet-swept expired residue). Throwing rolls back the
// event + consumption inserts above — a failed debit leaves no trace.
throw new InsufficientCreditsError(input.amount, plan.covered);
}
const updated = await tx.query<{ balance: number }>(
`UPDATE credit_wallet SET balance = balance - $2
WHERE account_id = $1 AND balance >= $2
RETURNING balance`,
[input.accountId, input.amount],
);
if (updated.rows.length === 0) {
// Insufficient wallet aggregate (e.g. a clawback outran the per-grant remainders): same
// rollback semantics — nothing recorded.
throw new InsufficientCreditsError(
input.amount,
await balance(tx, input.accountId),
);
}
return { balance: updated.rows[0]?.balance ?? 0, idempotent: false };
}- The `SELECT ... FOR UPDATE` on `credit_wallet` runs before the FIFO read, it serializes concurrent debits per account so two calls can never consume the same grant remainder.
- `unexpiredGrantsFifo` returns grants oldest-first and `planFifoDebit` walks them until the charge is covered, splitting one debit across multiple grants when a single grant's remainder falls short. The waterfall is pure and lives in one module, so the browser entry computes the identical draws.
- A plan that is not provably covered in full (`!(shortfall <= 0)`) throws `InsufficientCreditsError` and the whole transaction rolls back, the `insertEvent` and `insertConsumption` calls above never survive to be visible.