@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/rpc.d.ts CHANGED
@@ -47,6 +47,24 @@ export declare const changeSeatsDescriptor: ActionProcedureDescriptor<"billing.c
47
47
  currency: typeof Schema.String;
48
48
  }>, typeof BillingError>;
49
49
 
50
+ /**
51
+ * "Is this tenant entitled right now?" — the dunning-aware answer a billing
52
+ * banner needs. `entitled: false` means the grace period expired; `inGrace`
53
+ * with a `graceEndsAt` is the "your payment failed, you have until …" state.
54
+ * Timestamps are ISO strings (the descriptor's Schema is String, so a Date
55
+ * would fail encode at the boundary).
56
+ */
57
+ export declare const entitlementStatusDescriptor: QueryProcedureDescriptor<"billing.entitlementStatus", Schema.Struct<{}>, Schema.Struct<{
58
+ plan: typeof Schema.String;
59
+ billedPlan: typeof Schema.String;
60
+ status: typeof Schema.String;
61
+ entitled: typeof Schema.Boolean;
62
+ inGrace: typeof Schema.Boolean;
63
+ graceEndsAt: Schema.NullOr<typeof Schema.String>;
64
+ lockedSince: Schema.NullOr<typeof Schema.String>;
65
+ lockout: typeof Schema.String;
66
+ }>, typeof BillingError>;
67
+
50
68
  /** The provider's invoice history. `hostedUrl` / `pdfUrl` are the provider's
51
69
  * own pages — we never render an invoice ourselves. */
52
70
  export declare const invoicesDescriptor: QueryProcedureDescriptor<"billing.invoices", Schema.Struct<{}>, Schema.Struct<{
package/dist/rpc.js CHANGED
@@ -28,7 +28,21 @@ var i = n({
28
28
  cancelAt: t.NullOr(t.String)
29
29
  })) }),
30
30
  error: e
31
- }), s = n({
31
+ }), s = r({
32
+ name: "billing.entitlementStatus",
33
+ input: t.Struct({}),
34
+ output: t.Struct({
35
+ plan: t.String,
36
+ billedPlan: t.String,
37
+ status: t.String,
38
+ entitled: t.Boolean,
39
+ inGrace: t.Boolean,
40
+ graceEndsAt: t.NullOr(t.String),
41
+ lockedSince: t.NullOr(t.String),
42
+ lockout: t.String
43
+ }),
44
+ error: e
45
+ }), c = n({
32
46
  name: "billing.reportUsage",
33
47
  input: t.Struct({
34
48
  key: t.String,
@@ -36,20 +50,20 @@ var i = n({
36
50
  }),
37
51
  output: t.Struct({ ok: t.Boolean }),
38
52
  error: e
39
- }), c = t.Struct({
53
+ }), l = t.Struct({
40
54
  plan: t.String,
41
55
  quantity: t.Number,
42
56
  prorationMinor: t.Number,
43
57
  currency: t.String
44
- }), l = n({
58
+ }), u = n({
45
59
  name: "billing.previewChange",
46
60
  input: t.Struct({
47
61
  plan: t.optional(t.String),
48
62
  quantity: t.optional(t.Number)
49
63
  }),
50
- output: c,
64
+ output: l,
51
65
  error: e
52
- }), u = r({
66
+ }), d = r({
53
67
  name: "billing.invoices",
54
68
  input: t.Struct({}),
55
69
  output: t.Struct({ invoices: t.Array(t.Struct({
@@ -62,17 +76,17 @@ var i = n({
62
76
  pdfUrl: t.NullOr(t.String)
63
77
  })) }),
64
78
  error: e
65
- }), d = n({
79
+ }), f = n({
66
80
  name: "billing.changePlan",
67
81
  input: t.Struct({ plan: t.String }),
68
- output: c,
82
+ output: l,
69
83
  error: e
70
- }), f = n({
84
+ }), p = n({
71
85
  name: "billing.changeSeats",
72
86
  input: t.Struct({ quantity: t.Number }),
73
- output: c,
87
+ output: l,
74
88
  error: e
75
- }), p = [
89
+ }), m = [
76
90
  {
77
91
  tag: "billing.startCheckout",
78
92
  kind: "action",
@@ -97,6 +111,14 @@ var i = n({
97
111
  name: "subscriptionDescriptor"
98
112
  }
99
113
  },
114
+ {
115
+ tag: "billing.entitlementStatus",
116
+ kind: "query",
117
+ import: {
118
+ module: "@voltro/plugin-billing/rpc",
119
+ name: "entitlementStatusDescriptor"
120
+ }
121
+ },
100
122
  {
101
123
  tag: "billing.reportUsage",
102
124
  kind: "action",
@@ -139,4 +161,4 @@ var i = n({
139
161
  }
140
162
  ];
141
163
  //#endregion
142
- export { p as billingRpcClientImports, d as changePlanDescriptor, f as changeSeatsDescriptor, u as invoicesDescriptor, a as portalUrlDescriptor, l as previewChangeDescriptor, s as reportUsageDescriptor, i as startCheckoutDescriptor, o as subscriptionDescriptor };
164
+ export { m as billingRpcClientImports, f as changePlanDescriptor, p as changeSeatsDescriptor, s as entitlementStatusDescriptor, d as invoicesDescriptor, a as portalUrlDescriptor, u as previewChangeDescriptor, c as reportUsageDescriptor, i as startCheckoutDescriptor, o as subscriptionDescriptor };
package/dist/types.d.ts CHANGED
@@ -39,26 +39,33 @@ export declare type BillingEvent = {
39
39
  readonly currentPeriodStart?: Date | null;
40
40
  readonly currentPeriodEnd: Date | null;
41
41
  readonly cancelAt: Date | null;
42
+ /** When the PROVIDER emitted this — the out-of-order guard. See
43
+ * `occurredAt` on the union below. */
44
+ readonly occurredAt: Date | null;
42
45
  } | {
43
46
  readonly _tag: 'subscriptionCanceled';
44
47
  readonly tenantId: string;
45
48
  readonly providerSubscriptionId: string;
49
+ readonly occurredAt: Date | null;
46
50
  } | {
47
51
  readonly _tag: 'invoicePaid';
48
52
  readonly tenantId: string;
49
53
  readonly providerInvoiceId: string;
50
54
  readonly amountMinor: number;
51
55
  readonly currency: string;
56
+ readonly occurredAt: Date | null;
52
57
  } | {
53
58
  readonly _tag: 'invoicePaymentFailed';
54
59
  readonly tenantId: string;
55
60
  readonly providerInvoiceId: string;
56
61
  readonly amountMinor: number;
57
62
  readonly currency: string;
63
+ readonly occurredAt: Date | null;
58
64
  } | {
59
65
  readonly _tag: 'customerLinked';
60
66
  readonly tenantId: string;
61
67
  readonly providerCustomerId: string;
68
+ readonly occurredAt: Date | null;
62
69
  };
63
70
 
64
71
  /** The event tag union — handy for `onEvent` keys. */
@@ -105,6 +112,23 @@ export declare interface BillingProvider {
105
112
  * is a support ticket.
106
113
  */
107
114
  readonly previewSubscriptionChange: (input: SubscriptionUpdateInput) => Effect.Effect<SubscriptionChangeResult, BillingError>;
115
+ /**
116
+ * The provider's CURRENT view of a subscription — a direct read, not an
117
+ * event body.
118
+ *
119
+ * This is the method dunning refuses to lock a customer out without. An
120
+ * `invoice.payment_failed` can arrive after the retry that succeeded, a
121
+ * redelivery can arrive days late, and neither carries the state that
122
+ * matters — the subscription's status right now does. Returns null when the
123
+ * provider does not know the id.
124
+ */
125
+ readonly fetchSubscription: (providerSubscriptionId: string) => Effect.Effect<ProviderSubscriptionState | null, BillingError>;
126
+ /**
127
+ * The billing contact the PROVIDER has on file, when it has one. Used as the
128
+ * dunning recipient of last resort — `dunning.resolveRecipient` wins when
129
+ * the app keeps its own. Optional: a provider may model no customer email.
130
+ */
131
+ readonly customerEmail?: (providerCustomerId: string) => Effect.Effect<string | null, BillingError>;
108
132
  /** The provider's invoice history. We mirror it for listing but never
109
133
  * render an invoice ourselves — `hostedUrl` / `pdfUrl` are theirs. */
110
134
  readonly listInvoices: (input: {
@@ -138,6 +162,31 @@ export declare interface BillingServiceShape {
138
162
  readonly subscription: (tenantId: string) => Effect.Effect<Subscription | null, BillingError>;
139
163
  /** Resolve the tenant's plan id; defaults to `'free'` when no row. */
140
164
  readonly plan: (tenantId: string) => Effect.Effect<PlanId, BillingError>;
165
+ /**
166
+ * "Is this tenant entitled right now?" — the one truthful answer, dunning
167
+ * state included. A PURE read of the local row (no provider call, no write),
168
+ * so it is safe on a hot path: the grace clock is a column and the lockout
169
+ * is derived from it, never a flag some job had to remember to set.
170
+ */
171
+ readonly entitlementStatus: (tenantId: string) => Effect.Effect<EntitlementStatus, BillingError>;
172
+ /**
173
+ * Re-derive dunning state for a tenant FROM THE PROVIDER, then fire whatever
174
+ * notification steps are now due (each at most once per episode).
175
+ *
176
+ * Runs automatically after every subscription/invoice event, which is what
177
+ * makes the provider's own retry cadence the schedule. Call it directly only
178
+ * to force a re-check (e.g. from a support tool). It is idempotent, it never
179
+ * charges anything, and it never asks the provider to retry.
180
+ */
181
+ readonly reconcileDunning: (tenantId: string) => Effect.Effect<EntitlementStatus, BillingError>;
182
+ /**
183
+ * Reconcile every past-due tenant. Optional — the event path already covers
184
+ * the normal case; a sweep exists so a step configured for a moment the
185
+ * provider happens not to emit an event (and a reconcile missed during a
186
+ * provider outage) still lands. Wire it from your own `*.cron.tsx` if you
187
+ * want that; nothing schedules it for you. Returns the number reconciled.
188
+ */
189
+ readonly dunningSweep: () => Effect.Effect<number, BillingError>;
141
190
  /** Pure read — would `cost` units of `key` be allowed? No mutation. */
142
191
  readonly checkEntitlement: (tenantId: string, key: string, cost: number) => Effect.Effect<EntitlementDecision, BillingError>;
143
192
  /** Check + decrement, atomically. Fails `EntitlementExceeded` over-limit. */
@@ -251,6 +300,31 @@ declare const EntitlementExceeded_base: Schema.TaggedErrorClass<EntitlementExcee
251
300
  /** An entitlement limit: a finite quota or the unbounded `'unlimited'`. */
252
301
  export declare type EntitlementLimit = number | 'unlimited';
253
302
 
303
+ /** The truthful "is this tenant entitled right now" answer. */
304
+ export declare interface EntitlementStatus {
305
+ readonly tenantId: string;
306
+ /** The plan whose LIMITS apply right now — `'free'` under a hard lockout. */
307
+ readonly plan: PlanId;
308
+ /** The plan the tenant is subscribed to, lockout or not. */
309
+ readonly billedPlan: PlanId;
310
+ readonly status: SubscriptionStatus | 'none';
311
+ /**
312
+ * False ONLY when dunning has locked this tenant out. A canceled
313
+ * subscription is not a lockout — it is simply the free tier, and reporting
314
+ * it as "not entitled" would conflate "never paid" with "stopped paying".
315
+ */
316
+ readonly entitled: boolean;
317
+ /** Past due, but still inside the grace window. */
318
+ readonly inGrace: boolean;
319
+ /** When grace runs out; null when the tenant is not past due — or when the
320
+ * provider has not confirmed the past-due yet, in which case the tenant is
321
+ * in grace with no expiry rather than locked. */
322
+ readonly graceEndsAt: Date | null;
323
+ /** Non-null iff locked out — the instant grace expired. */
324
+ readonly lockedSince: Date | null;
325
+ readonly lockout: LockoutMode;
326
+ }
327
+
254
328
  export declare interface InvoiceRecord {
255
329
  readonly providerInvoiceId: string;
256
330
  readonly amountMinor: number;
@@ -262,6 +336,17 @@ export declare interface InvoiceRecord {
262
336
  readonly pdfUrl: string | null;
263
337
  }
264
338
 
339
+ /**
340
+ * What "locked" means once the grace period is over.
341
+ *
342
+ * - `'hard'` — entitlement limits fall to the `'free'` plan's. The app needs
343
+ * no new code: every existing `requireEntitlement` / `enforce` check
344
+ * starts answering with the free tier's numbers.
345
+ * - `'soft'` — limits stay on the paid plan; only `entitlementStatus()`
346
+ * reports the lockout, so the app decides what to withhold.
347
+ */
348
+ export declare type LockoutMode = 'hard' | 'soft';
349
+
265
350
  /** A monetary amount: integer minor units + ISO-4217 currency. */
266
351
  export declare interface Money {
267
352
  /** Integer minor units (cents). Never a float. */
@@ -296,6 +381,19 @@ export declare interface PortalInput {
296
381
  readonly returnUrl: string;
297
382
  }
298
383
 
384
+ /** The provider's CURRENT view of a subscription — read directly, not from an
385
+ * event body. `reconcileDunning` asks for this before it will lock anyone
386
+ * out. */
387
+ export declare interface ProviderSubscriptionState {
388
+ readonly status: SubscriptionStatus;
389
+ /** Null when the provider cannot map the price to one of our plans. */
390
+ readonly plan: PlanId | null;
391
+ readonly quantity: number;
392
+ readonly currentPeriodStart: Date | null;
393
+ readonly currentPeriodEnd: Date | null;
394
+ readonly cancelAt: Date | null;
395
+ }
396
+
299
397
  /** A tenant's subscription row, normalized across providers. */
300
398
  export declare interface Subscription {
301
399
  readonly tenantId: string;
@@ -313,6 +411,27 @@ export declare interface Subscription {
313
411
  readonly currentPeriodEnd: Date | null;
314
412
  /** When the subscription is scheduled to cancel; null if not scheduled. */
315
413
  readonly cancelAt: Date | null;
414
+ /**
415
+ * When this tenant went past due — the dunning grace clock, and the identity
416
+ * of the current dunning episode.
417
+ *
418
+ * Written ONLY by a provider-confirmed reconcile, never straight off a
419
+ * webhook: it is the value the lockout is derived from, and locking a
420
+ * customer out on an event body alone is the one mistake here with a
421
+ * real-world cost. Cleared the moment the provider reports the
422
+ * subscription healthy again, which is also what cancels the rest of the
423
+ * notification sequence.
424
+ */
425
+ readonly pastDueSince: Date | null;
426
+ /**
427
+ * The provider-event timestamp the current `status` came from — the
428
+ * out-of-order guard. Provider webhooks are at-least-once AND unordered, so
429
+ * an event that is older than what the row already reflects is DROPPED
430
+ * rather than applied (a stale `active` must not un-do a `pastDue`, and a
431
+ * stale `pastDue` must not resurrect a resolved one). Null on rows written
432
+ * before any dated event.
433
+ */
434
+ readonly statusEventAt: Date | null;
316
435
  }
317
436
 
318
437
  /** The outcome of a mid-cycle plan/seat change — the prorated settlement. */
@@ -337,6 +456,32 @@ export declare interface SubscriptionChangeResult {
337
456
  readonly currentPeriodEnd: Date | null;
338
457
  }
339
458
 
459
+ /**
460
+ * Raised when dunning has locked a tenant out — the grace period after a
461
+ * provider-confirmed past-due has expired. Distinct from
462
+ * `EntitlementExceeded`: that one means "you used your quota", this one means
463
+ * "you did not pay". Registered via `errorSchemas`, so a client can branch on
464
+ * `_tag === 'SubscriptionLocked'` and route the user to the billing portal
465
+ * rather than showing a generic failure.
466
+ *
467
+ * Dates cross the wire as ISO strings — a `Schema.DateFromSelf` would not
468
+ * survive the JSON boundary the error union is encoded through.
469
+ */
470
+ export declare class SubscriptionLocked extends SubscriptionLocked_base {
471
+ }
472
+
473
+ declare const SubscriptionLocked_base: Schema.TaggedErrorClass<SubscriptionLocked, "SubscriptionLocked", {
474
+ readonly _tag: Schema.tag<"SubscriptionLocked">;
475
+ } & {
476
+ tenantId: typeof Schema.String;
477
+ /** The subscription status at the time of the refusal (`'pastDue'`). */
478
+ status: typeof Schema.String;
479
+ /** ISO timestamp at which the grace period expired. */
480
+ lockedSince: typeof Schema.String;
481
+ /** `'hard'` (limits fell to the free plan) or `'soft'` (report only). */
482
+ lockout: typeof Schema.String;
483
+ }>;
484
+
340
485
  export declare type SubscriptionStatus = 'active' | 'trialing' | 'pastDue' | 'canceled' | 'incomplete';
341
486
 
342
487
  /** Apply a plan / seat change to the LIVE subscription at the provider. */
package/dist/types.js CHANGED
@@ -4,11 +4,16 @@ var n = class extends t.TaggedError()("BillingError", {
4
4
  source: t.String,
5
5
  message: t.String,
6
6
  transient: t.Boolean
7
- }) {}, r = class extends t.TaggedError()("EntitlementExceeded", {
7
+ }) {}, r = class extends t.TaggedError()("SubscriptionLocked", {
8
+ tenantId: t.String,
9
+ status: t.String,
10
+ lockedSince: t.String,
11
+ lockout: t.String
12
+ }) {}, i = class extends t.TaggedError()("EntitlementExceeded", {
8
13
  entitlement: t.String,
9
14
  limit: t.Number,
10
15
  used: t.Number,
11
16
  cost: t.Number
12
- }) {}, i = "free", a = class extends e.Tag("@voltro/plugin-billing/BillingService")() {};
17
+ }) {}, a = "free", o = class extends e.Tag("@voltro/plugin-billing/BillingService")() {};
13
18
  //#endregion
14
- export { n as BillingError, a as BillingService, i as DEFAULT_PLAN, r as EntitlementExceeded };
19
+ export { n as BillingError, o as BillingService, a as DEFAULT_PLAN, i as EntitlementExceeded, r as SubscriptionLocked };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-billing",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Billing on the official Stripe SDK — subscriptions, plans, entitlements and usage metering. Proration, failed-payment retries, tax, promotion codes, trials and the checkout seat stepper are Stripe's own; the plugin adds the entitlement engine, the enforce interceptor, typed BillingError/EntitlementExceeded, the /billing/webhook mount with Stripe's signature verification, the BillingService Tag, requireEntitlement(), and a browser-safe useStartCheckout() hook. Money is integer minor units end-to-end.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -37,7 +37,8 @@
37
37
  "types": "./dist/rpc.d.ts",
38
38
  "import": "./dist/rpc.js",
39
39
  "default": "./dist/rpc.js"
40
- }
40
+ },
41
+ "./package.json": "./package.json"
41
42
  },
42
43
  "main": "./dist/index.js",
43
44
  "module": "./dist/index.js",
@@ -47,11 +48,11 @@
47
48
  "node": ">=24.0.0"
48
49
  },
49
50
  "dependencies": {
50
- "@voltro/database": "0.32.0",
51
- "@voltro/env": "0.32.0",
52
- "@voltro/logger": "0.32.0",
53
- "@voltro/plugin-webhooks": "0.32.0",
54
- "@voltro/protocol": "0.32.0",
51
+ "@voltro/database": "0.34.0",
52
+ "@voltro/env": "0.34.0",
53
+ "@voltro/logger": "0.34.0",
54
+ "@voltro/plugin-webhooks": "0.34.0",
55
+ "@voltro/protocol": "0.34.0",
55
56
  "stripe": "22.3.2"
56
57
  },
57
58
  "peerDependencies": {