@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.320.0",
3
+ "version": "0.321.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -134,12 +134,12 @@
134
134
  "./workflow-runner": "./src/workflow-runner/index.ts"
135
135
  },
136
136
  "dependencies": {
137
- "@cosmicdrift/kumiko-dispatcher-live": "0.320.0",
138
- "@cosmicdrift/kumiko-framework": "0.320.0",
139
- "@cosmicdrift/kumiko-headless": "0.320.0",
140
- "@cosmicdrift/kumiko-renderer": "0.320.0",
141
- "@cosmicdrift/kumiko-renderer-web": "0.320.0",
142
- "@cosmicdrift/kumiko-types": "0.320.0",
137
+ "@cosmicdrift/kumiko-dispatcher-live": "0.321.0",
138
+ "@cosmicdrift/kumiko-framework": "0.321.0",
139
+ "@cosmicdrift/kumiko-headless": "0.321.0",
140
+ "@cosmicdrift/kumiko-renderer": "0.321.0",
141
+ "@cosmicdrift/kumiko-renderer-web": "0.321.0",
142
+ "@cosmicdrift/kumiko-types": "0.321.0",
143
143
  "@mollie/api-client": "^4.5.0",
144
144
  "@node-rs/argon2": "^2.0.2",
145
145
  "@types/mailparser": "^3.4.6",
@@ -168,8 +168,8 @@
168
168
  ],
169
169
  "devDependencies": {
170
170
  "@testing-library/user-event": "^14.6.1",
171
- "@cosmicdrift/kumiko-locale-de": "0.320.0",
172
- "@cosmicdrift/kumiko-locale-es": "0.320.0",
171
+ "@cosmicdrift/kumiko-locale-de": "0.321.0",
172
+ "@cosmicdrift/kumiko-locale-es": "0.321.0",
173
173
  "jsqr": "^1.4.0"
174
174
  }
175
175
  }
@@ -232,6 +232,7 @@ async function createSubscription(
232
232
  tier: string;
233
233
  providerSubscriptionId: string;
234
234
  providerCustomerId: string;
235
+ cancelAtIso: string | null;
235
236
  }> = {},
236
237
  onStack: TestStack = stack,
237
238
  ) {
@@ -247,6 +248,7 @@ async function createSubscription(
247
248
  status: overrides.status ?? SubscriptionStatuses.active,
248
249
  tier: overrides.tier ?? "starter",
249
250
  currentPeriodEndIso: "2026-06-01T00:00:00Z",
251
+ ...(overrides.cancelAtIso !== undefined && { cancelAtIso: overrides.cancelAtIso }),
250
252
  rawPayload: '{"raw":"payload"}',
251
253
  },
252
254
  admin,
@@ -503,6 +505,31 @@ describe("switch-plan", () => {
503
505
  expect(error.httpStatus).toBe(409);
504
506
  });
505
507
 
508
+ test("switching a subscription that is scheduled to cancel is a conflict, and the query offers unavailable instead of switch", async () => {
509
+ const admin = adminFor(7034);
510
+ await createSubscription(admin.tenantId, {
511
+ tier: "starter",
512
+ providerSubscriptionId: "sub_switch_7034",
513
+ cancelAtIso: "2026-07-01T00:00:00Z",
514
+ });
515
+
516
+ const result = (await stack.http.queryOk(
517
+ "billing-foundation:query:billing-plans",
518
+ {},
519
+ admin,
520
+ )) as { plans: Array<{ tier: string; action: string }> };
521
+ expect(result.plans.find((p) => p.tier === "pro")?.action).toBe("unavailable");
522
+
523
+ const error = await stack.http.writeErr(
524
+ SubscriptionFoundationHandlers.switchPlan,
525
+ { tier: "pro" },
526
+ admin,
527
+ );
528
+ expect(error.httpStatus).toBe(409);
529
+ expect(error.i18nKey).toBe("billing-foundation.errors.cancellationScheduled");
530
+ expect(switchCalls).toHaveLength(0);
531
+ });
532
+
506
533
  test("the provider rejecting the switch because two tiers share one Stripe product surfaces as a 422, not a 500", async () => {
507
534
  const admin = adminFor(7024);
508
535
  await createSubscription(admin.tenantId, {
@@ -1,15 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { UnconfiguredError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  // Aliased — an un-aliased `Temporal` would shadow the ambient global
4
5
  // `Temporal` TYPE `SubscriptionView.lastChangedAt` resolves against.
5
6
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
6
7
  import {
7
8
  assertRedirectOrigins,
9
+ isBillingEnabled,
8
10
  isNonEmptyStringArray,
9
11
  isOwnProviderCustomer,
10
12
  joinBaseUrl,
11
13
  } from "../checkout-core";
12
14
  import type { SubscriptionView } from "../get-subscription-for-tenant";
15
+ import type { SubscriptionProviderPlugin } from "../types";
13
16
 
14
17
  function subscriptionView(overrides: Partial<SubscriptionView> = {}): SubscriptionView {
15
18
  return {
@@ -151,3 +154,35 @@ describe("isOwnProviderCustomer", () => {
151
154
  expect(isOwnProviderCustomer(subscriptionView(), "mock", "cus_own")).toBe(true);
152
155
  });
153
156
  });
157
+
158
+ function fakeCtxWithProvider(
159
+ entries: ReadonlyArray<{ readonly entityName: string; readonly options: unknown }>,
160
+ ): HandlerContext {
161
+ return {
162
+ registry: {
163
+ getExtensionUsages: () => entries,
164
+ },
165
+ } as unknown as HandlerContext; // @cast-boundary test-fixture — only registry is exercised
166
+ }
167
+
168
+ describe("isBillingEnabled", () => {
169
+ test("false when no provider with that name is registered", async () => {
170
+ const ctx = fakeCtxWithProvider([]);
171
+ expect(await isBillingEnabled(ctx, "stripe")).toBe(false);
172
+ });
173
+
174
+ test("false when the registered plugin's own isBillingEnabled reports false", async () => {
175
+ const plugin: SubscriptionProviderPlugin = {
176
+ verifyAndParseWebhook: async () => null,
177
+ isBillingEnabled: async () => false,
178
+ };
179
+ const ctx = fakeCtxWithProvider([{ entityName: "stripe", options: plugin }]);
180
+ expect(await isBillingEnabled(ctx, "stripe")).toBe(false);
181
+ });
182
+
183
+ test("true when the registered plugin has no isBillingEnabled (defaults to enabled)", async () => {
184
+ const plugin: SubscriptionProviderPlugin = { verifyAndParseWebhook: async () => null };
185
+ const ctx = fakeCtxWithProvider([{ entityName: "stripe", options: plugin }]);
186
+ expect(await isBillingEnabled(ctx, "stripe")).toBe(true);
187
+ });
188
+ });
@@ -0,0 +1,469 @@
1
+ // Integration-test for the sync-subscription write-handler (the
2
+ // sync-subscriptions job's backfill target) — real HTTP dispatch through
3
+ // setupTestStack, never createTestDispatcher. Provider-specific
4
+ // retrieveSubscription mapping (Stripe) is covered in subscription-stripe's
5
+ // own plugin-methods.test.ts; this only proves the foundation's own
6
+ // no-live-subscription/provider_cannot_retrieve/not_found/unchanged/synced
7
+ // branching and that a synced drift lands as a real subscription.updated
8
+ // event on the aggregate stream.
9
+
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
12
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
+ import { createSystemUser, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import {
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ testTenantId,
20
+ unsafeCreateEntityTable,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { resetPiiSubjectKmsForTests, waitFor } from "@cosmicdrift/kumiko-framework/testing";
23
+ import {
24
+ createComplianceProfilesFeature,
25
+ tenantComplianceProfileEntity,
26
+ } from "../../compliance-profiles";
27
+ import { createConfigFeature } from "../../config";
28
+ import { TenantHandlers } from "../../tenant/constants";
29
+ import { createTenantFeature } from "../../tenant/feature";
30
+ import { tenantEntity } from "../../tenant/schema/tenant";
31
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
32
+ import { subscriptionAggregateId } from "../aggregate-id";
33
+ import {
34
+ SubscriptionFoundationHandlers,
35
+ SubscriptionFoundationQueries,
36
+ SubscriptionStatuses,
37
+ } from "../constants";
38
+ import { createBillingFoundationFeature } from "../feature";
39
+ import type { ProviderSubscriptionSnapshot, SubscriptionProviderPlugin } from "../types";
40
+
41
+ let retrieveSnapshot: ProviderSubscriptionSnapshot | null = null;
42
+ // Keyed by providerSubscriptionId — the job-path test fans the same
43
+ // dispatch out to multiple tenants concurrently, each needing its own
44
+ // snapshot; the single `retrieveSnapshot` above only serves the
45
+ // sequential single-tenant tests.
46
+ const retrieveSnapshotsById = new Map<string, ProviderSubscriptionSnapshot>();
47
+ const retrieveCalls: string[] = [];
48
+
49
+ const mockSyncProviderFeature = defineFeature("test-mock-sync-provider", (r) => {
50
+ r.requires("billing-foundation");
51
+ const plugin: SubscriptionProviderPlugin = {
52
+ verifyAndParseWebhook: async () => null,
53
+ retrieveSubscription: async (_ctx, providerSubscriptionId) => {
54
+ retrieveCalls.push(providerSubscriptionId);
55
+ return retrieveSnapshotsById.get(providerSubscriptionId) ?? retrieveSnapshot;
56
+ },
57
+ };
58
+ r.useExtension("subscriptionProvider", "mock-sync-provider", plugin);
59
+ });
60
+
61
+ // No `retrieveSubscription` — exercises the "provider_cannot_retrieve" branch.
62
+ const mockNoRetrieveProviderFeature = defineFeature("test-mock-no-retrieve-provider", (r) => {
63
+ r.requires("billing-foundation");
64
+ const plugin: SubscriptionProviderPlugin = { verifyAndParseWebhook: async () => null };
65
+ r.useExtension("subscriptionProvider", "mock-no-retrieve-provider", plugin);
66
+ });
67
+
68
+ let stack: TestStack;
69
+ let db: DbConnection;
70
+
71
+ beforeAll(async () => {
72
+ stack = await setupTestStack({
73
+ features: [
74
+ createConfigFeature(),
75
+ createTenantFeature(),
76
+ createComplianceProfilesFeature(),
77
+ createTenantLifecycleFeature(),
78
+ createBillingFoundationFeature({ baseUrl: "https://example.com" }),
79
+ mockSyncProviderFeature,
80
+ mockNoRetrieveProviderFeature,
81
+ ],
82
+ // Only the job-path describe-block below needs a real worker — every
83
+ // other test dispatches syncSubscription directly. `getActiveTenantIds`
84
+ // is omitted: the tenant feature is mounted, so the framework resolves
85
+ // active tenants via tenant:query:active-tenant-ids itself, same as prod.
86
+ jobs: { consumerLane: "worker" },
87
+ });
88
+ db = stack.db;
89
+ await unsafeCreateEntityTable(db, tenantEntity);
90
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
91
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
92
+ });
93
+
94
+ afterAll(async () => {
95
+ await stack.cleanup();
96
+ resetPiiSubjectKmsForTests();
97
+ });
98
+
99
+ beforeEach(() => {
100
+ retrieveSnapshot = null;
101
+ retrieveSnapshotsById.clear();
102
+ retrieveCalls.length = 0;
103
+ });
104
+
105
+ function adminFor(tenantNumber: number) {
106
+ return createTestUser({
107
+ id: tenantNumber,
108
+ tenantId: testTenantId(tenantNumber),
109
+ roles: ["TenantAdmin", "SystemAdmin"],
110
+ });
111
+ }
112
+
113
+ async function createSubscription(
114
+ tenantId: string,
115
+ overrides: Partial<{
116
+ providerEventId: string;
117
+ providerName: string;
118
+ status: string;
119
+ tier: string;
120
+ providerSubscriptionId: string;
121
+ providerCustomerId: string;
122
+ currentPeriodEndIso: string;
123
+ cancelAtIso: string | null;
124
+ }> = {},
125
+ ) {
126
+ const admin = createTestUser({ id: 0, tenantId, roles: ["TenantAdmin", "SystemAdmin"] });
127
+ return stack.http.writeOk(
128
+ SubscriptionFoundationHandlers.processEvent,
129
+ {
130
+ providerEventId: overrides.providerEventId ?? `evt_${tenantId}_create`,
131
+ providerName: overrides.providerName ?? "mock-sync-provider",
132
+ type: "subscription.created",
133
+ providerCustomerId: overrides.providerCustomerId ?? `cus_${tenantId}`,
134
+ providerSubscriptionId: overrides.providerSubscriptionId ?? `sub_${tenantId}`,
135
+ status: overrides.status ?? SubscriptionStatuses.active,
136
+ tier: overrides.tier ?? "pro",
137
+ currentPeriodEndIso: overrides.currentPeriodEndIso ?? "2026-06-01T00:00:00Z",
138
+ ...(overrides.cancelAtIso !== undefined && { cancelAtIso: overrides.cancelAtIso }),
139
+ rawPayload: '{"raw":"payload"}',
140
+ },
141
+ admin,
142
+ );
143
+ }
144
+
145
+ describe("sync-subscription", () => {
146
+ test("no subscription for the tenant → no_live_subscription, provider never called", async () => {
147
+ const admin = adminFor(6001);
148
+ const result = (await stack.http.writeOk(
149
+ SubscriptionFoundationHandlers.syncSubscription,
150
+ {},
151
+ admin,
152
+ )) as { synced: boolean; reason?: string };
153
+ expect(result).toEqual({ synced: false, reason: "no_live_subscription" });
154
+ expect(retrieveCalls).toHaveLength(0);
155
+ });
156
+
157
+ test("provider drift (new cancel_at) → synced, projection updated, a sync:-prefixed updated event appended; a same-state re-sync then reports unchanged with no new event", async () => {
158
+ const admin = adminFor(6002);
159
+ await createSubscription(admin.tenantId, {
160
+ providerSubscriptionId: "sub_6002",
161
+ providerCustomerId: "cus_6002",
162
+ tier: "pro",
163
+ });
164
+
165
+ retrieveSnapshot = {
166
+ providerCustomerId: "cus_6002",
167
+ providerSubscriptionId: "sub_6002",
168
+ status: SubscriptionStatuses.active,
169
+ tier: "pro",
170
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
171
+ cancelAt: "2026-05-01T00:00:00Z",
172
+ rawPayload: '{"raw":"provider-drift"}',
173
+ };
174
+
175
+ const result = (await stack.http.writeOk(
176
+ SubscriptionFoundationHandlers.syncSubscription,
177
+ {},
178
+ admin,
179
+ )) as { synced: boolean };
180
+ expect(result).toEqual({ synced: true });
181
+ expect(retrieveCalls).toEqual(["sub_6002"]);
182
+
183
+ const subs = (await stack.http.queryOk(
184
+ "billing-foundation:query:subscription:list",
185
+ {},
186
+ admin,
187
+ )) as { rows: Array<Record<string, unknown>> };
188
+ expect(String(subs.rows[0]?.["cancelAt"])).toBe(
189
+ Temporal.Instant.from("2026-05-01T00:00:00Z").toString(),
190
+ );
191
+
192
+ const esEvents = await loadAggregate(
193
+ db,
194
+ subscriptionAggregateId(admin.tenantId),
195
+ admin.tenantId,
196
+ );
197
+ expect(esEvents).toHaveLength(2); // create + sync-update
198
+ expect(esEvents[1]?.type).toBe("billing-foundation:event:subscription-updated");
199
+ expect(esEvents[1]?.metadata.headers?.["providerEventId"]).toMatch(/^sync:/);
200
+
201
+ // Round-trip: dispatching again against the SAME provider state (now
202
+ // read back from the DB, not the seed) must compare as unchanged and
203
+ // append nothing — proves the instant-comparison, not just the initial
204
+ // string-equality-by-luck.
205
+ const secondResult = (await stack.http.writeOk(
206
+ SubscriptionFoundationHandlers.syncSubscription,
207
+ {},
208
+ admin,
209
+ )) as { synced: boolean; reason?: string };
210
+ expect(secondResult).toEqual({ synced: false, reason: "unchanged" });
211
+
212
+ const esEventsAfterSecondSync = await loadAggregate(
213
+ db,
214
+ subscriptionAggregateId(admin.tenantId),
215
+ admin.tenantId,
216
+ );
217
+ expect(esEventsAfterSecondSync).toHaveLength(2);
218
+ });
219
+
220
+ test("cancel → sync → reactivate → sync → cancel-again → sync appends a third update, not a duplicate-hash skip", async () => {
221
+ const admin = adminFor(6007);
222
+ await createSubscription(admin.tenantId, {
223
+ providerSubscriptionId: "sub_6007",
224
+ providerCustomerId: "cus_6007",
225
+ tier: "pro",
226
+ });
227
+
228
+ const cancelSnapshot: ProviderSubscriptionSnapshot = {
229
+ providerCustomerId: "cus_6007",
230
+ providerSubscriptionId: "sub_6007",
231
+ status: SubscriptionStatuses.active,
232
+ tier: "pro",
233
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
234
+ cancelAt: "2026-05-01T00:00:00Z",
235
+ rawPayload: '{"raw":"cancel"}',
236
+ };
237
+ const reactivatedSnapshot: ProviderSubscriptionSnapshot = { ...cancelSnapshot, cancelAt: null };
238
+
239
+ retrieveSnapshot = cancelSnapshot;
240
+ expect(
241
+ (await stack.http.writeOk(SubscriptionFoundationHandlers.syncSubscription, {}, admin)) as {
242
+ synced: boolean;
243
+ },
244
+ ).toEqual({ synced: true });
245
+
246
+ retrieveSnapshot = reactivatedSnapshot;
247
+ expect(
248
+ (await stack.http.writeOk(SubscriptionFoundationHandlers.syncSubscription, {}, admin)) as {
249
+ synced: boolean;
250
+ },
251
+ ).toEqual({ synced: true });
252
+
253
+ retrieveSnapshot = cancelSnapshot; // same content as the FIRST sync's snapshot
254
+ const thirdResult = (await stack.http.writeOk(
255
+ SubscriptionFoundationHandlers.syncSubscription,
256
+ {},
257
+ admin,
258
+ )) as { synced: boolean };
259
+ expect(thirdResult).toEqual({ synced: true });
260
+
261
+ const esEvents = await loadAggregate(
262
+ db,
263
+ subscriptionAggregateId(admin.tenantId),
264
+ admin.tenantId,
265
+ );
266
+ expect(esEvents).toHaveLength(4); // create + 3 syncs, none skipped as a hash-duplicate
267
+ });
268
+
269
+ test("the sync-subscriptions job's own SYSTEM_ROLE-only actor (no SystemAdmin) can dispatch sync-subscription", async () => {
270
+ const tenantId = testTenantId(6008);
271
+ await createSubscription(tenantId, {
272
+ providerSubscriptionId: "sub_6008",
273
+ providerCustomerId: "cus_6008",
274
+ tier: "pro",
275
+ });
276
+
277
+ retrieveSnapshot = {
278
+ providerCustomerId: "cus_6008",
279
+ providerSubscriptionId: "sub_6008",
280
+ status: SubscriptionStatuses.active,
281
+ tier: "pro",
282
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
283
+ cancelAt: "2026-05-01T00:00:00Z",
284
+ rawPayload: '{"raw":"system-actor"}',
285
+ };
286
+
287
+ // Mirrors createSystemUser(tenantId) exactly — no extraRoles — matching
288
+ // what the sync-subscriptions job's JobContext.write actually dispatches
289
+ // as (job-runner.ts), unlike every other test here which authenticates
290
+ // as a SystemAdmin over HTTP.
291
+ const systemUser = createSystemUser(tenantId);
292
+ const result = (await stack.http.writeOk(
293
+ SubscriptionFoundationHandlers.syncSubscription,
294
+ {},
295
+ systemUser,
296
+ )) as { synced: boolean };
297
+ expect(result).toEqual({ synced: true });
298
+ });
299
+
300
+ test("the resolved provider plugin has no retrieveSubscription → provider_cannot_retrieve", async () => {
301
+ const admin = adminFor(6004);
302
+ await createSubscription(admin.tenantId, {
303
+ providerName: "mock-no-retrieve-provider",
304
+ providerSubscriptionId: "sub_6004",
305
+ providerCustomerId: "cus_6004",
306
+ });
307
+
308
+ const result = (await stack.http.writeOk(
309
+ SubscriptionFoundationHandlers.syncSubscription,
310
+ {},
311
+ admin,
312
+ )) as { synced: boolean; reason?: string };
313
+ expect(result).toEqual({ synced: false, reason: "provider_cannot_retrieve" });
314
+ });
315
+
316
+ test("the provider no longer knows the subscription → not_found", async () => {
317
+ const admin = adminFor(6005);
318
+ await createSubscription(admin.tenantId, {
319
+ providerSubscriptionId: "sub_6005",
320
+ providerCustomerId: "cus_6005",
321
+ });
322
+ retrieveSnapshot = null;
323
+
324
+ const result = (await stack.http.writeOk(
325
+ SubscriptionFoundationHandlers.syncSubscription,
326
+ {},
327
+ admin,
328
+ )) as { synced: boolean; reason?: string };
329
+ expect(result).toEqual({ synced: false, reason: "not_found" });
330
+ });
331
+
332
+ test("provider snapshot has gone terminal (canceled) → appends a canceled-type event, not updated", async () => {
333
+ const admin = adminFor(6009);
334
+ await createSubscription(admin.tenantId, {
335
+ providerSubscriptionId: "sub_6009",
336
+ providerCustomerId: "cus_6009",
337
+ tier: "pro",
338
+ });
339
+
340
+ retrieveSnapshot = {
341
+ providerCustomerId: "cus_6009",
342
+ providerSubscriptionId: "sub_6009",
343
+ status: SubscriptionStatuses.canceled,
344
+ tier: "pro",
345
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
346
+ cancelAt: "2026-05-01T00:00:00Z",
347
+ rawPayload: '{"raw":"provider-canceled"}',
348
+ };
349
+
350
+ const result = (await stack.http.writeOk(
351
+ SubscriptionFoundationHandlers.syncSubscription,
352
+ {},
353
+ admin,
354
+ )) as { synced: boolean };
355
+ expect(result).toEqual({ synced: true });
356
+
357
+ const esEvents = await loadAggregate(
358
+ db,
359
+ subscriptionAggregateId(admin.tenantId),
360
+ admin.tenantId,
361
+ );
362
+ expect(esEvents).toHaveLength(2); // create + sync-cancel
363
+ expect(esEvents[1]?.type).toBe("billing-foundation:event:subscription-canceled");
364
+
365
+ const subs = (await stack.http.queryOk(
366
+ SubscriptionFoundationQueries.listSubscriptions,
367
+ {},
368
+ admin,
369
+ )) as { rows: Array<Record<string, unknown>> };
370
+ expect(subs.rows[0]?.["status"]).toBe(SubscriptionStatuses.canceled);
371
+ });
372
+
373
+ test("a terminal (canceled) subscription → no_live_subscription, provider never called", async () => {
374
+ const admin = adminFor(6006);
375
+ await createSubscription(admin.tenantId, {
376
+ providerSubscriptionId: "sub_6006",
377
+ providerCustomerId: "cus_6006",
378
+ status: SubscriptionStatuses.canceled,
379
+ });
380
+
381
+ const result = (await stack.http.writeOk(
382
+ SubscriptionFoundationHandlers.syncSubscription,
383
+ {},
384
+ admin,
385
+ )) as { synced: boolean; reason?: string };
386
+ expect(result).toEqual({ synced: false, reason: "no_live_subscription" });
387
+ expect(retrieveCalls).toHaveLength(0);
388
+ });
389
+ });
390
+
391
+ // --- The actual `sync-subscriptions` job (perTenant fan-out via a real
392
+ // jobRunner/BullMQ worker), not the handler dispatched directly — every test
393
+ // above proves the handler's own branching; this proves the job wrapper
394
+ // resolves active tenants and fans out to each of them independently. ---
395
+ describe("sync-subscriptions job (perTenant fan-out)", () => {
396
+ test("dispatching the job syncs every active tenant's own live subscription independently", async () => {
397
+ if (!stack.jobRunner) {
398
+ throw new Error("stack.jobRunner not wired — setupTestStack's `jobs` option is required");
399
+ }
400
+ const admin1 = adminFor(6020);
401
+ const admin2 = adminFor(6021);
402
+ // active-tenant-ids (the framework's own perTenant fan-out source, since
403
+ // this stack mounts the tenant feature) only returns rows that actually
404
+ // exist in tenantTable — adminFor()'s tenantId alone is not enough.
405
+ await stack.http.writeOk(
406
+ TenantHandlers.create,
407
+ { id: admin1.tenantId, key: `t-${admin1.tenantId}`, name: "Job-Fanout Tenant A" },
408
+ admin1,
409
+ );
410
+ await stack.http.writeOk(
411
+ TenantHandlers.create,
412
+ { id: admin2.tenantId, key: `t-${admin2.tenantId}`, name: "Job-Fanout Tenant B" },
413
+ admin2,
414
+ );
415
+
416
+ await createSubscription(admin1.tenantId, {
417
+ providerSubscriptionId: "sub_6020",
418
+ providerCustomerId: "cus_6020",
419
+ tier: "pro",
420
+ });
421
+ await createSubscription(admin2.tenantId, {
422
+ providerSubscriptionId: "sub_6021",
423
+ providerCustomerId: "cus_6021",
424
+ tier: "pro",
425
+ });
426
+
427
+ retrieveSnapshotsById.set("sub_6020", {
428
+ providerCustomerId: "cus_6020",
429
+ providerSubscriptionId: "sub_6020",
430
+ status: SubscriptionStatuses.active,
431
+ tier: "pro",
432
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
433
+ cancelAt: "2026-05-01T00:00:00Z",
434
+ rawPayload: '{"raw":"job-fanout-a"}',
435
+ });
436
+ retrieveSnapshotsById.set("sub_6021", {
437
+ providerCustomerId: "cus_6021",
438
+ providerSubscriptionId: "sub_6021",
439
+ status: SubscriptionStatuses.active,
440
+ tier: "pro",
441
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
442
+ cancelAt: "2026-04-15T00:00:00Z",
443
+ rawPayload: '{"raw":"job-fanout-b"}',
444
+ });
445
+
446
+ await stack.jobRunner.dispatch("billing-foundation:job:sync-subscriptions", {});
447
+
448
+ async function cancelAtFor(admin: ReturnType<typeof adminFor>): Promise<unknown> {
449
+ const subs = (await stack.http.queryOk(
450
+ SubscriptionFoundationQueries.listSubscriptions,
451
+ {},
452
+ admin,
453
+ )) as { rows: Array<Record<string, unknown>> };
454
+ return subs.rows[0]?.["cancelAt"];
455
+ }
456
+
457
+ await waitFor(async () => {
458
+ expect(await cancelAtFor(admin1)).not.toBeNull();
459
+ expect(await cancelAtFor(admin2)).not.toBeNull();
460
+ });
461
+
462
+ expect(await cancelAtFor(admin1)).toBe(
463
+ Temporal.Instant.from("2026-05-01T00:00:00Z").toString(),
464
+ );
465
+ expect(await cancelAtFor(admin2)).toBe(
466
+ Temporal.Instant.from("2026-04-15T00:00:00Z").toString(),
467
+ );
468
+ });
469
+ });
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "breaking",
5
+ "title": "switch-plan rejects a subscription with a scheduled cancellation",
6
+ "detail": "`billing-foundation:write:switch-plan` now throws a `409 ConflictError`\n(`billing-foundation.errors.cancellationScheduled`) when the tenant's\nsubscription already has `cancelAt` set — switching plans mid-cancellation\npreviously silently proceeded and could leave the new plan itself\nscheduled to cancel. `billing-foundation:query:billing-plans` now resolves every\nnon-current plan's `action` to `unavailable` (instead of `switch`) while a\ncancellation is scheduled, and the billing-plans panel shows a\n`switchRequiresReactivation` message alongside the existing\n`cancelScheduled` banner.",
7
+ "migration": "A tenant that switches plans while their subscription is scheduled to\ncancel now gets a 409 instead of a successful switch. Callers driving\n`switch-plan` directly (not through the bundled panel) must reactivate the\nsubscription first (`create-portal-session` / the provider's own\nreactivation flow) before retrying the switch."
8
+ },
9
+ {
10
+ "version": "0.321.0",
11
+ "type": "improvement",
12
+ "title": "past_due banner on the billing-plans panel; sync-subscription backfill for provider-side drift",
13
+ "detail": "`billing-plans-panel` renders a `past_due`-status warning banner\n(`billing-foundation.plans.pastDue`) alongside the existing\npayment-pending/cancel-scheduled ones. `SubscriptionProviderPlugin` gained\nan optional `retrieveSubscription(ctx, providerSubscriptionId)` method\nreturning a `ProviderSubscriptionSnapshot`; the new\n`billing-foundation:write:sync-subscription` handler (`agent.expose:\nfalse`, `SYSTEM_ROLE`/`SystemAdmin`-only — the `sync-subscriptions` job's\nown systemUser only carries `SYSTEM_ROLE`) compares the live snapshot\nagainst `read_subscriptions` and appends a `subscription.updated` (or\n`subscription.canceled`, when the snapshot's own status is terminal)\nevent with a deterministic `sync:<sha256>` providerEventId when it has\ndrifted, a no-op otherwise. The `sync-subscriptions` job (manual-trigger +\nrunOnBoot, perTenant) dispatches it and is registered unconditionally.\n`isBillingEnabled(ctx, providerName)` returns `false` instead of throwing\nwhen `providerName` isn't registered.",
14
+ "migration": "No action needed — every part is additive. Apps on `subscription-stripe`\nautomatically get `retrieveSubscription` wired; a custom provider plugin\nwithout one makes `sync-subscription` report\n`{ synced: false, reason: \"provider_cannot_retrieve\" }` instead of syncing.\nOn the next deploy, `sync-subscriptions`' `runOnBoot` fires once per Redis\ndataset (not once per replica) with one provider API call per tenant that\nhas a live subscription. If that run is interrupted (deploy killed\nmid-fan-out, Redis restart), it is not re-run automatically on the next\nboot — trigger it manually via `billing-foundation:job:sync-subscriptions`\n(`jobs:write:trigger`) to catch up."
15
+ },
2
16
  {
3
17
  "version": "0.319.0",
4
18
  "type": "improvement",
@@ -141,15 +141,19 @@ export async function assertBillingEnabled(
141
141
  }
142
142
  }
143
143
 
144
- /** Public readiness-probe for a named provider — `resolveProviderPlugin` +
144
+ /** Public readiness-probe for a named provider — `findProviderPlugin` +
145
145
  * `isPluginBillingEnabled`, re-exported from index.ts so an app-owner can
146
- * check billing readiness without reaching into checkout-core internals. */
146
+ * check billing readiness without reaching into checkout-core internals.
147
+ * An unregistered provider is a "not live" state, not a config error here
148
+ * (unlike `resolveProviderPlugin`'s throw) — a readiness probe should
149
+ * answer false, not throw, for a provider that simply isn't mounted yet. */
147
150
  export async function isBillingEnabled(
148
151
  ctx: HandlerContext,
149
152
  providerName: string,
150
153
  ): Promise<boolean> {
151
- const { plugin } = resolveProviderPlugin(ctx, providerName);
152
- return isPluginBillingEnabled(ctx, plugin);
154
+ const found = findProviderPlugin(ctx, providerName);
155
+ if (!found) return false;
156
+ return isPluginBillingEnabled(ctx, found.plugin);
153
157
  }
154
158
 
155
159
  /** `create-portal-session`'s returnUrl, built server-side (the client no
@@ -44,6 +44,12 @@ export const SubscriptionFoundationHandlers = {
44
44
  * subscription to another plan tier via the provider's confirmation
45
45
  * page. Only registered when a `catalog` is configured. */
46
46
  switchPlan: "billing-foundation:write:switch-plan",
47
+ /** Backfill entry-point for the `sync-subscriptions` job (and any
48
+ * SystemAdmin that wants an on-demand reconciliation). Pulls the live
49
+ * provider-side state via the plugin's `retrieveSubscription` and
50
+ * appends drift as a `subscription.updated` event. `agent.expose: false`
51
+ * — programmatic only. */
52
+ syncSubscription: "billing-foundation:write:sync-subscription",
47
53
  } as const;
48
54
 
49
55
  // Qualified query handler names.