Credit System
The credit system is lib/credits/. It has only two files: ledger.ts (writes) and index.ts (reads + yearly drip settlement).
The two-bucket model
Every billing subject has two buckets:
| Bucket | Column | Semantics |
|---|---|---|
| Subscription credits | subscription_credits | Reset to the plan's monthly allowance on every grant — no rollover. Cleared when the subscription ends |
| Purchased credits | purchased_credits | Credit pack purchases, the signup bonus and admin grants land here. Never expire; only spent or clawed back after a refund |
Spending drains the subscription bucket first, because it is the one that expires — spend what will vanish, keep what will not.
Why subscription credits reset instead of accumulating
Accumulation piles credits up without bound, defeats the point of a monthly allowance, and turns refunds and downgrades into a calculation nightmare. With reset semantics, what a customer can use in one billing period is a known quantity.
This semantics is also what forces the "one live subscription per subject" guard in the Payment Flow: two concurrent subscriptions would reset each other's credits.
A reset writes two ledger rows, not one net delta
A grant writes two rows:
subscription_cycle_expire The unused remainder expires (skipped when the bucket is empty)
subscription_grant The full grant landsWith a single net row, a customer who kept 60 credits on a plan granting 100 would see "+40" — which reads like a shorted grant. Two rows let "how much expired" and "how much was granted" each speak for themselves.
The append-only ledger
The balance tables are never UPDATEd directly. Every change goes through mutateCredits, the single primitive in lib/credits/ledger.ts, which does all of this in one transaction:
dispatch on subject → row lock → upsert the balance → compute per-bucket deltas → append one ledger row with a snapshotThe key columns of credit_transactions:
| Column | Description |
|---|---|
seq | Monotonic insertion order. created_at alone cannot order rows written in one transaction (Postgres now() is transaction-fixed), so history queries tiebreak on this |
amount | Signed total: positive grants, negative spends and clawbacks |
subscription_delta / purchased_delta | Per-bucket deltas; they sum to amount |
subscription_credits_after / purchased_credits_after | Post-change snapshots, so auditing never has to replay |
refunded_transaction_id | Set only on usage_refund rows, pointing at the usage being reversed. UNIQUE — a spend can be refunded at most once |
Transaction types
| Type | When it happens |
|---|---|
purchase | Credit pack purchase |
subscription_grant | Periodic subscription grant (including the yearly drip and upgrade increments) |
subscription_cycle_expire | The unused remainder expiring when a new grant resets the bucket |
usage | Feature consumption |
usage_refund | A failed task returning its usage to the original buckets |
refund_revoke | Credits clawed back after a refund |
subscription_end_revoke | Remaining subscription credits cleared when the subscription ends |
admin_adjustment | Manual grant or deduction by an admin |
signup_bonus | Welcome credits on registration |
Invariants
- Balances never go negative. Clawback operations (
refund_revoke, a negativeadmin_adjustment) clamp at 0 - A no-op writes no ledger row. When the transition function returns
null(clearing an already-empty subscription bucket, say) neither the balance nor the ledger is touched
The monthly drip for annual subscriptions
An annual plan does not grant twelve months of credits at once:
On payment Grant month one immediately, and write the remaining 11 into the subscription row
Every month Cron settles one grant when it comes dueThe schedule is first-class state on the subscriptions table, not a blob inside jsonb:
| Column | Meaning |
|---|---|
monthly_credits | The amount granted each time |
remaining_grants | How many grants are left |
next_grant_at | When the next grant is due (UTC) |
The database is the scheduling queue — next_grant_at carries a partial index that the cron scans directly.
How grant dates are computed
The k-th grant = the yearly period anchor + k months (k = 12 - remaining), with end-of-month clamping via addMonthsClamped.
Clamping is required: a naive setMonth overflows (1/31 + 1 month = 3/3). And every date must be derived from the anchor, never by chaining month by month — chaining grinds the 31st permanently down to the 28th.
An annual subscription bought on the 31st therefore grants on 2/28, 3/31, 4/30… always aligned to the anniversary day.
Two triggers, one entry point
| Trigger | When |
|---|---|
Cron settleDueDripGrantsBatch | /api/cron/credits once a minute, at most 100 per tick, oldest due first |
Lazy settleDueDripGrants | Runs before a balance read or a spend, covering "due seconds ago, before the next tick" and "the cron is down" |
Both funnel into the same primitive, settleDripForSubscription: a row lock plus a re-check under it folds concurrent triggers into one grant.
When several months are overdue, the reset semantics collapse them into one reset rather than fabricating a fake month-by-month history.
The cron is mandatory
Lazy settlement only covers subjects who read or spend. An annual subscriber who does not log in for a month would never receive that month's credits without the cron. See Scheduled Tasks for setup.
Wiring a feature into credits
The user-side entry point is consumeCredits in actions/credits/index.ts:
import { consumeCredits } from '@/actions/credits'
const result = await consumeCredits({
amount: 10,
note: 'AI image generation', // Required; written to the ledger and visible on the billing page
})
if (!result.success) {
// Insufficient balance, and so on
return
}
const { subscriptionCredits, purchasedCredits, totalCredits, txId } = result.dataIt does three things: guards the session, picks the pool from the active workspace (a team workspace spends from the org's shared pool), and settles any due yearly drip before spending.
Refund on task failure
Once you hold the txId, if the task ultimately fails, return the credits to their original buckets with refundSpentCredits:
import { refundSpentCredits } from '@/lib/credits'
await refundSpentCredits({ userId, spendTxId: txId, note: 'Generation failed' })Two hard rules:
- Never wrap it in a client-callable server action. A customer who can refund at will turns every successful task into a free one
- The refund returns exactly the per-bucket split recorded on the usage row. If a monthly reset happened in between, this may momentarily push the subscription bucket above the plan amount — deliberately so, erring in the customer's favor. Refunding into
purchasedinstead would let customers launder expiring credits into permanent ones through deliberately failed tasks
The refund is idempotent: refunds serialize on the balance row lock, and the UNIQUE refunded_transaction_id admits at most one refund per usage row — a replay returns the current balance untouched.
A complete example
The boilerplate ships an interactive reference page (development mode only):
/dashboard/credit-usage-exampleThree cases: a successful spend, a simulated task failure with refund and idempotent replay, and an insufficient-balance upsell. The code lives in app/[locale]/(protected)/dashboard/credit-usage-example/.
Reading balances
| Function | Purpose |
|---|---|
getUserCredits(userId) | The two personal buckets |
getOrganizationCredits(orgId) | The organization's shared pool |
getUserBillingSummary(userId) | Balances + the current subscription (plan, status, period end, cancel-at-period-end, next drip date) |
getOrganizationBillingSummary(orgId) | The same, for an organization |
hasEntitledSubscription(userId) | Whether a live subscription is held |
Every read settles any due yearly drip first, so what you read is always what is owed.
The matching user-side server actions live in actions/credits/index.ts: getMyCredits, getMyBillingSummary, getMyCreditHistory.
What counts as a "live subscription"
ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due', 'unpaid']Dunning (past_due / unpaid) still counts as holding a subscription — entitlements are not cut off immediately. The checkout guard, team seat math and storage retention all share this definition: defined once, consistent everywhere.
The states that can actually receive a grant are narrower: only active and trialing.
The team shared pool
Team plan credits land in organization_credit_balances:
- The organization is the billing subject;
user_iddegrades to "the acting member" - The bucket shape and idempotency mechanics are identical to the personal path; only the balance table differs
- Spending picks the pool from the active workspace, so business code needs no branch
- Credit packs are personal assets and cannot be bought in a team workspace
The ledger is the same credit_transactions table, with the subject told apart by whether organization_id is set. For seats, the invitation flow and the organization deletion guard, see Teams and Organizations.
The signup bonus
Configured in config/credits.ts:
export const creditsConfig = {
/** Granted once on registration. Set to 0 to disable */
signupBonusCredits: 30,
} as constIt lands in the purchased bucket, so a later subscription reset cannot wipe it out.
Idempotency comes from "at most one signup_bonus ledger row per user"; the check and the grant share one transaction, so replays are harmless.
Admin operations
| Entry point | Capability |
|---|---|
/dashboard/admin/credits | Global credit ledger audit (filter by type, search by email/note) + batch grants by email |
/dashboard/admin/users/[userId] | One user's balances and history, with manual adjustment |
Batch grants are deduplicated and capped at 200 emails per call; unmatched emails are reported back.
Manual adjustment (adjustCredits) has these semantics: a positive delta lands in the purchased bucket (so it does not vanish on the next subscription reset); a negative delta drains purchased first, then subscription, clamped at zero overall.
Clawback on refunds
Refunds are webhook-driven — the admin refund button only calls the provider API, and local state plus the credit clawback are written when the webhook comes back.
applyRefund claws credits back proportionally to the refunded amount, clamped at 0 — credits already spent never push a balance negative. See Orders and Subscriptions.