Credits
The credit / usage system — how it works, every feature flag, per-plan grants, and the integration points your app can hook into.
The credits package adds a metered-usage layer on top of the boilerplate's existing subscription billing. Buyers can opt in to grant credits on subscription, refresh them daily, sell credit packs, auto-charge for overage, and notify users about credit events — each behaviour independently feature-flagged.
Credits is disabled by default. Apps that don't need it ship zero credit code (no balances tracked, no UI rendered, no API routes exposed).
Why credits
A subscription says "you can use this." Credits say "you can use this N times." That's the difference between a flat-fee plan and a metered plan. Most modern AI / API products combine both — a base subscription that includes a credit allowance, plus the option to top up.
The boilerplate's credit system is provider-agnostic at the storage and math layers, and gracefully integrates with all six payment providers for the parts that need a payment gateway (top-ups and overage).
Mental model
Two tables hold all the state:
User.creditBalance/Organization.creditBalance— the live balance the app reads on every page render.Decimal(20,6), never a float.CreditTransaction— append-only ledger. Every change writes a row with kind / amount / balanceAfter / metadata. The live balance and the ledger are written in the same DB transaction so they never drift.
Three operations move the balance:
addCredits— race-safe SQLbalance += Nplus a ledger row.deductCredits— guardedCASE WHEN balance >= cost THEN balance - cost ELSE 0. Returns{ creditsUsed, overflow, balanceAfter }so callers know what happened.runDailyCreditRefresh— the cron entry point. Top-up-to-cap.
That's the full surface. Everything else (UI widget, oRPC procedures, emails, webhook integration) is convenience built on top.
Freshness model
AlmostShipped ships Level-1 freshness: pull-based invalidation + light polling. No new infrastructure required, no long-lived connections, no extra services. Works on any deployment target.
What's wired
- AI generators invalidate credit + notification queries on
successful generation (
onSuccessfor image/video mutations,onFinishfor streaming text/code/chat viauseChat). - Top-up return retries invalidation at 1s / 3s / 6s after the provider's redirect-back, catching the webhook arrival window (Stripe / Paddle / etc. deliver the webhook 1–3s after the payment-complete redirect, so a single-shot invalidate races).
useCreditshook refetches on window focus — the dominant stale-balance case is "user switches tabs after a top-up / admin grant / daily-refresh cron and comes back".- Notification surfaces (
NotificationDropdownin the dashboard header andNotificationCenterin the alt-layout nav) poll the unread count every 60 seconds. TanStack Query'srefetchIntervalInBackgrounddefaults tofalse, so tabs hidden in the background stay idle — zero load when the user isn't looking.
Partial-prefix invalidation
All invalidations use the verified oRPC TanStack-Query key shape:
// Full key emitted by orpc.X.Y.queryKey({ input })
[["X", "Y"], { input: ..., type: "query" }]
// Partial-prefix key — matches BOTH personal-scope and org-scope
// queries because TanStack Query does prefix matching by default.
queryClient.invalidateQueries({ queryKey: [["credits", "balance"]] });This is critical for multi-tenant setups: useCredits({ organizationId })
emits a different key from useCredits(). A partial prefix matches both.
Scale expectations
At 1,000 daily active users:
- Notification polling: ~17 RPS on
unreadCount(single COUNT query, indexable, cheap on every database). - Credit balance: focus-only refetch — no sustained load. Spikes briefly when many users return to their tab at once (e.g., post- break-time on a working day).
If your DAU is in the tens of thousands and these become hot paths, move to Level 2 below.
Upgrade paths
Level 2 — Server-Sent Events (SSE): Push updates from the server when balance or notification state changes. One long-lived HTTP connection per active user. Works with Vercel functions (streaming responses), Cloudflare Workers, and any Node/Bun server. Eliminates polling cost; adds connection management.
Level 3 — WebSockets via managed realtime: Pusher, Ably, Supabase Realtime, etc. Bi-directional, scales further, but adds vendor dependency. Standard for multi-user collaborative apps. Overkill for credits + notifications alone — consider only when you also need typing indicators, presence, live cursors, etc.
For most buyers, Level 1 is the right answer through tens of thousands of DAU.
Quick start
1. Enable credits in config
// packages/payments/config.ts
export const config: PaymentsConfig = {
// ...existing fields...
credits: {
enabled: true, // master flag
scope: "auto", // mirror billingAttachedTo
dailyRefresh: { enabled: true },
notifications: {
lowBalance: { enabled: true, threshold: 100 },
},
},
};2. Configure per-plan grants
plans: {
pro: {
prices: [
{ type: "subscription", priceId: process.env.PRICE_ID_PRO_MONTHLY!, ... },
],
credits: {
subscriptionGrant: 5000, // granted on subscription create
dailyRefresh: 100, // topped up to this every day
},
},
}3. Render the widget
import { CreditBalance } from "@credits/index";
<CreditBalance />;The widget self-renders to null when credits are disabled.
4. Schedule the daily refresh cron
The boilerplate ships with the route already wired at
apps/saas/app/api/cron/credit-refresh/route.ts. It calls
runDailyCreditRefresh() and returns the summary as JSON.
Set CRON_TOKEN in your environment to gate the endpoint — requests must
then include the x-cron-token header matching that value. Leaving the
variable unset keeps the route open, which is fine for local dev.
Schedule a daily HTTP GET to /api/cron/credit-refresh from your runner
of choice (Vercel Cron, Render Cron, GitHub Actions, Trigger.dev).
Feature flag map
The credits config has nine flag points. Each gates a specific behaviour — buyers turn on exactly what their product needs.
| Flag | Default | Controls |
|---|---|---|
credits.enabled | false | Master toggle. When false, all credit operations are no-ops. |
credits.scope | "auto" | Where balances live: "auto" (mirrors billingAttachedTo), "user", or "organization". |
credits.dailyRefresh.enabled | true | Whether the daily refresh cron does anything. Set false if your model is "credits granted once per period, no daily top-up." |
credits.topUp.enabled | false | Allow users to buy one-off credit packs outside subscription cycles. |
credits.topUp.packs | [] | Available credit packs in the top-up UI. Each: { credits, priceId, label? }. |
credits.overage.enabled | false | Auto-charge users on overage instead of blocking. Stripe + Paddle only — other providers can't off-session charge. |
credits.overage.thresholdDollars | 50 | Minimum overage value before charging. |
credits.emails.lowBalance.enabled | false | Email when balance crosses threshold. |
credits.emails.lowBalance.threshold | 100 | Balance level that triggers the warning. |
credits.emails.lowBalance.cooldownHours | 24 | Minimum hours between repeat warnings. |
credits.emails.purchase.enabled | false | Receipt after a credit pack purchase. |
credits.emails.grantedOnSubscription.enabled | false | Email when subscription grants credits. Default off because the payment provider already sends a subscription receipt. |
credits.notifications.lowBalance.enabled | true | In-app notification (uses @repo/notifications) when balance crosses threshold. |
credits.notifications.lowBalance.threshold | 100 | Balance level for in-app warning. |
credits.notifications.grantReceived.enabled | false | In-app notification on grant. Default off — most apps don't bother. |
Per-plan grant flags
Each entry in plans can declare a credits block:
| Field | Controls |
|---|---|
plans.X.credits.subscriptionGrant | Credits granted on subscription create / renewal. Omit to skip. |
plans.X.credits.dailyRefresh | Daily top-up cap (used by the daily refresh cron). Omit to skip. |
Buyer scenarios — concrete flag combinations
Pure subscription SaaS, no credits
credits: {
enabled: false;
}Done. Zero credit code runs. The <CreditBalance> widget renders to
null, oRPC procedures return 404, the cron is a no-op.
AI app — full credit system
credits: {
enabled: true,
scope: "auto",
dailyRefresh: { enabled: true },
topUp: {
enabled: true,
packs: [
{ credits: 1000, priceId: process.env.PRICE_ID_TOPUP_1K! },
{ credits: 10000, priceId: process.env.PRICE_ID_TOPUP_10K! },
],
},
overage: { enabled: true, thresholdDollars: 50 },
emails: {
lowBalance: { enabled: true, threshold: 100, cooldownHours: 24 },
purchase: { enabled: true },
},
notifications: { lowBalance: { enabled: true, threshold: 100 } },
}
plans: {
pro: {
prices: [...],
credits: { subscriptionGrant: 5000, dailyRefresh: 100 },
},
team: {
prices: [...],
credits: { subscriptionGrant: 50000, dailyRefresh: 1000 },
},
}B2B SaaS — credits via subscription only, silent
credits: {
enabled: true,
scope: "organization", // org-level balance
dailyRefresh: { enabled: false }, // grant once per renewal, no daily top-up
topUp: { enabled: false },
overage: { enabled: false },
emails: {
/* all off — silent metering */
lowBalance: { enabled: false },
purchase: { enabled: false },
grantedOnSubscription: { enabled: false },
},
notifications: { lowBalance: { enabled: true, threshold: 1000 } },
}
plans: {
team: {
prices: [...],
credits: { subscriptionGrant: 50000 }, // big monthly grant, no daily refresh
},
}Internal tool — admin-only grants, no payments
credits: {
enabled: true,
topUp: { enabled: false },
overage: { enabled: false },
}
// No per-plan grants. All credits flow through the admin panel
// (credits.adminGrant procedure).What lives where
packages/database/prisma/schema.prisma
├── User.creditBalance ← live balance
├── Organization.creditBalance ← live balance (org-scope)
└── CreditTransaction ← append-only ledger
packages/payments/lib/credits/
├── balance.ts ← getCreditBalance / addCredits / deductCredits
└── daily-refresh.ts ← runDailyCreditRefresh
packages/api/modules/credits/
├── procedures/
│ ├── get-balance.ts ← credits.balance
│ ├── list-transactions.ts ← credits.transactions
│ ├── admin-grant.ts ← credits.adminGrant
│ └── admin-adjust.ts ← credits.adminAdjust
└── router.ts
apps/saas/modules/credits/
├── components/CreditBalance.tsx ← settings widget
├── hooks/use-credits.ts ← useCredits, useCreditTransactions
└── index.ts ← public exportsPublic API
Server-side helpers
Import from @repo/payments:
import {
getCreditBalance,
addCredits,
deductCredits,
resolveCreditScope,
runDailyCreditRefresh,
} from "@repo/payments";getCreditBalance(scope, entityId)
const { balance, scope, entityId } = await getCreditBalance("user", "user_xxx");addCredits(scope, entityId, amount, transaction)
const newBalance = await addCredits("user", "user_xxx", 100, {
kind: "grant",
reason: "Welcome bonus",
metadata: { campaignId: "launch-2026" },
});Returns the new balance after the grant. Race-safe.
deductCredits(scope, entityId, cost, transaction)
const result = await deductCredits("user", "user_xxx", 5, {
reason: "API call",
metadata: { endpoint: "/v1/generate" },
});
// { creditsUsed: 5, overflow: 0, balanceAfter: 95 }
//
// If balance was 3 and you asked for 5:
// { creditsUsed: 3, overflow: 2, balanceAfter: 0 }Caller decides what to do with overflow — block the user, charge for it,
let it slide.
resolveCreditScope({ userId, organizationId })
Helper that picks the right scope based on config.credits.scope. Pass
both — the helper picks the right one.
const target = resolveCreditScope({ userId: user.id, organizationId: org?.id });
if (target) {
await addCredits(target.scope, target.entityId, 100, { kind: "grant" });
}Client-side hooks
Import from @credits/index:
import { useCredits, useCreditTransactions } from "@credits/index";function MyComponent() {
const { balance, isEnabled, isLoading } = useCredits();
if (!isEnabled) return null;
return <p>{isLoading ? "..." : `${balance} credits`}</p>;
}oRPC procedures
| Procedure | Auth | What |
|---|---|---|
orpc.credits.balance | protectedProcedure (or org admin if organizationId) | Read balance for caller / organization |
orpc.credits.transactions | Same | Paginated ledger history |
orpc.credits.adminGrant | adminProcedure | Grant credits to any user/org |
orpc.credits.adminAdjust | adminProcedure | Set balance to exact value |
All four return 404 when credits.enabled = false so disabled-credits
buyers expose zero attack surface.
Audit trail — CreditTransaction.kind values
| Kind | Meaning |
|---|---|
grant | Initial signup grant or admin-administered gift |
subscription | Granted on subscription create / renewal webhook |
daily_refresh | Recurring tier-based replenishment from the cron |
purchase | One-off credit pack purchased via top-up flow |
deduct | Usage consumption (API call, AI generation, export, etc.) |
refund | Reverse a previous deduction |
expire | TTL-driven removal of stale credits |
Read the full ledger any time:
const history = await db.creditTransaction.findMany({
where: { userId: "user_xxx" },
orderBy: { createdAt: "desc" },
});Security notes
- No client-callable
deduct— credit deduction must happen server-side from inside other procedures (e.g., the AI generation endpoint). A client-callable deduct would let users empty arbitrary balances. - Idempotency on webhook grants — credit grants from
customer.subscription.created(and equivalents) run inside the webhook idempotency wrapper. Stripe retries don't double-grant. - Org auth —
credits.balanceandcredits.transactionsenforce the same org-admin check aspayments.listPurchaseswhenorganizationIdis in the input. - Admin grant audit metadata —
credits.adminGrantwritesmetadata.grantedByAdminIdso you can trace who granted what.