AlmostShipped

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 SQL balance += N plus a ledger row.
  • deductCredits — guarded CASE 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 (onSuccess for image/video mutations, onFinish for streaming text/code/chat via useChat).
  • 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).
  • useCredits hook 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 (NotificationDropdown in the dashboard header and NotificationCenter in the alt-layout nav) poll the unread count every 60 seconds. TanStack Query's refetchIntervalInBackground defaults to false, 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.

FlagDefaultControls
credits.enabledfalseMaster toggle. When false, all credit operations are no-ops.
credits.scope"auto"Where balances live: "auto" (mirrors billingAttachedTo), "user", or "organization".
credits.dailyRefresh.enabledtrueWhether the daily refresh cron does anything. Set false if your model is "credits granted once per period, no daily top-up."
credits.topUp.enabledfalseAllow 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.enabledfalseAuto-charge users on overage instead of blocking. Stripe + Paddle only — other providers can't off-session charge.
credits.overage.thresholdDollars50Minimum overage value before charging.
credits.emails.lowBalance.enabledfalseEmail when balance crosses threshold.
credits.emails.lowBalance.threshold100Balance level that triggers the warning.
credits.emails.lowBalance.cooldownHours24Minimum hours between repeat warnings.
credits.emails.purchase.enabledfalseReceipt after a credit pack purchase.
credits.emails.grantedOnSubscription.enabledfalseEmail when subscription grants credits. Default off because the payment provider already sends a subscription receipt.
credits.notifications.lowBalance.enabledtrueIn-app notification (uses @repo/notifications) when balance crosses threshold.
credits.notifications.lowBalance.threshold100Balance level for in-app warning.
credits.notifications.grantReceived.enabledfalseIn-app notification on grant. Default off — most apps don't bother.

Per-plan grant flags

Each entry in plans can declare a credits block:

FieldControls
plans.X.credits.subscriptionGrantCredits granted on subscription create / renewal. Omit to skip.
plans.X.credits.dailyRefreshDaily 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 exports

Public 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

ProcedureAuthWhat
orpc.credits.balanceprotectedProcedure (or org admin if organizationId)Read balance for caller / organization
orpc.credits.transactionsSamePaginated ledger history
orpc.credits.adminGrantadminProcedureGrant credits to any user/org
orpc.credits.adminAdjustadminProcedureSet 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

KindMeaning
grantInitial signup grant or admin-administered gift
subscriptionGranted on subscription create / renewal webhook
daily_refreshRecurring tier-based replenishment from the cron
purchaseOne-off credit pack purchased via top-up flow
deductUsage consumption (API call, AI generation, export, etc.)
refundReverse a previous deduction
expireTTL-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.balance and credits.transactions enforce the same org-admin check as payments.listPurchases when organizationId is in the input.
  • Admin grant audit metadata — credits.adminGrant writes metadata.grantedByAdminId so you can trace who granted what.

On this page