Skip to main content
Localizia

Credits

Overview

Credits are a usage currency for metered features (AI tokens, exports, anything you meter). They're separate from subscriptions: customers buy one-time packs, and your code spends credits with a safe reserve-then-settle primitive. Packs are defined in src/config/credits.config.ts.

Defining packs

Each pack is a one-time Stripe product (mode=payment, not subscription):

{
  id: "standard",
  name: "Standard",
  credits: 5_000,
  price: 4000,                  // display only — Stripe is authoritative
  stripePriceId: env.STRIPE_CREDIT_PACK_STANDARD_PRICE_ID,
  stripeProductId: env.STRIPE_CREDIT_PACK_STANDARD_PRODUCT_ID,
  isFeatured: true,
}

A pack with no stripePriceId isn't purchasable. On successful payment, the Stripe webhook resolves the pack from its price ID and grants the credits.

Where credits live

Credit distribution within an organization is configurable:

  • Pool — a shared org balance everyone spends from.
  • Per-member — each member has their own balance, with scheduled refills applied lazily (on read) so there's no cron to run.

Owners/admins manage member balances and overrides from the organization's credits settings.

Spending: estimate → reserve → settle

Never decrement a balance directly. The full metered lifecycle is:

1. Estimate. Costs live in a typed catalog in src/config/credits.config.ts (creditCosts — a flat cost per call plus an optional perUnit cost; each operation defines its own unit). Replace the example entries with your product's operations:

import { estimateCredits } from "@/config/credits.config"

const estimated = estimateCredits("ai.chat", expectedTokens / 1000)

Clients can pre-check affordability with the credits.estimate tRPC query, which returns { estimated, balance, sufficient } — useful to disable a button or show the cost before the user commits.

2–4. Reserve, run, settle — use withCreditHold, the recommended wrapper (from src/lib/billing/credits.ts):

const text = await withCreditHold(
  { db, organizationId, userId, estimated, reason: "ai.chat" },
  async () => {
    const res = await doTheWork()               // 3. run the operation
    return { result: res.text, actual: res.creditsUsed }
  },
)

It reserves the estimate atomically (throws errors.insufficientCredits before any work runs when funds are short), executes your function, and settles with the actual cost it reports — refunding the unused estimate or charging the overage. If your function throws, the hold is released (full refund) and the error is rethrown. The low-level primitives (reserveCredits / settleReservation / releaseReservation) remain exported for flows that don't fit the wrapper (e.g. settling from a webhook).

Guarantees:

  • Reserving is atomic — the estimate is deducted with a guarded update, so two concurrent requests can't both spend the last of the balance, and the deduction + reservation row commit in one transaction.
  • Settle/release are race-safe — the status flip is an atomic claim, so of concurrent settle/settle or settle/release calls exactly one moves money (the stale-reservation job can never double-refund an in-flight settle).
  • Balances never go negative. If the actual cost exceeds the estimate and the remaining balance, the charge is capped at zero and the uncovered difference is written to the ledger as an adjustment row (credits.tx.overageDeficit) — auditable, not just a log line. Reserve a generous estimate; anything unused is refunded at settle.
  • No consumption hits the ledger until settle, so a held reservation temporarily lowers the spendable balance by design. Abandoned holds are released by the release-stale-credit-reservations job after 24h.

Reading the balance

Client code reads the spendable balance through the credits tRPC router (credits.balance), which returns the pool balance or the member's own balance depending on the org's policy, with refills applied lazily — plus held, the sum of credits currently held by open reservations (already subtracted from the spendable number; the UI uses it to explain the difference).