# Billing

> Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units.



---

<!-- source: en/plugins/billing.md -->
## 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

```ts
// 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, dunning_notices) via `extendSchema.tables`.
- The `BillingService` Context.Tag — yield it in any handler.
- The `requireEntitlement(ctx, key, cost)` in-handler quota guard + the declarative `enforce` map.
- 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.entitlementStatus` are `openAccess` (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 the **`billing:manage`** scope, and `reportUsage` requires **`billing: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's `resolveScopes`; `admin:full` passes, 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` + `SubscriptionLocked` errors, merged into every procedure's wire error union.
- A browser-safe `useStartCheckout()` hook on the `/web` subpath.

## The BillingService

Yield the service in any handler:

```ts
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](#failed-payments--stripe-retries-dunning-composes-on-the-outcome) 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:

```ts
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:

```ts
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` / `deleted`
- `invoice.paid` / `invoice.payment_failed`
- `customer.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:

```ts
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:

```tsx
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`):

```ts
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)`:

```ts
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, seat `quantity`, period start + end, cancel-at).
- `_voltro_billing_invoices` — invoice history (`amountMinor` integer + `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 via `VOLTRO_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 via `VOLTRO_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:

```ts
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 to `table`; the tenant comes from the row's `tenantId`.
- **`ai`** — at flush time the autopilot sums the `_voltro_ai_usage` ledger (written by `@voltro/ai`'s `recordAiUsage`) 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:

```ts
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:

```ts
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:

```ts
const quote = yield* billing.previewChange(tenantId, { quantity: 40 })
// → { plan, quantity, prorationMinor, currency }   nothing has changed yet
```

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

- **`pastDueSince` is 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 a `payment_failed` genuinely can arrive after the retry that succeeded; reconciling against the object settles it.
- **An unreachable provider never escalates.** If the reconcile cannot reach Stripe, `pastDueSince` stays 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:

```ts
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: false` means **dunning has locked this tenant out**. A canceled subscription is not a lockout — it is simply the free tier.
- `inGrace: true` with a `graceEndsAt` is the "your payment failed, you have until …" state. Show the banner and a billing-portal link.
- `plan` is the plan whose **limits apply right now**; `billedPlan` is 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:

```ts
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:

```ts
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:

```ts
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.updated` cannot un-do a past-due, and a stale `invoice.payment_failed` cannot flip a paid invoice back to `open`: 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 existing `requireEntitlement` / `enforce` check starts answering with the free tier's numbers; you write no new code.
- **`lockout: 'soft'`** — limits stay on the paid plan and only `entitlementStatus()` 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:

```ts
import { billingPlugin } from '@voltro/plugin-billing'
import { myProvider } from './my-provider'

billingPlugin({ provider: myProvider(), plans: { /* … */ } })
```

## See also

- [plugin-webhooks](/docs/plugins/webhooks) — the inbound signature + idempotency machinery billing rides
- [plugin-rbac](/docs/plugins/rbac) — scopes (the orthogonal "may you call this" axis)
