@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.
- package/package.json +9 -9
- 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/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-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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
172
|
-
"@cosmicdrift/kumiko-locale-es": "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 — `
|
|
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.
|