Billing
Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units.
@voltro/plugin-billing wires a billing provider into Voltro's runtime. Subscriptions become rows in your DB; provider webhooks update them; entitlements gate features by quota. It rides @voltro/plugin-webhooks for inbound event signature verification + idempotency, so it never re-implements that machinery.
All monetary amounts are integer minor units (cents for USD/EUR, pence for GBP) paired with a currency string — never a float, never a decimal column.
Install
// app.config.ts
import { billingPlugin } from '@voltro/plugin-billing'
export default {
type: 'api' as const,
plugins: [
billingPlugin({
provider: 'stripe', // | 'mock' | a BillingProvider
apiKey: process.env.STRIPE_SECRET_KEY, // server-only; default STRIPE_SECRET_KEY
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, // default STRIPE_WEBHOOK_SECRET
plans: {
free: { entitlements: { aiCalls: 100, storageBytes: 1_000_000_000 } },
pro: { priceId: 'price_…', entitlements: { aiCalls: 5000, storageBytes: 50_000_000_000 } },
ent: { priceId: 'price_…', entitlements: { aiCalls: 'unlimited', storageBytes: 1_000_000_000_000 } },
},
}),
],
}plans is the single source of tier→limit truth — it lives in code, not the DB, so limits change by redeploy, not migration. Entitlement values are number | 'unlimited'. With no provider configured (and no STRIPE_SECRET_KEY), the plugin uses an in-memory mock provider — zero-config for dev and tests.
What it provides:
- The
_voltro_billing_*tables (customers, subscriptions, invoices, usage, flush_claims) viaextendSchema.tables. - The
BillingServiceContext.Tag — yield it in any handler. - The
requireEntitlement(ctx, key, cost)in-handler quota guard + the declarativeenforcemap. - A webhook receiver at
POST /billing/webhook. - The rpc routes
billing.startCheckout,billing.portalUrl,billing.subscription,billing.reportUsage,billing.changePlan,billing.changeSeats. - Seat-based billing; proration and failed-payment retries are Stripe's.
- The typed
BillingError+EntitlementExceedederrors, merged into every procedure's wire error union. - A browser-safe
useStartCheckout()hook on the/websubpath.
The BillingService
Yield the service in any handler:
import { Effect } from 'effect'
import { BillingService } from '@voltro/plugin-billing'
export default (input: { tenantId: string }, _ctx) =>
Effect.gen(function* () {
const billing = yield* BillingService
const sub = yield* billing.subscription(input.tenantId) // Subscription | null
const plan = yield* billing.plan(input.tenantId) // resolves tier, defaults 'free'
return { plan, status: sub?.status ?? 'none' }
})The subscription row lives in your DB; the provider is the source of truth and webhooks keep the row in sync. plan() returns 'free' when there is no active (or trialing) subscription.
Entitlement checks
There is no guards: field on defineMutation — procedures carry only name / input / output / error / target. Quota enforcement happens one of two real ways.
In-handler — requireEntitlement
The Effect-native guard, mirroring requireScope / permission(). It resolves the caller's tenantId from the subject, atomically checks-and-decrements the quota, and fails with the typed EntitlementExceeded when exhausted:
import { Effect } from 'effect'
import { requireEntitlement } from '@voltro/plugin-billing'
export default (input: { tokens: number }, ctx) =>
Effect.gen(function* () {
yield* requireEntitlement(ctx, 'aiCalls', Math.ceil(input.tokens / 1000))
// … the work the quota gates …
return { ok: true }
})requireEntitlement declares BillingService in its Effect requirements — the plugin's services layer provides it automatically. Compute the cost however you like; pass 1 for a flat per-call charge or a derived integer for metered work.
Declarative — the enforce map
For per-tag enforcement without touching the handler body, pass an enforce map (the rate-limit-plugin pattern). It installs an interceptor that consumes the entitlement BEFORE the executor runs — an over-quota call never reaches your code:
billingPlugin({
plans: { /* … */ },
enforce: {
'ai.heavy': { entitlement: 'aiCalls', cost: 1 },
},
})Either form fails with EntitlementExceeded (a Schema.TaggedError carrying { entitlement, limit, used, cost }), decoded typed on the client. An 'unlimited' plan limit short-circuits without touching the counter.
Quota windows are per calendar month (YYYY-MM) per (tenantId, entitlementKey). The check + decrement is a store-level atomic consume — a compare-and-set on the usage row (UNIQUE(tenant, key, period)), safe across replicas — that runs OUTSIDE the mutation's transaction: a handler that fails after consuming does not refund the quota.
Entitlements are orthogonal to RBAC scopes: a scope answers "may you call this proc"; an entitlement answers "do you have quota left". A procedure can require both.
Webhook handling
Provider events land at POST /billing/webhook. The plugin rides @voltro/plugin-webhooks' Stripe provider preset, so signature verification (the Stripe-Signature t=…,v1=… scheme, 5-minute replay window) and idempotency (Stripe event id, 30-day TTL) come for free. The handler maps the verified payload to a provider-agnostic BillingEvent, applies it to the DB rows, then runs any onEvent side effect.
Events handled:
customer.subscription.created/updated/deletedinvoice.paid/invoice.payment_failedcustomer.created(links the tenant via Stripe metadata)
applyEvent upserts by provider id, so it's idempotent even under a cross-process replay where the LRU idempotency cache wouldn't catch the duplicate. The webhook needs tenantId (and, for subscriptions, plan) in the Stripe object's metadata — startCheckout sets it automatically.
Per-event side effects run AFTER the row is updated:
import { Effect } from 'effect'
billingPlugin({
plans: { /* … */ },
onEvent: {
invoicePaymentFailed: (event) =>
Effect.sync(() => {
// event: { _tag: 'invoicePaymentFailed', tenantId, providerInvoiceId, amountMinor, currency }
console.warn('payment failed for', event.tenantId, event.amountMinor, event.currency)
}),
},
})onEvent keys are the normalized BillingEvent tags (subscriptionUpserted, invoicePaid, invoicePaymentFailed, customerLinked, subscriptionCanceled), not raw Stripe types.
Checkout + upgrade flows
The /web subpath ships a browser-safe hook. It imports nothing from the server module — no secret, no node-only lib ever reaches the browser bundle. Pass it the generated billing.startCheckout rpc binding:
import { useStartCheckout } from '@voltro/plugin-billing/web'
import { useAppClient } from '@voltro/web'
const UpgradeButton = () => {
const app = useAppClient('app')
const { startCheckout, pending } = useStartCheckout((input) => app.billing.startCheckout(input))
return (
<button
disabled={pending}
onClick={() => startCheckout({ plan: 'pro', successUrl: location.href, cancelUrl: location.href })}
>
Upgrade to Pro
</button>
)
}The rpc calls the provider, returns a hosted checkout URL, and the hook redirects. The provider captures the card; on success it redirects back to your successUrl and the webhook updates the subscription row. startCheckout fails with BillingError for a plan that has no priceId (free plans aren't paid checkouts).
Customer portal
For self-service plan changes, payment-method updates, and invoice download, mint a provider-hosted portal URL. The tenant must already have a linked provider customer (created on first checkout / customer.created):
import { Effect } from 'effect'
import { BillingService } from '@voltro/plugin-billing'
export default (input: { tenantId: string; returnUrl: string }, _ctx) =>
Effect.gen(function* () {
const billing = yield* BillingService
return yield* billing.portalUrl(input.tenantId, input.returnUrl) // { url }
})Usage reporting
For metered billing, record usage locally; the service flushes the aggregate to the provider in one batched push per (tenant, key):
const billing = yield* BillingService
yield* billing.reportUsage(tenantId, 'aiCalls', count)
// later — typically from a schedule:
yield* billing.flushUsage()flushUsage is a no-op on providers that report supportsMeteredUsage: false. Counters that have already been pushed are not re-sent.
Tables
All five are _voltro_-prefixed and built from the cross-dialect schema DSL (no raw SQL, no pg-only types, no TEXT defaults). Money is integer minor units + a currency text column:
_voltro_billing_customers— tenant ↔ provider customer link._voltro_billing_subscriptions— one subscription per tenant (plan, status, seatquantity, period start + end, cancel-at)._voltro_billing_invoices— invoice history (amountMinorinteger +currency)._voltro_billing_usage— per-tenant metered counters keyed by(tenantId, entitlementKey, period)._voltro_billing_flush_claims— INSERT-wins flush-window claims (multi-instance autopilot coordination); short-lived, retention defaults to 1 hour viaVOLTRO_BILLING_FLUSH_CLAIM_TTL_HOURS.
_voltro_billing_usage is append-only — one upserted counter row per (tenant, key, period) — so a closed period's row would otherwise live forever. The plugin registers a retention sweep on the row's updatedAt: a row is only touched while its window is current, so once a period closes it ages out, while the live period's row stays fresh and survives regardless. The bound defaults to ~400 days (a conservative window with headroom for end-of-period flush + back-dated reads) and is tunable via the VOLTRO_BILLING_USAGE_TTL_HOURS env var; the boot retention sweep drains rows past the TTL.
Card data never touches the DB — the provider's hosted portal owns it, so there is no payment-methods table.
Usage-based billing autopilot
Make the app meter + bill its own per-tenant usage with one declaration — no hand-wired counters. Pass metering and the plugin derives usage from the graph's own telemetry, then flushes it to the provider on a schedule:
billingPlugin({
provider: 'stripe',
plans: { pro: { priceId: 'price_…', entitlements: { apiCalls: 100_000 } } },
metering: {
apiCalls: { from: 'rpc', match: /^orders\./ }, // count SUCCESSFUL rpc calls (match: string | RegExp; optional kind)
rows: { from: 'cdc', table: 'orders' }, // count row writes (default op: insert)
aiTokens: { from: 'ai' }, // sum the _voltro_ai_usage ledger (metric: 'tokens' | 'costMicroUsd')
},
// flushIntervalMs: 60_000, // 0 disables the self-scheduled flush — call billing.flushUsage() from your own *.cron.tsx
})Each source taps something the framework already tracks:
rpc— the rpc interceptor counts matching calls per tenant, AFTER they succeed (a quota-rejected or failed call isn't counted).cdc— the post-commit ChangeEvent stream counts row writes totable; the tenant comes from the row'stenantId.ai— at flush time the autopilot sums the_voltro_ai_usageledger (written by@voltro/ai'srecordAiUsage) per tenant for the current period and reports the delta vs. what's already accrued — so re-running is idempotent.
All three route through the existing BillingService.reportUsage (a local per-period counter) + flushUsage (the batched provider push). Anonymous (tenant-less) calls aren't metered.
Idempotency + multi-instance. The self-scheduled flush is per-period idempotent (markReported makes a re-flush a no-op) AND cluster-coordinated by default: each replica self-schedules, but an INSERT-wins claim on the flush window (_voltro_billing_flush_claims) means exactly one replica flushes a given window — so two replicas never double-push it, with no extra wiring. Set flushIntervalMs: 0 to disable the timer entirely and drive billing.flushUsage() from your own *.cron.tsx instead.
Boundaries (v1). Metering captures writes the framework observes through ctx.store — the bulk helpers (updateMany / deleteMany) emit per-row ChangeEvents that ARE counted, but a single bulk SQL escape-hatch write isn't. The meter is best-effort post-commit telemetry, not a financial ledger of record.
Plans, seats & the billed amount
A plan carries an optional per-seat unit amount (integer minor units) and a currency, alongside its entitlement limits:
billingPlugin({
plans: {
free: { entitlements: { seats: 3 } },
starter: { priceId: 'price_starter', unitAmountMinor: 1000, currency: 'usd', entitlements: { seats: 10 } },
pro: { priceId: 'price_pro', unitAmountMinor: 3000, currency: 'usd', entitlements: { seats: 'unlimited' } },
},
})A subscription carries a seat quantity (default 1). The billed amount is plan.unitAmountMinor × quantity — all integer minor units, no float. The quantity comes off the provider event (Stripe's first line-item quantity) and round-trips through the subscription row.
Proration — Stripe's, and it is actually billed
A mid-cycle change (plan upgrade/downgrade, seat change) settles the difference for the unused remainder of the period. Stripe computes it and invoices it — the framework does not do this arithmetic, because a number we computed ourselves would differ from the charge by Stripe's rounding, its tax calculation, and any credit balance on the customer, and every one of those differences is a support ticket.
changePlan / changeSeats apply the change AT Stripe with proration_behavior: create_prorations and return what Stripe booked:
import { Effect } from 'effect'
import { BillingService } from '@voltro/plugin-billing'
export default (input: { tenantId: string }, _ctx) =>
Effect.gen(function* () {
const billing = yield* BillingService
const change = yield* billing.changePlan(input.tenantId, 'pro')
const seats = yield* billing.changeSeats(input.tenantId, 5)
return { prorationMinor: change.prorationMinor, seatsProration: seats.prorationMinor }
})Each returns { plan, quantity, prorationMinor, currency }, where prorationMinor is the sum of Stripe's proration lines on the upcoming invoice — positive is a charge, negative a credit. The local subscription row is written from Stripe's answer, not from what was requested, so the two cannot drift.
Quote before you charge
To show a figure in a confirmation dialog, use previewChange — it reads Stripe's invoice preview without applying anything:
const quote = yield* billing.previewChange(tenantId, { quantity: 40 })
// → { plan, quantity, prorationMinor, currency } nothing has changed yetNever quote a locally estimated number. The one Stripe previews is the one it charges.
Failed payments — Stripe retries, you read the status
There is no dunning subsystem here. Stripe Smart Retries runs the retry schedule (configured in the Stripe Dashboard, where it can use Stripe's own timing models) and reports the outcome as a subscription status change:
| From | On | To |
|---|---|---|
active |
payment fails | pastDue |
pastDue |
payment recovers | active |
pastDue |
Stripe gives up | canceled |
Those transitions arrive as customer.subscription.updated webhooks and land on the subscription row. Key your UI on subscription.status, not on a retry record:
const sub = yield* billing.subscription(tenantId)
if (sub?.status === 'pastDue') {
// Show a "update your payment method" banner + a link to the billing portal.
const { url } = yield* billing.portalUrl(tenantId, returnUrl)
}pastDue deliberately keeps the customer's entitlements: a bounced card is a payment problem, and locking an organisation out of its own data over a bank decline is a support incident, not enforcement. Stripe cancels when it has genuinely given up, and canceled is what removes access.
Provider portability
The surface (BillingService, the entitlement engine, the DB rows) is provider-agnostic. A BillingProvider is a dumb adapter: checkout/portal URL minting, usage push, and a pure normalizeEvent mapping the provider's payload to a BillingEvent. Stripe and an in-memory mock ship in the box; a new provider is a new adapter against the same contract — pass it directly:
import { billingPlugin } from '@voltro/plugin-billing'
import { myProvider } from './my-provider'
billingPlugin({ provider: myProvider(), plans: { /* … */ } })See also
- plugin-webhooks — the inbound signature + idempotency machinery billing rides
- plugin-rbac — scopes (the orthogonal "may you call this" axis)