credits
An integer credit wallet with an append-only ledger and debit-before-spend, an empty balance fails closed, never a negative wallet.
@caisson/credits is the credit wallet: grant, debit, and query an account's balance as
whole integer units, never a float. A debit only records once the wallet can cover it:
insufficient credits throw before any paid work runs, and nothing is written on that path.
What it does
- Grant and debit:
grant()adds credits from a purchase, subscription allotment, top-up, or a registered feature grant;debit()subtracts them for codegen, an AI feature, or a registered feature debit. Both are idempotent on a suppliedsourceEventIdoridempotencyKey: a retried webhook is absorbed, not double-counted. - Debit-before-spend, fail-closed: an insufficient balance throws
InsufficientCreditsErrorand rolls back the whole transaction. No debit row, no wallet mutation. - FIFO grant consumption: a debit walks the account's unexpired grants oldest-first and records which grant(s) it drew from, splitting across grants when one remainder can't cover it.
- Append-only ledger:
getLedger()reads every grant/debit event for an account; nothing is ever mutated or deleted. - Expiry: grants default to a 12-month expiry;
sweepExpiredGrants()claws back unspent residue pastexpires_at, andsweepExpiryNotices()emails accounts inside a configurable expiring-soon window. Both ship as@caisson/jobstask definitions. - Clawback:
clawback()reverses unspent credits tied to a specific purchase line, so a partial refund only claws back that line's grant.
Install
bun add @caisson/creditsQuickstart
Every call runs inside a tenant transaction from @caisson/tenancy-rls, which scopes the
wallet and ledger rows to the account via RLS:
import { withTenant } from "@caisson/tenancy-rls";
import { asCredits } from "@caisson/kernel";
import { grant, debit, balance } from "@caisson/credits";
await withTenant(pg, accountId, async (tx) => {
await grant(tx, {
accountId,
eventType: "purchase",
amount: asCredits(500),
sourceEventId: paddleTransactionId,
});
await debit(tx, {
accountId,
eventType: "codegen_debit",
amount: asCredits(10),
idempotencyKey: requestId,
});
return balance(tx, accountId); // 490
});Fail-closed on an empty balance
import { InsufficientCreditsError } from "@caisson/kernel";
try {
await debit(tx, { accountId, eventType: "ai_feature_debit", amount: asCredits(1000) });
} catch (err) {
if (err instanceof InsufficientCreditsError) {
// 402 — nothing was recorded, the wallet is unchanged.
}
}Reading the ledger
spendableBalance() is the display-safe figure, the lower of the raw wallet aggregate
and the FIFO sum over unexpired grants, so it never promises more than a debit will
actually cover:
import { getLedger, spendableBalance } from "@caisson/credits";
const entries = await getLedger(tx, accountId); // every grant/debit event, oldest first
const spendable = await spendableBalance(tx, accountId);Expiry sweeps
import {
defineCreditExpirySweepTask,
defineCreditExpiryNoticeTask,
} from "@caisson/credits";
const sweepTask = defineCreditExpirySweepTask({ db });
const noticeTask = defineCreditExpiryNoticeTask({
db,
emailer,
recipientFor: (accountId) => lookupAccountEmail(accountId),
dashboardUrl: "https://app.example.com/credits",
});Register both on a @caisson/jobs queue and enqueue one payload per account on a cron
tick, both sweeps are idempotent, so a replayed tick is a no-op.
Composition
@caisson/credits sits on @caisson/kernel (the Credits/RoundedMoney branded types
and InsufficientCreditsError), @caisson/tenancy-rls (the TenantExecutor every
function takes), and @caisson/jobs (the expiry-sweep task definitions). It is a
commercial module in the AI-Production bundle, entitlement is required to install it
from the registry.
prompt-registry
Append-only prompt versioning with name@version and name@alias addressing, a mutable alias pointer for zero-redeploy promotion, and injection-safe rendering.
Local-first
On-device inference and vector search behind a default-deny privacy gate, two-way offline sync, and per-tenant field encryption. Your data stays on-device by default.