ShipAgent

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). Past perIp (3) or perDevice (2), the account is still created but gets no gift, a warning is logged and Discord gets notifySignupGiftWithheld.
  • 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:

  1. The yearly payment grants month one at once (a reset to monthlyCredits) and sets remaining_grants = 11 and next_grant_at on the subscriptions row.
  2. 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 …).
  3. settleDripForSubscription locks the row, checks again, resets the bucket and moves the schedule. Several overdue months collapse into one reset.
  4. Only active and trialing subscriptions receive drip grants. Ending, refunding or cancelling sets remaining_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:

  • POST with Authorization: Bearer $CRON_SECRET runs one tick and answers { ok, at, drip, renewals, pendingCaptures }.
  • GET with the same header runs the same tick (Cloudflare's scheduled handler and Vercel Cron only send GET). GET without 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 (consumeCredits reads the active workspace through getWorkspaceContext, which re-checks membership on every call).
  • Pool ledger rows carry organization_id and the acting member's user_id.
  • Credit packs cannot be bought from a team workspace; the only money into a pool is a team subscription.
  • getTeamOverview and getTeamCreditHistory in actions/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