@voltro/plugin-billing 0.1.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.
@@ -0,0 +1,887 @@
1
+ import { ColumnDefinition } from '@voltro/database';
2
+ import { Context } from 'effect';
3
+ import { DataStore } from '@voltro/database';
4
+ import { Effect } from 'effect';
5
+ import { IncomingRequest } from '@voltro/plugin-webhooks';
6
+ import { IncomingResponse } from '@voltro/plugin-webhooks';
7
+ import { IncomingWebhookDescriptor } from '@voltro/plugin-webhooks';
8
+ import { Layer } from 'effect';
9
+ import { mountIncomingWebhook } from '@voltro/plugin-webhooks';
10
+ import { RpcInterceptor } from '@voltro/protocol';
11
+ import { RpcKind } from '@voltro/protocol';
12
+ import { Schema } from 'effect';
13
+ import { Subject } from '@voltro/protocol';
14
+ import { TableIndex } from '@voltro/database';
15
+ import { TableLike } from '@voltro/database';
16
+ import { VoltroPlugin } from '@voltro/protocol';
17
+
18
+ /**
19
+ * Advance one due dunning record: if there is a NEXT offset in the schedule,
20
+ * move to it (CAS on `attempt`); otherwise exhaust — cancel the subscription
21
+ * and close the record. Returns which happened, or `null` if another tick won
22
+ * the advance first (the CAS lost).
23
+ */
24
+ export declare const advanceDunning: (opts: DunningEngineOptions, record: DunningState) => Effect.Effect<"retried" | "canceled" | null, BillingError>;
25
+
26
+ export declare const BILLING_CUSTOMERS_TABLE = "_voltro_billing_customers";
27
+
28
+ export declare const BILLING_DUNNING_TABLE = "_voltro_billing_dunning";
29
+
30
+ export declare const BILLING_FLUSH_CLAIMS_TABLE = "_voltro_billing_flush_claims";
31
+
32
+ export declare const BILLING_INVOICES_TABLE = "_voltro_billing_invoices";
33
+
34
+ export declare const BILLING_SUBSCRIPTIONS_TABLE = "_voltro_billing_subscriptions";
35
+
36
+ export declare const BILLING_USAGE_TABLE = "_voltro_billing_usage";
37
+
38
+ /** The webhook id + the path the doc + plugin mount at. */
39
+ export declare const BILLING_WEBHOOK_ID = "billing";
40
+
41
+ export declare const BILLING_WEBHOOK_PATH = "/billing/webhook";
42
+
43
+ /**
44
+ * The shape every handler executor receives carries the resolved subject
45
+ * under `ctx.request.subject` (mirrors `@voltro/plugin-rbac`'s
46
+ * `RbacContext`). Kept structural so callers need no @voltro/runtime dep.
47
+ */
48
+ export declare interface BillingContext {
49
+ readonly request: {
50
+ readonly subject: Subject;
51
+ };
52
+ }
53
+
54
+ /** tenant ↔ provider customer link. */
55
+ export declare const billingCustomersTable: BillingTable;
56
+
57
+ /**
58
+ * Dunning ledger — one row per subscription with a failed payment being
59
+ * retried. `providerSubscriptionId` is UNIQUE so the create-path `insertIgnore`
60
+ * gate makes opening dunning idempotent under a replayed payment-failed
61
+ * webhook. Deleted on recovery / cancellation, so the table is bounded by the
62
+ * count of subscriptions CURRENTLY in dunning (small).
63
+ */
64
+ export declare const billingDunningTable: BillingTable;
65
+
66
+ /**
67
+ * A billing failure. `transient: true` marks failures the service retries
68
+ * (network blip talking to the provider); non-transient failures (a 4xx
69
+ * from the provider, a misconfigured plan) are surfaced immediately. The
70
+ * `billingPlugin` registers this via `errorSchemas` so it decodes TYPED on
71
+ * the client rather than crossing as an untyped defect.
72
+ */
73
+ export declare class BillingError extends BillingError_base {
74
+ }
75
+
76
+ declare const BillingError_base: Schema.TaggedErrorClass<BillingError, "BillingError", {
77
+ readonly _tag: Schema.tag<"BillingError">;
78
+ } & {
79
+ /** Where the failure originated — provider name or an internal stage. */
80
+ source: typeof Schema.String;
81
+ message: typeof Schema.String;
82
+ /** Retryable? The service retries `transient` failures on a Schedule. */
83
+ transient: typeof Schema.Boolean;
84
+ }>;
85
+
86
+ /**
87
+ * The normalized event union a `BillingProvider.normalizeEvent` produces
88
+ * from a verified, decoded provider webhook payload. `BillingService`
89
+ * applies these to the DB rows — switching provider swaps the adapter;
90
+ * the service + rows + entitlement engine are unchanged.
91
+ */
92
+ export declare type BillingEvent = {
93
+ readonly _tag: 'subscriptionUpserted';
94
+ readonly tenantId: string;
95
+ readonly providerSubscriptionId: string;
96
+ readonly plan: PlanId;
97
+ readonly status: SubscriptionStatus;
98
+ /** Seat quantity from the provider event; defaults to 1 when absent. */
99
+ readonly quantity?: number;
100
+ readonly currentPeriodStart?: Date | null;
101
+ readonly currentPeriodEnd: Date | null;
102
+ readonly cancelAt: Date | null;
103
+ } | {
104
+ readonly _tag: 'subscriptionCanceled';
105
+ readonly tenantId: string;
106
+ readonly providerSubscriptionId: string;
107
+ } | {
108
+ readonly _tag: 'invoicePaid';
109
+ readonly tenantId: string;
110
+ readonly providerInvoiceId: string;
111
+ readonly amountMinor: number;
112
+ readonly currency: string;
113
+ } | {
114
+ readonly _tag: 'invoicePaymentFailed';
115
+ readonly tenantId: string;
116
+ readonly providerInvoiceId: string;
117
+ readonly amountMinor: number;
118
+ readonly currency: string;
119
+ } | {
120
+ readonly _tag: 'customerLinked';
121
+ readonly tenantId: string;
122
+ readonly providerCustomerId: string;
123
+ };
124
+
125
+ /** The event tag union — handy for `onEvent` keys. */
126
+ export declare type BillingEventTag = BillingEvent['_tag'];
127
+
128
+ /** One row per flush window — `windowKey` UNIQUE makes the INSERT the gate. */
129
+ export declare const billingFlushClaimsTable: TableLike;
130
+
131
+ /** Invoice history (money as integer minor units). */
132
+ export declare const billingInvoicesTable: BillingTable;
133
+
134
+ export declare const billingPlugin: (options?: BillingPluginOptions) => VoltroPlugin;
135
+
136
+ export declare interface BillingPluginOptions {
137
+ /** `'stripe' | 'mock'` (built from env) or a `BillingProvider`. Default:
138
+ * `'stripe'` when `STRIPE_SECRET_KEY` is set, else `'mock'`. */
139
+ readonly provider?: BillingProviderName | BillingProvider;
140
+ /** Provider api key — server-only. Default `STRIPE_SECRET_KEY` env. */
141
+ readonly apiKey?: string;
142
+ /** Provider webhook signing secret (`whsec_…`). Default `STRIPE_WEBHOOK_SECRET` env. */
143
+ readonly webhookSecret?: string;
144
+ /** The single source of tier→limit truth. Keyed by plan id. */
145
+ readonly plans?: Readonly<Record<PlanId, PlanConfig>>;
146
+ /** Per-rpc-tag entitlement enforcement (the doc's fictional `guards:`
147
+ * replacement) — installs the interceptor when present. */
148
+ readonly enforce?: EnforceMap;
149
+ /** Usage-based billing autopilot (round-2/02): derive per-tenant usage from
150
+ * the graph's own telemetry (rpc calls / row writes / AI tokens) and flush
151
+ * to the provider on a schedule — zero hand-wired counters. Keyed by the
152
+ * meter (entitlement/usage) key reported to the provider. */
153
+ readonly metering?: MeteringConfig;
154
+ /** Autopilot flush cadence in ms (default 60_000). `0` disables the
155
+ * self-scheduled flush — call `billing.flushUsage()` from your own
156
+ * `*.cron.tsx` instead (e.g. for cluster-coordinated flushing). */
157
+ readonly flushIntervalMs?: number;
158
+ /** Dunning retry schedule — day-offsets from the original payment failure at
159
+ * which to retry before canceling. Default `[1, 3, 5, 7]`. A failed payment
160
+ * transitions the subscription `active → pastDue`; exhausting the schedule
161
+ * transitions it to `canceled`; a recovered payment returns it to `active`. */
162
+ readonly dunningSchedule?: DunningSchedule;
163
+ /** Typed per-event side effects, run AFTER the row is updated. */
164
+ readonly onEvent?: OnEventMap;
165
+ /** Transient-failure retries on provider calls. Default 3. */
166
+ readonly attempts?: number;
167
+ /**
168
+ * Per-tenant entitlement-limit override — the seam for a cloud-issued license
169
+ * snapshot. Pass `@voltro/plugin-licensing`'s `entitlementResolver` here to let
170
+ * a signed license decide limits per-tenant; a `null` result falls back to the
171
+ * static `plans` registry. Absent → static registry only.
172
+ */
173
+ readonly resolveEntitlementLimit?: (tenantId: string, key: string) => Effect.Effect<number | null, BillingError>;
174
+ /** Disambiguates multiple instances of this plugin in one app. */
175
+ readonly name?: string;
176
+ }
177
+
178
+ /**
179
+ * Dumb adapter over a billing provider. Holds NO policy — checkout/portal
180
+ * URL minting, usage push, and pure payload→event mapping only. The
181
+ * `BillingService` wraps it with the entitlement engine, the DB rows, and
182
+ * transient retry.
183
+ *
184
+ * `normalizeEvent` receives a payload whose signature was ALREADY verified
185
+ * by @voltro/plugin-webhooks' mounter — this is pure mapping, never a
186
+ * second signature check.
187
+ */
188
+ export declare interface BillingProvider {
189
+ readonly name: string;
190
+ /** Whether the provider supports server-pushed metered usage. Stripe
191
+ * does (subscription-item usage records); a flat-plan provider may not.
192
+ * When false, `BillingService.reportUsage` records locally but never
193
+ * calls `provider.reportUsage`. */
194
+ readonly supportsMeteredUsage: boolean;
195
+ readonly createCheckoutSession: (input: CheckoutInput) => Effect.Effect<{
196
+ url: string;
197
+ }, BillingError>;
198
+ readonly createPortalSession: (input: PortalInput) => Effect.Effect<{
199
+ url: string;
200
+ }, BillingError>;
201
+ readonly reportUsage: (input: UsagePush) => Effect.Effect<void, BillingError>;
202
+ /** Map a verified, decoded webhook payload to a `BillingEvent`, or
203
+ * `null` for an event type this provider doesn't model (handler
204
+ * no-ops, still 200). */
205
+ readonly normalizeEvent: (raw: unknown) => Effect.Effect<BillingEvent | null, BillingError>;
206
+ }
207
+
208
+ export declare type BillingProviderName = 'stripe' | 'mock';
209
+
210
+ /** The billing service Tag — `const billing = yield* BillingService`. */
211
+ export declare class BillingService extends BillingService_base {
212
+ }
213
+
214
+ declare const BillingService_base: Context.TagClass<BillingService, "@voltro/plugin-billing/BillingService", BillingServiceShape>;
215
+
216
+ /** Wrap a built service in a `Layer` for the plugin's `services` field. */
217
+ export declare const billingServiceLayer: (service: BillingServiceShape) => Layer.Layer<BillingService, never, never>;
218
+
219
+ export declare interface BillingServiceOptions {
220
+ readonly provider: BillingProvider;
221
+ readonly plans: PlanRegistry;
222
+ /** Stores — memory by default; swapped to DataStore-backed via `bindDataStore`. */
223
+ readonly stores?: BillingStores;
224
+ /** Transient-failure retries on provider calls. Default 3. */
225
+ readonly attempts?: number;
226
+ /** Clock injection for deterministic period windowing in tests. */
227
+ readonly now?: () => Date;
228
+ /** Dunning retry schedule (day-offsets from the original failure). Default
229
+ * `[1, 3, 5, 7]`. */
230
+ readonly dunningSchedule?: DunningSchedule;
231
+ /**
232
+ * Optional per-tenant entitlement-limit override — the seam for a
233
+ * cloud-issued license snapshot (e.g. `@voltro/plugin-licensing`'s
234
+ * `entitlementResolver`). Called before the static plan registry: a non-null
235
+ * result is used as the limit for `(tenantId, key)`; `null` falls back to the
236
+ * tenant's plan tier. Absent → behavior is exactly the static registry.
237
+ */
238
+ readonly resolveEntitlementLimit?: (tenantId: string, key: string) => Effect.Effect<number | null, BillingError>;
239
+ }
240
+
241
+ export declare interface BillingServiceShape {
242
+ /** Read the tenant's subscription row; null when none exists. */
243
+ readonly subscription: (tenantId: string) => Effect.Effect<Subscription | null, BillingError>;
244
+ /** Resolve the tenant's plan id; defaults to `'free'` when no row. */
245
+ readonly plan: (tenantId: string) => Effect.Effect<PlanId, BillingError>;
246
+ /** Pure read — would `cost` units of `key` be allowed? No mutation. */
247
+ readonly checkEntitlement: (tenantId: string, key: string, cost: number) => Effect.Effect<EntitlementDecision, BillingError>;
248
+ /** Check + decrement, atomically. Fails `EntitlementExceeded` over-limit. */
249
+ readonly consumeEntitlement: (tenantId: string, key: string, cost: number) => Effect.Effect<void, BillingError | EntitlementExceeded>;
250
+ /** Record metered usage locally; flushed to the provider in batches. */
251
+ readonly reportUsage: (tenantId: string, key: string, qty: number) => Effect.Effect<void, BillingError>;
252
+ /** Flush all pending local usage counters to the provider. */
253
+ readonly flushUsage: () => Effect.Effect<void, BillingError>;
254
+ /** Mint a provider-hosted checkout URL for `plan`. */
255
+ readonly startCheckout: (input: {
256
+ readonly tenantId: string;
257
+ readonly plan: PlanId;
258
+ readonly successUrl: string;
259
+ readonly cancelUrl: string;
260
+ }) => Effect.Effect<{
261
+ url: string;
262
+ }, BillingError>;
263
+ /** Mint a provider-hosted billing-portal URL. */
264
+ readonly portalUrl: (tenantId: string, returnUrl: string) => Effect.Effect<{
265
+ url: string;
266
+ }, BillingError>;
267
+ /** Apply a normalized provider event to the DB rows (idempotent upsert). */
268
+ readonly applyEvent: (event: BillingEvent) => Effect.Effect<void, BillingError>;
269
+ /**
270
+ * Change the tenant's plan mid-cycle. Computes a TIME-BASED prorated
271
+ * settlement on the amount difference for the unused remainder of the
272
+ * current period (positive = charge on upgrade, negative = credit on
273
+ * downgrade), persists the new plan, and returns the settlement. A same-plan
274
+ * call is a no-op with a zero delta. Fails `BillingError` when the tenant has
275
+ * no subscription or the target plan is unknown.
276
+ */
277
+ readonly changePlan: (tenantId: string, newPlan: PlanId, changeAt?: Date) => Effect.Effect<SubscriptionChange, BillingError>;
278
+ /**
279
+ * Set the tenant's seat quantity mid-cycle. Prorates the amount delta
280
+ * (`unitAmountMinor × Δquantity`) over the unused remainder of the period,
281
+ * persists the new quantity, and returns the settlement. `quantity` must be a
282
+ * positive integer. Fails `BillingError` on no subscription / bad quantity.
283
+ */
284
+ readonly changeSeats: (tenantId: string, quantity: number, changeAt?: Date) => Effect.Effect<SubscriptionChange, BillingError>;
285
+ /**
286
+ * Record a failed payment: schedule the first dunning retry (per the
287
+ * configured schedule) and transition the subscription `active → pastDue`.
288
+ * Idempotent — a replayed failure for a subscription already in dunning does
289
+ * not re-arm or double-schedule.
290
+ */
291
+ readonly recordPaymentFailure: (tenantId: string, at?: Date) => Effect.Effect<DunningState | null, BillingError>;
292
+ /**
293
+ * Record a recovered payment: clear any open dunning and transition the
294
+ * subscription back to `active`. Idempotent / a no-op when not in dunning.
295
+ */
296
+ readonly recordPaymentSuccess: (tenantId: string, at?: Date) => Effect.Effect<void, BillingError>;
297
+ /**
298
+ * Advance every due dunning entry: subscriptions whose next retry is due move
299
+ * to the next offset in the schedule; those that have exhausted the schedule
300
+ * transition `pastDue → canceled`. Idempotent per due-time (a claim gate
301
+ * makes a re-run over the same window a no-op). Driven by the self-scheduled
302
+ * interval, or call it from your own `*.cron.tsx`.
303
+ */
304
+ readonly runDunningCycle: (now?: Date) => Effect.Effect<DunningCycleResult, BillingError>;
305
+ }
306
+
307
+ /** The five billing stores bundled — swapped wholesale by `bindDataStore`. */
308
+ export declare interface BillingStores {
309
+ readonly customers: CustomerStore;
310
+ readonly subscriptions: SubscriptionStore;
311
+ readonly invoices: InvoiceStore;
312
+ readonly usage: UsageStore;
313
+ readonly dunning: DunningStore;
314
+ }
315
+
316
+ /** One active subscription per tenant. */
317
+ export declare const billingSubscriptionsTable: BillingTable;
318
+
319
+ /**
320
+ * The slice of a built `Table` this package exposes. Annotated explicitly
321
+ * because the full inferred `Table` type leaks @voltro/database's private
322
+ * column-builder class across the package boundary (TS4094) — the public
323
+ * surface only needs the table name, its columns, and its indexes.
324
+ */
325
+ export declare interface BillingTable extends TableLike {
326
+ readonly fields: Record<string, ColumnDefinition<unknown>>;
327
+ readonly appliedIndexes: ReadonlyArray<TableIndex>;
328
+ }
329
+
330
+ /** All billing tables, ready to spread into `extendSchema.tables`. */
331
+ export declare const billingTables: () => ReadonlyArray<BillingTable>;
332
+
333
+ /** Per-tenant metered counters driving the entitlement engine. */
334
+ export declare const billingUsageTable: BillingTable;
335
+
336
+ /**
337
+ * The incoming-webhook descriptor for billing. `payload` is `Unknown` —
338
+ * each provider's payload shape differs and `normalizeEvent` does the
339
+ * narrowing; the signature + idempotency layers don't need a typed body.
340
+ */
341
+ export declare const billingWebhookDescriptor: (options: BillingWebhookOptions) => IncomingWebhookDescriptor<unknown>;
342
+
343
+ declare interface BillingWebhookOptions {
344
+ readonly provider: BillingProvider;
345
+ readonly service: BillingServiceShape;
346
+ readonly onEvent?: OnEventMap;
347
+ }
348
+
349
+ /**
350
+ * Build the BillingService implementation. The interceptor + the plugin's
351
+ * rpc routes share this single instance (so `enforce` and the rpc routes
352
+ * see the same swapped-in stores).
353
+ */
354
+ export declare const buildBillingService: (options: BillingServiceOptions) => {
355
+ readonly service: BillingServiceShape;
356
+ readonly holder: StoreHolder;
357
+ };
358
+
359
+ /**
360
+ * Build the interceptor for the `enforce` map. Before the executor runs,
361
+ * for a matched rpc tag it consumes the rule's cost from the subject's
362
+ * tenant — short-circuiting with `EntitlementExceeded` BEFORE the
363
+ * executor when the quota is exhausted (the ratelimit precedent).
364
+ *
365
+ * The `service` is captured once at plugin-build time (the same memory/DB
366
+ * service the `services` layer exposes), so the interceptor doesn't need
367
+ * to resolve the Tag from a layer it isn't wired into.
368
+ */
369
+ export declare const buildEnforceInterceptor: (enforce: EnforceMap, service: BillingServiceShape) => RpcInterceptor;
370
+
371
+ /** Build the registry from `billingPlugin({ plans })`. */
372
+ export declare const buildPlanRegistry: (plans: Readonly<Record<PlanId, PlanConfig>>) => PlanRegistry;
373
+
374
+ export declare interface CheckoutInput {
375
+ readonly tenantId: string;
376
+ /** Provider price id to subscribe to. */
377
+ readonly priceId: string;
378
+ readonly successUrl: string;
379
+ readonly cancelUrl: string;
380
+ /** Optional existing provider customer id to attach the checkout to. */
381
+ readonly providerCustomerId?: string;
382
+ }
383
+
384
+ /**
385
+ * Try to claim the flush window. INSERT-wins: the first replica's row is the
386
+ * winner; a racing replica's `insertIgnore` returns that same row, so it sees a
387
+ * `claimedBy` that isn't its own and stands down. Returns true iff THIS replica
388
+ * owns the window (→ it runs the flush). Fails OPEN on a store error (better a
389
+ * possible double-flush — which markReported makes idempotent — than no flush).
390
+ */
391
+ export declare const claimFlushWindow: (store: DataStore, windowKey: string, replicaId: string, nowMs: number) => Promise<boolean>;
392
+
393
+ /**
394
+ * Compute the prorated settlement for a mid-cycle amount change.
395
+ *
396
+ * Fails `BillingError` (non-transient) when the period is degenerate
397
+ * (`periodEnd <= periodStart`) — a caller must supply a real paid period; a
398
+ * plan with no period (`currentPeriodEnd === null`) is not prorated at all
399
+ * (the service short-circuits before calling this).
400
+ */
401
+ export declare const computeProration: (input: ProrationInput) => ProrationResult;
402
+
403
+ declare interface CustomerRecord {
404
+ readonly tenantId: string;
405
+ readonly provider: string;
406
+ readonly providerCustomerId: string;
407
+ }
408
+
409
+ export declare interface CustomerStore {
410
+ readonly upsert: (record: CustomerRecord) => Effect.Effect<void, BillingError>;
411
+ readonly getByTenant: (tenantId: string) => Effect.Effect<CustomerRecord | null, BillingError>;
412
+ }
413
+
414
+ export declare const dataStoreStores: (store: DataStore) => BillingStores;
415
+
416
+ export declare const DEFAULT_DUNNING_SCHEDULE: DunningSchedule;
417
+
418
+ /** The free/fallback plan id every app implicitly has. */
419
+ export declare const DEFAULT_PLAN: PlanId;
420
+
421
+ /** What one `runDunningCycle` did. */
422
+ export declare interface DunningCycleResult {
423
+ /** Subscriptions advanced to the next retry offset. */
424
+ readonly retried: number;
425
+ /** Subscriptions that exhausted the schedule and were canceled. */
426
+ readonly canceled: number;
427
+ }
428
+
429
+ export declare interface DunningEngineOptions {
430
+ readonly subscriptions: SubscriptionStore;
431
+ readonly dunning: DunningStore;
432
+ readonly schedule: DunningSchedule;
433
+ }
434
+
435
+ /** The dunning retry schedule: day-offsets from the original failure at which
436
+ * to retry. Default `[1, 3, 5, 7]` (four retries over a week, then cancel). */
437
+ export declare type DunningSchedule = ReadonlyArray<number>;
438
+
439
+ /** A tenant's live dunning record — a failed payment being retried. */
440
+ export declare interface DunningState {
441
+ readonly tenantId: string;
442
+ readonly providerSubscriptionId: string;
443
+ /** 0-based index into the configured retry-offset schedule. */
444
+ readonly attempt: number;
445
+ /** When the current retry is due. */
446
+ readonly nextRetryAt: Date;
447
+ /** When dunning was first opened (the original failure). */
448
+ readonly startedAt: Date;
449
+ }
450
+
451
+ export declare interface DunningStore {
452
+ /**
453
+ * Open a dunning record for a subscription (attempt 0, due at `nextRetryAt`),
454
+ * IDEMPOTENTLY — if one already exists it is left untouched and `false` is
455
+ * returned. Returns `true` only when THIS call opened it. The create-path
456
+ * `insertIgnore` over the `providerSubscriptionId` UNIQUE makes a replayed
457
+ * payment-failed webhook a no-op across instances.
458
+ */
459
+ readonly open: (tenantId: string, providerSubscriptionId: string, nextRetryAt: Date, at: Date) => Effect.Effect<boolean, BillingError>;
460
+ readonly getByTenant: (tenantId: string) => Effect.Effect<DunningState | null, BillingError>;
461
+ /** All dunning records due at/before `at` (`nextRetryAt <= at`). */
462
+ readonly due: (at: Date) => Effect.Effect<ReadonlyArray<DunningState>, BillingError>;
463
+ /** Advance a record to the next attempt/retry time, CONDITIONALLY on the
464
+ * record still being at `fromAttempt` (a CAS so two ticks can't both advance
465
+ * the same record). Returns `true` iff this call won the advance. */
466
+ readonly advance: (providerSubscriptionId: string, fromAttempt: number, toAttempt: number, nextRetryAt: Date) => Effect.Effect<boolean, BillingError>;
467
+ /** Close (delete) the dunning record for a subscription — on recovery or on
468
+ * exhaustion→cancel. No-op if absent. */
469
+ readonly close: (providerSubscriptionId: string) => Effect.Effect<void, BillingError>;
470
+ }
471
+
472
+ /** A registry with a single zero-quota `free` plan — the zero-config default. */
473
+ export declare const emptyPlanRegistry: () => PlanRegistry;
474
+
475
+ export declare type EnforceMap = Readonly<Record<string, EnforceRule>>;
476
+
477
+ /** One per-tag enforcement rule from `billingPlugin({ enforce })`. */
478
+ export declare interface EnforceRule {
479
+ /** The entitlement key the tag consumes. */
480
+ readonly entitlement: string;
481
+ /** Units charged per call. Default 1. */
482
+ readonly cost?: number;
483
+ }
484
+
485
+ /** The result of a pure (non-mutating) entitlement check. */
486
+ export declare interface EntitlementDecision {
487
+ /** Whether the call would be allowed. */
488
+ readonly allowed: boolean;
489
+ /** The entitlement key checked. */
490
+ readonly entitlement: string;
491
+ /** The static limit for the caller's plan (`Infinity` for `'unlimited'`). */
492
+ readonly limit: number;
493
+ /** How much is already used in the current window. */
494
+ readonly used: number;
495
+ /** The cost the call would charge. */
496
+ readonly cost: number;
497
+ }
498
+
499
+ /**
500
+ * Raised when a caller has exhausted an entitlement (quota). Distinct from
501
+ * the scope/permission `Forbidden` / `ScopeError` — those gate "may you
502
+ * call this proc"; this gates "do you have quota left". Both axes compose.
503
+ * `billingPlugin` registers it via `errorSchemas` so callers can
504
+ * pattern-match on `_tag === 'EntitlementExceeded'`.
505
+ */
506
+ export declare class EntitlementExceeded extends EntitlementExceeded_base {
507
+ }
508
+
509
+ declare const EntitlementExceeded_base: Schema.TaggedErrorClass<EntitlementExceeded, "EntitlementExceeded", {
510
+ readonly _tag: Schema.tag<"EntitlementExceeded">;
511
+ } & {
512
+ /** The entitlement key that was exhausted (`'aiCalls'`). */
513
+ entitlement: typeof Schema.String;
514
+ /** The static limit for the caller's plan. */
515
+ limit: typeof Schema.Number;
516
+ /** How much was already used in the current window. */
517
+ used: typeof Schema.Number;
518
+ /** The cost the rejected call would have charged. */
519
+ cost: typeof Schema.Number;
520
+ }>;
521
+
522
+ /** An entitlement limit: a finite quota or the unbounded `'unlimited'`. */
523
+ export declare type EntitlementLimit = number | 'unlimited';
524
+
525
+ /**
526
+ * Pure entitlement decision. `limit` is `Infinity` for an `'unlimited'`
527
+ * plan. `'unlimited'` and any non-positive cost always allow without
528
+ * touching the counter; otherwise allow iff `used + cost <= limit`.
529
+ */
530
+ export declare const evaluateEntitlement: (entitlement: string, limit: number, used: number, cost: number) => EntitlementDecision;
531
+
532
+ /** The window a `now` falls in, bucketed by the flush interval. Two replicas
533
+ * flushing within the same interval land on the same key → one wins. */
534
+ export declare const flushWindowKey: (nowMs: number, intervalMs: number) => string;
535
+
536
+ declare interface InvoiceRecord {
537
+ readonly tenantId: string;
538
+ readonly provider: string;
539
+ readonly providerInvoiceId: string;
540
+ readonly amountMinor: number;
541
+ readonly currency: string;
542
+ /** 'paid' | 'open' | 'uncollectible' | 'void'. */
543
+ readonly status: string;
544
+ }
545
+
546
+ export declare interface InvoiceStore {
547
+ /** Upsert by `providerInvoiceId` (idempotent under webhook replay). */
548
+ readonly upsert: (invoice: InvoiceRecord) => Effect.Effect<void, BillingError>;
549
+ readonly listByTenant: (tenantId: string) => Effect.Effect<ReadonlyArray<InvoiceRecord>, BillingError>;
550
+ }
551
+
552
+ export declare const memoryStores: () => BillingStores;
553
+
554
+ /** `meterKey → source`. The meterKey is the entitlement/usage key reported to
555
+ * the provider (`billing.reportUsage(tenant, meterKey, qty)`). */
556
+ declare type MeteringConfig = Readonly<Record<string, MeterSource>>;
557
+
558
+ /** One meter's source — where its usage is derived from. */
559
+ declare type MeterSource =
560
+ /** Count rpc calls whose tag matches (string = exact, RegExp = test). Optional
561
+ * `kind` restricts to mutation/query/action. */
562
+ {
563
+ readonly from: 'rpc';
564
+ readonly match: string | RegExp;
565
+ readonly kind?: RpcKind;
566
+ }
567
+ /** Count row writes to `table` (default op `insert`). */
568
+ | {
569
+ readonly from: 'cdc';
570
+ readonly table: string;
571
+ readonly op?: 'insert' | 'update' | 'delete';
572
+ }
573
+ /** Sum the AI usage ledger — `tokens` (input+output, default) or `costMicroUsd`. */
574
+ | {
575
+ readonly from: 'ai';
576
+ readonly metric?: 'tokens' | 'costMicroUsd';
577
+ };
578
+
579
+ /**
580
+ * In-memory provider with deterministic outputs. Checkout/portal URLs are
581
+ * stable functions of the input so tests can assert on them; usage pushes
582
+ * are recorded on `pushed` for assertion. `normalizeEvent` accepts the
583
+ * already-normalized `BillingEvent` shape directly (the test feeds events
584
+ * straight through) OR a `{ type, data }` envelope mirroring the Stripe
585
+ * mapping, so the same webhook path exercises the mock end-to-end.
586
+ */
587
+ export declare interface MockProvider extends BillingProvider {
588
+ /** Recorded usage pushes — assertion surface for tests. */
589
+ readonly pushed: ReadonlyArray<UsagePush>;
590
+ }
591
+
592
+ export declare const mockProvider: (options?: {
593
+ readonly baseUrl?: string;
594
+ readonly supportsMeteredUsage?: boolean;
595
+ }) => MockProvider;
596
+
597
+ /** A monetary amount: integer minor units + ISO-4217 currency. */
598
+ export declare interface Money {
599
+ /** Integer minor units (cents). Never a float. */
600
+ readonly amountMinor: number;
601
+ /** ISO-4217 currency code, lowercased (`'usd'`, `'eur'`). */
602
+ readonly currency: string;
603
+ }
604
+
605
+ /**
606
+ * Build the request handler for the billing webhook. The returned function
607
+ * takes the transport-agnostic `{ method, headers, rawBody }` triple and
608
+ * returns the typed `IncomingResponse`.
609
+ */
610
+ export declare const mountBillingWebhook: (options: MountBillingWebhookOptions) => (request: IncomingRequest) => Promise<IncomingResponse>;
611
+
612
+ declare interface MountBillingWebhookOptions extends BillingWebhookOptions {
613
+ /** The provider webhook signing secret (`whsec_…`). Null skips verification. */
614
+ readonly webhookSecret: string | null;
615
+ /** Optional idempotency-cache override (tests pass a fresh cache). */
616
+ readonly idempotencyCache?: Parameters<typeof mountIncomingWebhook>[1]['idempotencyCache'];
617
+ }
618
+
619
+ /**
620
+ * Map a verified, decoded Stripe webhook event (`{ type, data: { object } }`)
621
+ * to a `BillingEvent`, or null for unhandled types. Pure — the signature
622
+ * was already checked by the webhook mounter.
623
+ */
624
+ export declare const normalizeStripeEvent: (raw: unknown) => BillingEvent | null;
625
+
626
+ /** Per-event side effect, run AFTER the row is updated. */
627
+ export declare type OnEventMap = Readonly<Record<string, (event: BillingEvent) => Effect.Effect<void, unknown>>>;
628
+
629
+ /**
630
+ * Open dunning for a subscription on a payment failure: schedule the first
631
+ * retry (offset[0]) and transition `active → pastDue`. Idempotent — a replayed
632
+ * failure whose record already exists is a no-op (returns `null`). Returns the
633
+ * opened `DunningState` when this call opened it.
634
+ */
635
+ export declare const openDunning: (opts: DunningEngineOptions, tenantId: string, providerSubscriptionId: string, at: Date) => Effect.Effect<DunningState | null, BillingError>;
636
+
637
+ /** Calendar-month window key (`'YYYY-MM'`) for metered counters. */
638
+ export declare const periodKey: (now?: Date) => string;
639
+
640
+ /** One plan/tier: its entitlement limits + (for paid plans) a provider price id. */
641
+ export declare interface PlanConfig {
642
+ /** Provider price id (Stripe `price_…`). Absent for free/zero-cost plans. */
643
+ readonly priceId?: string;
644
+ /** Per-entitlement-key limits. Values are `number | 'unlimited'`. */
645
+ readonly entitlements: Readonly<Record<string, EntitlementLimit>>;
646
+ /** The plan's per-period price in INTEGER minor units (cents) — the unit
647
+ * amount for ONE seat. Multiplied by the subscription's seat `quantity` to
648
+ * get the billed amount, and drives mid-cycle proration. Absent for free
649
+ * plans (treated as 0). Never a float. */
650
+ readonly unitAmountMinor?: number;
651
+ /** ISO-4217 currency for `unitAmountMinor`, lowercased (`'usd'`). Defaults
652
+ * to `'usd'` when a `unitAmountMinor` is set without one. */
653
+ readonly currency?: string;
654
+ }
655
+
656
+ /** Plan id (the key in `billingPlugin({ plans })`). */
657
+ export declare type PlanId = string;
658
+
659
+ export declare interface PlanRegistry {
660
+ /** Whether `plan` is a known plan id. */
661
+ readonly has: (plan: PlanId) => boolean;
662
+ /**
663
+ * The static limit for `(plan, key)`. `Infinity` for `'unlimited'`, `0`
664
+ * for an undeclared key. When `plan` is unknown the registry FAILS
665
+ * CLOSED — returns `0` — never silently granting an unknown plan's quota.
666
+ * So a redeploy that drops a live plan id DENIES that tenant's quota
667
+ * rather than falling back to the free-tier limit.
668
+ */
669
+ readonly entitlementLimit: (plan: PlanId, key: string) => number;
670
+ /** The plan id whose `priceId` matches, or null. */
671
+ readonly planForPriceId: (priceId: string) => PlanId | null;
672
+ /** The provider price id for a plan, or null (free plans have none). */
673
+ readonly priceIdFor: (plan: PlanId) => string | null;
674
+ /** The per-seat unit amount in integer minor units for a plan; `0` for a
675
+ * free/undeclared plan. Multiplied by seat quantity for the billed amount. */
676
+ readonly unitAmountFor: (plan: PlanId) => number;
677
+ /** The ISO-4217 currency for a plan's price, lowercased; `'usd'` default. */
678
+ readonly currencyFor: (plan: PlanId) => string;
679
+ /** All declared plan ids. */
680
+ readonly planIds: () => ReadonlyArray<PlanId>;
681
+ }
682
+
683
+ export declare interface PortalInput {
684
+ readonly tenantId: string;
685
+ /** Provider customer id whose billing portal to open. */
686
+ readonly providerCustomerId: string;
687
+ readonly returnUrl: string;
688
+ }
689
+
690
+ /** The inputs to one proration computation. All amounts are integer minor units. */
691
+ export declare interface ProrationInput {
692
+ /** The amount the subscription was billing for the current period (minor units). */
693
+ readonly oldAmountMinor: number;
694
+ /** The amount it will bill after the change (minor units). */
695
+ readonly newAmountMinor: number;
696
+ /** Start of the current paid period. */
697
+ readonly periodStart: Date;
698
+ /** End of the current paid period (`currentPeriodEnd`). */
699
+ readonly periodEnd: Date;
700
+ /** When the change takes effect (usually "now"). */
701
+ readonly changeAt: Date;
702
+ }
703
+
704
+ /** The result of a proration: the settlement delta + the fraction it was based on. */
705
+ export declare interface ProrationResult {
706
+ /** Integer minor-unit settlement: > 0 charge (upgrade), < 0 credit
707
+ * (downgrade), 0 at the period boundary or when the amount is unchanged. */
708
+ readonly deltaMinor: number;
709
+ /** The unused fraction of the period the delta was prorated over, in [0, 1]. */
710
+ readonly unusedFraction: number;
711
+ }
712
+
713
+ /**
714
+ * Recover a subscription: close any open dunning record and set it back to
715
+ * `active`. Idempotent / no-op when not in dunning.
716
+ */
717
+ export declare const recoverDunning: (opts: DunningEngineOptions, providerSubscriptionId: string) => Effect.Effect<void, BillingError>;
718
+
719
+ /**
720
+ * In-handler entitlement guard. Resolves `ctx.request.subject.tenantId`,
721
+ * consumes `cost` units of `key`, and fails with the typed
722
+ * `EntitlementExceeded` when the quota is exhausted. Effect-native.
723
+ *
724
+ * ```ts
725
+ * export default (input, ctx) => Effect.gen(function* () {
726
+ * yield* requireEntitlement(ctx, 'aiCalls', Math.ceil(input.tokens / 1000))
727
+ * // …
728
+ * })
729
+ * ```
730
+ *
731
+ * Reads `BillingService` from the per-request layer — declare it in the
732
+ * effect's R channel (it's provided by `billingPlugin`'s `services`).
733
+ */
734
+ export declare const requireEntitlement: (ctx: BillingContext, key: string, cost: number) => Effect.Effect<void, BillingError | EntitlementExceeded, BillingService>;
735
+
736
+ /**
737
+ * Resolve `options.provider` to a concrete `BillingProvider`. A passed
738
+ * object is used verbatim. `'stripe'` needs an api key (option or
739
+ * `STRIPE_SECRET_KEY`). `'mock'` (and the absent default) is the
740
+ * zero-config in-memory provider.
741
+ */
742
+ export declare const resolveProvider: (input: ResolveProviderInput) => BillingProvider;
743
+
744
+ declare interface ResolveProviderInput {
745
+ readonly provider?: BillingProviderName | BillingProvider;
746
+ readonly apiKey?: string;
747
+ readonly env: NodeJS.ProcessEnv;
748
+ }
749
+
750
+ /**
751
+ * The retry time for attempt `index` (0-based) given the original failure
752
+ * `startedAt` and the schedule of day-offsets. Returns `null` when `index` is
753
+ * past the end of the schedule — the signal to CANCEL rather than reschedule.
754
+ */
755
+ export declare const retryTimeFor: (startedAt: Date, index: number, schedule: DunningSchedule) => Date | null;
756
+
757
+ /** Round half away from zero — symmetric for charges (+) and credits (−) so
758
+ * proration doesn't systematically favour either party. `Math.round` rounds
759
+ * half toward +∞ (−0.5 → 0, +0.5 → 1), which WOULD bias credits; this
760
+ * corrects the negative side. Operates on a float intermediate and returns an
761
+ * integer. */
762
+ export declare const roundHalfAwayFromZero: (value: number) => number;
763
+
764
+ /**
765
+ * Run one dunning cycle: for every record due at `now`, advance it (retry to
766
+ * the next offset, or cancel if exhausted). Idempotent per due-time — the CAS
767
+ * on `attempt` means a re-run over an already-advanced record is a no-op.
768
+ */
769
+ export declare const runDunningCycle: (opts: DunningEngineOptions, now: Date) => Effect.Effect<DunningCycleResult, BillingError>;
770
+
771
+ /**
772
+ * A mutable holder so `bindDataStore` can swap the stores AFTER the service
773
+ * layer is built. The service reads `holder.stores` on every call rather
774
+ * than capturing a snapshot — identical to how storage rebinds its ref
775
+ * store once the DataStore exists.
776
+ */
777
+ declare interface StoreHolder {
778
+ stores: BillingStores;
779
+ }
780
+
781
+ export declare const stripeProvider: (options: StripeProviderOptions) => BillingProvider;
782
+
783
+ export declare interface StripeProviderOptions {
784
+ readonly apiKey: string;
785
+ /** Override metered-usage support (Stripe supports it by default). */
786
+ readonly supportsMeteredUsage?: boolean;
787
+ }
788
+
789
+ /** A tenant's subscription row, normalized across providers. */
790
+ export declare interface Subscription {
791
+ readonly tenantId: string;
792
+ readonly provider: string;
793
+ readonly providerSubscriptionId: string;
794
+ readonly plan: PlanId;
795
+ readonly status: SubscriptionStatus;
796
+ /** Per-seat quantity — the billed amount is `plan.unitAmountMinor × quantity`.
797
+ * Defaults to 1 for a single-seat subscription. */
798
+ readonly quantity: number;
799
+ /** Start of the current paid period; null for plans without a period.
800
+ * Anchors mid-cycle proration (the "used" side of the period). */
801
+ readonly currentPeriodStart: Date | null;
802
+ /** When the current paid period ends; null for plans without a period. */
803
+ readonly currentPeriodEnd: Date | null;
804
+ /** When the subscription is scheduled to cancel; null if not scheduled. */
805
+ readonly cancelAt: Date | null;
806
+ }
807
+
808
+ /** The outcome of a mid-cycle plan/seat change — the prorated settlement. */
809
+ export declare interface SubscriptionChange {
810
+ readonly tenantId: string;
811
+ readonly plan: PlanId;
812
+ readonly quantity: number;
813
+ /** Integer minor-unit settlement: > 0 charge, < 0 credit, 0 at boundary. */
814
+ readonly prorationMinor: number;
815
+ readonly currency: string;
816
+ }
817
+
818
+ export declare type SubscriptionStatus = 'active' | 'trialing' | 'pastDue' | 'canceled' | 'incomplete';
819
+
820
+ export declare interface SubscriptionStore {
821
+ /** Upsert by `providerSubscriptionId` — the webhook's idempotency anchor. */
822
+ readonly upsert: (sub: Subscription) => Effect.Effect<void, BillingError>;
823
+ readonly getByTenant: (tenantId: string) => Effect.Effect<Subscription | null, BillingError>;
824
+ /** Mark a subscription canceled by provider id (no-op if absent). */
825
+ readonly markCanceled: (providerSubscriptionId: string) => Effect.Effect<void, BillingError>;
826
+ /** Apply a mid-cycle plan/seat/status change to the tenant's row (no-op if
827
+ * absent). Only the provided fields are patched. */
828
+ readonly patchByTenant: (tenantId: string, patch: {
829
+ readonly plan?: PlanId;
830
+ readonly quantity?: number;
831
+ readonly status?: SubscriptionStatus;
832
+ }) => Effect.Effect<void, BillingError>;
833
+ /** Set a subscription's status by provider id (no-op if absent) — the
834
+ * dunning state machine's transition primitive. */
835
+ readonly setStatus: (providerSubscriptionId: string, status: SubscriptionStatus) => Effect.Effect<void, BillingError>;
836
+ }
837
+
838
+ export declare interface UsagePush {
839
+ readonly tenantId: string;
840
+ readonly entitlementKey: string;
841
+ /** Aggregate quantity to report for the current period. */
842
+ readonly quantity: number;
843
+ /** End of the period the usage falls in (provider timestamp anchor). */
844
+ readonly periodEnd?: Date;
845
+ }
846
+
847
+ declare interface UsageRow {
848
+ readonly tenantId: string;
849
+ readonly entitlementKey: string;
850
+ readonly period: string;
851
+ readonly used: number;
852
+ readonly reportedToProvider: number;
853
+ }
854
+
855
+ export declare interface UsageStore {
856
+ /** Current used count for `(tenant, key, period)`; 0 when no row. */
857
+ readonly used: (tenantId: string, key: string, period: string) => Effect.Effect<number, BillingError>;
858
+ /**
859
+ * Atomic check-and-increment — the entitlement quota gate. In ONE
860
+ * critical section: read the current `used`, and increment by `cost`
861
+ * IFF `used + cost <= limit`. Returns the decision; `allowed:false`
862
+ * means NOTHING was incremented. This is the fix for the read-then-write
863
+ * race the service layer had — the check and the increment can no longer
864
+ * interleave with a concurrent caller (memory: one sync tick; DataStore:
865
+ * a compare-and-set loop so it's safe across instances, not just one
866
+ * process). `limit` is finite and `cost > 0` by the time this is called
867
+ * (the service short-circuits unlimited / non-positive cost).
868
+ */
869
+ readonly consume: (tenantId: string, key: string, period: string, cost: number, limit: number) => Effect.Effect<EntitlementDecision, BillingError>;
870
+ /** Add `delta` to the used count, returning the new total. */
871
+ readonly increment: (tenantId: string, key: string, period: string, delta: number) => Effect.Effect<number, BillingError>;
872
+ /** All rows with un-flushed usage (`used > reportedToProvider`). */
873
+ readonly pending: () => Effect.Effect<ReadonlyArray<UsageRow>, BillingError>;
874
+ /** Mark `(tenant, key, period)` flushed up to `used`. */
875
+ readonly markReported: (tenantId: string, key: string, period: string, reported: number) => Effect.Effect<void, BillingError>;
876
+ }
877
+
878
+ /**
879
+ * Validate a dunning schedule at boot — fails LOUD on a bad config rather than
880
+ * silently never retrying. Offsets must be a non-empty list of strictly
881
+ * increasing positive numbers (days).
882
+ */
883
+ export declare const validateDunningSchedule: (schedule: DunningSchedule) => void;
884
+
885
+ export { VoltroPlugin }
886
+
887
+ export { }