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
onUserCreatedinsrc/server/auth-hooks.ts, wired into the better-authuser.create.afterhook insrc/server/auth.ts - Top-up via Stripe one-time payment (
mode: 'payment')
Key files
| File | Purpose |
|---|---|
src/features/credits/server/credits.server.ts | getCredits, deductCredit, addCredits |
src/features/credits/server/credits.queries.ts | getCreditsFn server function |
src/features/billing/server/stripe-checkout.server.ts | handleCreditsPurchase grants credits on checkout.session.completed |
config.ts | credits.enabled, freeCreditsOnSignup, topUpPriceId, creditsPerTopUp |
Server functions
| Function | Method | Description |
|---|---|---|
getCreditsFn | GET | Current user's credit balance |
buyCreditsCheckoutFn | POST | Create Stripe checkout session for top-up |
Gating a mutation
deductCredit operates on the shared users table. Call it inside withUserTransaction, passing the tx handle, alongside the write it guards:
import { deductCredit } from '@/features/credits/server/credits.server';
return withUserTransaction(user.id, async (tx, userId) => {
const deduct = await deductCredit(tx, userId);
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';
return withUserTransaction(user.id, async (tx, userId) => {
const deduct = await deductCredit(tx, userId);
if (!deduct.ok) return err(ERROR_CODES.NO_CREDITS, 'No credits remaining');
try {
const result = await startMeteredOperation();
return ok(result);
} catch (e) {
await refundCredit(tx, userId);
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
- User clicks top-up on
/dashboard/settings/credits buyCreditsCheckoutFncreates a Stripemode: 'payment'session withmetadata.userIdandmetadata.amount- On
checkout.session.completed,handleCreditsPurchaseinstripe-checkout.server.tsgrants the credits inside adb.transaction: it dedups onsession.payment_intentagainst thepurchasestable (insert a marker row) before incrementingusers.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).