@voltro/cli 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,324 @@
1
+ # Billing
2
+
3
+ > Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/billing.md -->
10
+ ## Billing
11
+
12
+ _Subscriptions, plans, entitlements, and usage metering over a pluggable provider (Stripe + mock). Money is integer minor units._
13
+
14
+ `@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.
15
+
16
+ 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.
17
+
18
+ ## Install
19
+
20
+ ```ts
21
+ // app.config.ts
22
+ import { billingPlugin } from '@voltro/plugin-billing'
23
+
24
+ export default {
25
+ type: 'api' as const,
26
+ plugins: [
27
+ billingPlugin({
28
+ provider: 'stripe', // | 'mock' | a BillingProvider
29
+ apiKey: process.env.STRIPE_SECRET_KEY, // server-only; default STRIPE_SECRET_KEY
30
+ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, // default STRIPE_WEBHOOK_SECRET
31
+ plans: {
32
+ free: { entitlements: { aiCalls: 100, storageBytes: 1_000_000_000 } },
33
+ pro: { priceId: 'price_…', entitlements: { aiCalls: 5000, storageBytes: 50_000_000_000 } },
34
+ ent: { priceId: 'price_…', entitlements: { aiCalls: 'unlimited', storageBytes: 1_000_000_000_000 } },
35
+ },
36
+ }),
37
+ ],
38
+ }
39
+ ```
40
+
41
+ `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.
42
+
43
+ What it provides:
44
+
45
+ - The `_voltro_billing_*` tables (customers, subscriptions, invoices, usage, flush_claims) via `extendSchema.tables`.
46
+ - The `BillingService` Context.Tag — yield it in any handler.
47
+ - The `requireEntitlement(ctx, key, cost)` in-handler quota guard + the declarative `enforce` map.
48
+ - A webhook receiver at `POST /billing/webhook`.
49
+ - The rpc routes `billing.startCheckout`, `billing.portalUrl`, `billing.subscription`, `billing.reportUsage`, `billing.changePlan`, `billing.changeSeats`.
50
+ - **Seat-based billing**; proration and failed-payment retries are Stripe's.
51
+ - The typed `BillingError` + `EntitlementExceeded` errors, merged into every procedure's wire error union.
52
+ - A browser-safe `useStartCheckout()` hook on the `/web` subpath.
53
+
54
+ ## The BillingService
55
+
56
+ Yield the service in any handler:
57
+
58
+ ```ts
59
+ import { Effect } from 'effect'
60
+ import { BillingService } from '@voltro/plugin-billing'
61
+
62
+ export default (input: { tenantId: string }, _ctx) =>
63
+ Effect.gen(function* () {
64
+ const billing = yield* BillingService
65
+ const sub = yield* billing.subscription(input.tenantId) // Subscription | null
66
+ const plan = yield* billing.plan(input.tenantId) // resolves tier, defaults 'free'
67
+ return { plan, status: sub?.status ?? 'none' }
68
+ })
69
+ ```
70
+
71
+ 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.
72
+
73
+ ## Entitlement checks
74
+
75
+ There is **no `guards:` field** on `defineMutation` — procedures carry only `name / input / output / error / target`. Quota enforcement happens one of two real ways.
76
+
77
+ ### In-handler — `requireEntitlement`
78
+
79
+ 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:
80
+
81
+ ```ts
82
+ import { Effect } from 'effect'
83
+ import { requireEntitlement } from '@voltro/plugin-billing'
84
+
85
+ export default (input: { tokens: number }, ctx) =>
86
+ Effect.gen(function* () {
87
+ yield* requireEntitlement(ctx, 'aiCalls', Math.ceil(input.tokens / 1000))
88
+ // … the work the quota gates …
89
+ return { ok: true }
90
+ })
91
+ ```
92
+
93
+ `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.
94
+
95
+ ### Declarative — the `enforce` map
96
+
97
+ 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:
98
+
99
+ ```ts
100
+ billingPlugin({
101
+ plans: { /* … */ },
102
+ enforce: {
103
+ 'ai.heavy': { entitlement: 'aiCalls', cost: 1 },
104
+ },
105
+ })
106
+ ```
107
+
108
+ 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.
109
+
110
+ 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.
111
+
112
+ 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.
113
+
114
+ ## Webhook handling
115
+
116
+ 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.
117
+
118
+ Events handled:
119
+
120
+ - `customer.subscription.created` / `updated` / `deleted`
121
+ - `invoice.paid` / `invoice.payment_failed`
122
+ - `customer.created` (links the tenant via Stripe metadata)
123
+
124
+ `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.
125
+
126
+ Per-event side effects run AFTER the row is updated:
127
+
128
+ ```ts
129
+ import { Effect } from 'effect'
130
+
131
+ billingPlugin({
132
+ plans: { /* … */ },
133
+ onEvent: {
134
+ invoicePaymentFailed: (event) =>
135
+ Effect.sync(() => {
136
+ // event: { _tag: 'invoicePaymentFailed', tenantId, providerInvoiceId, amountMinor, currency }
137
+ console.warn('payment failed for', event.tenantId, event.amountMinor, event.currency)
138
+ }),
139
+ },
140
+ })
141
+ ```
142
+
143
+ `onEvent` keys are the normalized `BillingEvent` tags (`subscriptionUpserted`, `invoicePaid`, `invoicePaymentFailed`, `customerLinked`, `subscriptionCanceled`), not raw Stripe types.
144
+
145
+ ## Checkout + upgrade flows
146
+
147
+ 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:
148
+
149
+ ```tsx
150
+ import { useStartCheckout } from '@voltro/plugin-billing/web'
151
+ import { useAppClient } from '@voltro/web'
152
+
153
+ const UpgradeButton = () => {
154
+ const app = useAppClient('app')
155
+ const { startCheckout, pending } = useStartCheckout((input) => app.billing.startCheckout(input))
156
+ return (
157
+ <button
158
+ disabled={pending}
159
+ onClick={() => startCheckout({ plan: 'pro', successUrl: location.href, cancelUrl: location.href })}
160
+ >
161
+ Upgrade to Pro
162
+ </button>
163
+ )
164
+ }
165
+ ```
166
+
167
+ 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).
168
+
169
+ ## Customer portal
170
+
171
+ 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`):
172
+
173
+ ```ts
174
+ import { Effect } from 'effect'
175
+ import { BillingService } from '@voltro/plugin-billing'
176
+
177
+ export default (input: { tenantId: string; returnUrl: string }, _ctx) =>
178
+ Effect.gen(function* () {
179
+ const billing = yield* BillingService
180
+ return yield* billing.portalUrl(input.tenantId, input.returnUrl) // { url }
181
+ })
182
+ ```
183
+
184
+ ## Usage reporting
185
+
186
+ For metered billing, record usage locally; the service flushes the aggregate to the provider in one batched push per `(tenant, key)`:
187
+
188
+ ```ts
189
+ const billing = yield* BillingService
190
+ yield* billing.reportUsage(tenantId, 'aiCalls', count)
191
+ // later — typically from a schedule:
192
+ yield* billing.flushUsage()
193
+ ```
194
+
195
+ `flushUsage` is a no-op on providers that report `supportsMeteredUsage: false`. Counters that have already been pushed are not re-sent.
196
+
197
+ ## Tables
198
+
199
+ 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:
200
+
201
+ - `_voltro_billing_customers` — tenant ↔ provider customer link.
202
+ - `_voltro_billing_subscriptions` — one subscription per tenant (plan, status, seat `quantity`, period start + end, cancel-at).
203
+ - `_voltro_billing_invoices` — invoice history (`amountMinor` integer + `currency`).
204
+ - `_voltro_billing_usage` — per-tenant metered counters keyed by `(tenantId, entitlementKey, period)`.
205
+ - `_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`.
206
+
207
+ `_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.
208
+
209
+ Card data never touches the DB — the provider's hosted portal owns it, so there is no payment-methods table.
210
+
211
+ ## Usage-based billing autopilot
212
+
213
+ 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:
214
+
215
+ ```ts
216
+ billingPlugin({
217
+ provider: 'stripe',
218
+ plans: { pro: { priceId: 'price_…', entitlements: { apiCalls: 100_000 } } },
219
+ metering: {
220
+ apiCalls: { from: 'rpc', match: /^orders\./ }, // count SUCCESSFUL rpc calls (match: string | RegExp; optional kind)
221
+ rows: { from: 'cdc', table: 'orders' }, // count row writes (default op: insert)
222
+ aiTokens: { from: 'ai' }, // sum the _voltro_ai_usage ledger (metric: 'tokens' | 'costMicroUsd')
223
+ },
224
+ // flushIntervalMs: 60_000, // 0 disables the self-scheduled flush — call billing.flushUsage() from your own *.cron.tsx
225
+ })
226
+ ```
227
+
228
+ Each source taps something the framework **already tracks**:
229
+
230
+ - **`rpc`** — the rpc interceptor counts matching calls per tenant, AFTER they succeed (a quota-rejected or failed call isn't counted).
231
+ - **`cdc`** — the post-commit ChangeEvent stream counts row writes to `table`; the tenant comes from the row's `tenantId`.
232
+ - **`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.
233
+
234
+ 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.
235
+
236
+ **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.
237
+
238
+ **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.
239
+
240
+ ## Plans, seats & the billed amount
241
+
242
+ A plan carries an optional per-seat **unit amount** (integer minor units) and a `currency`, alongside its entitlement limits:
243
+
244
+ ```ts
245
+ billingPlugin({
246
+ plans: {
247
+ free: { entitlements: { seats: 3 } },
248
+ starter: { priceId: 'price_starter', unitAmountMinor: 1000, currency: 'usd', entitlements: { seats: 10 } },
249
+ pro: { priceId: 'price_pro', unitAmountMinor: 3000, currency: 'usd', entitlements: { seats: 'unlimited' } },
250
+ },
251
+ })
252
+ ```
253
+
254
+ 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.
255
+
256
+ ## Proration — Stripe's, and it is actually billed
257
+
258
+ 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.
259
+
260
+ `changePlan` / `changeSeats` apply the change AT Stripe with `proration_behavior: create_prorations` and return what Stripe booked:
261
+
262
+ ```ts
263
+ import { Effect } from 'effect'
264
+ import { BillingService } from '@voltro/plugin-billing'
265
+
266
+ export default (input: { tenantId: string }, _ctx) =>
267
+ Effect.gen(function* () {
268
+ const billing = yield* BillingService
269
+ const change = yield* billing.changePlan(input.tenantId, 'pro')
270
+ const seats = yield* billing.changeSeats(input.tenantId, 5)
271
+ return { prorationMinor: change.prorationMinor, seatsProration: seats.prorationMinor }
272
+ })
273
+ ```
274
+
275
+ 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.
276
+
277
+ ### Quote before you charge
278
+
279
+ To show a figure in a confirmation dialog, use `previewChange` — it reads Stripe's invoice preview without applying anything:
280
+
281
+ ```ts
282
+ const quote = yield* billing.previewChange(tenantId, { quantity: 40 })
283
+ // → { plan, quantity, prorationMinor, currency } nothing has changed yet
284
+ ```
285
+
286
+ Never quote a locally estimated number. The one Stripe previews is the one it charges.
287
+
288
+ ## Failed payments — Stripe retries, you read the status
289
+
290
+ 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:
291
+
292
+ | From | On | To |
293
+ | --- | --- | --- |
294
+ | `active` | payment fails | `pastDue` |
295
+ | `pastDue` | payment recovers | `active` |
296
+ | `pastDue` | Stripe gives up | `canceled` |
297
+
298
+ 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:
299
+
300
+ ```ts
301
+ const sub = yield* billing.subscription(tenantId)
302
+ if (sub?.status === 'pastDue') {
303
+ // Show a "update your payment method" banner + a link to the billing portal.
304
+ const { url } = yield* billing.portalUrl(tenantId, returnUrl)
305
+ }
306
+ ```
307
+
308
+ `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.
309
+
310
+ ## Provider portability
311
+
312
+ 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:
313
+
314
+ ```ts
315
+ import { billingPlugin } from '@voltro/plugin-billing'
316
+ import { myProvider } from './my-provider'
317
+
318
+ billingPlugin({ provider: myProvider(), plans: { /* … */ } })
319
+ ```
320
+
321
+ ## See also
322
+
323
+ - [plugin-webhooks](/docs/plugins/webhooks) — the inbound signature + idempotency machinery billing rides
324
+ - [plugin-rbac](/docs/plugins/rbac) — scopes (the orthogonal "may you call this" axis)
@@ -0,0 +1,144 @@
1
+ # Broadcast
2
+
3
+ > Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/broadcast.md -->
10
+ ## Broadcast
11
+
12
+ _Cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects — closes the single-instance gap so a write on one pod surfaces on another._
13
+
14
+ `@voltro/plugin-broadcast` closes the cross-replica reactivity gap for dialects that have no native cross-instance change feed. Postgres fans out natively via `LISTEN/NOTIFY` and mariadb via binlog CDC; every other dialect (mysql / mssql / cockroach / planetscale / azure) emits change events **in-process on the writing instance only**. Without a bus, a write on one pod never wakes a subscription on another pod.
15
+
16
+ **Status:** ✓ shipped.
17
+
18
+ > For the full cross-replica reactivity story — when you need it, the dialect matrix, and the boot-log signals — see [Database → Multi-replica](/docs/database/multi-replica). This page documents the plugin itself.
19
+
20
+ ## Wiring
21
+
22
+ ```ts
23
+ // app.config.ts
24
+ import { broadcastPlugin } from '@voltro/plugin-broadcast'
25
+
26
+ export default {
27
+ type: 'api' as const,
28
+ name: 'api',
29
+ plugins: [broadcastPlugin()],
30
+ }
31
+ ```
32
+
33
+ **Broadcast is opt-in — it never turns on from a shared `REDIS_URL` alone.** The bus enables only on an explicit signal: `BROADCAST_URL`, `BROADCAST_REDIS_URL`, `BROADCAST_PROVIDER`, or the `connection` / `provider` / `url` option. So a deployment running the cache on `REDIS_URL` keeps broadcast on the single-process memory bus until you deliberately switch it on. This differs from `ctx.cache` / `ctx.kv`, which are gated by their own `CACHE_BACKEND` / `KV_BACKEND` selectors — same principle, explicit per concern.
34
+
35
+ Once enabled on redis, the connection resolves own → shared: an explicit `url` / broker-agnostic `BROADCAST_URL` → `BROADCAST_REDIS_URL` (this connection's own) → the shared `REDIS_URL`. A `redis://` / `rediss://` URL selects Redis; a `nats://` URL selects NATS.
36
+
37
+ ```ts
38
+ broadcastPlugin({ connection: 'broadcast' }) // opt in + share the app's redis: BROADCAST_REDIS_URL → REDIS_URL
39
+ broadcastPlugin({ provider: 'redis', url: process.env.BROADCAST_URL }) // or an explicit url / provider
40
+ ```
41
+
42
+ The `connection` option follows the same `<NAME>_REDIS_URL` → `REDIS_URL` convention as the [cache, KV and rate limiter](/docs/caching/key-value) — but setting it (or another explicit signal) is the deliberate opt-in; the shared `REDIS_URL` is only the connection detail. NATS stays broker-agnostic via `BROADCAST_URL`.
43
+
44
+ `BroadcastPluginOptions`: `provider` (`'redis' | 'nats' | 'memory'` or a `BroadcastProvider`), `url`, `connection` (named redis connection, default `'broadcast'`), and `name` (disambiguates multiple instances of the plugin in one app — the second instance's name becomes `@voltro/plugin-broadcast#<name>`).
45
+
46
+ ## How it works
47
+
48
+ The plugin is mostly a **carrier**: it resolves a `BroadcastProvider` at construction and exposes it so the serve pipeline can attach the bus to the live `DataStore` **after** the store is built (the bus needs the store's `onChange` + `injectExternalChange` seam, which doesn't exist at plugin-activation time).
49
+
50
+ The bus is **additive** to the inline emit path:
51
+
52
+ - It publishes `{ origin, event }` on the `<namespace>:changes` channel.
53
+ - It injects remote events into every other replica's store, skipping its own origin so there's no double-emit.
54
+ - A broker outage degrades cross-replica fan-out only — local reactivity keeps working.
55
+
56
+ The plugin declares the `network:outbound:*` permission. The boot banner names the resolved tier (cross-instance via redis/nats, or off for the dialect when no broker is configured).
57
+
58
+ ## A dropped message cannot leave a client stale
59
+
60
+ Pub/sub has no retention. If a replica's broker connection blips it simply never
61
+ learns that a change happened — and its clients keep their sockets, so the
62
+ client-side reconnect never fires and their live queries never re-run. They would
63
+ show stale rows until something else touched the same table, which on a quiet
64
+ table can be never.
65
+
66
+ Every change carries a **per-origin serial**, so a receiving replica can tell
67
+ exactly how many it missed — a count, not an estimate. On a gap it re-runs every
68
+ live subscription.
69
+
70
+ That works because **a live query is idempotent**: re-running one always lands on
71
+ the truth, so a proven loss is repaired by refreshing rather than by replaying
72
+ something nobody kept. The refresh goes through each subscription's own
73
+ descriptor, so guards, row filters and tenant predicates apply unchanged — and it
74
+ is a re-query, not a push: if the snapshot has not moved, the subscriber sees
75
+ nothing.
76
+
77
+ A replica that just started reports no gap however high a peer's serial is. It
78
+ missed nothing; it was not there.
79
+
80
+ Nothing to configure. It follows from having a broker.
81
+
82
+ ## Sharing one broker between apps — the namespace
83
+
84
+ Every framework channel on the broker hangs off **one namespace**: `<ns>:changes`,
85
+ `<ns>:events:<name>`, `<ns>:members`, `<ns>:presence`.
86
+
87
+ It defaults to your app's name, so **two different apps pointed at one Redis or
88
+ NATS separate on their own** — nothing to configure, and nothing to forget.
89
+
90
+ ```ts
91
+ broadcast({ provider: 'redis', namespace: 'shop-prod' })
92
+ ```
93
+
94
+ Set it explicitly for the one case the default cannot see: **several deployments
95
+ of the same app on one broker.** Staging and production share a name, share the
96
+ code and share every fingerprint, so nothing derivable tells them apart. There,
97
+ this option — or `VOLTRO_BROADCAST_NAMESPACE` — is the only thing that works.
98
+
99
+ <Callout type="warn">
100
+ If you are upgrading and used `broadcast({ channel })`, note what the codemod
101
+ tells you: **that option never took effect.** It was declared, and documented as
102
+ the fix for exactly this, and nothing read it — your deployments were sharing
103
+ channels regardless of what you set. `voltro update` rewrites it to `namespace`
104
+ and strips the trailing `:changes`, since the framework appends the channel kind
105
+ itself.
106
+ </Callout>
107
+
108
+ Resolution order: `broadcast({ namespace })` → `VOLTRO_BROADCAST_NAMESPACE` →
109
+ your app's name. The value is lowercased and reduced to `a-z0-9_-`, which closes
110
+ three things NATS punishes (measured against nats:2 — Redis is indifferent):
111
+
112
+ | In a name | What NATS does |
113
+ | --- | --- |
114
+ | a `.` beside a `>` (`shop.>`) | matches `shop.other` — wildcards are token-level, and tokens are dot-separated |
115
+ | a name that IS `>` or `*` | matches **every** subject on the server |
116
+ | whitespace (`My App`) | rejects the subject outright — the app receives nothing at all |
117
+
118
+ A wildcard *inside* a token is harmless (`shop>:changes` does not match
119
+ `other:changes`), so the dangerous inputs are narrower than they look — and a
120
+ name that reduces to nothing falls through to the next candidate rather than
121
+ becoming an empty prefix.
122
+
123
+ ## The namespace is a broker subject — and it is normalised
124
+
125
+ ```sh
126
+ VOLTRO_BROADCAST_NAMESPACE="prod env" # → resolves to "prod-env"
127
+ VOLTRO_BROADCAST_NAMESPACE="prod.env" # → ALSO "prod-env" — same channel
128
+ ```
129
+
130
+ Whitespace, dots and wildcards are folded to `-` and the result is lowercased,
131
+ because the value becomes a broker subject: NATS refuses a subject containing
132
+ whitespace outright and delivers nothing, with no error on the publishing side.
133
+
134
+ **Two differently-configured deployments can therefore collapse onto one
135
+ channel** — which is what this option exists to prevent. Nothing refuses (the
136
+ resolved value is safe either way, and failing a boot over a dot would be worse
137
+ than the collapse), but the boot logs the substitution whenever it changes what
138
+ you wrote. If you see it, check that the RESOLVED names differ, not the ones you
139
+ typed.
140
+
141
+
142
+ ## Caveat — app-mutation changes only
143
+
144
+ The bus carries changes written through `ctx.store` (app mutations). It does **not** capture out-of-band DB writes (a `psql` session, another service) — only postgres `LISTEN/NOTIFY` and mariadb binlog observe those. Without a broker URL the plugin falls back to the in-process memory bus (single-process only) and warns at boot.
@@ -0,0 +1,116 @@
1
+ # CDC-out (reverse-ETL)
2
+
3
+ > Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/cdc-out.md -->
10
+ ## CDC-out (reverse-ETL)
11
+
12
+ _Declaratively mirror table changes outward to external sinks (webhook / Kafka / Snowflake / BigQuery) through a durable outbox — ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered._
13
+
14
+ # CDC-out — declarative reverse-ETL
15
+
16
+ `@voltro/plugin-cdc-out` streams your table changes OUTWARD to an external
17
+ sink. Declare `{ table → sink, map }` and the plugin mirrors every change
18
+ through a **durable outbox** in your app's own database — no separate
19
+ Debezium / Fivetran pipeline. The data-team wedge: app data in the warehouse,
20
+ declared in code, composed onto the change stream you already have.
21
+
22
+ ## Wiring
23
+
24
+ ```ts
25
+ // app.config.ts
26
+ import { cdcOutPlugin, webhookSink } from '@voltro/plugin-cdc-out'
27
+
28
+ export default {
29
+ type: 'api' as const,
30
+ name: 'api',
31
+ plugins: [
32
+ cdcOutPlugin({
33
+ sinks: [
34
+ {
35
+ table: 'orders',
36
+ sink: webhookSink('https://warehouse.example.com/ingest'),
37
+ // Map a row → the outbound record body (default: the row unchanged).
38
+ map: (row) => ({ id: row.id, total: row.total, status: row.status }),
39
+ // Optional: only mirror some changes.
40
+ filter: (event) => event.op !== 'delete',
41
+ batchSize: 500, // max records per delivered batch (default 100)
42
+ },
43
+ ],
44
+ maxAttempts: 5, // per-record delivery attempts before dead-letter
45
+ backoffBaseMs: 200, // first retry delay; doubles per attempt, jittered
46
+ deliveryTimeoutMs: 10_000, // per-attempt timeout — aborts the sink call
47
+ }),
48
+ ],
49
+ }
50
+ ```
51
+
52
+ One sink config per table per instance — a duplicate `table` is a **loud boot
53
+ error**. For multiple sinks on one table, wire a second instance with a
54
+ `name` (`cdcOutPlugin({ name: 'analytics', sinks: […] })`); it suffixes the
55
+ plugin name (`@voltro/plugin-cdc-out#analytics`) and the inspect mount
56
+ (`/_voltro/inspect/plugins/cdc-out--analytics/…`).
57
+
58
+ ## Sinks
59
+
60
+ - **`memorySink()`** — in-process, records every delivered batch. Dev + tests.
61
+ - **`webhookSink(url, { headers? })`** — POSTs each batch as
62
+ `{ records: [...] }` JSON, honoring the engine's per-attempt abort signal.
63
+ Its host is declared as a `network:outbound:<host>` permission automatically.
64
+ - **Warehouse / Kafka** — implement the `CdcSink` interface
65
+ (`{ name, deliver(batch, ctx), outboundHost? }`). `deliver` may return a
66
+ **Promise or an Effect** — both compose without wrapping. The engine is
67
+ connector-agnostic; the sink is the only thing that changes.
68
+
69
+ ## Delivery guarantees — exactly what holds
70
+
71
+ Every mirrored change becomes a row in `_voltro_cdcout_outbox` (contributed
72
+ via `extendSchema`, migrated by `voltro dev`). The row's TypeID id **is** the
73
+ record's `deliveryKey` — unique across replicas, stable across restarts and
74
+ retries.
75
+
76
+ - **Exactly-once ENQUEUE, fleet-wide.** On `changeScope: 'local'` stores each
77
+ replica enqueues only its OWN commits (injected cross-replica events are
78
+ skipped). On `'fleet'` stores (postgres `changeStrategy: 'cdc'`
79
+ LISTEN/NOTIFY, mysql binlog) only the holder of the leader lease
80
+ (`_voltro_cdcout_leases`, TTL-heartbeat) enqueues. Fail closed: no lease,
81
+ no enqueue.
82
+ - **At-least-once FROM ENQUEUE.** The tap is post-commit — a crash in the
83
+ narrow window between commit and the outbox insert loses that one event;
84
+ the plugin does not claim better. From enqueue on, delivery survives
85
+ restarts, retries with exponential backoff + jitter, and re-sends the SAME
86
+ `deliveryKey`s — a sink that upserts on them dedupes safely.
87
+ - **Ordered per pipe.** One in-flight batch per pipe, consumed strictly in
88
+ commit (id) order; the queue is head-blocking, so a batch waiting out its
89
+ retry backoff is never overtaken.
90
+ - **Dead-letter, never silent drop.** A record that exhausts `maxAttempts`
91
+ moves to status `dead` with its last error — queryable at
92
+ `GET /_voltro/inspect/plugins/cdc-out/dead-letter` — and unblocks the pipe.
93
+ - **Bounded storage.** Delivered/dead rows are purged by the framework
94
+ retention sweep after `retentionHours` (default 72, env
95
+ `CDCOUT_RETENTION_HOURS`); pending rows are never purged.
96
+
97
+ ## Multi-tenancy
98
+
99
+ The change tap fires for **every tenant's rows**, and a sink is app-global,
100
+ developer-authored config: whatever `map` returns (default: the raw row,
101
+ including `tenantId`) is mirrored to ONE external endpoint. Use `filter` /
102
+ `map` to scope or strip tenant data, and only ever point sinks at
103
+ **operator-controlled** URLs — never at a tenant-supplied one.
104
+
105
+ ## Backfill
106
+
107
+ Seed a pipe with existing rows once — they flow through the SAME durable
108
+ outbox as live changes (ordered, retried, dead-lettered, stable per-row
109
+ `deliveryKey`s). Call from a `*.startup.tsx` or a CLI with the app's store:
110
+
111
+ ```ts
112
+ import { enqueueBackfill } from '@voltro/plugin-cdc-out'
113
+ import { sinkConfig } from './cdc'
114
+
115
+ const n = await enqueueBackfill(store, sinkConfig, rows) // every current row, enqueued as an insert
116
+ ```