@voltro/plugin-billing 0.32.0 → 0.34.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.
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ import { VoltroPlugin } from '@voltro/protocol';
18
18
 
19
19
  export declare const BILLING_CUSTOMERS_TABLE = "_voltro_billing_customers";
20
20
 
21
+ export declare const BILLING_DUNNING_NOTICES_TABLE = "_voltro_billing_dunning_notices";
22
+
21
23
  export declare const BILLING_FLUSH_CLAIMS_TABLE = "_voltro_billing_flush_claims";
22
24
 
23
25
  export declare const BILLING_INVOICES_TABLE = "_voltro_billing_invoices";
@@ -45,6 +47,17 @@ export declare interface BillingContext {
45
47
  /** tenant ↔ provider customer link. */
46
48
  export declare const billingCustomersTable: BillingTable;
47
49
 
50
+ /**
51
+ * The sent-notice ledger. One row per `(tenant, episode, step)` — and the
52
+ * UNIQUE over exactly those three is the send gate, not a report: a notice is
53
+ * CLAIMED by `insertIgnore` before it is sent, so a duplicated webhook
54
+ * delivery loses the race and sends nothing.
55
+ */
56
+ export declare const billingDunningNoticesTable: TableLike & {
57
+ readonly fields: Record<string, ColumnDefinition<unknown>>;
58
+ readonly appliedIndexes: ReadonlyArray<TableIndex>;
59
+ };
60
+
48
61
  /**
49
62
  * A billing failure. `transient: true` marks failures the service retries
50
63
  * (network blip talking to the provider); non-transient failures (a 4xx
@@ -82,26 +95,33 @@ export declare type BillingEvent = {
82
95
  readonly currentPeriodStart?: Date | null;
83
96
  readonly currentPeriodEnd: Date | null;
84
97
  readonly cancelAt: Date | null;
98
+ /** When the PROVIDER emitted this — the out-of-order guard. See
99
+ * `occurredAt` on the union below. */
100
+ readonly occurredAt: Date | null;
85
101
  } | {
86
102
  readonly _tag: 'subscriptionCanceled';
87
103
  readonly tenantId: string;
88
104
  readonly providerSubscriptionId: string;
105
+ readonly occurredAt: Date | null;
89
106
  } | {
90
107
  readonly _tag: 'invoicePaid';
91
108
  readonly tenantId: string;
92
109
  readonly providerInvoiceId: string;
93
110
  readonly amountMinor: number;
94
111
  readonly currency: string;
112
+ readonly occurredAt: Date | null;
95
113
  } | {
96
114
  readonly _tag: 'invoicePaymentFailed';
97
115
  readonly tenantId: string;
98
116
  readonly providerInvoiceId: string;
99
117
  readonly amountMinor: number;
100
118
  readonly currency: string;
119
+ readonly occurredAt: Date | null;
101
120
  } | {
102
121
  readonly _tag: 'customerLinked';
103
122
  readonly tenantId: string;
104
123
  readonly providerCustomerId: string;
124
+ readonly occurredAt: Date | null;
105
125
  };
106
126
 
107
127
  /** The event tag union — handy for `onEvent` keys. */
@@ -137,6 +157,19 @@ export declare interface BillingPluginOptions {
137
157
  * self-scheduled flush — call `billing.flushUsage()` from your own
138
158
  * `*.cron.tsx` instead (e.g. for cluster-coordinated flushing). */
139
159
  readonly flushIntervalMs?: number;
160
+ /**
161
+ * Dunning: the past-due notification sequence, the grace clock, and the
162
+ * lockout. Composed on the PROVIDER'S outcomes — nothing here retries a
163
+ * payment or schedules one; Stripe owns the retry cadence and each attempt
164
+ * it makes is the tick that evaluates whichever steps have come due.
165
+ *
166
+ * Defaults: enabled, a 7-day grace, a 3-step sequence (0h / 72h / 144h), a
167
+ * hard lockout — and NO transport, so until `notify` is wired the sequence
168
+ * only claims + logs. Every number is overridable here and by env
169
+ * (`VOLTRO_BILLING_GRACE_HOURS`, `VOLTRO_BILLING_DUNNING_STEP_HOURS`,
170
+ * `VOLTRO_BILLING_LOCKOUT`, `VOLTRO_BILLING_DUNNING`).
171
+ */
172
+ readonly dunning?: DunningConfig;
140
173
  /** Typed per-event side effects, run AFTER the row is updated. */
141
174
  readonly onEvent?: OnEventMap;
142
175
  /** Transient-failure retries on provider calls. Default 3. */
@@ -163,7 +196,25 @@ export declare interface BillingPluginOptions {
163
196
  * static `plans` registry. Absent → static registry only.
164
197
  */
165
198
  readonly resolveEntitlementLimit?: (tenantId: string, key: string) => Effect.Effect<number | null, BillingError>;
166
- /** Disambiguates multiple instances of this plugin in one app. */
199
+ /**
200
+ * Namespace for this plugin's rpc tags + inspect endpoints. Default `billing`.
201
+ *
202
+ * Set it when your app already publishes under that name — an exact tag
203
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
204
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
205
+ * installations within it.
206
+ *
207
+ * The cost, stated because nothing else states it: the local and cloud
208
+ * dashboards fetch this plugin's panel at the DEFAULT slug, so an aliased
209
+ * install keeps working while its dashboard panel 404s. Alias to escape a
210
+ * collision, not for taste.
211
+ */
212
+ readonly alias?: string;
213
+ /**
214
+ * Discriminator for a SECOND installation of this plugin, when one app runs
215
+ * two (`@voltro/plugin-billing#analytics`). Not a rename — for that use
216
+ * `alias`.
217
+ */
167
218
  readonly name?: string;
168
219
  }
169
220
 
@@ -208,6 +259,23 @@ export declare interface BillingProvider {
208
259
  * is a support ticket.
209
260
  */
210
261
  readonly previewSubscriptionChange: (input: SubscriptionUpdateInput) => Effect.Effect<SubscriptionChangeResult, BillingError>;
262
+ /**
263
+ * The provider's CURRENT view of a subscription — a direct read, not an
264
+ * event body.
265
+ *
266
+ * This is the method dunning refuses to lock a customer out without. An
267
+ * `invoice.payment_failed` can arrive after the retry that succeeded, a
268
+ * redelivery can arrive days late, and neither carries the state that
269
+ * matters — the subscription's status right now does. Returns null when the
270
+ * provider does not know the id.
271
+ */
272
+ readonly fetchSubscription: (providerSubscriptionId: string) => Effect.Effect<ProviderSubscriptionState | null, BillingError>;
273
+ /**
274
+ * The billing contact the PROVIDER has on file, when it has one. Used as the
275
+ * dunning recipient of last resort — `dunning.resolveRecipient` wins when
276
+ * the app keeps its own. Optional: a provider may model no customer email.
277
+ */
278
+ readonly customerEmail?: (providerCustomerId: string) => Effect.Effect<string | null, BillingError>;
211
279
  /** The provider's invoice history. We mirror it for listing but never
212
280
  * render an invoice ourselves — `hostedUrl` / `pdfUrl` are theirs. */
213
281
  readonly listInvoices: (input: {
@@ -259,8 +327,14 @@ export declare interface BillingServiceOptions {
259
327
  /** Free-trial length. The provider runs the trial and reports `trialing`. */
260
328
  readonly trialDays?: number;
261
329
  };
262
- /** Clock injection for deterministic period windowing in tests. */
330
+ /** Clock injection for deterministic period + grace windowing in tests. */
263
331
  readonly now?: () => Date;
332
+ /**
333
+ * Dunning policy, already resolved against env by `billingPlugin`. Omitted
334
+ * → the documented defaults (7-day grace, 3-step sequence, hard lockout, no
335
+ * transport, so the sequence logs and sends nothing).
336
+ */
337
+ readonly dunning?: ResolvedDunningConfig;
264
338
  /**
265
339
  * Optional per-tenant entitlement-limit override — the seam for a
266
340
  * cloud-issued license snapshot (e.g. `@voltro/plugin-licensing`'s
@@ -276,6 +350,31 @@ export declare interface BillingServiceShape {
276
350
  readonly subscription: (tenantId: string) => Effect.Effect<Subscription | null, BillingError>;
277
351
  /** Resolve the tenant's plan id; defaults to `'free'` when no row. */
278
352
  readonly plan: (tenantId: string) => Effect.Effect<PlanId, BillingError>;
353
+ /**
354
+ * "Is this tenant entitled right now?" — the one truthful answer, dunning
355
+ * state included. A PURE read of the local row (no provider call, no write),
356
+ * so it is safe on a hot path: the grace clock is a column and the lockout
357
+ * is derived from it, never a flag some job had to remember to set.
358
+ */
359
+ readonly entitlementStatus: (tenantId: string) => Effect.Effect<EntitlementStatus, BillingError>;
360
+ /**
361
+ * Re-derive dunning state for a tenant FROM THE PROVIDER, then fire whatever
362
+ * notification steps are now due (each at most once per episode).
363
+ *
364
+ * Runs automatically after every subscription/invoice event, which is what
365
+ * makes the provider's own retry cadence the schedule. Call it directly only
366
+ * to force a re-check (e.g. from a support tool). It is idempotent, it never
367
+ * charges anything, and it never asks the provider to retry.
368
+ */
369
+ readonly reconcileDunning: (tenantId: string) => Effect.Effect<EntitlementStatus, BillingError>;
370
+ /**
371
+ * Reconcile every past-due tenant. Optional — the event path already covers
372
+ * the normal case; a sweep exists so a step configured for a moment the
373
+ * provider happens not to emit an event (and a reconcile missed during a
374
+ * provider outage) still lands. Wire it from your own `*.cron.tsx` if you
375
+ * want that; nothing schedules it for you. Returns the number reconciled.
376
+ */
377
+ readonly dunningSweep: () => Effect.Effect<number, BillingError>;
279
378
  /** Pure read — would `cost` units of `key` be allowed? No mutation. */
280
379
  readonly checkEntitlement: (tenantId: string, key: string, cost: number) => Effect.Effect<EntitlementDecision, BillingError>;
281
380
  /** Check + decrement, atomically. Fails `EntitlementExceeded` over-limit. */
@@ -331,6 +430,8 @@ export declare interface BillingStores {
331
430
  readonly subscriptions: SubscriptionStore;
332
431
  readonly invoices: InvoiceStore;
333
432
  readonly usage: UsageStore;
433
+ /** The dunning sent-notice ledger — the send gate, not a report. */
434
+ readonly dunningNotices: DunningNoticeStore;
334
435
  }
335
436
 
336
437
  /** One active subscription per tenant. */
@@ -363,8 +464,10 @@ declare interface BillingWebhookOptions {
363
464
  readonly provider: BillingProvider;
364
465
  readonly service: BillingServiceShape;
365
466
  readonly onEvent?: OnEventMap;
366
- /** The signing secret, so the provider can run its OWN verification on the
367
- * raw bytes. Null only the mount's generic check applies. */
467
+ /** The signing secret. It is BOTH the secret the mount's generic HMAC check
468
+ * verifies with AND the one the provider's own verifier (Stripe's
469
+ * `constructEvent`) uses. Null → the mount answers 503: an unverifiable
470
+ * billing webhook must not reach subscription state. */
368
471
  readonly webhookSecret?: string | null;
369
472
  }
370
473
 
@@ -433,11 +536,166 @@ export declare interface CustomerStore {
433
536
  readonly getByTenant: (tenantId: string) => Effect.Effect<CustomerRecord | null, BillingError>;
434
537
  }
435
538
 
539
+ export declare const dataStoreDunningNoticeStore: (store: DataStore) => DunningNoticeStore;
540
+
436
541
  export declare const dataStoreStores: (store: DataStore) => BillingStores;
437
542
 
543
+ /**
544
+ * The default sequence: on the first failure, again after 3 days, and a final
545
+ * warning 24h before the default grace runs out. Every number here is a
546
+ * default the framework picked on the app's behalf, so every one of them is
547
+ * overridable in `app.config.ts` AND by env.
548
+ */
549
+ export declare const DEFAULT_DUNNING_STEPS: ReadonlyArray<DunningStep>;
550
+
551
+ /** Default grace: 7 days from the first confirmed past-due. */
552
+ export declare const DEFAULT_GRACE_HOURS = 168;
553
+
554
+ /** Default lockout once grace expires. */
555
+ export declare const DEFAULT_LOCKOUT: LockoutMode;
556
+
438
557
  /** The free/fallback plan id every app implicitly has. */
439
558
  export declare const DEFAULT_PLAN: PlanId;
440
559
 
560
+ /**
561
+ * The default notice copy. Deliberately plain, unbranded, and link-light —
562
+ * it exists so the sequence is usable the moment `notify` is wired, not so
563
+ * apps ship it unchanged. Override by rendering from the structured fields in
564
+ * a custom `notify`.
565
+ */
566
+ export declare const defaultDunningEmail: (notice: {
567
+ readonly stepId: string;
568
+ readonly locked: boolean;
569
+ readonly graceEndsAt: Date;
570
+ readonly portalUrl: string | null;
571
+ }) => {
572
+ readonly subject: string;
573
+ readonly html: string;
574
+ readonly text: string;
575
+ };
576
+
577
+ /** The steps whose `afterHours` have elapsed. Ledger claims — not this
578
+ * function — decide which of them actually SEND. */
579
+ export declare const dueSteps: (steps: ReadonlyArray<DunningStep>, pastDueSince: Date, now: Date) => ReadonlyArray<DunningStep>;
580
+
581
+ /** The synthetic step fired the first time an episode is observed LOCKED. */
582
+ export declare const DUNNING_LOCK_STEP_ID = "locked";
583
+
584
+ export declare interface DunningConfig {
585
+ /** Master switch. Default true — but with no `notify` the sequence only
586
+ * logs, so nothing is sent until the app wires a transport. */
587
+ readonly enabled?: boolean;
588
+ /** Hours of grace from the first PROVIDER-CONFIRMED past-due. Default 168. */
589
+ readonly graceHours?: number;
590
+ /** The sequence. Default `DEFAULT_DUNNING_STEPS`. */
591
+ readonly steps?: ReadonlyArray<DunningStep>;
592
+ /** Hard (limits fall to free) or soft (report only). Default `'hard'`. */
593
+ readonly lockout?: LockoutMode;
594
+ /**
595
+ * Where a notice goes. Absent → the sequence is inert (it logs, claims the
596
+ * ledger, and sends nothing), which is the correct default for an
597
+ * irreversible action the framework cannot address on the app's behalf.
598
+ * `dunningMailNotifier(mail)` bridges to `@voltro/plugin-mail`.
599
+ */
600
+ readonly notify?: (notice: DunningNotice) => Effect.Effect<void, unknown>;
601
+ /**
602
+ * The billing contact for a tenant. Takes precedence over the provider's
603
+ * customer email — an app that keeps its own billing contact should say so
604
+ * rather than have Stripe's copy of an address win.
605
+ */
606
+ readonly resolveRecipient?: (tenantId: string) => Effect.Effect<string | null, BillingError>;
607
+ /** When set, each notice carries a freshly minted provider portal URL that
608
+ * returns here — the one link a dunning email actually needs. */
609
+ readonly portalReturnUrl?: string;
610
+ }
611
+
612
+ /** The slice of `@voltro/plugin-mail`'s `MailService` a notice needs. Typed
613
+ * STRUCTURALLY so this package carries no dependency on the mail plugin (the
614
+ * `@voltro/plugin-auth` `mailSender` precedent). */
615
+ export declare interface DunningMailLike {
616
+ readonly send: (message: {
617
+ readonly to: string;
618
+ readonly subject: string;
619
+ readonly html: string;
620
+ readonly text?: string;
621
+ }) => Effect.Effect<unknown, unknown>;
622
+ }
623
+
624
+ /**
625
+ * Bridge `notify` to `@voltro/plugin-mail`:
626
+ *
627
+ * ```ts
628
+ * const mail = yield* MailService
629
+ * billingPlugin({ dunning: { notify: dunningMailNotifier(mail) } })
630
+ * ```
631
+ *
632
+ * A notice with no resolvable recipient is skipped rather than failing — the
633
+ * ledger already recorded the step, and a hard failure here would only mean
634
+ * the webhook that triggered it gets retried into the same dead end.
635
+ */
636
+ export declare const dunningMailNotifier: (mail: DunningMailLike) => ((notice: DunningNotice) => Effect.Effect<void, unknown>);
637
+
638
+ /** The rendered notice handed to `notify`. */
639
+ export declare interface DunningNotice {
640
+ /** The step's id, or `'locked'` for the lockout notice. */
641
+ readonly stepId: string;
642
+ readonly tenantId: string;
643
+ /** Resolved billing contact, or null when neither `resolveRecipient` nor the
644
+ * provider could name one. A null `to` is still delivered to `notify` — an
645
+ * app may route it in-product rather than by email. */
646
+ readonly to: string | null;
647
+ /** The plan the tenant is SUBSCRIBED to (not the locked-down effective one). */
648
+ readonly plan: PlanId;
649
+ readonly status: SubscriptionStatus;
650
+ readonly pastDueSince: Date;
651
+ readonly graceEndsAt: Date;
652
+ /** True once grace has run out — i.e. this is (or follows) the lockout. */
653
+ readonly locked: boolean;
654
+ readonly lockout: LockoutMode;
655
+ /** Provider-hosted billing portal, when `portalReturnUrl` is configured. */
656
+ readonly portalUrl: string | null;
657
+ /** Ready-to-send default copy. A custom `notify` may ignore all three. */
658
+ readonly subject: string;
659
+ readonly html: string;
660
+ readonly text: string;
661
+ }
662
+
663
+ export declare interface DunningNoticeRow {
664
+ readonly tenantId: string;
665
+ readonly episode: string;
666
+ readonly stepId: string;
667
+ readonly sentAt: Date | null;
668
+ }
669
+
670
+ export declare interface DunningNoticeStore {
671
+ /**
672
+ * Claim `(tenant, episode, step)`. `true` means THIS caller won and must
673
+ * send; `false` means someone already claimed it — a duplicate delivery, a
674
+ * second replica, or a re-reconcile.
675
+ *
676
+ * The claim happens BEFORE the send, deliberately. The failure mode of
677
+ * claim-then-send is a notice that is claimed but never delivered (one
678
+ * missing email); the failure mode of send-then-claim is a customer
679
+ * receiving the same dunning email twice. Only one of those is a real-world
680
+ * harm, so the ordering is not arbitrary.
681
+ */
682
+ readonly claim: (tenantId: string, episode: string, stepId: string, at: Date) => Effect.Effect<boolean, BillingError>;
683
+ /** Steps already claimed for an episode — assertion + inspect surface. */
684
+ readonly sentSteps: (tenantId: string, episode: string) => Effect.Effect<ReadonlyArray<DunningNoticeRow>, BillingError>;
685
+ }
686
+
687
+ /**
688
+ * One step of the sequence. `afterHours` is measured from `pastDueSince` —
689
+ * NOT from the previous step and NOT from a retry attempt, so a step cannot be
690
+ * pulled forward or pushed back by how often the provider happens to retry.
691
+ */
692
+ export declare interface DunningStep {
693
+ /** Stable ledger key. Changing it re-opens the step for every live episode. */
694
+ readonly id: string;
695
+ /** Hours after the tenant went past-due at which this step becomes due. */
696
+ readonly afterHours: number;
697
+ }
698
+
441
699
  /** A registry with a single zero-quota `free` plan — the zero-config default. */
442
700
  export declare const emptyPlanRegistry: () => PlanRegistry;
443
701
 
@@ -491,6 +749,49 @@ declare const EntitlementExceeded_base: Schema.TaggedErrorClass<EntitlementExcee
491
749
  /** An entitlement limit: a finite quota or the unbounded `'unlimited'`. */
492
750
  export declare type EntitlementLimit = number | 'unlimited';
493
751
 
752
+ /** The truthful "is this tenant entitled right now" answer. */
753
+ export declare interface EntitlementStatus {
754
+ readonly tenantId: string;
755
+ /** The plan whose LIMITS apply right now — `'free'` under a hard lockout. */
756
+ readonly plan: PlanId;
757
+ /** The plan the tenant is subscribed to, lockout or not. */
758
+ readonly billedPlan: PlanId;
759
+ readonly status: SubscriptionStatus | 'none';
760
+ /**
761
+ * False ONLY when dunning has locked this tenant out. A canceled
762
+ * subscription is not a lockout — it is simply the free tier, and reporting
763
+ * it as "not entitled" would conflate "never paid" with "stopped paying".
764
+ */
765
+ readonly entitled: boolean;
766
+ /** Past due, but still inside the grace window. */
767
+ readonly inGrace: boolean;
768
+ /** When grace runs out; null when the tenant is not past due — or when the
769
+ * provider has not confirmed the past-due yet, in which case the tenant is
770
+ * in grace with no expiry rather than locked. */
771
+ readonly graceEndsAt: Date | null;
772
+ /** Non-null iff locked out — the instant grace expired. */
773
+ readonly lockedSince: Date | null;
774
+ readonly lockout: LockoutMode;
775
+ }
776
+
777
+ /** The episode a `pastDueSince` identifies. Recovery clears the column, so the
778
+ * next failure necessarily produces a different key. */
779
+ export declare const episodeKeyOf: (pastDueSince: Date) => string;
780
+
781
+ /**
782
+ * Derive the entitlement state from the subscription row. PURE — no I/O, no
783
+ * writes, so every read path can call it on the hot path.
784
+ *
785
+ * The one subtlety worth stating out loud: a `pastDue` row whose
786
+ * `pastDueSince` is null reports as IN GRACE, never locked. `pastDueSince` is
787
+ * written only after the provider itself confirmed the past-due (see
788
+ * `service.ts`'s `reconcileDunning`), so a `pastDue` status that arrived by
789
+ * webhook alone — or one whose reconcile failed against an unreachable
790
+ * provider — can never lock a customer out. Locking on unverified event data
791
+ * is the one failure mode here with a real-world cost.
792
+ */
793
+ export declare const evaluateBillingEntitlement: (sub: Subscription | null, config: Pick<ResolvedDunningConfig, "graceHours" | "lockout" | "enabled">, now: Date, tenantId?: string) => EntitlementStatus;
794
+
494
795
  /**
495
796
  * Pure entitlement decision. `limit` is `Infinity` for an `'unlimited'`
496
797
  * plan. `'unlimited'` and any non-positive cost always allow without
@@ -502,6 +803,9 @@ export declare const evaluateEntitlement: (entitlement: string, limit: number, u
502
803
  * flushing within the same interval land on the same key → one wins. */
503
804
  export declare const flushWindowKey: (nowMs: number, intervalMs: number) => string;
504
805
 
806
+ /** When the grace for an episode runs out. */
807
+ export declare const graceEndOf: (pastDueSince: Date, graceHours: number) => Date;
808
+
505
809
  declare interface InvoiceRecord {
506
810
  readonly providerInvoiceId: string;
507
811
  readonly amountMinor: number;
@@ -521,14 +825,43 @@ declare interface InvoiceRecord_2 {
521
825
  readonly currency: string;
522
826
  /** 'paid' | 'open' | 'uncollectible' | 'void'. */
523
827
  readonly status: string;
828
+ /** Provider-event timestamp this `status` came from; null when undated. */
829
+ readonly statusEventAt: Date | null;
524
830
  }
525
831
 
526
832
  export declare interface InvoiceStore {
527
- /** Upsert by `providerInvoiceId` (idempotent under webhook replay). */
833
+ /**
834
+ * Upsert by `providerInvoiceId` (idempotent under webhook replay), with the
835
+ * same stale-event guard the subscription row carries: a late-delivered
836
+ * `payment_failed` must not flip an invoice that has since been paid back to
837
+ * `'open'`, which is what a naive last-write-wins mirror does under Stripe's
838
+ * unordered delivery.
839
+ */
528
840
  readonly upsert: (invoice: InvoiceRecord_2) => Effect.Effect<void, BillingError>;
529
841
  readonly listByTenant: (tenantId: string) => Effect.Effect<ReadonlyArray<InvoiceRecord_2>, BillingError>;
530
842
  }
531
843
 
844
+ /**
845
+ * Is `incoming` older than what the row already reflects? Undated events never
846
+ * displace a dated one, and a dated event always wins over an undated row —
847
+ * that ordering is what makes a hand-built (undated) payload usable in tests
848
+ * without letting it stomp production ordering.
849
+ */
850
+ export declare const isStaleStatusEvent: (existing: Date | null, incoming: Date | null) => boolean;
851
+
852
+ /**
853
+ * What "locked" means once the grace period is over.
854
+ *
855
+ * - `'hard'` — entitlement limits fall to the `'free'` plan's. The app needs
856
+ * no new code: every existing `requireEntitlement` / `enforce` check
857
+ * starts answering with the free tier's numbers.
858
+ * - `'soft'` — limits stay on the paid plan; only `entitlementStatus()`
859
+ * reports the lockout, so the app decides what to withhold.
860
+ */
861
+ export declare type LockoutMode = 'hard' | 'soft';
862
+
863
+ export declare const memoryDunningNoticeStore: () => DunningNoticeStore;
864
+
532
865
  export declare const memoryStores: () => BillingStores;
533
866
 
534
867
  /** `meterKey → source`. The meterKey is the entitlement/usage key reported to
@@ -571,6 +904,17 @@ export declare interface MockProvider extends BillingProvider {
571
904
  * a change which never reaches the provider is a change the customer is
572
905
  * not billed for — the exact defect this contract was widened to fix. */
573
906
  readonly updates: ReadonlyArray<SubscriptionUpdateInput>;
907
+ /**
908
+ * Set what the provider will report for `fetchSubscription`. This is the
909
+ * mock's most load-bearing seam: dunning refuses to act on an event body and
910
+ * reads the provider instead, so a test that wants "the retry actually
911
+ * succeeded" says so HERE, not by crafting a webhook.
912
+ */
913
+ readonly setSubscription: (providerSubscriptionId: string, state: ProviderSubscriptionState | null) => void;
914
+ /** Set the customer email `customerEmail` will report. */
915
+ readonly setCustomerEmail: (providerCustomerId: string, email: string | null) => void;
916
+ /** Every `fetchSubscription` id asked for — proves the reconcile happened. */
917
+ readonly fetched: ReadonlyArray<string>;
574
918
  }
575
919
 
576
920
  export declare const mockProvider: (options?: {
@@ -594,7 +938,8 @@ export declare interface Money {
594
938
  export declare const mountBillingWebhook: (options: MountBillingWebhookOptions) => (request: IncomingRequest) => Promise<IncomingResponse>;
595
939
 
596
940
  declare interface MountBillingWebhookOptions extends BillingWebhookOptions {
597
- /** The provider webhook signing secret (`whsec_…`). Null skips verification. */
941
+ /** The provider webhook signing secret (`whsec_…`). Null does NOT skip
942
+ * verification — every delivery answers 503 until it is configured. */
598
943
  readonly webhookSecret: string | null;
599
944
  /** Optional idempotency-cache override (tests pass a fresh cache). */
600
945
  readonly idempotencyCache?: Parameters<typeof mountIncomingWebhook>[1]['idempotencyCache'];
@@ -663,6 +1008,40 @@ export declare interface PortalInput {
663
1008
  readonly returnUrl: string;
664
1009
  }
665
1010
 
1011
+ /** The provider's CURRENT view of a subscription — read directly, not from an
1012
+ * event body. `reconcileDunning` asks for this before it will lock anyone
1013
+ * out. */
1014
+ export declare interface ProviderSubscriptionState {
1015
+ readonly status: SubscriptionStatus;
1016
+ /** Null when the provider cannot map the price to one of our plans. */
1017
+ readonly plan: PlanId | null;
1018
+ readonly quantity: number;
1019
+ readonly currentPeriodStart: Date | null;
1020
+ readonly currentPeriodEnd: Date | null;
1021
+ readonly cancelAt: Date | null;
1022
+ }
1023
+
1024
+ /**
1025
+ * In-handler DUNNING guard: refuse when the tenant's grace period has run out.
1026
+ *
1027
+ * Orthogonal to `requireEntitlement`, which answers "do you have quota left".
1028
+ * This answers "did you pay". A tenant with plenty of quota can still be locked
1029
+ * out, and a tenant in grace is NOT locked out — that is the whole point of the
1030
+ * grace window.
1031
+ *
1032
+ * Under `lockout: 'hard'` the quota guards already degrade to the free plan's
1033
+ * limits on their own, so reach for this when a feature has no numeric quota to
1034
+ * degrade (an export, a webhook target, an admin action).
1035
+ *
1036
+ * ```ts
1037
+ * export default (input, ctx) => Effect.gen(function* () {
1038
+ * yield* requireEntitled(ctx)
1039
+ * // …
1040
+ * })
1041
+ * ```
1042
+ */
1043
+ export declare const requireEntitled: (ctx: BillingContext) => Effect.Effect<void, BillingError | SubscriptionLocked, BillingService>;
1044
+
666
1045
  /**
667
1046
  * In-handler entitlement guard. Resolves `ctx.request.subject.tenantId`,
668
1047
  * consumes `cost` units of `key`, and fails with the typed
@@ -680,6 +1059,28 @@ export declare interface PortalInput {
680
1059
  */
681
1060
  export declare const requireEntitlement: (ctx: BillingContext, key: string, cost: number) => Effect.Effect<void, BillingError | EntitlementExceeded, BillingService>;
682
1061
 
1062
+ /** A `DunningConfig` with every default resolved and every number concrete. */
1063
+ export declare interface ResolvedDunningConfig {
1064
+ readonly enabled: boolean;
1065
+ readonly graceHours: number;
1066
+ readonly steps: ReadonlyArray<DunningStep>;
1067
+ readonly lockout: LockoutMode;
1068
+ readonly notify?: (notice: DunningNotice) => Effect.Effect<void, unknown>;
1069
+ readonly resolveRecipient?: (tenantId: string) => Effect.Effect<string | null, BillingError>;
1070
+ readonly portalReturnUrl?: string;
1071
+ }
1072
+
1073
+ /**
1074
+ * Resolve the config against `app.config.ts` first and env second.
1075
+ *
1076
+ * Env overrides exist because these numbers gate money-adjacent behaviour and
1077
+ * an operator must be able to widen the grace on a live incident without a
1078
+ * redeploy. They FAIL LOUD rather than falling back to a default: a typo'd
1079
+ * `VOLTRO_BILLING_GRACE_HOURS` that silently means "7 days" is precisely the
1080
+ * kind of quiet wrong number this repo has been bitten by.
1081
+ */
1082
+ export declare const resolveDunningConfig: (config: DunningConfig | undefined, env: NodeJS.ProcessEnv) => ResolvedDunningConfig;
1083
+
683
1084
  /**
684
1085
  * Resolve `options.provider` to a concrete `BillingProvider`. A passed
685
1086
  * object is used verbatim. `'stripe'` needs an api key (option or
@@ -743,6 +1144,27 @@ export declare interface Subscription {
743
1144
  readonly currentPeriodEnd: Date | null;
744
1145
  /** When the subscription is scheduled to cancel; null if not scheduled. */
745
1146
  readonly cancelAt: Date | null;
1147
+ /**
1148
+ * When this tenant went past due — the dunning grace clock, and the identity
1149
+ * of the current dunning episode.
1150
+ *
1151
+ * Written ONLY by a provider-confirmed reconcile, never straight off a
1152
+ * webhook: it is the value the lockout is derived from, and locking a
1153
+ * customer out on an event body alone is the one mistake here with a
1154
+ * real-world cost. Cleared the moment the provider reports the
1155
+ * subscription healthy again, which is also what cancels the rest of the
1156
+ * notification sequence.
1157
+ */
1158
+ readonly pastDueSince: Date | null;
1159
+ /**
1160
+ * The provider-event timestamp the current `status` came from — the
1161
+ * out-of-order guard. Provider webhooks are at-least-once AND unordered, so
1162
+ * an event that is older than what the row already reflects is DROPPED
1163
+ * rather than applied (a stale `active` must not un-do a `pastDue`, and a
1164
+ * stale `pastDue` must not resurrect a resolved one). Null on rows written
1165
+ * before any dated event.
1166
+ */
1167
+ readonly statusEventAt: Date | null;
746
1168
  }
747
1169
 
748
1170
  /** The outcome of a mid-cycle plan/seat change — the prorated settlement. */
@@ -767,23 +1189,68 @@ declare interface SubscriptionChangeResult {
767
1189
  readonly currentPeriodEnd: Date | null;
768
1190
  }
769
1191
 
1192
+ /**
1193
+ * Raised when dunning has locked a tenant out — the grace period after a
1194
+ * provider-confirmed past-due has expired. Distinct from
1195
+ * `EntitlementExceeded`: that one means "you used your quota", this one means
1196
+ * "you did not pay". Registered via `errorSchemas`, so a client can branch on
1197
+ * `_tag === 'SubscriptionLocked'` and route the user to the billing portal
1198
+ * rather than showing a generic failure.
1199
+ *
1200
+ * Dates cross the wire as ISO strings — a `Schema.DateFromSelf` would not
1201
+ * survive the JSON boundary the error union is encoded through.
1202
+ */
1203
+ export declare class SubscriptionLocked extends SubscriptionLocked_base {
1204
+ }
1205
+
1206
+ declare const SubscriptionLocked_base: Schema.TaggedErrorClass<SubscriptionLocked, "SubscriptionLocked", {
1207
+ readonly _tag: Schema.tag<"SubscriptionLocked">;
1208
+ } & {
1209
+ tenantId: typeof Schema.String;
1210
+ /** The subscription status at the time of the refusal (`'pastDue'`). */
1211
+ status: typeof Schema.String;
1212
+ /** ISO timestamp at which the grace period expired. */
1213
+ lockedSince: typeof Schema.String;
1214
+ /** `'hard'` (limits fell to the free plan) or `'soft'` (report only). */
1215
+ lockout: typeof Schema.String;
1216
+ }>;
1217
+
1218
+ /** Fields a mid-cycle / reconcile patch may set on the tenant's row. */
1219
+ export declare interface SubscriptionPatch {
1220
+ readonly plan?: PlanId;
1221
+ readonly quantity?: number;
1222
+ readonly status?: SubscriptionStatus;
1223
+ readonly currentPeriodStart?: Date | null;
1224
+ readonly currentPeriodEnd?: Date | null;
1225
+ /** The dunning grace clock. `null` clears it (recovery). */
1226
+ readonly pastDueSince?: Date | null;
1227
+ /** Provider-event (or direct-read) timestamp the `status` came from. */
1228
+ readonly statusEventAt?: Date | null;
1229
+ }
1230
+
770
1231
  export declare type SubscriptionStatus = 'active' | 'trialing' | 'pastDue' | 'canceled' | 'incomplete';
771
1232
 
772
1233
  export declare interface SubscriptionStore {
773
- /** Upsert by `providerSubscriptionId` — the webhook's idempotency anchor. */
1234
+ /**
1235
+ * Upsert by `providerSubscriptionId` — the webhook's idempotency anchor.
1236
+ *
1237
+ * STALE-EVENT GUARD: when the incoming `statusEventAt` is OLDER than the one
1238
+ * already on the row, the whole upsert is skipped. Provider webhooks are
1239
+ * at-least-once and unordered, so without this a redelivered `active` from
1240
+ * before a decline silently un-does the past-due (and with it the dunning
1241
+ * clock the lockout is derived from). `pastDueSince` is never written here —
1242
+ * only a provider-confirmed reconcile sets it.
1243
+ */
774
1244
  readonly upsert: (sub: Subscription) => Effect.Effect<void, BillingError>;
775
1245
  readonly getByTenant: (tenantId: string) => Effect.Effect<Subscription | null, BillingError>;
1246
+ /** Every subscription in a given status — the dunning sweep's only query. */
1247
+ readonly listByStatus: (status: SubscriptionStatus) => Effect.Effect<ReadonlyArray<Subscription>, BillingError>;
776
1248
  /** Mark a subscription canceled by provider id (no-op if absent). */
777
1249
  readonly markCanceled: (providerSubscriptionId: string) => Effect.Effect<void, BillingError>;
778
1250
  /** Apply a mid-cycle plan/seat/status change to the tenant's row (no-op if
779
1251
  * absent). Only the provided fields are patched. */
780
- readonly patchByTenant: (tenantId: string, patch: {
781
- readonly plan?: PlanId;
782
- readonly quantity?: number;
783
- readonly status?: SubscriptionStatus;
784
- }) => Effect.Effect<void, BillingError>;
785
- /** Set a subscription's status by provider id (no-op if absent) — the
786
- * dunning state machine's transition primitive. */
1252
+ readonly patchByTenant: (tenantId: string, patch: SubscriptionPatch) => Effect.Effect<void, BillingError>;
1253
+ /** Set a subscription's status by provider id (no-op if absent). */
787
1254
  readonly setStatus: (providerSubscriptionId: string, status: SubscriptionStatus) => Effect.Effect<void, BillingError>;
788
1255
  }
789
1256