@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.
- package/package.json +9 -9
- package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
- package/src/agent-tools/agent-manifest.ts +13 -3
- package/src/agent-tools/tool-dispatch.ts +17 -12
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
- package/src/step-dispatcher/changes.json +7 -0
- package/src/step-dispatcher/feature.ts +48 -15
- package/src/step-dispatcher/index.ts +3 -1
- package/src/step-dispatcher/webhook-runner.ts +59 -19
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
- 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 — `
|
|
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
|
|
152
|
-
|
|
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 {
|
|
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 },
|