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.
A production boot refuses billing that would be quietly wrong. With
NODE_ENVanything but development ('',development,dev,test) —stagingincluded —billingPluginthrows when the mock would run by absence (noSTRIPE_SECRET_KEY), when Stripe runs withoutSTRIPE_WEBHOOK_SECRET, or when Stripe runs withoutredirectOrigins. A deployment that does not sell writesprovider: 'mock'; that is a decision on the page, not an absence. The provider selected never depends onNODE_ENV— only the refusal does.
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,billing.cancel,billing.resume,billing.sync. 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 — open to every member, not to the public: every billing route refuses an anonymous subject, even one that names a tenant through thex-tenantheader, and a signed-in subject without a tenant), while the routes that change what the tenant pays —startCheckout,portalUrl,previewChange,changePlan,changeSeats,cancel,resume,sync— 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. ABillingErroris a wire type, so a failed Stripe call carries only the operation (source), the SDK's error class and Stripe's enumerated code —StripeCardError (card_declined, insufficient_funds)— and a failed store call only the operation; Stripe's text and the driver's message go to the server log. - 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_failedcheckout.session.completed(links the tenant to the customer Checkout created)customer.created(links the tenant via Stripe metadata)customer.deleted(drops the link, so the portal and the next checkout do not address a customer that is gone)
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. A subscription invoice has no metadata of its own; the plugin reads the subscription snapshot Stripe copies to parent.subscription_details.metadata, so invoice.paid and invoice.payment_failed resolve the tenant too.
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, customerUnlinked, subscriptionCanceled), not raw Stripe types.
A side effect that fails fails the delivery: the webhook answers 500 and releases the idempotency key, so Stripe redelivers the event. The row update is an idempotent upsert, so the replay only re-runs what failed. Make every side effect idempotent.
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), and for a tenant that already pays (active, trialing or pastDue) — a second checkout would bill alongside the first, so change the plan instead. The check reads the local row and, for a linked customer, Stripe's own list, which also covers a webhook that has not arrived yet.
Who may pay, and where Stripe sends them back
Three safeguards sit in front of every call that changes what a tenant pays:
admit(tenantId, action)is the app's say on the tenant itself.billing:managedecides who in a tenant may act; it cannot see whether the tenant is suspended or deleted. Fail with aBillingErrorto refuse — the provider is not called.actionis'checkout','change','resume','portal'or'cancel'.redirectOriginslists the originssuccessUrl,cancelUrland the portal'sreturnUrlmay point at. Without it any URL is accepted (a warning at boot says so when the Stripe provider is active), which makes checkout a redirect to anywhere.- One customer, one open checkout.
startCheckoutcreates the tenant's Stripe customer first (found bymetadata.tenantId, else created under a per-tenant idempotency key), names it on every session, and expires the tenant's other open checkouts, so two browser tabs cannot both be paid. Also turn on Limit customers to one subscription in the Stripe Checkout settings; it only works because every session names its customer.
billingPlugin({
plans: { /* … */ },
admit: (tenantId, action) =>
Effect.gen(function* () {
const org = yield* loadOrg(tenantId) // your own read
if (org.status !== 'active' && action !== 'cancel') {
return yield* Effect.fail(new BillingError({ source: 'app', message: 'organization is not active', transient: false }))
}
}),
redirectOrigins: ['https://app.example.com'],
})STRIPE_WEBHOOK_EVENTS is the list of event types the plugin maps. Subscribe the endpoint to exactly these.
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 }
})Cancelling, resuming and closing
billing.cancel (cancelSubscription(tenantId)) is the customer's own cancellation: Stripe keeps the subscription active until the paid period ends, then ends it. The row's cancelAt says when, and the plan stays until then. billing.resume (resumeSubscription(tenantId)) takes it back before that date. changePlan(tenantId, 'free') is the same period-end cancellation; a plan without a priceId other than free is refused.
On the app's own billing page, useSubscriptionCancel from @voltro/plugin-billing/web drives the two-step flow — an entry, a separate confirmation naming the end date, and the way back — from the subscription the page already reads:
import { useSubscriptionCancel } from '@voltro/plugin-billing/web'
const flow = useSubscriptionCancel({ subscription, cancel: (i) => app.billing.cancel(i), resume: (i) => app.billing.resume(i) })
// flow.stage: 'active' → begin() · 'confirming' → confirm() / keep() · 'scheduled' → resume() · 'ended'closeAccount(tenantId) ends every subscription of a tenant, for deleting an organization or account. It flushes pending usage first, then sets each subscription (the local rows and Stripe's list for the linked customer) to end once that usage has settled on the meter — closeSettleSeconds on stripeProvider, 600 by default. Stripe's final invoice at that moment carries the usage and a proration credit for unused time. Ending it on the spot is not offered: meter events aggregate asynchronously, and a subscription ended the moment its last usage was sent is invoiced for none of it. Until Stripe's deletion event arrives, the rows show the end as cancelAt. The Stripe customer and its invoices are kept, because they are the accounting record. When the flush fails, nothing is ended and the call fails, so a retry loses no usage.
import { Effect } from 'effect'
import { BillingService } from '@voltro/plugin-billing'
export const closeOrgBilling = (orgId: string) =>
Effect.gen(function* () {
const billing = yield* BillingService
const { canceled } = yield* billing.closeAccount(orgId) // provider subscription ids
return canceled
})Staying in step with Stripe
Stripe is the source of truth; the plugin's rows follow it through three mechanisms, and none of them is a timer:
- Every webhook is a trigger, not the state. For the customer an event names, the plugin reads the customer's subscriptions from Stripe now and brings the rows to that. Delivery order and duplicates stop mattering, and a subscription created in the Stripe Dashboard is attributed through its customer. The current state is dispatched through
onEventon every delivery — also when the row already agreed — so a projection that failed on the first delivery runs again on the redelivery. Only what a read cannot give (a paid invoice, a linked or deleted customer) is taken from the payload. - Checkpoints where a stale row would matter.
syncTenant(tenantId)— also thebilling.syncroute — syncs a tenant and answers with its subscription. Call it where checkout and the portal return the customer and when a billing page loads. Every call that changes what a tenant pays (changePlan,changeSeats,cancelSubscription,resumeSubscription) runs it first, so it decides on what Stripe says, not on a lagging row. - Catch-up on what the endpoint missed. Stripe keeps every event it could not deliver (30 days). The plugin asks for them at startup and when an event arrives long after it was created — the sign that deliveries were failing — claims each once across replicas, and syncs its customer.
isTenantClosed(tenantId) is the net under every deletion path: any sync of a closed tenant that is still paying ends its subscriptions (closeAccount). Answer true only on positive evidence — a row marked deleted; an unknown tenant is not closed.
billingPlugin({
plans: { /* … */ },
isTenantClosed: (tenantId) => Effect.succeed(deletedTenants.has(tenantId)), // your own read
})reconcileFromProvider() is the deliberate full pass for an operator — after an incident, after restoring a database: it compares every row with Stripe's list, adopts what the webhooks never delivered, adds missing meteredPriceIds, reports tenants with more than one paying subscription as duplicates without cancelling anything, and dispatches its corrections through onEvent. When Stripe has never heard of many live subscriptions at once (at least three, and at least half), it cancels none and logs an error instead: that is the wrong account — a key from another Stripe account, or the mock where Stripe ran — not that many cancellations (refusedUnknown counts them). Nothing schedules it.
The plan of a subscription is read from the price it carries, and metadata.plan only when the price maps to no plan, so a plan change in the Stripe portal or Dashboard is followed. Once a row reads canceled it stays canceled, and a tenant's current subscription is its live one, never a newer event on an ended one.
The Stripe account as code
StripeCatalog declares the account an app sells with — products and their prices (found by lookup_key), meters, tax settings, the customer portal and the webhook endpoint, per environment (sandbox and live). runStripeProvision checks an environment against it or brings it in line, so a sandbox and live stay the same account:
// api/stripeProvision.ts
import { runStripeProvision } from '@voltro/plugin-billing'
import { catalog } from './lib/stripeCatalog'
process.exit(await runStripeProvision(catalog, process.argv.slice(2), { dotenv: '.env' }))npx tsx stripeProvision.ts --env sandbox # check; key from the dotenv file
npx tsx stripeProvision.ts --env sandbox --apply --env-out .env
STRIPE_PROVISION_KEY=… npx tsx stripeProvision.ts --env live --apply --confirm-live --env-out /secure/live.env
npx tsx stripeProvision.ts --env live --strict # launch readinessObjects created before the catalog are adopted, never duplicated. The webhook endpoint subscribes to STRIPE_WEBHOOK_EVENTS on the SDK's API version. Exit code 0 means the account matches, 1 that something differs, 2 a refused invocation; --strict also fails on what only a human can do (account activation, tax registrations, the Dashboard-only settings in STRIPE_DASHBOARD_REMINDERS). The safeguards: a key of the other mode is refused and the account's own livemode is checked; a live write needs --confirm-live; a changed price needs --allow-price-change (existing subscriptions keep the old one); a webhook endpoint is only created with --env-out, because Stripe shows its secret once; --env-out refuses a file git would track; the output names catalog keys only — never a key, a price id or a secret.
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) // key: 1–100 of [A-Za-z0-9_.:-], a letter or digit first
// 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.
With Stripe, the flush sends a Billing Meter event whose event_name is the entitlement key and whose stripe_customer_id is the tenant's linked Stripe customer (create the meter with customer mapping by_id on stripe_customer_id). Each delta carries its own identifier, so a replayed flush is deduplicated while the next delta still counts. A tenant with no linked customer yet keeps its usage pending and a warning is logged; nothing is sent unattributed or dropped.
For the usage to reach an invoice, the subscription needs the meter's price as an item. List it on the plan and checkout adds it as a second, quantity-less line item:
pro: { priceId: 'price_…', meteredPriceIds: ['price_…'], entitlements: { aiCalls: 5000 } },Plan and seat reads and changes use the subscription's licensed item, so the metered item never counts as a seat.
The self-scheduled flush tick (flushIntervalMs, default 60 s) pushes this hand-reported usage too, with or without a metering config. Set flushIntervalMs: 0 to drive billing.flushUsage() from your own cron instead.
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)
Dashboard inspection
The Billing view shows the selected plugin instance's provider, plans, enforced procedure tags and pending metered usage. Configuration is version-declaration evidence; it does not assert provider connectivity or that a payment succeeded. Loading and failed reads are distinct from an empty usage list.
Replica views in the dashboard
Dashboard reads retain the identity of the answering replica. Select one replica or compare several in separate panels. Store-backed snapshots are process observations: memory and custom stores need not be shared, while durable counters may already aggregate shared data. Do not sum these snapshots across replicas.