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.
Under
NODE_ENV=production, a mock chosen by ABSENCE warns.STRIPE_SECRET_KEYis a deployment variable, and one that silently stops being set — a rotated secret, a typo in a values file, a CI variable nobody created — is routine. Without the line, an app that had been charging customers keeps answering every billing call successfully, reaches nobody, and leaves nothing to find afterwards. Set the key, or writeprovider: 'mock'so the mock is a decision on the page rather than an absence. An explicitly declared mock stays silent.
What it provides:
- The
_voltro_billing_*tables (customers, subscriptions, invoices, usage, flush_claims, dunning_notices) 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.entitlementStatus,billing.reportUsage,billing.previewChange,billing.invoices,billing.changePlan,billing.changeSeats. Every route resolves the tenant from the caller's subject, and each declares its access decision:billing.subscription+billing.entitlementStatusareopenAccess(the tenant-scoped reads every member's account UI renders), while the routes that change what the tenant pays —startCheckout,portalUrl,previewChange,changePlan,changeSeats— and the invoice history (invoices) require thebilling:managescope, andreportUsagerequiresbilling:report(a metering credential's scope — an unguarded usage report would let any session inflate its tenant's counters). Grant the scopes via an rbac role or your auth strategy'sresolveScopes;admin:fullpasses, as always. - Seat-based billing; proration and failed-payment retries are Stripe's.
- Dunning — a past-due notification sequence, a grace period and a lockout, composed on Stripe's outcomes.
- The typed
BillingError,EntitlementExceeded+SubscriptionLockederrors, 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 the plan whose limits apply right now: 'free' with no subscription, the paid plan while active or trialing, and — because a bounced card should not downgrade a customer on the same second — the paid plan for the whole grace period after a failed payment, falling back only once the lockout is real.
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.
Every event carries an occurredAt (Stripe's event.created). Webhook delivery is at-least-once and unordered, so the subscription and invoice rows compare it against the timestamp of the state they already reflect and drop anything older — a redelivered active from before a decline cannot un-do the past-due, and a late payment_failed cannot flip a paid invoice back to open.
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 six 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_dunning_notices— the sent-notice ledger,UNIQUE (tenantId, episode, stepId). It is the send gate, not a report: a notice is claimed here before it goes out, so a duplicated webhook sends nothing. Retention defaults to ~400 days viaVOLTRO_BILLING_DUNNING_TTL_HOURS— deliberately generous, because pruning a row belonging to a still-open episode would let its notice go out a second time.
_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.
A cdc meter accrues once fleet-wide, not once per replica. The change tap it
rides is delivered to EVERY replica — that is what makes a changeScope: 'fleet'
store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
and accrual is an increment on a shared counter, so a tenant on two pods used to
be invoiced twice. Each change is now claimed in _voltro_change_claims before it
accrues, through the same INSERT-wins arbiter behind
defineSubscriber({ once }) and a reaction's
dedupeKey. Nothing to configure; the boot log warns loudly if a deployment ever
runs the tap ungated, because an over-counted meter is indistinguishable from a
correct one by looking at the number.
The claim key names the CHANGE, not the row: two genuine edits to one row share an
id, so an id-keyed meter on op: 'update' would count the first and drop every one
after it.
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, dunning composes on the outcome
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 |
The framework does not reimplement that cadence and never will — a local retry schedule ran here once, on a fixed [1,3,5,7]-day rhythm, and drifted from Stripe's the moment the two disagreed. What the plugin adds is the part Stripe does not do for your app: a past-due notification sequence, a grace period, and a lockout your entitlement checks can read.
The grace clock is a column, not a timer
When the provider confirms a subscription is past due, the plugin stamps pastDueSince on the subscription row. Everything else is derived from it at read time — so there is no job to miss a tick, fire twice, or run on two replicas at once, and nothing "expires" a grace period in the background.
Two rules keep it honest, and both exist because sending an email and locking a customer out cannot be undone:
pastDueSinceis only written after the provider confirms it. A failed-payment event triggers a reconcile that reads the subscription's current status from Stripe (subscriptions.retrieve) and writes the row from that — never from the event body. Webhook delivery is at-least-once and unordered, so apayment_failedgenuinely can arrive after the retry that succeeded; reconciling against the object settles it.- An unreachable provider never escalates. If the reconcile cannot reach Stripe,
pastDueSincestays unset — and without it there is no clock to expire, so the tenant stays in grace. Failing open is the only safe direction: the alternative is locking a paying customer out because your network was down.
Asking the truthful question
entitlementStatus() is the one answer to "is this tenant entitled right now". It is a pure read of the local row — no provider call — so it is safe on a hot path:
import { Effect } from 'effect'
import { BillingService } from '@voltro/plugin-billing'
export default (input: { tenantId: string }, _ctx) =>
Effect.gen(function* () {
const billing = yield* BillingService
const status = yield* billing.entitlementStatus(input.tenantId)
// { plan, billedPlan, status, entitled, inGrace, graceEndsAt, lockedSince, lockout }
return status
})entitled: falsemeans dunning has locked this tenant out. A canceled subscription is not a lockout — it is simply the free tier.inGrace: truewith agraceEndsAtis the "your payment failed, you have until …" state. Show the banner and a billing-portal link.planis the plan whose limits apply right now;billedPlanis what they are subscribed to. Under a hard lockout the two differ.
The same shape is available to the browser as the billing.entitlementStatus rpc query (timestamps as ISO strings).
For a feature with no numeric quota to degrade — an export, an admin action — guard it directly:
import { Effect } from 'effect'
import { requireEntitled } from '@voltro/plugin-billing'
export default (input: { id: string }, ctx) =>
Effect.gen(function* () {
yield* requireEntitled(ctx) // fails SubscriptionLocked once grace expired
return { ok: true }
})SubscriptionLocked is a typed Schema.TaggedError carrying { tenantId, status, lockedSince, lockout }, decoded on the client like every other framework error — so the UI can route to the billing portal instead of showing a generic failure.
The sequence
Steps are declared with an afterHours measured from pastDueSince, so a step cannot be pulled forward by how often the provider happens to retry. Each provider event is the tick that evaluates whichever steps have come due:
import { billingPlugin, dunningMailNotifier } from '@voltro/plugin-billing'
import { MailService } from '@voltro/plugin-mail'
billingPlugin({
plans: { /* … */ },
dunning: {
graceHours: 168, // default: 7 days
steps: [ // default: exactly these three
{ id: 'payment-failed', afterHours: 0 },
{ id: 'reminder', afterHours: 72 },
{ id: 'final-warning', afterHours: 144 },
],
lockout: 'hard', // default | 'soft'
portalReturnUrl: 'https://acme.com/billing',
notify: (notice) => Effect.sync(() => { /* your transport */ }),
},
})Each notice carries { stepId, tenantId, to, plan, status, pastDueSince, graceEndsAt, locked, lockout, portalUrl } plus ready-to-send default subject / html / text — plain and unbranded, so the sequence works the moment notify is wired without inviting you to ship it unchanged.
With no notify, nothing is sent. The sequence claims and logs. That is the deliberate default: the framework cannot address a customer on your behalf, and an email is irreversible.
To send with @voltro/plugin-mail, bridge it — the plugin types the mail service structurally, so it takes on no dependency:
const mail = yield* MailService
billingPlugin({ dunning: { notify: dunningMailNotifier(mail) } })The recipient is resolved as: your dunning.resolveRecipient(tenantId) first, then the provider's customer email. A notice with no resolvable recipient still reaches notify (with to: null) so you can route it in-product.
Idempotency, and what happens when delivery lies
Every send is claimed in _voltro_billing_dunning_notices under a UNIQUE (tenantId, episode, stepId) before it goes out. The episode key IS the clock — the epoch-ms of pastDueSince — so:
- a duplicated webhook lands on a claimed key and sends nothing;
- a recovery clears
pastDueSince, which cancels every step still pending for that episode — a later failure opens a genuinely new episode and legitimately starts over; - a stale
customer.subscription.updatedcannot un-do a past-due, and a staleinvoice.payment_failedcannot flip a paid invoice back toopen: both rows carry the event's timestamp and drop anything older than the state they already reflect.
Claim-then-send is on purpose. Its failure mode is one notice that never arrives; send-then-claim's is a customer receiving the same dunning email twice.
The lockout
Once graceEndsAt passes, entitled turns false and one locked notice fires on the next reconcile.
lockout: 'hard'(default) — entitlement limits fall to the'free'plan's. Every existingrequireEntitlement/enforcecheck starts answering with the free tier's numbers; you write no new code.lockout: 'soft'— limits stay on the paid plan and onlyentitlementStatus()reports the lockout, so your app decides what to withhold.
Recovery at any point — including after the lockout — restores the paid entitlements on the next reconcile.
Tunables
Every number the framework picked on your behalf is a field with a default and an env override:
| Option | Env | Default |
|---|---|---|
dunning.enabled |
VOLTRO_BILLING_DUNNING (on / off) |
true |
dunning.graceHours |
VOLTRO_BILLING_GRACE_HOURS |
168 (7 days) |
dunning.steps[].afterHours |
VOLTRO_BILLING_DUNNING_STEP_HOURS (positional, comma-separated) |
0,72,144 |
dunning.lockout |
VOLTRO_BILLING_LOCKOUT (hard / soft) |
hard |
| notice-ledger retention | VOLTRO_BILLING_DUNNING_TTL_HOURS |
9600 (~400 days) |
The env parsers fail the boot on a value they cannot read, and VOLTRO_BILLING_DUNNING_STEP_HOURS refuses a list whose length differs from the declared sequence — an operator re-timing a sequence they are not looking at is exactly the quiet wrong number this refuses to become.
With dunning.enabled: false there is no grace clock and no reconcile: pastDue degrades entitlements at once, which is what this plugin did before dunning existed.
The optional sweep
Provider events drive the sequence, and that covers the normal case — Stripe emits an event per retry attempt. billing.dunningSweep() reconciles every past-due tenant in one pass, for a step configured at an hour the provider happens not to emit an event at, or a reconcile missed during a provider outage. Nothing schedules it for you; wire it from your own *.cron.tsx if you want it. It is idempotent, it never charges anything, and it never asks the provider to retry.
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, a fetchSubscription direct read (the current status dunning refuses to lock a customer out without), 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)