Credits System

Credits are the internal currency for AI usage. The accounting model is deliberately simple: 1 credit = 1 LLM token.

How AI credits are charged
  • Chat: charged inputTokens + outputTokens reported by the provider, decremented once after the stream completes (no upfront reserve).
  • RAG embeddings & search: charged 1:1 with the embedding usage.total_tokens.
  • A fast pre-flight check rejects requests below aiConfig.minCreditsRequired before the LLM call (returns 402).
  • Concurrent requests can both pass pre-flight; the DB CHECK (credits_balance >= 0) constraint is the last-resort safety net (a sub-zero post-stream decrement is logged, not blocked — the user already got their answer).
Buy Credits

Credit pack purchase modal with multiple options

Credits Architecture

Credits Sources                    Credits Usage
─────────────────                  ─────────────
┌─────────────────┐               ┌─────────────────┐
│  Subscription   │               │    AI Chat      │
│  Monthly Refill │──┐         ┌──│    Requests     │
└─────────────────┘  │         │  └─────────────────┘
                     │         │
┌─────────────────┐  │  ┌───┐  │  ┌─────────────────┐
│  Credit Pack    │──┼─▶│ $ │──┼──│    API Calls    │
│  Purchase       │  │  └───┘  │  └─────────────────┘
└─────────────────┘  │         │
                     │         │  ┌─────────────────┐
┌─────────────────┐  │         └──│    Agent        │
│  Admin Manual   │──┘            │    Tasks        │
│  Adjustment     │               └─────────────────┘
└─────────────────┘

         accounts.credits_balance

Database Schema

The credits system uses the accounts.credits_balance column for the current balance, with the credit_transactions table providing a full audit trail. Each transaction records the amount, direction (credit/debit), reason, source, and timestamp. This dual-storage approach enables both fast balance lookups and complete transaction history.

Atomic Credit Operations

Credits are managed through two PostgreSQL RPC functions to ensure atomicity. The decrement_credits function subtracts from the balance and logs the transaction with a reason (e.g., "AI chat message"). The add_credits function increases the balance and logs with a source (e.g., 'subscription_refill', 'license_purchase'). Both functions operate within a transaction to prevent race conditions and maintain the audit trail in credit_transactions.

Real signatures from supabase/schema.sql:

-- Adds credits to an account; returns the new balance.
add_credits(
  p_account_id uuid,
  p_amount     integer,
  p_source     credit_source,
  p_reason     text  default null,
  p_metadata   jsonb default '{}'::jsonb
) returns integer

-- Deducts credits; raises a CHECK violation if the result would go below zero.
decrement_credits(
  p_account_id uuid,
  p_amount     integer,
  p_reason     text          default null,
  p_metadata   jsonb         default '{}'::jsonb,
  p_source     credit_source default 'ai_usage'
) returns integer

Using Credits in Code

To deduct credits for an operation (e.g., an AI request), call the decrement_credits RPC with the account ID, amount, and a descriptive reason. Wrap calls in a try/catch — the RPC raises a CHECK (credits_balance >= 0) violation when the result would go negative (it does not return a sentinel value). To grant credits (e.g., after a purchase or subscription refill), call add_credits with the account ID, amount, and source identifier.

Both RPCs are REVOKE EXECUTE FROM authenticated, anon + GRANT TO service_role. Call them via supabaseAdmin or createServiceClient() only — never via createClient() (the user client lacks execute permission).

Anti-pattern A3 — never bypass the ledger

Never UPDATE accounts SET credits_balance = ... directly. Every credit change MUST flow through these RPCs so a credit_transactions ledger row is written with source + reason + metadata. Direct writes break reconciliation, MRR, GDPR export, and refund clawback flows — this regression has shipped to main more than once (see .claude/rules/anti-patterns.md §A3).

Monthly Refill

On every invoice.paid webhook, applyInvoicePaid in core/billing/mutations.ts refills the configured plan credits via add_credits with source 'subscription_refill'. Idempotency is enforced two ways:

  • Prior-refill count guard — the handler counts existing credit_transactions rows tagged with the subscription id and only refills when priorRefillCount <= 1 on the first invoice. Replays of the same invoice no-op.
  • End-trial sentinel skip — when a customer ends their trial early via POST /api/billing/end-trial, a sentinel payments row with stripe_session_id = end_trial_<subId> is written and credits are granted synchronously. applyInvoicePaid detects this sentinel on the subsequent conversion invoice and sets refillAmount = 0 so the synchronous + webhook paths never double-grant. Subsequent monthly invoices ignore the sentinel and refill normally.

See .claude/rules/billing.md for the full event matrix.

Displaying Credits

The CreditDisplay component in components/billing/credit-display.tsx shows the current credit balance for the active account. It reads from accounts.credits_balance and updates in real time. The component is used in the dashboard header and billing pages to give users visibility into their remaining credits.

Credit Packs Purchase

Credit packs are one-time purchases that add credits to an account. They are defined in config/pricing.ts alongside subscription plans. When a user buys a pack, a Stripe Checkout session is created for the pack's price. On successful payment (via the checkout.session.completed webhook), the system adds the purchased credits to the account using the add_credits RPC and records the payment in the payments table.

Credit Transaction History

Every credit operation is recorded in the credit_transactions table, creating a complete audit trail. Transactions track the amount, balance after the operation, reason, and source. This data is displayed in the organization billing page and the admin dashboard for transparency and debugging.