@voltro/plugin-billing 0.81.0 → 0.83.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
@@ -27,6 +27,18 @@ declare const BillingError_base: Schema.TaggedErrorClass<BillingError, "BillingE
27
27
  * emitted into the generated client group. Kept in lockstep with the exports. */
28
28
  export declare const billingRpcClientImports: ReadonlyArray<PluginRpcClientDescriptor>;
29
29
 
30
+ /** The customer's own cancellation: active until the paid period ends, then
31
+ * ended. `cancelAt` on the answer is when. */
32
+ export declare const cancelSubscriptionDescriptor: ActionProcedureDescriptor<"billing.cancel", Schema.Struct<{}>, Schema.Struct<{
33
+ subscription: Schema.Struct<{
34
+ plan: typeof Schema.String;
35
+ status: typeof Schema.String;
36
+ quantity: typeof Schema.Number;
37
+ currentPeriodEnd: Schema.NullOr<typeof Schema.String>;
38
+ cancelAt: Schema.NullOr<typeof Schema.String>;
39
+ }>;
40
+ }>, typeof BillingError>;
41
+
30
42
  export declare const changePlanDescriptor: ActionProcedureDescriptor<"billing.changePlan", Schema.Struct<{
31
43
  plan: typeof Schema.String;
32
44
  }>, Schema.Struct<{
@@ -106,6 +118,17 @@ export declare const reportUsageDescriptor: ActionProcedureDescriptor<"billing.r
106
118
  ok: typeof Schema.Boolean;
107
119
  }>, typeof BillingError>;
108
120
 
121
+ /** Take back a cancellation before the period ends. */
122
+ export declare const resumeSubscriptionDescriptor: ActionProcedureDescriptor<"billing.resume", Schema.Struct<{}>, Schema.Struct<{
123
+ subscription: Schema.Struct<{
124
+ plan: typeof Schema.String;
125
+ status: typeof Schema.String;
126
+ quantity: typeof Schema.Number;
127
+ currentPeriodEnd: Schema.NullOr<typeof Schema.String>;
128
+ cancelAt: Schema.NullOr<typeof Schema.String>;
129
+ }>;
130
+ }>, typeof BillingError>;
131
+
109
132
  export declare const startCheckoutDescriptor: ActionProcedureDescriptor<"billing.startCheckout", Schema.Struct<{
110
133
  plan: typeof Schema.String;
111
134
  successUrl: typeof Schema.String;
@@ -126,4 +149,17 @@ export declare const subscriptionDescriptor: QueryProcedureDescriptor<"billing.s
126
149
  }>>;
127
150
  }>, typeof BillingError>;
128
151
 
152
+ /** The checkpoint: sync the caller's tenant from the provider and answer with
153
+ * its current subscription. Call it on the return from checkout or the
154
+ * portal, and when a billing page loads. */
155
+ export declare const syncDescriptor: ActionProcedureDescriptor<"billing.sync", Schema.Struct<{}>, Schema.Struct<{
156
+ subscription: Schema.NullOr<Schema.Struct<{
157
+ plan: typeof Schema.String;
158
+ status: typeof Schema.String;
159
+ quantity: typeof Schema.Number;
160
+ currentPeriodEnd: Schema.NullOr<typeof Schema.String>;
161
+ cancelAt: Schema.NullOr<typeof Schema.String>;
162
+ }>>;
163
+ }>, typeof BillingError>;
164
+
129
165
  export { }
package/dist/rpc.js CHANGED
@@ -19,19 +19,19 @@ var i = n({
19
19
  output: t.Struct({ url: t.String }),
20
20
  error: e,
21
21
  guards: [{ scope: "billing:manage" }]
22
- }), o = r({
22
+ }), o = t.Struct({
23
+ plan: t.String,
24
+ status: t.String,
25
+ quantity: t.Number,
26
+ currentPeriodEnd: t.NullOr(t.String),
27
+ cancelAt: t.NullOr(t.String)
28
+ }), s = r({
23
29
  name: "billing.subscription",
24
30
  input: t.Struct({}),
25
- output: t.Struct({ subscription: t.NullOr(t.Struct({
26
- plan: t.String,
27
- status: t.String,
28
- quantity: t.Number,
29
- currentPeriodEnd: t.NullOr(t.String),
30
- cancelAt: t.NullOr(t.String)
31
- })) }),
31
+ output: t.Struct({ subscription: t.NullOr(o) }),
32
32
  error: e,
33
33
  openAccess: "tenant-scoped read of the caller's own tenant's plan/seat state for account UI every member renders; carries no payment credentials"
34
- }), s = r({
34
+ }), c = r({
35
35
  name: "billing.entitlementStatus",
36
36
  input: t.Struct({}),
37
37
  output: t.Struct({
@@ -46,7 +46,7 @@ var i = n({
46
46
  }),
47
47
  error: e,
48
48
  openAccess: "tenant-scoped read of the caller's own tenant's entitlement/grace state — the banner every signed-in member renders on payment failure"
49
- }), c = n({
49
+ }), l = n({
50
50
  name: "billing.reportUsage",
51
51
  input: t.Struct({
52
52
  key: t.String,
@@ -55,21 +55,21 @@ var i = n({
55
55
  output: t.Struct({ ok: t.Boolean }),
56
56
  error: e,
57
57
  guards: [{ scope: "billing:report" }]
58
- }), l = t.Struct({
58
+ }), u = t.Struct({
59
59
  plan: t.String,
60
60
  quantity: t.Number,
61
61
  prorationMinor: t.Number,
62
62
  currency: t.String
63
- }), u = n({
63
+ }), d = n({
64
64
  name: "billing.previewChange",
65
65
  input: t.Struct({
66
66
  plan: t.optional(t.String),
67
67
  quantity: t.optional(t.Number)
68
68
  }),
69
- output: l,
69
+ output: u,
70
70
  error: e,
71
71
  guards: [{ scope: "billing:manage" }]
72
- }), d = r({
72
+ }), f = r({
73
73
  name: "billing.invoices",
74
74
  input: t.Struct({}),
75
75
  output: t.Struct({ invoices: t.Array(t.Struct({
@@ -83,19 +83,37 @@ var i = n({
83
83
  })) }),
84
84
  error: e,
85
85
  guards: [{ scope: "billing:manage" }]
86
- }), f = n({
86
+ }), p = n({
87
87
  name: "billing.changePlan",
88
88
  input: t.Struct({ plan: t.String }),
89
- output: l,
89
+ output: u,
90
90
  error: e,
91
91
  guards: [{ scope: "billing:manage" }]
92
- }), p = n({
92
+ }), m = n({
93
93
  name: "billing.changeSeats",
94
94
  input: t.Struct({ quantity: t.Number }),
95
- output: l,
95
+ output: u,
96
+ error: e,
97
+ guards: [{ scope: "billing:manage" }]
98
+ }), h = n({
99
+ name: "billing.cancel",
100
+ input: t.Struct({}),
101
+ output: t.Struct({ subscription: o }),
102
+ error: e,
103
+ guards: [{ scope: "billing:manage" }]
104
+ }), g = n({
105
+ name: "billing.sync",
106
+ input: t.Struct({}),
107
+ output: t.Struct({ subscription: t.NullOr(o) }),
108
+ error: e,
109
+ guards: [{ scope: "billing:manage" }]
110
+ }), _ = n({
111
+ name: "billing.resume",
112
+ input: t.Struct({}),
113
+ output: t.Struct({ subscription: o }),
96
114
  error: e,
97
115
  guards: [{ scope: "billing:manage" }]
98
- }), m = [
116
+ }), v = [
99
117
  {
100
118
  tag: "billing.startCheckout",
101
119
  kind: "action",
@@ -152,6 +170,30 @@ var i = n({
152
170
  name: "changeSeatsDescriptor"
153
171
  }
154
172
  },
173
+ {
174
+ tag: "billing.cancel",
175
+ kind: "action",
176
+ import: {
177
+ module: "@voltro/plugin-billing/rpc",
178
+ name: "cancelSubscriptionDescriptor"
179
+ }
180
+ },
181
+ {
182
+ tag: "billing.resume",
183
+ kind: "action",
184
+ import: {
185
+ module: "@voltro/plugin-billing/rpc",
186
+ name: "resumeSubscriptionDescriptor"
187
+ }
188
+ },
189
+ {
190
+ tag: "billing.sync",
191
+ kind: "action",
192
+ import: {
193
+ module: "@voltro/plugin-billing/rpc",
194
+ name: "syncDescriptor"
195
+ }
196
+ },
155
197
  {
156
198
  tag: "billing.previewChange",
157
199
  kind: "action",
@@ -170,4 +212,4 @@ var i = n({
170
212
  }
171
213
  ];
172
214
  //#endregion
173
- 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 };
215
+ export { v as billingRpcClientImports, h as cancelSubscriptionDescriptor, p as changePlanDescriptor, m as changeSeatsDescriptor, c as entitlementStatusDescriptor, f as invoicesDescriptor, a as portalUrlDescriptor, d as previewChangeDescriptor, l as reportUsageDescriptor, _ as resumeSubscriptionDescriptor, i as startCheckoutDescriptor, s as subscriptionDescriptor, g as syncDescriptor };
package/dist/types.d.ts CHANGED
@@ -2,6 +2,14 @@ import { Context } from 'effect';
2
2
  import { Effect } from 'effect';
3
3
  import { Schema } from 'effect';
4
4
 
5
+ /**
6
+ * What a billing call would do to what the tenant pays — the unit an app's
7
+ * `admit` hook decides on. `checkout` starts paying, `change` moves a plan or
8
+ * seat count, `resume` takes a cancellation back, `portal` opens the provider's
9
+ * self-service page (which can do all of those), `cancel` stops paying.
10
+ */
11
+ export declare type BillingAction = 'checkout' | 'change' | 'resume' | 'portal' | 'cancel';
12
+
5
13
  /**
6
14
  * A billing failure. `transient: true` marks failures the service retries
7
15
  * (network blip talking to the provider); non-transient failures (a 4xx
@@ -66,6 +74,14 @@ export declare type BillingEvent = {
66
74
  readonly tenantId: string;
67
75
  readonly providerCustomerId: string;
68
76
  readonly occurredAt: Date | null;
77
+ } | {
78
+ /** The provider customer was deleted (e.g. in the provider's dashboard).
79
+ * `tenantId` is null when the customer carried no tenant metadata — the
80
+ * link is then resolved from our own customer table. */
81
+ readonly _tag: 'customerUnlinked';
82
+ readonly tenantId: string | null;
83
+ readonly providerCustomerId: string;
84
+ readonly occurredAt: Date | null;
69
85
  };
70
86
 
71
87
  /** The event tag union — handy for `onEvent` keys. */
@@ -88,6 +104,10 @@ export declare interface BillingProvider {
88
104
  * When false, `BillingService.reportUsage` records locally but never
89
105
  * calls `provider.reportUsage`. */
90
106
  readonly supportsMeteredUsage: boolean;
107
+ /** Whether a usage push must name a linked provider customer. Stripe
108
+ * meters map events to a customer by id, so an unlinked tenant's usage
109
+ * stays pending instead of being sent unattributable. */
110
+ readonly usageRequiresCustomer?: boolean;
91
111
  readonly createCheckoutSession: (input: CheckoutInput) => Effect.Effect<{
92
112
  url: string;
93
113
  }, BillingError>;
@@ -123,6 +143,43 @@ export declare interface BillingProvider {
123
143
  * provider does not know the id.
124
144
  */
125
145
  readonly fetchSubscription: (providerSubscriptionId: string) => Effect.Effect<ProviderSubscriptionState | null, BillingError>;
146
+ /**
147
+ * Every live (not yet ended) subscription at the provider, optionally for
148
+ * one customer. The provider-side half of reconciliation: what it lists is
149
+ * the truth our rows are corrected to.
150
+ */
151
+ readonly listSubscriptions: (input: {
152
+ readonly providerCustomerId?: string;
153
+ }) => Effect.Effect<ReadonlyArray<ProviderSubscriptionSnapshot>, BillingError>;
154
+ /** End a subscription — at period end (reversible) or immediately with a
155
+ * final invoice. Returns the provider's state afterwards. */
156
+ readonly cancelSubscription: (input: {
157
+ readonly providerSubscriptionId: string;
158
+ readonly mode: CancelMode;
159
+ }) => Effect.Effect<ProviderSubscriptionState, BillingError>;
160
+ /** Undo a period-end cancellation that has not taken effect yet. */
161
+ readonly resumeSubscription: (providerSubscriptionId: string) => Effect.Effect<ProviderSubscriptionState, BillingError>;
162
+ /**
163
+ * The provider customer for a tenant, created when it has none — BEFORE the
164
+ * first checkout, so every checkout names its customer. That is what lets
165
+ * the provider refuse a second subscription for the same customer, and
166
+ * lets open checkouts be found and expired. Must be idempotent per tenant:
167
+ * two concurrent first checkouts get the same customer.
168
+ */
169
+ readonly ensureCustomer: (input: {
170
+ readonly tenantId: string;
171
+ }) => Effect.Effect<string, BillingError>;
172
+ /** Expire every checkout still open for a customer, so only the newest one
173
+ * can be paid. Returns how many were expired. */
174
+ readonly expireOpenCheckouts: (input: {
175
+ readonly providerCustomerId: string;
176
+ }) => Effect.Effect<number, BillingError>;
177
+ /** Add prices the subscription is missing (metered items), without
178
+ * proration. Prices already present are left alone. */
179
+ readonly addSubscriptionPrices: (input: {
180
+ readonly providerSubscriptionId: string;
181
+ readonly priceIds: ReadonlyArray<string>;
182
+ }) => Effect.Effect<void, BillingError>;
126
183
  /**
127
184
  * The billing contact the PROVIDER has on file, when it has one. Used as the
128
185
  * dunning recipient of last resort — `dunning.resolveRecipient` wins when
@@ -149,6 +206,23 @@ export declare interface BillingProvider {
149
206
  * `null` for an event type this provider doesn't model (handler
150
207
  * no-ops, still 200). */
151
208
  readonly normalizeEvent: (raw: unknown) => Effect.Effect<BillingEvent | null, BillingError>;
209
+ /**
210
+ * Who and when a verified payload is about: the provider customer it names
211
+ * and when the provider created it. The webhook syncs that customer from a
212
+ * fresh read instead of trusting the payload, and an event created long
213
+ * before it arrives means deliveries were failing — the cue to catch up.
214
+ * Optional: without it the webhook applies the normalized event as is.
215
+ */
216
+ readonly eventContext?: (raw: unknown) => {
217
+ readonly providerCustomerId: string | null;
218
+ readonly createdAt: Date | null;
219
+ };
220
+ /**
221
+ * Events the provider could not deliver to the app's webhook, as far back as
222
+ * it keeps them (Stripe: 30 days) — what an outage of the endpoint left
223
+ * behind. Optional: a provider without it has no catch-up.
224
+ */
225
+ readonly listUndeliveredEvents?: () => Effect.Effect<ReadonlyArray<UndeliveredEvent>, BillingError>;
152
226
  }
153
227
 
154
228
  /** The billing service Tag — `const billing = yield* BillingService`. */
@@ -210,6 +284,8 @@ export declare interface BillingServiceShape {
210
284
  readonly plan: PlanId;
211
285
  readonly successUrl: string;
212
286
  readonly cancelUrl: string;
287
+ /** Seats to buy up front. Omit for 1. */
288
+ readonly quantity?: number;
213
289
  }) => Effect.Effect<{
214
290
  url: string;
215
291
  }, BillingError>;
@@ -217,6 +293,48 @@ export declare interface BillingServiceShape {
217
293
  readonly portalUrl: (tenantId: string, returnUrl: string) => Effect.Effect<{
218
294
  url: string;
219
295
  }, BillingError>;
296
+ /**
297
+ * The customer's own cancellation: the subscription stays active until the
298
+ * paid period ends, then ends. The provider applies it first; the row
299
+ * mirrors its answer (`cancelAt`). Reversible with `resumeSubscription`.
300
+ */
301
+ readonly cancelSubscription: (tenantId: string) => Effect.Effect<Subscription, BillingError>;
302
+ /** Take back a period-end cancellation before it takes effect. */
303
+ readonly resumeSubscription: (tenantId: string) => Effect.Effect<Subscription, BillingError>;
304
+ /**
305
+ * End every provider subscription of a tenant — for deleting an
306
+ * organization or account. Pending usage is flushed first; each subscription
307
+ * then ends once that usage has settled on the meter (minutes, see
308
+ * `CancelMode` 'close') with a final invoice for it and a proration credit.
309
+ * The rows carry the end as `cancelAt` until the provider's deletion event
310
+ * marks them canceled. The provider customer and its invoices are KEPT: they
311
+ * are the accounting record. Fails without ending anything when the flush
312
+ * fails, so no usage is lost; the caller retries.
313
+ */
314
+ readonly closeAccount: (tenantId: string) => Effect.Effect<CloseAccountResult, BillingError>;
315
+ /**
316
+ * Correct every local row from the provider: what the provider lists wins,
317
+ * a local live row the provider no longer lists is re-read directly, missing
318
+ * metered items are added back. Run it on a schedule — it heals whatever a
319
+ * missed or failed webhook left behind.
320
+ */
321
+ readonly reconcileFromProvider: () => Effect.Effect<ProviderReconcileResult, BillingError>;
322
+ /**
323
+ * Bring one provider customer's rows to what the provider says now, and
324
+ * return its current state as events (all of it, not just changes). The
325
+ * webhook uses it for the customer an event names. Does not run `onEvent`.
326
+ */
327
+ readonly syncCustomer: (providerCustomerId: string, hintTenantId?: string) => Effect.Effect<{
328
+ readonly tenantId: string | null;
329
+ readonly events: ReadonlyArray<BillingEvent>;
330
+ }, BillingError>;
331
+ /**
332
+ * The checkpoint: sync a tenant from the provider, run `onEvent` for what it
333
+ * found, and answer with its current subscription. Call it where a stale row
334
+ * would matter — the return from checkout or the portal, a billing page
335
+ * load. Every call that changes what a tenant pays already runs it first.
336
+ */
337
+ readonly syncTenant: (tenantId: string) => Effect.Effect<Subscription | null, BillingError>;
220
338
  /** Apply a normalized provider event to the DB rows (idempotent upsert). */
221
339
  readonly applyEvent: (event: BillingEvent) => Effect.Effect<void, BillingError>;
222
340
  /**
@@ -237,10 +355,29 @@ export declare interface BillingServiceShape {
237
355
  readonly changeSeats: (tenantId: string, quantity: number, changeAt?: Date) => Effect.Effect<SubscriptionChange, BillingError>;
238
356
  }
239
357
 
358
+ /** How a subscription ends. */
359
+ export declare type CancelMode =
360
+ /** Stays active until the paid period ends, then ends — the customer's own
361
+ * cancellation. Reversible with `resumeSubscription` until then. */
362
+ 'periodEnd'
363
+ /**
364
+ * Closing an account: ends as soon as the last usage has settled on the
365
+ * meter — a few minutes, not a period — with a final invoice for that usage
366
+ * and a proration credit for the unused time.
367
+ *
368
+ * Not "now", on purpose: meter events aggregate asynchronously, and a
369
+ * subscription ended the moment its last usage was sent is invoiced for none
370
+ * of it (measured: the meter showed 250, the final invoice 0, and a finalized
371
+ * invoice never picks the usage up later).
372
+ */
373
+ | 'close';
374
+
240
375
  export declare interface CheckoutInput {
241
376
  readonly tenantId: string;
242
377
  /** Provider price id to subscribe to. */
243
378
  readonly priceId: string;
379
+ /** Usage-based prices added next to `priceId` (no quantity). */
380
+ readonly meteredPriceIds?: ReadonlyArray<string>;
244
381
  readonly successUrl: string;
245
382
  readonly cancelUrl: string;
246
383
  /** Optional existing provider customer id to attach the checkout to. */
@@ -257,6 +394,13 @@ export declare interface CheckoutInput {
257
394
  readonly trialDays?: number;
258
395
  }
259
396
 
397
+ /** What `closeAccount` did at the provider. */
398
+ export declare interface CloseAccountResult {
399
+ readonly tenantId: string;
400
+ /** Provider subscriptions ended (immediately, with a final invoice). */
401
+ readonly canceled: ReadonlyArray<string>;
402
+ }
403
+
260
404
  /** The free/fallback plan id every app implicitly has. */
261
405
  export declare const DEFAULT_PLAN: PlanId;
262
406
 
@@ -359,6 +503,13 @@ export declare interface Money {
359
503
  export declare interface PlanConfig {
360
504
  /** Provider price id (Stripe `price_…`). Absent for free/zero-cost plans. */
361
505
  readonly priceId?: string;
506
+ /**
507
+ * Usage-based provider prices subscribed ALONGSIDE `priceId` (Stripe prices
508
+ * with `recurring.meter`). Checkout adds each as a quantity-less line item,
509
+ * so the subscription carries the metered item from its first invoice —
510
+ * without it, meter events are recorded at Stripe but never billed.
511
+ */
512
+ readonly meteredPriceIds?: ReadonlyArray<string>;
362
513
  /** Per-entitlement-key limits. Values are `number | 'unlimited'`. */
363
514
  readonly entitlements: Readonly<Record<string, EntitlementLimit>>;
364
515
  /** The plan's per-period price in INTEGER minor units (cents) — the unit
@@ -381,6 +532,45 @@ export declare interface PortalInput {
381
532
  readonly returnUrl: string;
382
533
  }
383
534
 
535
+ /** What `reconcileFromProvider` found and corrected. */
536
+ export declare interface ProviderReconcileResult {
537
+ /** Subscriptions compared (provider-listed plus local rows it did not list). */
538
+ readonly checked: number;
539
+ /** Tenants whose local rows changed because the provider said otherwise. */
540
+ readonly corrected: ReadonlyArray<string>;
541
+ /** Tenants with more than one live subscription at the provider — billed
542
+ * twice. Never resolved automatically: refunding is a human decision. */
543
+ readonly duplicates: ReadonlyArray<string>;
544
+ /** Provider subscriptions naming no tenant; nothing to attribute them to. */
545
+ readonly unattributed: number;
546
+ /** Subscriptions that had a plan's metered price added back. */
547
+ readonly meteredItemsRestored: number;
548
+ /** Tenants the app reports closed whose subscriptions were still paying —
549
+ * ended now (see `isTenantClosed`). */
550
+ readonly closedTenants: ReadonlyArray<string>;
551
+ /** Live rows the provider has never heard of that were NOT canceled, because
552
+ * so many at once means the wrong account or provider (0 normally). */
553
+ readonly refusedUnknown: number;
554
+ /** Every correction as the event it stands for, for the app's `onEvent`
555
+ * projections; the plugin's scheduled reconcile dispatches them. */
556
+ readonly events: ReadonlyArray<BillingEvent>;
557
+ }
558
+
559
+ /**
560
+ * One subscription as the provider lists it — the input of the provider-side
561
+ * reconcile. Carries the ids a local row is matched by and the price ids a
562
+ * plan's metered items are checked against.
563
+ */
564
+ export declare interface ProviderSubscriptionSnapshot extends ProviderSubscriptionState {
565
+ readonly providerSubscriptionId: string;
566
+ readonly providerCustomerId: string | null;
567
+ /** From the subscription's own metadata; null when the provider object was
568
+ * not created through our checkout and names no tenant. */
569
+ readonly tenantId: string | null;
570
+ /** Every price the subscription carries, licensed and metered. */
571
+ readonly priceIds: ReadonlyArray<string>;
572
+ }
573
+
384
574
  /** The provider's CURRENT view of a subscription — read directly, not from an
385
575
  * event body. `reconcileDunning` asks for this before it will lock anyone
386
576
  * out. */
@@ -504,6 +694,13 @@ export declare interface SubscriptionUpdateInput {
504
694
  readonly proration?: 'prorate' | 'none' | 'always_invoice';
505
695
  }
506
696
 
697
+ /** An event the provider failed to deliver, reduced to what a catch-up needs. */
698
+ export declare interface UndeliveredEvent {
699
+ readonly id: string;
700
+ readonly providerCustomerId: string | null;
701
+ readonly createdAt: Date | null;
702
+ }
703
+
507
704
  export declare interface UsagePush {
508
705
  readonly tenantId: string;
509
706
  readonly entitlementKey: string;
@@ -511,6 +708,14 @@ export declare interface UsagePush {
511
708
  readonly quantity: number;
512
709
  /** End of the period the usage falls in (provider timestamp anchor). */
513
710
  readonly periodEnd?: Date;
711
+ /** The provider customer the usage is billed to, when one is linked. */
712
+ readonly providerCustomerId?: string;
713
+ /**
714
+ * Names THIS delta: the same flush replayed carries the same key (so the
715
+ * provider can dedupe it), the next delta carries a new one. A key per
716
+ * (tenant, key, period) alone would make every later delta a "duplicate".
717
+ */
718
+ readonly idempotencyKey?: string;
514
719
  }
515
720
 
516
721
  export { }
@@ -1,3 +1,12 @@
1
+ /** The fields of `billing.subscription` the cancel flow reads. */
2
+ export declare interface CancelableSubscription {
3
+ readonly status: string;
4
+ /** ISO instant a scheduled cancellation takes effect, else null. */
5
+ readonly cancelAt: string | null;
6
+ /** ISO instant the paid period ends. */
7
+ readonly currentPeriodEnd: string | null;
8
+ }
9
+
1
10
  /** The plan id + redirect URLs a checkout needs. */
2
11
  export declare interface StartCheckoutInput {
3
12
  readonly plan: string;
@@ -15,6 +24,9 @@ export declare type StartCheckoutRpc = (input: StartCheckoutInput) => Promise<{
15
24
  readonly url: string;
16
25
  }>;
17
26
 
27
+ /** The generated `billing.cancel` / `billing.resume` action bindings. */
28
+ export declare type SubscriptionActionRpc = (input: Record<string, never>) => Promise<unknown>;
29
+
18
30
  /**
19
31
  * React hook that drives provider-hosted checkout. Pass the generated
20
32
  * `billing.startCheckout` rpc binding; the hook tracks pending/error and
@@ -38,4 +50,49 @@ export declare interface UseStartCheckoutResult {
38
50
  readonly error: Error | null;
39
51
  }
40
52
 
53
+ /**
54
+ * The two-step cancellation an app renders on its own billing page: an entry,
55
+ * then a separate confirmation that names the end date, then — once scheduled —
56
+ * the way back. The cancellation itself is the provider's period-end cancel
57
+ * (`billing.cancel`); the subscription stays active until it ends.
58
+ *
59
+ * The state comes from the subscription the app already reads, so a
60
+ * cancellation made in the provider's portal shows here too.
61
+ *
62
+ * ```tsx
63
+ * const flow = useSubscriptionCancel({ subscription, cancel: rpc.billing.cancel, resume: rpc.billing.resume })
64
+ * flow.stage === 'active' && <button onClick={flow.begin}>Cancel your subscription here</button>
65
+ * flow.stage === 'confirming' && <><p>Ends on {flow.endsOn}</p><button onClick={flow.confirm}>Cancel now</button><button onClick={flow.keep}>Keep</button></>
66
+ * flow.stage === 'scheduled' && <button onClick={flow.resume}>Take the cancellation back</button>
67
+ * ```
68
+ */
69
+ export declare const useSubscriptionCancel: (input: {
70
+ readonly subscription: CancelableSubscription;
71
+ readonly cancel: SubscriptionActionRpc;
72
+ readonly resume: SubscriptionActionRpc;
73
+ }) => UseSubscriptionCancelResult;
74
+
75
+ export declare interface UseSubscriptionCancelResult {
76
+ /**
77
+ * - `active` — nothing scheduled; show the entry ("cancel your subscription here")
78
+ * - `confirming` — the separate confirmation step; show `endsOn`, "cancel now" and "keep"
79
+ * - `scheduled` — cancelled, ends on `endsOn`; show "take it back"
80
+ * - `ended` — the subscription has ended; nothing to offer
81
+ */
82
+ readonly stage: 'active' | 'confirming' | 'scheduled' | 'ended';
83
+ /** When the subscription ends (scheduled) or would end (confirming), ISO. */
84
+ readonly endsOn: string | null;
85
+ /** Open the confirmation step. Calls nothing. */
86
+ readonly begin: () => void;
87
+ /** Leave the confirmation step. Calls nothing. */
88
+ readonly keep: () => void;
89
+ /** The confirmed cancellation — at period end, reversible until then. */
90
+ readonly confirm: () => Promise<void>;
91
+ /** Take a scheduled cancellation back. */
92
+ readonly resume: () => Promise<void>;
93
+ readonly pending: boolean;
94
+ /** The last failure, or null. The subscription is unchanged when set. */
95
+ readonly error: Error | null;
96
+ }
97
+
41
98
  export { }
package/dist/web/index.js CHANGED
@@ -17,6 +17,31 @@ var n = () => globalThis.window, r = (r) => {
17
17
  pending: i,
18
18
  error: o
19
19
  };
20
+ }, i = (n) => {
21
+ let [r, i] = t(!1), [a, o] = t(!1), [s, c] = t(null), { subscription: l, cancel: u, resume: d } = n, f = e(async (e) => {
22
+ o(!0), c(null);
23
+ try {
24
+ await e(), i(!1);
25
+ } catch (e) {
26
+ c(e instanceof Error ? e : Error(String(e)));
27
+ } finally {
28
+ o(!1);
29
+ }
30
+ }, []), p = e(() => f(() => u({})), [f, u]), m = e(() => f(() => d({})), [f, d]), h = e(() => {
31
+ c(null), i(!0);
32
+ }, []), g = e(() => {
33
+ i(!1);
34
+ }, []);
35
+ return {
36
+ stage: l.status === "canceled" ? "ended" : l.cancelAt === null ? r ? "confirming" : "active" : "scheduled",
37
+ endsOn: l.cancelAt ?? l.currentPeriodEnd,
38
+ begin: h,
39
+ keep: g,
40
+ confirm: p,
41
+ resume: m,
42
+ pending: a,
43
+ error: s
44
+ };
20
45
  };
21
46
  //#endregion
22
- export { r as useStartCheckout };
47
+ export { r as useStartCheckout, i as useSubscriptionCancel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-billing",
3
- "version": "0.81.0",
3
+ "version": "0.83.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",
@@ -48,11 +48,11 @@
48
48
  "node": ">=24.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@voltro/database": "0.81.0",
52
- "@voltro/env": "0.81.0",
53
- "@voltro/logger": "0.81.0",
54
- "@voltro/plugin-webhooks": "0.81.0",
55
- "@voltro/protocol": "0.81.0"
51
+ "@voltro/database": "0.83.0",
52
+ "@voltro/env": "0.83.0",
53
+ "@voltro/logger": "0.83.0",
54
+ "@voltro/plugin-webhooks": "0.83.0",
55
+ "@voltro/protocol": "0.83.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "effect": "^3.22.2",