Credits

Credits

Per-call metering using a credit balance stored on the users table (shared DB). Atomic deduction ensures the balance never goes below zero without a race condition.

Architecture

  • Balance stored in users.credits (shared Drizzle DB, integer column)
  • Deduction uses UPDATE WHERE credits > 0 RETURNING , single statement, no race
  • Free credits granted on signup via onUserCreated in src/server/auth-hooks.ts, wired into the better-auth user.create.after hook in src/server/auth.ts
  • Top-up via Stripe one-time payment (mode: 'payment')

Key files

FilePurpose
src/features/credits/server/credits.server.tsgetCredits, deductCredit, addCredits
src/features/credits/server/credits.queries.tsgetCreditsFn server function
src/features/billing/server/stripe-checkout.server.tshandleCreditsPurchase grants credits on checkout.session.completed
config.tscredits.enabled, freeCreditsOnSignup, topUpPriceId, creditsPerTopUp

Server functions

FunctionMethodDescription
getCreditsFnGETCurrent user's credit balance
buyCreditsCheckoutFnPOSTCreate Stripe checkout session for top-up

Gating a mutation

deductCredit operates on the shared DB, not per-user SQLite. Call it inside withWriteLock alongside the write it guards:

import { deductCredit } from '@/features/credits/server/credits.server';
import { db } from '@/lib/db';

return withWriteLock(user.id, async () => {
  const userDb = getUserDb(user.id);
  const deduct = await deductCredit(db, user.id);
  if (!deduct.ok) return err(ERROR_CODES.NO_CREDITS, 'No credits remaining');

  // ... metered operation ...
  return ok(result);
});

deductCredit is server-only , import directly, not via barrel (import protection blocks it in client bundles).

Refunding a failed metered call

If credit was deducted but the metered operation never actually ran (e.g. the downstream API failed to even start), use refundCredit(db, userId) to give the credit back , same file, same import rule as deductCredit:

import { deductCredit, refundCredit } from '@/features/credits/server/credits.server';
import { db } from '@/lib/db';

return withWriteLock(user.id, async () => {
  const deduct = await deductCredit(db, user.id);
  if (!deduct.ok) return err(ERROR_CODES.NO_CREDITS, 'No credits remaining');

  try {
    const result = await startMeteredOperation();
    return ok(result);
  } catch (e) {
    await refundCredit(db, user.id);
    throw e;
  }
});

Reference implementation: src/routes/api/v1/ai-chat.ts refunds the credit when the OpenRouter stream fails to start. Only refund on a genuine "never happened" failure , don't refund for a partial/degraded response the user still received value from.

Configuration

// config.ts
credits: {
  enabled: true,
  freeCreditsOnSignup: 10,
  topUpPriceId: 'price_xxx',   // VITE_STRIPE_CREDITS_PRICE_ID
  creditsPerTopUp: 100
}

Set enabled: false to disable the entire credits system. When disabled, the credits balance, settings page section, and top-up flow are all hidden.

Top-up flow

  1. User clicks top-up on /dashboard/settings/credits
  2. buyCreditsCheckoutFn creates a Stripe mode: 'payment' session with metadata.userId and metadata.amount
  3. On checkout.session.completed, handleCreditsPurchase in stripe-checkout.server.ts grants the credits inside a db.transaction: it dedups on session.payment_intent against the purchases table (insert a marker row) before incrementing users.credits, so a redelivered webhook can't double-grant. See docs/warpkit/patterns/webhook-replay.md for the full pattern.

Free credits on signup

config.credits.freeCreditsOnSignup credits are added when a new account is created, via onUserCreated in src/server/auth-hooks.ts (called from the user.create.after hook in src/server/auth.ts).