PackagesbillingMenu

@intelligo-dev/billing

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

Install

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

drizzle-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:

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. 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 }) 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

npm · source