Credits and Cost Control
The credit ledger, plans and credit packs, server-side refusals, the daily budget, refunds and admin recovery.
The ledger
Every balance change is a row in the append-only ledger table in D1. The balance is the sum of its rows. Each row id encodes the user, the kind and the source, so a retry, a queue redelivery or a double click writes the same id and is ignored:
| Kind | Id pattern | Written when |
|---|---|---|
grant | {user}:grant:{source} | Welcome credits after email verification, a paid order, an audited admin grant |
reserve | {user}:reserve:{task} | Before a generation is sent to a provider |
release | {user}:release:{task} | Once, when a reserved generation fails, is canceled or is refunded |
A successful generation keeps its reservation; there is no second charge at completion. Rows also carry reason and ref, which the usage page at /app/usage turns into readable lines. Any new writer must set them, and a reversing row copies them from the row it reverses.
Never deduct credits in a component or before the request, and never charge again inside a queue retry. Change balances only through these ledger writers.
Selling credits
The sellable catalog is in packages/config/src/index.ts:
| Entry | kind | Default |
|---|---|---|
| Pro | plan, recurring | 1,000 credits a month |
| Lifetime | plan, one-time | 10,000 credits |
| 1,000 credits | pack, one-time | 1,000 credits; set requiresPlan to sell packs only to plan holders |
Packs are always one-time. The credits are fixed on the order at checkout, so editing the catalog later does not change what an existing order grants. Fill in each gateway's product ids and enable gateways as described in Billing setup.
Server-side refusals
Every generation is priced on the server and refused before anything is reserved:
| Status | Code | When |
|---|---|---|
402 | balance and next step | The balance is below the price; the body says whether a plan or a pack is the next step |
429 | too_many_active_tasks | The user already has ai.maxActiveTasks unfinished tasks |
503 | capacity_reached | Today's estimated upstream spend plus this task would pass ai.dailyBudgetUsd |
Disabling a button in the UI is not a substitute for these checks.
Daily budget
Each catalog model carries an estimated costUsd. Reserving a task adds its estimate to the spend since UTC midnight; the cap is shared by all users and protects your provider bill from abuse or a pricing mistake. Mock runs count as free. Set ai.dailyBudgetUsd to 0 to turn the cap off, and keep provider-side limits as well.
The margin check keeps prices honest: a model is valid only when credits × cheapest credit price ≥ costUsd × (1 + ai.minGrossMargin). Re-run pnpm test:unit after changing a price or a pack.
Failures and refunds
- A retryable provider failure is attempted again up to
ai.maxAttempts(3). A final failure releases the credits once. - A provider content refusal releases the credits while
ai.refundOnContentRefusalistrue. - Every five minutes the cron reclaims expired leases, releases tasks that ran out of attempts or time (video after 30 minutes) and redispatches the outbox.
Admin recovery
Admins see recent AI tasks at /app/admin, backed by GET /api/admin/ai_tasks: status, attempts, error category, cost and whether credits were returned. POST /api/admin/ai-refund with { "taskId": "..." } returns the credits of a finished task once; a second call answers 409 not_refundable. Admin grants through POST /api/admin/credits are audited.
Verify
- Generate with too few credits and confirm
402and an unchanged balance. - Start more than
ai.maxActiveTasksgenerations and confirm429. - Lower
ai.dailyBudgetUsdin a live test and confirm503. - Cancel a queued task, or let a live task fail, and confirm exactly one
releaserow.pnpm test:workerscovers retries and refunds. - Refund a finished task as an admin twice and confirm the second call is refused.