@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.322.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 (50) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
  4. package/src/agent-tools/agent-manifest.ts +13 -3
  5. package/src/agent-tools/tool-dispatch.ts +17 -12
  6. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  7. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  8. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  9. package/src/billing-foundation/changes.json +14 -0
  10. package/src/billing-foundation/checkout-core.ts +8 -4
  11. package/src/billing-foundation/constants.ts +6 -0
  12. package/src/billing-foundation/feature.ts +37 -2
  13. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  14. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  15. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  16. package/src/billing-foundation/i18n.ts +9 -0
  17. package/src/billing-foundation/index.ts +1 -0
  18. package/src/billing-foundation/plan-catalog.ts +22 -5
  19. package/src/billing-foundation/types.ts +27 -0
  20. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  21. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  22. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  23. package/src/channel-email/changes.json +9 -1
  24. package/src/channel-email/email-channel.ts +31 -6
  25. package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
  26. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  27. package/src/delivery/changes.json +7 -0
  28. package/src/delivery/feature.ts +1 -1
  29. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  30. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  31. package/src/delivery/index.ts +2 -1
  32. package/src/delivery/public-names.ts +1 -1
  33. package/src/delivery/unsubscribe.ts +167 -89
  34. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  35. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
  36. package/src/step-dispatcher/changes.json +7 -0
  37. package/src/step-dispatcher/feature.ts +48 -15
  38. package/src/step-dispatcher/index.ts +3 -1
  39. package/src/step-dispatcher/webhook-runner.ts +59 -19
  40. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  41. package/src/subscription-stripe/feature.ts +5 -1
  42. package/src/subscription-stripe/plugin-methods.ts +53 -0
  43. package/src/subscription-stripe/verify-webhook.ts +52 -28
  44. package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
  45. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  46. package/src/tenant-handover/changes.json +7 -0
  47. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  48. package/src/tenant-handover/move-entity-graph.ts +30 -44
  49. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
  50. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
@@ -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.
@@ -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 },