@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.321.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +9 -9
  2. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  3. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  4. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  5. package/src/billing-foundation/changes.json +14 -0
  6. package/src/billing-foundation/checkout-core.ts +8 -4
  7. package/src/billing-foundation/constants.ts +6 -0
  8. package/src/billing-foundation/feature.ts +37 -2
  9. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  10. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  11. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  12. package/src/billing-foundation/i18n.ts +9 -0
  13. package/src/billing-foundation/index.ts +1 -0
  14. package/src/billing-foundation/plan-catalog.ts +22 -5
  15. package/src/billing-foundation/types.ts +27 -0
  16. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  17. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  18. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  19. package/src/channel-email/changes.json +9 -1
  20. package/src/channel-email/email-channel.ts +31 -6
  21. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  22. package/src/delivery/changes.json +7 -0
  23. package/src/delivery/feature.ts +1 -1
  24. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  25. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  26. package/src/delivery/index.ts +2 -1
  27. package/src/delivery/public-names.ts +1 -1
  28. package/src/delivery/unsubscribe.ts +167 -89
  29. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  30. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
  31. package/src/step-dispatcher/changes.json +7 -0
  32. package/src/step-dispatcher/feature.ts +48 -15
  33. package/src/step-dispatcher/index.ts +3 -1
  34. package/src/step-dispatcher/webhook-runner.ts +59 -19
  35. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  36. package/src/subscription-stripe/feature.ts +5 -1
  37. package/src/subscription-stripe/plugin-methods.ts +53 -0
  38. package/src/subscription-stripe/verify-webhook.ts +52 -28
  39. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  40. package/src/tenant-handover/changes.json +7 -0
  41. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  42. package/src/tenant-handover/move-entity-graph.ts +30 -44
@@ -59,7 +59,11 @@ import {
59
59
  // `Temporal` TYPE `ResolvedBillingFoundationOptions.now`'s return type
60
60
  // resolves against, see event-store.ts's own import comment (#1438).
61
61
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
62
- import { BILLING_FOUNDATION_FEATURE, SUBSCRIPTION_PROVIDER_EXTENSION } from "./constants";
62
+ import {
63
+ BILLING_FOUNDATION_FEATURE,
64
+ SUBSCRIPTION_PROVIDER_EXTENSION,
65
+ SubscriptionFoundationHandlers,
66
+ } from "./constants";
63
67
  import { paymentEntity, subscriptionEntity } from "./entities";
64
68
  import {
65
69
  INVOICE_PAID_EVENT_QN,
@@ -87,6 +91,7 @@ import { processEventHandler } from "./handlers/process-event.write";
87
91
  import { processPaymentEventHandler } from "./handlers/process-payment-event.write";
88
92
  import { createStartPlanCheckoutHandler } from "./handlers/start-plan-checkout.write";
89
93
  import { createSwitchPlanHandler } from "./handlers/switch-plan.write";
94
+ import { syncSubscriptionHandler } from "./handlers/sync-subscription.write";
90
95
  import { BILLING_FOUNDATION_I18N } from "./i18n";
91
96
  import {
92
97
  applyInvoicePaid,
@@ -137,7 +142,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
137
142
 
138
143
  return defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
139
144
  r.describe(
140
- "Plugin host for subscription billing — manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers and a dormant billing-plans dashboard screen/panel from the catalog. Low-level building block — use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
145
+ "Plugin host for subscription billing — manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers and a dormant billing-plans dashboard screen/panel from the catalog. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event — catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block — use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
141
146
  );
142
147
  r.uiHints({
143
148
  displayLabel: "Billing · Foundation",
@@ -215,6 +220,12 @@ export function createBillingFoundationFeature<TTier extends string = string>(
215
220
  // - process-payment-event: programmatic entry-point from the webhook-
216
221
  // handler for one-off-payments; appends onto the payment-aggregate
217
222
  r.writeHandler(processPaymentEventHandler);
223
+ // - sync-subscription: backfill entry-point for the sync-subscriptions
224
+ // job below; pulls the live provider state and appends drift as a
225
+ // subscription.updated event. Registered unconditionally (not
226
+ // catalog-gated) — it operates on whatever subscription already
227
+ // exists for the tenant, independent of the billing-plans catalog.
228
+ r.writeHandler(syncSubscriptionHandler);
218
229
 
219
230
  // Custom list-query on the subscription-projection (raw drizzle
220
231
  // table; no r.entity since writes go through projection-apply).
@@ -233,6 +244,30 @@ export function createBillingFoundationFeature<TTier extends string = string>(
233
244
  r.screen(createBillingPlansScreen(catalog.viewRoles));
234
245
  }
235
246
 
247
+ // Backfill job: catches provider-side drift (e.g. a cancel_at set on
248
+ // the provider's own dashboard) that never reached us as a webhook.
249
+ // manual-trigger + runOnBoot (not cron) — this is an on-demand/boot
250
+ // reconciliation pass, not a recurring sweep; an app that wants a
251
+ // recurring sync can dispatch billing-foundation:job:sync-subscriptions
252
+ // from its own cron job. perTenant + runOnBoot are compatible (only
253
+ // bootGate is not). Registered unconditionally, same reasoning as the
254
+ // sync-subscription write-handler above.
255
+ r.job({
256
+ name: "sync-subscriptions",
257
+ trigger: { manual: true },
258
+ perTenant: true,
259
+ runOnBoot: true,
260
+ handler: async (_payload, ctx) => {
261
+ const result = await ctx.write(SubscriptionFoundationHandlers.syncSubscription, {});
262
+ if (!result.isSuccess) {
263
+ throw new Error(
264
+ `billing-foundation:sync-subscriptions: sync-subscription write failed: ${JSON.stringify(result.error)}`,
265
+ );
266
+ }
267
+ ctx.log.info(`[billing-foundation:sync-subscriptions] ${JSON.stringify(result.data)}`);
268
+ },
269
+ });
270
+
236
271
  r.useExtension(EXT_TENANT_DATA, "subscription", {
237
272
  destroy: subscriptionTenantDestroyHook,
238
273
  escapeHatch: { reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON },
@@ -16,7 +16,7 @@ import {
16
16
  configuredPiiSubjectKms,
17
17
  encryptPiiFieldValues,
18
18
  } from "@cosmicdrift/kumiko-framework/crypto";
19
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
19
+ import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
20
20
  import * as z from "zod";
21
21
  import { subscriptionAggregateId } from "../aggregate-id";
22
22
  import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
@@ -66,7 +66,7 @@ export const processEventSchema = z.object({
66
66
  cancelAtIso: z.string().min(1).nullable().optional(),
67
67
  rawPayload: z.string().min(1),
68
68
  });
69
- type ProcessEventPayload = z.infer<typeof processEventSchema>;
69
+ export type ProcessEventPayload = z.infer<typeof processEventSchema>;
70
70
 
71
71
  // Map normalized SubscriptionEventType → fully-qualified ES event-name.
72
72
  const NORMALIZED_TO_ES_EVENT: Readonly<Record<string, string>> = {
@@ -77,6 +77,114 @@ const NORMALIZED_TO_ES_EVENT: Readonly<Record<string, string>> = {
77
77
  [SubscriptionEventTypes.invoicePaymentFailed]: INVOICE_PAYMENT_FAILED_EVENT_QN,
78
78
  } satisfies Readonly<Record<string, string>>;
79
79
 
80
+ // =============================================================================
81
+ // appendSubscriptionEvent — the append-body, shared by processEventHandler
82
+ // (webhook path) and sync-subscription.write.ts (backfill path). Both call
83
+ // this with the same normalized payload shape; the difference is only in
84
+ // who constructs `payload` and what `providerEventId` they use.
85
+ // =============================================================================
86
+
87
+ export async function appendSubscriptionEvent(
88
+ ctx: HandlerContext,
89
+ tenantId: string,
90
+ payload: ProcessEventPayload,
91
+ ): Promise<{ readonly duplicate: boolean; readonly subscriptionAggregateId: string }> {
92
+ const aggId = subscriptionAggregateId(tenantId);
93
+
94
+ // ---------------------------------------------------------------
95
+ // 1. Idempotency: load the subscription stream and check whether this
96
+ // providerEventId was already seen. A provider retry storm (Stripe
97
+ // resends up to 5x within 4h) hits the same stream and finds the
98
+ // event id in metadata.
99
+ //
100
+ // **Performance caveat:** O(N) per stream. With 5 years of history
101
+ // (monthly recurring = ~60 events) still <50ms. For much longer
102
+ // streams, optimize via snapshot or a per-tenant dedup table as the
103
+ // idempotency anchor (like cap-counter).
104
+ // ---------------------------------------------------------------
105
+ const existingEvents = await ctx.loadAggregate(aggId);
106
+ const alreadySeen = existingEvents.some((e) => {
107
+ const headers = e.metadata.headers ?? {};
108
+ return (
109
+ headers["providerEventId"] === payload.providerEventId &&
110
+ headers["providerName"] === payload.providerName
111
+ );
112
+ });
113
+ if (alreadySeen) {
114
+ return { duplicate: true, subscriptionAggregateId: aggId };
115
+ }
116
+
117
+ // ---------------------------------------------------------------
118
+ // 2. Map normalized event-type → ES event-FQN.
119
+ // ---------------------------------------------------------------
120
+ const esEventType = NORMALIZED_TO_ES_EVENT[payload.type];
121
+ if (!esEventType) {
122
+ // Schema validation above should already catch this; defensive against
123
+ // drift between the SubscriptionEventTypes enum and the NORMALIZED map.
124
+ throw new Error(`subscription-foundation: no ES event-type mapping for "${payload.type}"`);
125
+ }
126
+
127
+ // ---------------------------------------------------------------
128
+ // 3. Encrypt the two provider-subject PII fields before they touch
129
+ // storage — this is the ONLY write path onto the subscription
130
+ // stream, so encrypting here covers both the event-log payload AND
131
+ // (via projection.ts copying the event fields as-is) the
132
+ // read_subscriptions row with a single call. The subject is the
133
+ // TENANT (tenantOwned) — tenant-destroy's subject-keys stage
134
+ // (eraseSubjectKeys) erases exactly this key, so both copies become
135
+ // genuinely unreadable (#800) once that stage runs, not just
136
+ // "encrypted at rest". No adapter configured = engine off (fields
137
+ // stay plaintext, pre-#724-phase-C behavior) — mirrors how the
138
+ // event-store-executor treats an absent piiKms().
139
+ // ---------------------------------------------------------------
140
+ const piiKms = configuredPiiSubjectKms();
141
+ const encryptedFields = piiKms
142
+ ? await encryptPiiFieldValues(
143
+ {
144
+ tenantId,
145
+ providerCustomerId: payload.providerCustomerId,
146
+ providerSubscriptionId: payload.providerSubscriptionId,
147
+ },
148
+ subscriptionEntity,
149
+ SUBSCRIPTION_PII_FIELDS,
150
+ piiKms,
151
+ { requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
152
+ { tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
153
+ )
154
+ : {
155
+ providerCustomerId: payload.providerCustomerId,
156
+ providerSubscriptionId: payload.providerSubscriptionId,
157
+ };
158
+
159
+ // ---------------------------------------------------------------
160
+ // 4. Append the event onto the subscription stream. The inline projection
161
+ // materializes the read_subscriptions row in the same TX.
162
+ // ---------------------------------------------------------------
163
+ const eventPayload: SubscriptionEventPayload = {
164
+ providerName: payload.providerName,
165
+ providerCustomerId: encryptedFields["providerCustomerId"] as string,
166
+ providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
167
+ status: payload.status,
168
+ tier: payload.tier,
169
+ currentPeriodEndIso: payload.currentPeriodEndIso,
170
+ ...(payload.cancelAtIso !== undefined && { cancelAtIso: payload.cancelAtIso }),
171
+ };
172
+ const headers: SubscriptionEventHeaders = {
173
+ providerEventId: payload.providerEventId,
174
+ providerName: payload.providerName,
175
+ rawPayload: payload.rawPayload,
176
+ };
177
+ await ctx.unsafeAppendEvent({
178
+ aggregateId: aggId,
179
+ aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
180
+ type: esEventType,
181
+ payload: eventPayload,
182
+ headers,
183
+ });
184
+
185
+ return { duplicate: false, subscriptionAggregateId: aggId };
186
+ }
187
+
80
188
  // =============================================================================
81
189
  // Handler
82
190
  // =============================================================================
@@ -92,109 +200,7 @@ export const processEventHandler: WriteHandlerDef = {
92
200
  handler: async (event, ctx) => {
93
201
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
94
202
  const payload = event.payload as ProcessEventPayload;
95
- const tenantId = event.user.tenantId;
96
- const aggId = subscriptionAggregateId(tenantId);
97
-
98
- // ---------------------------------------------------------------
99
- // 1. Idempotency: load subscription-stream + check ob dieser
100
- // providerEventId bereits gesehen wurde. Provider-Retry-Storm
101
- // (Stripe sendet bis zu 5x in 4h) trifft denselben Stream und
102
- // findet den event-id in metadata.
103
- //
104
- // **Performance-caveat:** O(N) pro stream. Bei 5 Jahren history
105
- // (recurring monatlich = ~60 events) noch <50ms. Bei deutlich
106
- // längeren streams optimieren via snapshot oder per-tenant
107
- // dedup-table als idempotency-anchor (analog cap-counter).
108
- // ---------------------------------------------------------------
109
- const existingEvents = await ctx.loadAggregate(aggId);
110
- const alreadySeen = existingEvents.some((e) => {
111
- const headers = e.metadata.headers ?? {};
112
- return (
113
- headers["providerEventId"] === payload.providerEventId &&
114
- headers["providerName"] === payload.providerName
115
- );
116
- });
117
- if (alreadySeen) {
118
- return {
119
- isSuccess: true as const,
120
- data: { duplicate: true as const, subscriptionAggregateId: aggId },
121
- };
122
- }
123
-
124
- // ---------------------------------------------------------------
125
- // 2. Map normalized event-type → ES event-FQN.
126
- // ---------------------------------------------------------------
127
- const esEventType = NORMALIZED_TO_ES_EVENT[payload.type];
128
- if (!esEventType) {
129
- // Schema-validation oben sollte das schon fangen, aber defensive
130
- // gegen drift im SubscriptionEventTypes-enum vs NORMALIZED-Map.
131
- throw new Error(`subscription-foundation: no ES event-type mapping for "${payload.type}"`);
132
- }
133
-
134
- // ---------------------------------------------------------------
135
- // 3. Encrypt the two provider-subject PII fields before they touch
136
- // storage — this is the ONLY write path onto the subscription
137
- // stream, so encrypting here covers both the event-log payload AND
138
- // (via projection.ts copying the event fields as-is) the
139
- // read_subscriptions row with a single call. The subject is the
140
- // TENANT (tenantOwned) — tenant-destroy's subject-keys stage
141
- // (eraseSubjectKeys) erases exactly this key, so both copies become
142
- // genuinely unreadable (#800) once that stage runs, not just
143
- // "encrypted at rest". No adapter configured = engine off (fields
144
- // stay plaintext, pre-#724-phase-C behavior) — mirrors how the
145
- // event-store-executor treats an absent piiKms().
146
- // ---------------------------------------------------------------
147
- const piiKms = configuredPiiSubjectKms();
148
- const encryptedFields = piiKms
149
- ? await encryptPiiFieldValues(
150
- {
151
- tenantId,
152
- providerCustomerId: payload.providerCustomerId,
153
- providerSubscriptionId: payload.providerSubscriptionId,
154
- },
155
- subscriptionEntity,
156
- SUBSCRIPTION_PII_FIELDS,
157
- piiKms,
158
- { requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
159
- { tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
160
- )
161
- : {
162
- providerCustomerId: payload.providerCustomerId,
163
- providerSubscriptionId: payload.providerSubscriptionId,
164
- };
165
-
166
- // ---------------------------------------------------------------
167
- // 4. Append event auf den subscription-stream. Inline-projection
168
- // materialisiert die read_subscriptions-row in derselben TX.
169
- // ---------------------------------------------------------------
170
- const eventPayload: SubscriptionEventPayload = {
171
- providerName: payload.providerName,
172
- providerCustomerId: encryptedFields["providerCustomerId"] as string,
173
- providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
174
- status: payload.status,
175
- tier: payload.tier,
176
- currentPeriodEndIso: payload.currentPeriodEndIso,
177
- ...(payload.cancelAtIso !== undefined && { cancelAtIso: payload.cancelAtIso }),
178
- };
179
- const headers: SubscriptionEventHeaders = {
180
- providerEventId: payload.providerEventId,
181
- providerName: payload.providerName,
182
- rawPayload: payload.rawPayload,
183
- };
184
- await ctx.unsafeAppendEvent({
185
- aggregateId: aggId,
186
- aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
187
- type: esEventType,
188
- payload: eventPayload,
189
- headers,
190
- });
191
-
192
- return {
193
- isSuccess: true as const,
194
- data: {
195
- duplicate: false as const,
196
- subscriptionAggregateId: aggId,
197
- },
198
- };
203
+ const result = await appendSubscriptionEvent(ctx, event.user.tenantId, payload);
204
+ return { isSuccess: true as const, data: result };
199
205
  },
200
206
  };
@@ -57,6 +57,13 @@ export function createSwitchPlanHandler(
57
57
  "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
58
58
  });
59
59
  }
60
+ if (sub.cancelAt !== null) {
61
+ throw new ConflictError({
62
+ i18nKey: "billing-foundation.errors.cancellationScheduled",
63
+ message:
64
+ "subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
65
+ });
66
+ }
60
67
  if (payload.tier === sub.tier) {
61
68
  throw new ConflictError({
62
69
  i18nKey: "billing-foundation.errors.alreadyOnPlan",
@@ -0,0 +1,164 @@
1
+ // sync-subscription — backfill handler for the `sync-subscriptions` job
2
+ // (registered in feature.ts). Pulls the live provider-side state of the
3
+ // tenant's subscription and appends it as a `subscription.updated` (or
4
+ // `subscription.canceled`, when the snapshot's own status is terminal)
5
+ // event when it has drifted from `read_subscriptions` — catches
6
+ // provider-side changes (e.g. a `cancel_at` set on the provider's own
7
+ // dashboard, or a cancellation) that never reached us as a webhook.
8
+ //
9
+ // SYSTEM_ROLE, not just "SystemAdmin": the `sync-subscriptions` job's own
10
+ // systemUser only carries `roles: ["system"]` (createSystemUser(tenantId),
11
+ // no extra roles — see job-runner.ts), unlike the webhook path's
12
+ // dispatchSystemWrite which adds "SystemAdmin". Same access shape as
13
+ // tenant/handlers/memberships.query.ts and active-tenant-ids.query.ts for
14
+ // the same reason. A human SystemAdmin can still dispatch this directly.
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { SYSTEM_ROLE, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
18
+ // Aliased — an un-aliased `Temporal` would shadow the ambient global
19
+ // `Temporal` TYPE `SubscriptionView.currentPeriodEnd`/`.cancelAt` resolve
20
+ // against, same reasoning as constants.ts's own import.
21
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
22
+ import * as z from "zod";
23
+ import { findProviderPlugin } from "../checkout-core";
24
+ import { isTerminalSubscriptionStatus, SubscriptionEventTypes } from "../constants";
25
+ import { getSubscriptionForTenant } from "../get-subscription-for-tenant";
26
+ import { appendSubscriptionEvent } from "./process-event.write";
27
+
28
+ export const syncSubscriptionSchema = z.object({}).strict();
29
+
30
+ export type SyncSubscriptionSkipReason =
31
+ | "no_live_subscription"
32
+ | "provider_cannot_retrieve"
33
+ | "not_found"
34
+ | "unchanged";
35
+
36
+ export type SyncSubscriptionResult =
37
+ | { readonly synced: true }
38
+ | { readonly synced: false; readonly reason: SyncSubscriptionSkipReason };
39
+
40
+ // Parses both sides fresh via the polyfill before comparing — tolerant of a
41
+ // provider snapshot's ISO string using a different (but equivalent)
42
+ // representation than `Temporal.Instant#toString()`'s canonical form
43
+ // (offset spelling, sub-second precision, ...), unlike a plain `===` on the
44
+ // raw strings.
45
+ function isoInstantsEqual(a: string, b: string): boolean {
46
+ return (
47
+ TemporalPolyfill.Instant.compare(
48
+ TemporalPolyfill.Instant.from(a),
49
+ TemporalPolyfill.Instant.from(b),
50
+ ) === 0
51
+ );
52
+ }
53
+
54
+ // Deterministic per (snapshot-content, current stream head) — not per-call.
55
+ // Folding in `lastChangedAt` (not just the snapshot's own fields) closes a
56
+ // gap a content-only hash has: dashboard-cancel → sync(A) → dashboard-
57
+ // reactivate → sync(B) → dashboard-cancel-again → sync would hash back to
58
+ // A's already-seen providerEventId and get silently treated as a duplicate,
59
+ // leaving the row stuck on B even though the provider is on A again.
60
+ // `lastChangedAt` advances on every applied event, so the third sync's hash
61
+ // differs from the first's even though the snapshot content matches.
62
+ function syncProviderEventId(
63
+ snapshot: {
64
+ readonly providerSubscriptionId: string;
65
+ readonly status: string;
66
+ readonly tier: string;
67
+ readonly currentPeriodEnd: string;
68
+ readonly cancelAt: string | null;
69
+ },
70
+ lastChangedAt: string,
71
+ ): string {
72
+ const hashInput = [
73
+ snapshot.providerSubscriptionId,
74
+ snapshot.status,
75
+ snapshot.tier,
76
+ snapshot.currentPeriodEnd,
77
+ snapshot.cancelAt ?? "",
78
+ lastChangedAt,
79
+ ].join("|");
80
+ return `sync:${createHash("sha256").update(hashInput).digest("hex")}`;
81
+ }
82
+
83
+ export const syncSubscriptionHandler: WriteHandlerDef = {
84
+ name: "sync-subscription",
85
+ agent: { expose: false },
86
+ schema: syncSubscriptionSchema,
87
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
88
+ handler: async (event, ctx) => {
89
+ const tenantId = event.user.tenantId;
90
+ const sub = await getSubscriptionForTenant(ctx, tenantId);
91
+ if (!sub || isTerminalSubscriptionStatus(sub.status)) {
92
+ return {
93
+ isSuccess: true as const,
94
+ data: { synced: false, reason: "no_live_subscription" } satisfies SyncSubscriptionResult,
95
+ };
96
+ }
97
+
98
+ // Non-throwing — a provider deregistered since the subscription was
99
+ // created is the same "can't reconcile right now" outcome as a plugin
100
+ // that never implemented retrieveSubscription, not a hard failure.
101
+ const found = findProviderPlugin(ctx, sub.providerName);
102
+ if (!found?.plugin.retrieveSubscription) {
103
+ return {
104
+ isSuccess: true as const,
105
+ data: {
106
+ synced: false,
107
+ reason: "provider_cannot_retrieve",
108
+ } satisfies SyncSubscriptionResult,
109
+ };
110
+ }
111
+
112
+ const snapshot = await found.plugin.retrieveSubscription(ctx, sub.providerSubscriptionId);
113
+ if (!snapshot) {
114
+ return {
115
+ isSuccess: true as const,
116
+ data: { synced: false, reason: "not_found" } satisfies SyncSubscriptionResult,
117
+ };
118
+ }
119
+
120
+ const cancelAtUnchanged =
121
+ snapshot.cancelAt === null
122
+ ? sub.cancelAt === null
123
+ : sub.cancelAt !== null && isoInstantsEqual(snapshot.cancelAt, sub.cancelAt.toString());
124
+ const unchanged =
125
+ snapshot.status === sub.status &&
126
+ snapshot.tier === sub.tier &&
127
+ isoInstantsEqual(snapshot.currentPeriodEnd, sub.currentPeriodEnd.toString()) &&
128
+ cancelAtUnchanged;
129
+ if (unchanged) {
130
+ return {
131
+ isSuccess: true as const,
132
+ data: { synced: false, reason: "unchanged" } satisfies SyncSubscriptionResult,
133
+ };
134
+ }
135
+
136
+ const appendResult = await appendSubscriptionEvent(ctx, tenantId, {
137
+ providerEventId: syncProviderEventId(snapshot, sub.lastChangedAt.toString()),
138
+ providerName: sub.providerName,
139
+ // A snapshot that already landed on a terminal status (canceled on the
140
+ // provider's own dashboard, never arrived as a webhook) must append as
141
+ // a real `canceled` event, not `updated` — anything downstream that
142
+ // reads the event-log's own type (not just the projection's status
143
+ // column, which both apply-functions patch the same way) needs the
144
+ // accurate event-type to react to a cancellation.
145
+ type: isTerminalSubscriptionStatus(snapshot.status)
146
+ ? SubscriptionEventTypes.canceled
147
+ : SubscriptionEventTypes.updated,
148
+ providerCustomerId: snapshot.providerCustomerId,
149
+ providerSubscriptionId: snapshot.providerSubscriptionId,
150
+ status: snapshot.status,
151
+ tier: snapshot.tier,
152
+ currentPeriodEndIso: snapshot.currentPeriodEnd,
153
+ cancelAtIso: snapshot.cancelAt,
154
+ rawPayload: snapshot.rawPayload,
155
+ });
156
+
157
+ return {
158
+ isSuccess: true as const,
159
+ data: (appendResult.duplicate
160
+ ? { synced: false, reason: "unchanged" }
161
+ : { synced: true }) satisfies SyncSubscriptionResult,
162
+ };
163
+ },
164
+ };
@@ -24,9 +24,15 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
24
24
  "billing-foundation.plans.cancelScheduled": {
25
25
  en: "Your subscription is scheduled to end on {date}.",
26
26
  },
27
+ "billing-foundation.plans.switchRequiresReactivation": {
28
+ en: "Reactivate your subscription before switching plans.",
29
+ },
27
30
  "billing-foundation.plans.paymentPending": {
28
31
  en: "Payment is still being completed.",
29
32
  },
33
+ "billing-foundation.plans.pastDue": {
34
+ en: "Your last payment failed. Update your payment method to avoid interruption.",
35
+ },
30
36
  "billing-foundation.plans.priceUnavailable": { en: "Price not available" },
31
37
  "billing-foundation.plans.purchaseNotAllowed": {
32
38
  en: "Only administrators can change the plan.",
@@ -50,6 +56,9 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
50
56
  "billing-foundation.errors.noActiveSubscription": {
51
57
  en: "This tenant has no active subscription to switch.",
52
58
  },
59
+ "billing-foundation.errors.cancellationScheduled": {
60
+ en: "This subscription is scheduled to cancel. Reactivate it before switching plans.",
61
+ },
53
62
  "billing-foundation.errors.alreadyOnPlan": { en: "This tenant is already on that plan." },
54
63
  "billing-foundation.errors.planSwitchNotSupported": {
55
64
  en: "This provider does not support switching plans.",
@@ -73,6 +73,7 @@ export {
73
73
  KNOWN_RECURRING_INTERVALS,
74
74
  type PaymentEvent,
75
75
  type ProviderPrice,
76
+ type ProviderSubscriptionSnapshot,
76
77
  type RecurringInterval,
77
78
  type SubscriptionEvent,
78
79
  type SubscriptionProviderPlugin,
@@ -103,8 +103,27 @@ type ActiveSubscription = {
103
103
  readonly status: string;
104
104
  readonly tier: string;
105
105
  readonly terminal: boolean;
106
+ readonly cancelAt: string | null;
106
107
  };
107
108
 
109
+ /** Whether an existing subscription can move to `tier` via the provider's
110
+ * plan-switch session — pulled out of resolvePlanAction so that function
111
+ * stays under the guard's complexity budget. A pending cancellation
112
+ * (`cancelAt` set) must be reactivated via create-portal-session before a
113
+ * switch is allowed — same gate `switch-plan.write.ts` enforces server-side. */
114
+ function canSwitchToTier(
115
+ tier: string,
116
+ subscription: ActiveSubscription,
117
+ plugin: SubscriptionProviderPlugin | null,
118
+ ): boolean {
119
+ return (
120
+ isSwitchableSubscriptionStatus(subscription.status) &&
121
+ tier !== subscription.tier &&
122
+ subscription.cancelAt === null &&
123
+ plugin?.createPlanSwitchSession !== undefined
124
+ );
125
+ }
126
+
108
127
  /** One plan-row's action — pulled out of `buildBillingPlans`' map callback so
109
128
  * that function's own complexity stays under the guard's budget. paymentPending
110
129
  * is checked before the general unavailable-fallback: the tier a not-yet-
@@ -133,11 +152,9 @@ function resolvePlanAction(
133
152
 
134
153
  if (!subscription || subscription.terminal) return BillingPlanActions.checkout;
135
154
 
136
- const canSwitch =
137
- isSwitchableSubscriptionStatus(subscription.status) &&
138
- tier !== subscription.tier &&
139
- plugin?.createPlanSwitchSession;
140
- return canSwitch ? BillingPlanActions.switch : BillingPlanActions.unavailable;
155
+ return canSwitchToTier(tier, subscription, plugin)
156
+ ? BillingPlanActions.switch
157
+ : BillingPlanActions.unavailable;
141
158
  }
142
159
 
143
160
  /** `plugin === null` — no provider is registered/resolvable for this
@@ -281,6 +281,33 @@ export type SubscriptionProviderPlugin = {
281
281
  readonly returnUrl: string;
282
282
  },
283
283
  ) => Promise<{ readonly url: string }>;
284
+
285
+ /**
286
+ * Fetches the live provider-side state of one subscription, for the
287
+ * `sync-subscriptions` backfill job — catches drift (e.g. a `cancel_at`
288
+ * set on the provider's own dashboard) that never reached us as a
289
+ * webhook. Missing → sync-subscription reports `provider_cannot_retrieve`
290
+ * instead of throwing. Null → the implementation could not resolve a
291
+ * snapshot for this id — the subscription no longer exists at the
292
+ * provider (deleted account, ...), or its state no longer maps onto this
293
+ * app's config (e.g. Stripe's price-to-tier — same silent-drop the
294
+ * webhook path already applies to an unmapped price).
295
+ */
296
+ readonly retrieveSubscription?: (
297
+ ctx: HandlerContext,
298
+ providerSubscriptionId: string,
299
+ ) => Promise<ProviderSubscriptionSnapshot | null>;
300
+ };
301
+
302
+ export type ProviderSubscriptionSnapshot = {
303
+ readonly providerCustomerId: string;
304
+ readonly providerSubscriptionId: string;
305
+ readonly status: SubscriptionStatus;
306
+ readonly tier: string;
307
+ /** ISO instant string, same shape as `SubscriptionEventPayload.currentPeriodEndIso`. */
308
+ readonly currentPeriodEnd: string;
309
+ readonly cancelAt: string | null;
310
+ readonly rawPayload: string;
284
311
  };
285
312
 
286
313
  // =============================================================================
@@ -351,6 +351,34 @@ describe("BillingPlansPanel", () => {
351
351
  expect(screen.queryByTestId("billing-plan-card-pro-cta")).toBeNull();
352
352
  });
353
353
 
354
+ test("a scheduled cancellation shows cancelScheduled and switchRequiresReactivation together", () => {
355
+ queryState = {
356
+ data: result({
357
+ subscription: subscription({ cancelAt: "2024-03-01T00:00:00Z" }),
358
+ plans: [plan({ isCurrent: true, action: BillingPlanActions.current })],
359
+ }),
360
+ loading: false,
361
+ error: null,
362
+ };
363
+ renderPanel();
364
+ const banner = screen.getByTestId("billing-plans-panel-cancel-scheduled");
365
+ expect(banner.textContent).toContain("billing-foundation.plans.cancelScheduled");
366
+ expect(banner.textContent).toContain("billing-foundation.plans.switchRequiresReactivation");
367
+ });
368
+
369
+ test("past_due status renders the past-due banner", () => {
370
+ queryState = {
371
+ data: result({
372
+ subscription: subscription({ status: "past_due" }),
373
+ plans: [plan({ isCurrent: true, action: BillingPlanActions.current })],
374
+ }),
375
+ loading: false,
376
+ error: null,
377
+ };
378
+ renderPanel();
379
+ expect(screen.getByTestId("billing-plans-panel-past-due")).toBeTruthy();
380
+ });
381
+
354
382
  test("price=null renders the price-unavailable fallback and a disabled cta", () => {
355
383
  queryState = {
356
384
  data: result({ plans: [plan({ price: null, action: BillingPlanActions.unavailable })] }),