@intelligo-dev/billing
Quota engine, credits, Stripe, feature gates, trials and rate limiting.
Install
pnpm add @intelligo-dev/billing drizzle-orm stripe zoddrizzle-orm, stripe and zod are peers.
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:
// 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:
import { requireFeature } from "@intelligo-dev/billing";
await requireFeature(workspace.id, "exports"); // throws FeatureNotAvailableErrorA row in feature_flags overrides the registered matrix — isActive: false
is a kill switch for every plan. 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:
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:
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:
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 }) issues the
invoice through the provider and records it in payments at the price the
server decided. settleLocalInvoice({ invoiceId, workspaceId, fulfil }) asks
the provider that issued it; when it is paid, the row is marked fulfilled and
the grant fulfil returns — { plan: slug, days? } or { credits: Money } —
is applied in the same transaction, so concurrent polls grant once. A plan
with days lapses that long after it is granted; bought again before then,
the days add to the running grant.
A buyer who pays in their bank’s app and closes the tab is not left
unfulfilled: settlePendingLocalInvoices({ fulfil }) settles every
unfulfilled invoice opened in the last day (and at least a minute ago, which
the tab may still be polling) against the workspace that recorded it, and
settlePendingLocalInvoices({ invoiceId, fulfil }) settles the one a
provider’s callback names. Both ask the provider; a callback’s body is never
taken as payment. A provider whose callbacks name an invoice implements
invoiceIdFromCallback(request). The payment-poll item serves both at
/api/payments/local/settle (a CRON_SECRET schedule) and
/api/webhooks/local-payment. An invoice of
another workspace is payment_not_found. The payment-poll registry item is
the transport over both. A provider is registerPaymentProvider(mode, { createPayment, checkPayment, cancelPayment }) from /payment, bound in the
composition root; creditBundleOffer(bundle) turns one of the product’s credit
bundles into the offer the lib/local-payment.ts seam returns.
A provider is registerPaymentProvider(mode, { currency, createPayment, checkPayment, cancelPayment }) from /payment, bound in the composition root.
Give a real provider its currency: createPayment takes bare minor units, and
only a provider that names its currency makes openLocalInvoice refuse a price
in another one (currency_mismatch) instead of sending that number as its own.
Registering one without it in production logs a warning.
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.
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