Credits
Overview
Credits are a usage currency for metered features (AI tokens, exports, anything
you meter). They're separate from subscriptions: customers buy one-time
packs, and your code spends credits with a safe reserve-then-settle
primitive. Packs are defined in src/config/credits.config.ts.
Defining packs
Each pack is a one-time Stripe product (mode=payment, not subscription):
{
id: "standard",
name: "Standard",
credits: 5_000,
price: 4000, // display only — Stripe is authoritative
stripePriceId: env.STRIPE_CREDIT_PACK_STANDARD_PRICE_ID,
stripeProductId: env.STRIPE_CREDIT_PACK_STANDARD_PRODUCT_ID,
isFeatured: true,
}
A pack with no stripePriceId isn't purchasable. On successful payment, the
Stripe webhook resolves the pack from its price ID and grants the credits.
Where credits live
Credit distribution within an organization is configurable:
- Pool — a shared org balance everyone spends from.
- Per-member — each member has their own balance, with scheduled refills applied lazily (on read) so there's no cron to run.
Owners/admins manage member balances and overrides from the organization's credits settings.
Spending: estimate → reserve → settle
Never decrement a balance directly. The full metered lifecycle is:
1. Estimate. Costs live in a typed catalog in src/config/credits.config.ts
(creditCosts — a flat cost per call plus an optional perUnit cost; each
operation defines its own unit). Replace the example entries with your product's
operations:
import { estimateCredits } from "@/config/credits.config"
const estimated = estimateCredits("ai.chat", expectedTokens / 1000)
Clients can pre-check affordability with the credits.estimate tRPC query,
which returns { estimated, balance, sufficient } — useful to disable a button
or show the cost before the user commits.
2–4. Reserve, run, settle — use withCreditHold, the recommended wrapper
(from src/lib/billing/credits.ts):
const text = await withCreditHold(
{ db, organizationId, userId, estimated, reason: "ai.chat" },
async () => {
const res = await doTheWork() // 3. run the operation
return { result: res.text, actual: res.creditsUsed }
},
)
It reserves the estimate atomically (throws errors.insufficientCredits
before any work runs when funds are short), executes your function, and
settles with the actual cost it reports — refunding the unused estimate or
charging the overage. If your function throws, the hold is released (full
refund) and the error is rethrown. The low-level primitives
(reserveCredits / settleReservation / releaseReservation) remain exported
for flows that don't fit the wrapper (e.g. settling from a webhook).
Guarantees:
- Reserving is atomic — the estimate is deducted with a guarded update, so two concurrent requests can't both spend the last of the balance, and the deduction + reservation row commit in one transaction.
- Settle/release are race-safe — the status flip is an atomic claim, so of concurrent settle/settle or settle/release calls exactly one moves money (the stale-reservation job can never double-refund an in-flight settle).
- Balances never go negative. If the actual cost exceeds the estimate and
the remaining balance, the charge is capped at zero and the uncovered
difference is written to the ledger as an
adjustmentrow (credits.tx.overageDeficit) — auditable, not just a log line. Reserve a generous estimate; anything unused is refunded at settle. - No consumption hits the ledger until settle, so a held reservation
temporarily lowers the spendable balance by design. Abandoned holds are
released by the
release-stale-credit-reservationsjob after 24h.
Reading the balance
Client code reads the spendable balance through the credits tRPC router
(credits.balance), which returns the pool balance or the member's own balance
depending on the org's policy, with refills applied lazily — plus held, the
sum of credits currently held by open reservations (already subtracted from the
spendable number; the UI uses it to explain the difference).