GenerateSaaS

Credits

Meter any paid feature with usage-based credits - per-plan grants, recurring re-grants, custom top-ups, and automatic refills scoped to users or organizations.

A general-purpose usage meter owned by @repo/payments: one balance per billing entity that any feature can draw from - AI runs, external API calls (search data, enrichment, scraping), renders, exports, anything you charge per use. The shipped AI chat is just the first consumer; wiring your own feature is a few lines (see Metering your own features).

Configured in pricingConfig (packages/config/src/pricing.ts) and gated by pricingConfig.credits.enabled (default true). pricingConfig is a separate named export from @repo/config, not a key on the config object. With credits.enabled: false, grants and the credits UI are suppressed; balances simply stay at 0.

Storage

Balances belong to a billing entity - user or organization, per config.tenancy.billingScope. Better Auth persists them through billingAdditionalFields (packages/config/src/billing-fields.ts), all input: false so only the server mutates them via the Billing(entityId) accessor.

FieldDefaultPurpose
credits0Signed balance. A negative value is debt.
creditsLastGrantedAt-Timestamp of last recurring grant.
autoTopUpEnabledfalsePer-entity auto top-up opt-in.
autoTopUpThreshold-Balance that triggers a top-up.
autoTopUpAmount-Credits bought per top-up.

The Billing(...) accessor (packages/payments/src/billing.ts) exposes addCredits, removeCredits, settleCredits, setCredits, triggerAutoTopUpIfNeeded, a hasCredits getter, and hasCreditsFor(amount).

Metering your own features

Meter any service from a server-side code path - a Hono route, background job, or webhook. Derive the price, gate work that can still be refused, perform it, then settle its full cost:

import { Billing, costToCredits } from "@repo/payments";

const billing = await Billing(session); // or Billing(entityId)
const credits = costToCredits(0.4); // this lookup costs you $0.40

if (!billing.hasCreditsFor(credits)) {
	return c.json({ error: "Insufficient credits" }, 402);
}

const data = await fetchSerpResults(query); // your paid upstream call

await billing.settleCredits(credits, {
	source: "system",
	type: "credit_usage",
	feature: "seo.serp-lookup",
	description: "SERP lookup"
}, {
	cause: "usage",
	idempotencyKey: `serp_${runId}`
});

Use a globally unique run ID created once for the logical lookup and reuse it on retries. The stable idempotency key makes a retry resolve without landing the same settlement twice.

There are two public debit operations:

OperationBehaviorUse when
removeCreditsFull debit or INSUFFICIENT_CREDITS; never overdraws.Work can still be refused.
settleCreditsFull signed debit; may create debt.Usage, refund, or chargeback already happened.

Migrating code that used removeCredits(..., { drain: true }) is a semantic choice: use settleCredits only when the cost or reversal already happened. If the action can still be refused, keep strict removeCredits and drop the old option.

A negative balance is debt. Every later credit addition uses normal signed addition, repaying that debt before spendable credit appears; for example, adding 12 to -10 leaves 2. hasCreditsFor keeps new metered work gated while debt exists.

Token-billed streaming cannot know its exact cost until the run ends, so the shipped AI surfaces layer three guards instead of one: a pre-flight estimate gate rejects clearly unaffordable work before a provider call, a mid-run budget stop bounds the agent loop, and signed settlement records the exact completed cost. An estimate miss or concurrent run can therefore expose debt instead of hiding incurred usage.

Tag every usage debit with a feature id ("ai.chat", "seo.serp-lookup", ...). It lands on the billing log row, filters queryBillingLogs({ feature }), and powers sumCreditUsageByFeature(entityType, entityId, range?) for per-feature usage breakdowns. The shipped AI surfaces tag ai.chat, ai.summarize, and ai.schedule - your features live alongside them in the same ledger, history UI, and analytics.

Pricing: one markup for everything

When a feature has a real upstream cost (an AI run or metered API call), price the debit with costToCredits(costUsd) from @repo/payments, as in the lookup above, instead of hand-picking a number.

costToCredits applies the shared economics on pricingConfig.credits:

KeyShippedMeaning
creditsPerUsd2Credits per 1 USD of underlying cost. Must be present, finite, and > 0 when credits are enabled - costToCredits throws on a missing, zero, or non-finite scale rather than silently under-billing every metered run.
markupPercent20The ONE margin knob for every cost-priced debit - AI, external APIs, your features. 20 charges cost + 20%.

Check your unit economics

Custom purchases and packs sell credits at 1 credit = 1 base-currency unit, so at the shipped creditsPerUsd: 2 + markupPercent: 20 one credit carries about 0.42 USD of underlying cost - a ~58% gross margin on credit sales. Whenever you change plan credits, prices, or the markup, verify each plan still holds:

plan.credits / creditsPerUsd * (1 + markupPercent / 100)  <  plan price

That is the plan's maximum AI spend per cycle at FULL credit utilization. Two traps to avoid:

  • Non-USD storefronts: AI providers bill in USD while your prices are in your base currency. If your currency is weak against the dollar (or zero-decimal, like JPY), the same numeric prices are worth far less - re-run the check in USD terms and raise prices or creditsPerUsd accordingly.
  • Lifetime prices with recurring credits: a plan sold once but granting credits every creditInterval would be an unbounded liability. The recurring-credits cron enforces this for you: a LIFETIME holding of any non-free plan receives its allotment exactly once (at purchase), regardless of the plan's creditInterval - the interval applies only to the plan's recurring (monthly/yearly) purchases.

A single markup for everything is deliberate: per-feature margins invite silent pricing mistakes (a discount tuned against one feature's markup loses money on another's). Change markupPercent once and every metered feature follows. The shipped AI surfaces price through the same helper, so your features and AI can never drift apart.

Per-plan grants

Each plan in pricingConfig.plans may carry these credit fields (PricePlan in packages/config/src/types/index.ts):

KeyTypeDefaultDescription
creditsnumber0Credits the plan includes per cycle.
creditIntervalnumber (days)0How often credits re-grant. Omit or 0 to disable recurring grants.
grantCreditsOnTrialbooleanunset = withholdDuring a trialDays trial, credits are withheld until the first payment unless set to true (the runtime check is === true) - up-front trial credits are farmable across throwaway trials.

The shipped plans grant free: 5, starter: 10, pro: 50, all on creditInterval: 30. Resolve them with getCreditsForPlan(planId) / getCreditIntervalForPlan(planId) (return 0 when absent).

grantCreditsOnTrial is opt-in: webhook handlers grant trial credits only when plan.grantCreditsOnTrial === true. Leave it unset or set it to false to withhold credits until the first paid grant - the recurring-credits job also skips trialing entities.

Recurring re-grants

The recurring-credits job (packages/api/src/functions/billing/recurring-credits.ts) runs daily on cron 0 0 * * *.

Collect plans where creditInterval > 0, then load active-plan entities for the billing scope (trialing entities are excluded).
For each, compare creditsLastGrantedAt + creditInterval against now; skip those not yet due.
Claim a per-day idempotency key, grant via addCredits(...) - which stamps creditsLastGrantedAt in the same update - and write a CREDITS_ADDED audit entry.

See Background jobs for the scheduler and Audit logs for the entries.

Custom purchases and auto top-up

The shipped config is credits: { enabled: true } with no customPurchase block, so custom purchases and auto top-up are off. Add pricingConfig.credits.customPurchase to let users buy arbitrary credits beyond their plan; these are the fields you set when you add it:

KeyTypeDescription
enabledbooleanAllow custom credit purchases.
autoTopUpbooleanAllow users to opt into automatic refills.
minimumCreditsnumberFloor per custom purchase.
presetCreditsnumberSuggested amount in the purchase UI.
stripeProductId / polarProductIdstring?Metered line item per provider.

Ordinary removeCredits calls (successful or insufficient) run the auto top-up check, as does settleCredits with cause: "usage". The shipped insufficient-balance gates also invoke triggerAutoTopUpIfNeeded() explicitly. Refund and chargeback settlements never auto-top-up, so a reversal cannot charge the customer again. With no customPurchase block the check returns early and nothing fires. Once you add the block, a top-up fires only when all hold: customPurchase.enabled and customPurchase.autoTopUp are on, the active provider is "stripe", the entity has set autoTopUpEnabled/autoTopUpThreshold/autoTopUpAmount (threshold must be > 0), and the balance is below autoTopUpThreshold.

Auto top-up is Stripe-only. Under Polar, users can still buy credits manually, but checkAndTriggerAutoTopUp returns early and no automatic refill runs.

Asynchronous payment methods (SEPA/ACH debit) confirm days after the charge is created. While a top-up charge is pending, further below-threshold triggers are blocked by a pending-charge marker so one low balance cannot mint multiple live charges; the marker clears when Stripe delivers payment_intent.succeeded or payment_intent.payment_failed (enable BOTH events in your Stripe webhook), or after a 3-day backstop TTL if the terminal event is lost.

Notifications

When credits land, @repo/notifications fires the credits_added trigger (notifyCreditsAdded), creating an in-app notification linking to billing settings - gated by config.notifications.enabled (default true).

notifyUser is user-scoped only - it is a no-op for organizations. Under config.tenancy.billingScope: "organization", org credits are granted but no credits_added notification fires.

Refunds and chargebacks

Prepaid credits are non-refundable - the standard for prepaid balances, and the primary defense against buy-spend-refund abuse. A notice to that effect always shows at the point of purchase. This is policy copy only; it does not block a merchant-issued refund or the corresponding credit reversal.

When you do issue a refund from your Stripe/Polar dashboard, the charge.refunded / order.refunded webhook settles the applicable credit reversal in full. Custom-credit and auto-top-up refunds reverse each newly refunded share; product and lifetime-plan benefits reverse once on the first reversal event. Already-spent credits become debt: a 100-credit reversal against a balance of 10 leaves -90, and later additions repay that amount naturally.

The durable charge_reversals table owns provider reversal accounting, not credit balances. It tracks cumulative refunded and disputed amounts, whether a one-shot benefit was reversed, and the remaining budget for each charge or order. Repeated, partial, or out-of-order events cannot reuse a prior delta or reverse more than the original grant. The signed balance remains on the user or organization.

Chargebacks (a customer disputing the charge with their bank) are the real fraud vector, because you cannot decline them the way you can a refund request. On Stripe's charge.dispute.created event the handler settles the dispute's newly reversible credit share and always suspends the buyer (Better Auth banned): going straight to a chargeback instead of contacting support is payment abuse, so a serial abuser cannot repeat buy-spend-chargeback. An admin lifts the ban after review.

Enable the charge.dispute.created event in your Stripe webhook for chargeback handling to fire - it is not on by default. Polar is a Merchant of Record and handles disputes itself (a lost dispute arrives as a normal refund), so this handling is Stripe-only.

Frequently asked questions

Who owns a credit balance - the user or the org?

Whichever config.tenancy.billingScope selects. The same Billing(entityId) accessor routes to the users or organizations table accordingly.

Are credits granted during a free trial?

Only when the plan sets grantCreditsOnTrial: true. Otherwise they are withheld until the first paid grant after the trial converts.

Can I charge credits for things other than AI?

Yes - that is the design. Gate work that can still be refused with hasCreditsFor; use strict removeCredits for an authorized debit or settleCredits after the cost has happened. Tag a feature id for per-feature analytics. See Metering your own features.

Why don't org members see a credits notification?

The notification system is user-scoped; notifyUser no-ops for organizations, so org-scoped grants move the balance silently.

On this page