MuseMVP
MuseMVP Docs
AI

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:

KindId patternWritten 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:

EntrykindDefault
Proplan, recurring1,000 credits a month
Lifetimeplan, one-time10,000 credits
1,000 creditspack, one-time1,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:

StatusCodeWhen
402balance and next stepThe balance is below the price; the body says whether a plan or a pack is the next step
429too_many_active_tasksThe user already has ai.maxActiveTasks unfinished tasks
503capacity_reachedToday'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.refundOnContentRefusal is true.
  • 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

  1. Generate with too few credits and confirm 402 and an unchanged balance.
  2. Start more than ai.maxActiveTasks generations and confirm 429.
  3. Lower ai.dailyBudgetUsd in a live test and confirm 503.
  4. Cancel a queued task, or let a live task fail, and confirm exactly one release row. pnpm test:workers covers retries and refunds.
  5. Refund a finished task as an admin twice and confirm the second call is refused.

On this page