Menu

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:

BucketColumnSemantics
Subscription creditssubscription_creditsReset to the plan's monthly allowance on every grant — no rollover. Cleared when the subscription ends
Purchased creditspurchased_creditsCredit 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 lands

With 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 snapshot

The key columns of credit_transactions:

ColumnDescription
seqMonotonic insertion order. created_at alone cannot order rows written in one transaction (Postgres now() is transaction-fixed), so history queries tiebreak on this
amountSigned total: positive grants, negative spends and clawbacks
subscription_delta / purchased_deltaPer-bucket deltas; they sum to amount
subscription_credits_after / purchased_credits_afterPost-change snapshots, so auditing never has to replay
refunded_transaction_idSet only on usage_refund rows, pointing at the usage being reversed. UNIQUE — a spend can be refunded at most once

Transaction types

TypeWhen it happens
purchaseCredit pack purchase
subscription_grantPeriodic subscription grant (including the yearly drip and upgrade increments)
subscription_cycle_expireThe unused remainder expiring when a new grant resets the bucket
usageFeature consumption
usage_refundA failed task returning its usage to the original buckets
refund_revokeCredits clawed back after a refund
subscription_end_revokeRemaining subscription credits cleared when the subscription ends
admin_adjustmentManual grant or deduction by an admin
signup_bonusWelcome credits on registration

Invariants

  • Balances never go negative. Clawback operations (refund_revoke, a negative admin_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 due

The schedule is first-class state on the subscriptions table, not a blob inside jsonb:

ColumnMeaning
monthly_creditsThe amount granted each time
remaining_grantsHow many grants are left
next_grant_atWhen 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

TriggerWhen
Cron settleDueDripGrantsBatch/api/cron/credits once a minute, at most 100 per tick, oldest due first
Lazy settleDueDripGrantsRuns 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.data

It 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:

  1. Never wrap it in a client-callable server action. A customer who can refund at will turns every successful task into a free one
  2. 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 purchased instead 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-example

Three 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

FunctionPurpose
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_id degrades 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:

config/credits.ts
export const creditsConfig = {
  /** Granted once on registration. Set to 0 to disable */
  signupBonusCredits: 30,
} as const

It 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 pointCapability
/dashboard/admin/creditsGlobal 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.