PackagesbillingMenu

@intelligo-dev/billing

Quota engine, credits, Stripe, feature gates, trials and rate limiting.

Install

Terminal
pnpm add @intelligo-dev/billing drizzle-orm react stripe zod

drizzle-orm, react, stripe and zod are peers; the trial and payment emails render with React.

What it owns

Admission (estimateQuota / reserveQuota) and settlement, the credit ledger, Stripe checkout and the webhook receiver, feature gating, trial grants and expiry, and per-plan rate limits.

Use

The plan catalogue is the product’s, registered from the composition root:

TypeScript
// lib/intelligo.ts
import {
  registerProductFeatures,
  registerProductPlans,
  setDefaultProductSlug,
} from "@intelligo-dev/billing/plans";
import { fromMajor } from "@intelligo-dev/core/money";

setDefaultProductSlug("acme");
registerProductPlans("acme", {
  free: {
    name: "Free",
    slug: "free",
    description: "Try it",
    priceOneTime: 0,
    targetAudience: "Everyone",
    aiModelLabel: "Base",
    monthlyAllowance: fromMajor(0.5, "USD"),
    limits: { chatMessages: 30 },
    features: ["30 messages a month"],
  },
});
registerProductFeatures("acme", { exports: ["pro"] });

A transport then gates on the workspace’s plan:

TypeScript
import { requireFeature } from "@intelligo-dev/billing";

await requireFeature(workspace.id, "exports"); // throws FeatureNotAvailableError

A row in feature_flags overrides the registered matrix — isActive: false is a kill switch for every plan — and while the flags cannot be read, a feature is refused rather than granted. AI spend goes through the execution boundary instead: the composition root binds reserveQuota, recordTokenUsage and releaseReservation to the ports of @intelligo-dev/executions, so a run is admitted against the plan’s allowance and the credit balance, and settled exactly once.

A fixed price per unit of work

Not everything a product sells is a model’s tokens. An execution can carry a fixed price instead — a report, an export, a document — and goes through the same admission and settlement as a turn:

TypeScript
import { fromMajor } from "@intelligo-dev/core/money";

const run = await executions.begin({
  workspaceId: workspace.id,
  userId: session?.user.id ?? null, // null for work nobody signed in started
  capability: "report.generate",
  price: fromMajor(1, "USD"),
});
if (!run.allowed) return refuse(run.code); // insufficient_credits, allowance_depleted…

try {
  const report = await generateReport(recordId);
  await run.complete(); // charges exactly the price
  return report;
} catch (error) {
  await run.fail({ error }); // charges nothing and releases the hold
  throw error;
}

executions is the one lib/intelligo.ts exports (createBillingExecutions()). begin holds exactly the price under the workspace lock and refuses when the plan allowance, top-up and trial cannot cover it, so two concurrent runs against a balance that fits one admit one. complete records a usage_records row of type fixed_charge and the charge on the execution, which the usage page shows; reconcile() settles a run stuck mid-settlement at the same price, which the row keeps. A requestId names one attempt and is unique across the deployment: a refused attempt’s id is spent, so a retry begins with a new one. begin with a used id throws an ExecutionError (request_id_taken). With a model as well, the price wins for the hold and the charge, and the usage row keeps the model, tokens and provider cost, so the margin on fixed-price work stays visible. The price must be a positive whole number of micros in the deployment’s billing currency; one that is not throws a ChargeError (invalid_amount, currency_mismatch) rather than refusing. estimateQuota(workspaceId, { amount }) answers the same question without holding anything.

Refunds and credits

A charge that should not have happened is a failed run, not a refund. For money that was rightly taken and is given back:

TypeScript
import { creditWorkspace, refundCharge } from "@intelligo-dev/billing";

await refundCharge(workspace.id, run.requestId, {
  reason: "delivered late",
  amount: fromMajor(0.5, "USD"), // optional: all of the charge by default
  actorId: admin.id,
});
await creditWorkspace(workspace.id, fromMajor(5, "USD"), {
  reason: "launch promotion",
  requestId: `promo-2026-10:${workspace.id}`, // the idempotency key
  actorId: admin.id,
});

Both land on the top-up balance, whichever pool paid the charge, and are recorded three ways: the ledger, a usage_records row of type credit with a negative charge, and a billing.refund or billing.credit audit event with the actor and the reason. Neither is an execution, so execution totals show what ran, not what was given back. One refund per charge: a second call replays the first, a partial refund included. Their keys are stored as refund:<requestId> and credit:<requestId>, so neither meets a charge’s. The replay check runs under the workspace lock, so concurrent calls with one key credit once. An amount the ledger cannot take throws a ChargeError.

Subpaths that import neither Stripe nor server-only

Subpath What
/plans Plan types and the per-product plan helpers
/plan-registry Every register/clear pair: plans, features, upgrade copy, trials, rate limits
/payment The payment provider contract and the mock provider
/quota-types Quota result and admission types, no enforcement

The four are safe to reach from a client bundle or an edge runtime. An architecture test walks their imports so a Stripe or server-only import cannot creep in.

What a deployment bills in

ensureBillingSettingsRow writes the currency, the USD rate and the margin on first boot and never touches an existing row again. After that the row changes through updateBillingSettings({ currency?, usdRateMicros?, marginBp? }), which refuses a non-positive rate or margin and an invalid currency code, and drops the cache. Changing the currency converts nothing: balances, trial grants and the period’s allowance usage stay in the currency they were written in, and the engine refuses a write in another one until those rows are migrated.

The plan allowance is money, so the 80% and 100% notices (in-app and email) state an amount used out of an amount allowed. The billing period is the calendar month in UTC (getCurrentPeriodStart, getCurrentPeriodEnd, getCurrentPeriodKey), whatever zone the host runs in.

Rate limits and the payment provider

checkRateLimit(workspaceId, planSlug, endpoint?) counts in the "chat" bucket unless the caller names another; a route that should not spend chat’s allowance passes its own name. checkRateLimit(subject, { limit, windowMs, endpoint }) counts any subject — a workspace, or a hashed IP for requests nobody signed in to make — against its own limit per window of whole seconds, a day included:

TypeScript
const daily = await checkRateLimit(`ip:${sha256(ip)}`, {
  endpoint: "export",
  limit: 3,
  windowMs: 86_400_000,
});

Windows are fixed and aligned to the Unix epoch in UTC — a day resets at 00:00 UTC. planSlug gives a plan’s per-minute rate and goes with the default one-minute window only.

getPaymentProvider() resolves PAYMENT_MODE, which defaults to mock. Outside production the in-memory mock needs no registration; in production it is refused, and any other mode must be registered from the composition root.

Local payments

A QR-and-poll payment (QPay, PIX, UPI…) goes through two calls. openLocalInvoice({ workspaceId, userId, reference, price, grant }) issues the invoice through the provider and records it in payments at the price the server decided, with the grant it buys — { plan: slug, days? } or { credits: Money }. settleLocalInvoice({ invoiceId, workspaceId, fulfil }) asks the provider that issued it; when it is paid, the row is marked fulfilled and its stored grant is applied in the same transaction, so concurrent polls grant once and a change to the offer after the invoice was opened changes nothing it buys. fulfil decides the grant only for an invoice opened without one. A plan with days lapses that long after it is granted; bought again before then, the days add to the running grant. An invoice of another workspace is payment_not_found.

A buyer who pays in their bank’s app and closes the tab is not left unfulfilled: settlePendingLocalInvoices({ fulfil }) settles every unfulfilled invoice opened at least a minute ago (which the tab may still be polling) against the workspace that recorded it, until it is paid or past its expiry, and settlePendingLocalInvoices({ invoiceId, fulfil }) settles the one a provider’s callback names. An invoice still unpaid LOCAL_INVOICE_EXPIRY_GRACE_MS after the expiresAt its provider returned is cancelled at the provider (cancelPayment) and marked failed. Both ask the provider; a callback’s body is never taken as payment.

A provider is registered from the composition root with registerPaymentProvider(mode, { currency, createPayment, checkPayment, cancelPayment, invoiceIdFromCallback?, callbackResponse? }) from /payment. openLocalInvoice records the invoice before it asks the provider and passes the row’s id as paymentId: use it as the provider’s merchant-side invoice number or in the callback URL. A provider whose callbacks name an invoice implements invoiceIdFromCallback(request), which may return that paymentId in place of the provider’s own id, and one that expects a particular answer (a status, a fixed body) implements callbackResponse({ settled }).

currency is the ISO 4217 code the provider charges in. createPayment takes bare minor units, as toMinor gives them: a zero-decimal currency such as MNT or JPY arrives in whole units. With currency set, openLocalInvoice refuses a price in any other currency (currency_mismatch). Without it, such a price is sent as that many of the provider’s own units, and registering the provider in production logs a warning.

A plan is not sold through a local invoice to a workspace a live Stripe subscription bills: openLocalInvoice refuses it (subscription_active), since the subscription’s next webhook would put its own plan back.

The payment-poll registry item is the transport: it serves the sweep at /api/payments/local/settle (a CRON_SECRET schedule) and the provider’s callback at /api/webhooks/local-payment. creditBundleOffer(bundle) turns one of the product’s credit bundles into the offer the lib/local-payment.ts seam returns.

Granting a plan

grantPlan({ workspaceId, planSlug, reason, actorId?, endsAt? | days? }) puts a workspace on a plan as a product decision (a reward, a partner deal) rather than a payment. With endsAt or days the grant lapses, and processExpiredPlanGrants() — a step of the maintenance route — returns it to the free plan, audited as billing.plan_grant.expired. days counts from the end of a running grant of the same plan, so renewals stack. On a workspace Stripe bills, the plan moves but the period stays Stripe’s.

The webhook receiver

createStripeWebhookHandler verifies the signature before anything else, writes the receipt before running handlers, claims the event with UPDATE … WHERE processed_at IS NULL so two concurrent deliveries cannot both run, and answers 500 on a handler error so Stripe retries. Answering 202 to an error tells Stripe the event was handled and silently drops it.

Stripe does not deliver events in order. customer.subscription.updated reads the subscription back from Stripe and writes what it holds now, so a retried older update cannot undo a newer one. charge.refunded and charge.dispute.created take the refunded or disputed share of a credit purchase’s grant back out of the balance, never below zero, recorded as a credit usage row and a billing.credit_reversed audit event; a purchase refunded or disputed before it was granted is never granted.

Entry points

  • @intelligo-dev/billing
  • @intelligo-dev/billing/plans
  • @intelligo-dev/billing/payment
  • @intelligo-dev/billing/plan-registry
  • @intelligo-dev/billing/quota-types
  • @intelligo-dev/billing/rate-limit
  • @intelligo-dev/billing/quota

npm · source