Credits
The two credit buckets, the ledger functions that change them, the yearly drip and its cron, team pools and admin adjustments.
Every account, and every team, has two credit buckets. Subscription credits reset on each grant and disappear when the subscription ends. Purchased credits never expire. A spend takes from the subscription bucket first. Every change to a balance writes one row to credit_transactions in the same transaction, so the ledger always explains the balance.
| Layer | File | Use it for |
|---|---|---|
| Write layer | lib/credits/ledger.ts |
The only code that changes a balance |
| Read layer and facade | lib/credits/index.ts |
Balances, billing summary, yearly drip settlement; re-exports the ledger |
| User actions | actions/credits/index.ts |
consumeCredits, getMyCredits, getMyBillingSummary, getMyCreditHistory |
| Admin actions | actions/credits/admin.ts |
adjustUserCredits, grantCreditsToUsers, getAdminCreditTransactions, getUserCreditsForAdmin |
| Settings | config/pricing.ts (paid credits), config/credits.ts (signup gift) |
Import from @/lib/credits, not from ledger.ts directly: the facade's spendCredits settles any due yearly grant before it spends.
The two buckets
| Subscription bucket | Purchased bucket | |
|---|---|---|
| Filled by | a subscription payment or a yearly-plan monthly grant | credit packs, the signup gift, positive admin adjustments |
| On the next grant | reset to the plan's monthlyCredits (no rollover) |
unchanged |
| When the subscription ends | cleared | unchanged |
| Spent | first | after the subscription bucket is empty |
| Table column | subscription_credits |
purchased_credits |
Personal balances live in credit_balances (one row per user). Team balances live in organization_credit_balances (one row per organization), with the same two columns.
Balances never go below zero. Clawbacks (refunds, negative adjustments) stop at zero.
Spending credits
From a feature the user triggers, call the Server Action:
import { consumeCredits } from '@/actions/credits'
const result = await consumeCredits({ amount: 10, note: 'Image generation' })
if (!result.success) {
// result.customCode === 'INSUFFICIENT_CREDITS' when the balance is short
} else {
const { txId, totalCredits } = result.data // keep txId if the task can still fail
}
consumeCredits requires a signed-in user, a positive integer amount and a note. It spends from the active workspace: the team pool in a team workspace, the personal buckets otherwise.
From server code (a route handler, a webhook), call the facade directly:
import { spendCredits, InsufficientCreditsError } from '@/lib/credits'
const { txId, buckets, spentFromSubscription, spentFromPurchased } = await spendCredits({
userId,
organizationId, // optional: spend from this team's pool
amount: 10,
note: 'Video generation',
})
It throws InsufficientCreditsError when the total is short; nothing is taken in that case. The demo generation routes under app/api/ai-demo/ leave charging as a TODO [Credits] comment; the add-tool-page skill wires it for a real tool. A worked example of spend and refund is at /dashboard/credit-usage-example.
Refunding a failed task
import { refundSpentCredits } from '@/lib/credits'
await refundSpentCredits({ userId, spendTxId: txId, note: 'Generation failed' })
The refund returns the exact split the usage row recorded, to the pool and buckets it came from. The column refunded_transaction_id on the ledger is unique, so a spend can be refunded once; a second call returns the current balance and writes nothing.
refundSpentCredits is deliberately not a Server Action. Call it only from trusted server code that knows the task failed; a refund the browser could trigger would make every task free.
Ledger functions
All of these are exported from @/lib/credits. Each takes an optional transaction handle as its last argument so a caller can bundle the balance change with its own writes. Each takes an optional organizationId; when it is set, the team pool is changed and userId records the member who acted.
| Function | Bucket | Ledger type |
Called by |
|---|---|---|---|
grantPurchasedCredits({ userId, organizationId?, amount, orderId?, note? }) |
purchased, adds | purchase |
fulfillment (credit packs) |
resetSubscriptionCredits({ userId, organizationId?, amount, orderId?, note? }) |
subscription, set to amount |
subscription_cycle_expire then subscription_grant |
fulfillment, yearly drip |
grantSubscriptionUpgradeCredits({ … amount … }) |
subscription, adds | subscription_grant |
fulfillment (mid-cycle upgrade) |
spendCredits({ userId, organizationId?, amount, note }) |
subscription first | usage |
features |
refundSpentCredits({ userId?, spendTxId, note? }) |
as recorded on the usage row | usage_refund |
features, on task failure |
revokeCreditsForRefund({ userId, organizationId?, amount, bucket, orderId?, note? }) |
the named bucket, down to 0 | refund_revoke |
fulfillment (payment refunds) |
clearSubscriptionCredits({ userId, organizationId?, note? }) |
subscription, to 0 | subscription_end_revoke |
fulfillment (subscription ended) |
adjustCredits({ userId, organizationId?, delta, note }) |
positive into purchased; negative from purchased first, then subscription | admin_adjustment |
admin actions |
grantSignupBonusCredits({ userId, amount, note? }) |
purchased, adds, once per user | signup_bonus |
the signup hook |
Never call the grant functions from feature code. Paid credits come from the fulfillment core (Payments), which grants them only once money has settled.
A subscription reset writes two rows: the unused credits expiring, then the full grant. The grant row therefore always shows the plan's full amount, not a net difference.
Read functions: getUserCredits(userId), getOrganizationCredits(organizationId), getUserBillingSummary(userId), getOrganizationBillingSummary(organizationId) and hasEntitledSubscription(userId). The balance reads settle any due yearly grant first. ENTITLED_SUBSCRIPTION_STATUSES (active, trialing, past_due, unpaid) is the one definition of "currently subscribed" used across the code.
The signup gift
config/credits.ts sets signupBonusCredits (30 by default; 0 turns it off). The user.create.after hook in lib/auth/index.ts calls grantSignupRewards (lib/auth/signup-rewards.ts), which asks lib/signup-gift-gate first:
- The gate counts signups per source IP and per device fingerprint in Redis over
windowDays(30). PastperIp(3) orperDevice(2), the account is still created but gets no gift, a warning is logged and Discord getsnotifySignupGiftWithheld. - Without Redis, without the signals, or on a Redis error, the gift is granted. The gate never fails closed.
- The ledger grants the gift at most once per user, checked inside the same transaction.
Yearly plans: the monthly drip
A yearly subscription is charged once a year but grants monthlyCredits month by month:
- The yearly payment grants month one at once (a reset to
monthlyCredits) and setsremaining_grants = 11andnext_grant_aton thesubscriptionsrow. - Each later grant is due at the period start plus k months, clamped to the end of shorter months (
addMonthsClamped: bought on the 31st, grants land on 2/28, 3/31, 4/30 …). settleDripForSubscriptionlocks the row, checks again, resets the bucket and moves the schedule. Several overdue months collapse into one reset.- Only
activeandtrialingsubscriptions receive drip grants. Ending, refunding or cancelling setsremaining_grants = 0.
Two triggers call that one function:
| Trigger | Function | When |
|---|---|---|
| Cron | settleDueDripGrantsBatch(100) |
each call of /api/cron/credits |
| Lazy | settleDueDripGrants(subject) |
before a balance read or a spend for that user or team |
The lazy path means a subscriber can spend a grant the second it is due, even if the cron is not running. The cron is still needed so balances and the ledger are current for users who are not active.
The cron route
app/api/cron/credits/route.ts:
POSTwithAuthorization: Bearer $CRON_SECRETruns one tick and answers{ ok, at, drip, renewals, pendingCaptures }.GETwith the same header runs the same tick (Cloudflare's scheduled handler and Vercel Cron only sendGET).GETwithout it answers{ ok: true, status: 'healthy' }and does nothing.- A tick does three things: settle due drip grants (up to 100), reconcile overdue renewals (up to 20), reconcile pending PayPal captures (up to 20). Backlogs drain over the following ticks.
- It is designed to run every minute. There is no lock between ticks; overlapping ticks are safe because each grant re-checks under a row lock.
- A tick whose rows failed still answers
200; the next tick retries them.
The cron is not scheduled in the repo by default. Add it when the product sells subscriptions or uses PayPal: on Cloudflare in triggers.crons in wrangler.jsonc plus the CRONS table in cloudflare/middleware.ts, on Vercel in vercel.json. The deploy skills cover both (Cloudflare, Vercel).
Team pools
A team plan (audience: 'team' in config/pricing.ts) bills an organization. Its grants land in organization_credit_balances; the subscription row carries organization_id.
- A member working in the team workspace spends from the pool (
consumeCreditsreads the active workspace throughgetWorkspaceContext, which re-checks membership on every call). - Pool ledger rows carry
organization_idand the acting member'suser_id. - Credit packs cannot be bought from a team workspace; the only money into a pool is a team subscription.
getTeamOverviewandgetTeamCreditHistoryinactions/team/serve the team page.
Admin adjustments
| Where | Action | What it does |
|---|---|---|
| Admin users page, row action "Adjust credits" | adjustUserCredits({ userId, delta, reason }) |
adjustCredits on the personal buckets; reason is required and lands in the note with the admin's email |
| Admin credits page, "Batch grant" | grantCreditsToUsers({ emails, amount, reason }) |
The same, for up to 200 emails at once; returns the emails that matched no user |
| Admin credits page, table | getAdminCreditTransactions({ pageIndex, pageSize, userId?, type?, search? }) |
The whole ledger, filterable by user, type and email or note |
Every admin action checks isAdmin(), which reads the fresh session, so a demoted admin is cut off at once. The admin actions adjust personal buckets only; they do not take an organizationId.
The credit_transactions table
Defined in lib/db/schema.ts. Rows are only ever inserted.
| Column | Meaning |
|---|---|
id |
uuid |
seq |
identity column, insertion order; orders rows written in the same transaction, where created_at is equal |
user_id |
personal row: the owner; team row: the acting member. SET NULL when the user is deleted |
organization_id |
set for team-pool rows. RESTRICT on organization delete |
type |
purchase, subscription_grant, subscription_cycle_expire, usage, usage_refund, refund_revoke, subscription_end_revoke, admin_adjustment, signup_bonus |
amount |
signed total change (positive grants, negative spends and revokes) |
subscription_delta, purchased_delta |
the change per bucket; amount is their sum |
subscription_credits_after, purchased_credits_after |
balances after this row |
note |
free text, shown in the history |
order_id |
the order that caused it, if any. SET NULL |
refunded_transaction_id |
on usage_refund rows only: the usage row it reverses. Unique |
created_at |
timestamp with time zone |
The user-facing history (getMyCreditHistory) shows personal rows only and hides subscription_cycle_expire bookkeeping rows.
When it fails
| Symptom | Likely cause | Fix |
|---|---|---|
| A yearly subscriber's balance did not refresh on the due date | The cron is not scheduled or CRON_SECRET is missing, and the user has not opened the app since |
Schedule /api/cron/credits; the next balance read settles it anyway |
INSUFFICIENT_CREDITS right after a team subscription |
The user is in the personal workspace, or a credit pack was bought into personal buckets | Switch workspace |
| New users get no welcome credits | The signup-gift gate withheld it (same IP or device), or signupBonusCredits is 0 |
Check the Discord alert and the warn log; adjust signupGiftClusterCaps in config/credits.ts |
| A balance looks wrong | Read the user's rows in the admin credits page; each row's *_after columns show the balance after it |