@cosmicdrift/kumiko-bundled-features 0.297.0 → 0.304.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 (40) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth.integration.test.ts +2 -2
  3. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +5 -5
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +2 -2
  5. package/src/auth-email-password/__tests__/login-anonymous-identity-rate-limit.integration.test.ts +124 -0
  6. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +6 -6
  7. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +28 -0
  8. package/src/auth-email-password/changes.json +6 -0
  9. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +2 -1
  10. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  11. package/src/auth-email-password/handlers/invite-accept.write.ts +3 -3
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +6 -4
  14. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  16. package/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  18. package/src/auth-email-password/handlers/verify-email.write.ts +2 -1
  19. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +6 -9
  20. package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -6
  21. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +3 -2
  22. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +4 -2
  23. package/src/auth-mfa/handlers/verify.write.ts +3 -2
  24. package/src/auth-mfa/mfa-status-checker.ts +2 -2
  25. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +11 -37
  26. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
  27. package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
  28. package/src/billing-foundation/changes.json +12 -0
  29. package/src/billing-foundation/feature.ts +3 -3
  30. package/src/billing-foundation/handlers/process-event.write.ts +1 -1
  31. package/src/billing-foundation/index.ts +2 -4
  32. package/src/billing-foundation/subscription-tier-sync.ts +110 -83
  33. package/src/billing-foundation/webhook-handler.ts +117 -187
  34. package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
  35. package/src/inbound-mail-foundation/changes.json +6 -0
  36. package/src/inbound-mail-foundation/connect-routes.ts +218 -197
  37. package/src/inbound-mail-foundation/index.ts +1 -2
  38. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
  39. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
  40. package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
@@ -8,8 +8,8 @@
8
8
  // beweist die Foundation-eigene Verdrahtung (atomic insert + upsert,
9
9
  // Idempotency via deterministic aggregate-ids, tenant-isolation).
10
10
  //
11
- // Webhook-Handler-Factory (createSubscriptionWebhookHandler) wird in
12
- // einem separaten Test mit Hono-mock geprüft.
11
+ // Webhook-route (createSubscriptionWebhookRoute) gets its own dedicated
12
+ // coverage in webhook-handler.integration.test.ts.
13
13
 
14
14
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
15
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -22,7 +22,7 @@ import {
22
22
  PII_ERASED_SENTINEL,
23
23
  } from "@cosmicdrift/kumiko-framework/crypto";
24
24
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
- import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
27
27
  import {
28
28
  createTestUser,
@@ -32,7 +32,6 @@ import {
32
32
  unsafeCreateEntityTable,
33
33
  } from "@cosmicdrift/kumiko-framework/stack";
34
34
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
35
- import { Hono } from "hono";
36
35
  import {
37
36
  ComplianceProfileHandlers,
38
37
  createComplianceProfilesFeature,
@@ -53,7 +52,7 @@ import { billingFoundationFeature } from "../feature";
53
52
  import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
54
53
  import { subscriptionTenantDestroyHook } from "../tenant-destroy-hook";
55
54
  import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
56
- import { createSubscriptionWebhookHandler } from "../webhook-handler";
55
+ import { createSubscriptionWebhookRoute } from "../webhook-handler";
57
56
 
58
57
  // =============================================================================
59
58
  // Mock-plugin für create-checkout-session + create-portal-session-Tests.
@@ -126,7 +125,6 @@ const mockPaymentProviderFeature = defineFeature("test-mock-payment-provider", (
126
125
 
127
126
  let stack: TestStack;
128
127
  let db: DbConnection;
129
- let paymentWebhookApp: Hono;
130
128
 
131
129
  beforeAll(async () => {
132
130
  stack = await setupTestStack({
@@ -139,6 +137,10 @@ beforeAll(async () => {
139
137
  mockProviderFeature,
140
138
  mockPaymentProviderFeature,
141
139
  ],
140
+ // Scenario 11 exercises the real payment-branch of the webhook route
141
+ // (webhook-handler.ts) mounted through the same stack — no hand-rolled
142
+ // Hono app, real HTTP via stack.app.
143
+ extraRoutes: [createSubscriptionWebhookRoute()],
142
144
  });
143
145
  db = stack.db;
144
146
  // subscriptionsProjectionTable wird von setupTestStack automatisch
@@ -150,34 +152,6 @@ beforeAll(async () => {
150
152
  // see feature.ts), so process-event.write.ts calls configuredPiiSubjectKms()
151
153
  // directly and needs one configured, same as run{Prod,Dev}App do at boot.
152
154
  configurePiiSubjectKms(new InMemoryKmsAdapter());
153
-
154
- // Webhook-app for scenario 11 — same mountWebhook shape as
155
- // stripe-foundation.integration.test.ts, exercising the real
156
- // createSubscriptionWebhookHandler payment-branch (webhook-handler.ts).
157
- paymentWebhookApp = new Hono();
158
- paymentWebhookApp.post(
159
- "/api/subscription/webhook/:providerName",
160
- createSubscriptionWebhookHandler({
161
- dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
162
- const systemUser = createTestUser({
163
- id: 1,
164
- tenantId: tenantId as TenantId,
165
- roles: ["SystemAdmin"],
166
- });
167
- const res = await stack.http.write(handlerQn, payload, systemUser);
168
- const body = await res.json();
169
- return body.isSuccess
170
- ? { isSuccess: true, data: body.data }
171
- : { isSuccess: false, error: body.error };
172
- },
173
- resolveProvider: (providerName) => {
174
- const usage = stack.registry
175
- .getExtensionUsages("subscriptionProvider")
176
- .find((u) => u.entityName === providerName);
177
- return usage?.options as SubscriptionProviderPlugin | undefined;
178
- },
179
- }),
180
- );
181
155
  });
182
156
 
183
157
  afterAll(async () => {
@@ -993,7 +967,7 @@ describe("scenario 11: one-off payment — own aggregate, own read_payments-row"
993
967
  expect(rowsB).toHaveLength(1);
994
968
  });
995
969
 
996
- test("webhook-handler payment-branch (createSubscriptionWebhookHandler): POST → row, retry POST → duplicate:true, still one row", async () => {
970
+ test("webhook-route payment-branch (createSubscriptionWebhookRoute): POST → row, retry POST → duplicate:true, still one row", async () => {
997
971
  const tenantId = testTenantId(4004);
998
972
  const body = JSON.stringify({
999
973
  providerEventId: "evt_webhook_payment_001",
@@ -1002,7 +976,7 @@ describe("scenario 11: one-off payment — own aggregate, own read_payments-row"
1002
976
  priceId: "price_webhook_test",
1003
977
  });
1004
978
 
1005
- const first = await paymentWebhookApp.request("/api/subscription/webhook/mock-payment", {
979
+ const first = await stack.app.request("/api/subscription/webhook/mock-payment", {
1006
980
  method: "POST",
1007
981
  body,
1008
982
  });
@@ -1011,7 +985,7 @@ describe("scenario 11: one-off payment — own aggregate, own read_payments-row"
1011
985
  expect(firstBody["processed"]).toBe(true);
1012
986
  expect(firstBody["duplicate"]).toBe(false);
1013
987
 
1014
- const second = await paymentWebhookApp.request("/api/subscription/webhook/mock-payment", {
988
+ const second = await stack.app.request("/api/subscription/webhook/mock-payment", {
1015
989
  method: "POST",
1016
990
  body,
1017
991
  });
@@ -1,28 +1,28 @@
1
- // Integration test for createSubscriptionTierSync()'s onSyncError option
2
- // (#3027). Forces a sync failure via a dispatchSystemWrite stub that fails
3
- // only the tier-engine handlers (the primary billing-foundation writes run
4
- // for real against the test DB) — proving that "log" (default) still
5
- // answers the webhook successfully, while "fail-webhook" surfaces the sync
6
- // error all the way into the HTTP response.
1
+ // Integration test for createSubscriptionTierSync() (kumiko-framework
2
+ // #3050 rewrite). No stubbed dispatchSystemWrite anymore — the sync reads
3
+ // and writes through the real dispatcher via the extraRoute's
4
+ // dispatchSystemQuery/dispatchSystemWrite, exercised with real HTTP.
5
+ //
6
+ // `createWebhookRoute()` has a fixed path (SUBSCRIPTION_WEBHOOK_PATH), so
7
+ // the two onSyncError modes ("log" vs "fail-webhook") each need their own
8
+ // stack — you can't mount the same path twice on one Hono app.
9
+ //
10
+ // The forced sync failure is a REAL one: a stack that never mounts
11
+ // tier-engine, so `TierEngineQueries.list` resolves to no handler and
12
+ // dispatchSystemQuery throws — exactly what a consumer forgetting to mount
13
+ // tier-engine would hit in production.
7
14
 
8
15
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
16
  import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
10
- import { buildEntityTable, type EntityTable } from "@cosmicdrift/kumiko-framework/db";
11
- import {
12
- defineFeature,
13
- type EntityDefinition,
14
- type TenantId,
15
- } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
18
  import {
17
19
  createTestUser,
18
20
  setupTestStack,
19
21
  type TestStack,
20
22
  testTenantId,
21
23
  unsafeCreateEntityTable,
22
- unsafePushTables,
23
24
  } from "@cosmicdrift/kumiko-framework/stack";
24
25
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
25
- import { Hono } from "hono";
26
26
  import {
27
27
  createComplianceProfilesFeature,
28
28
  tenantComplianceProfileEntity,
@@ -32,16 +32,12 @@ import { createTenantFeature } from "../../tenant/feature";
32
32
  import { tenantEntity } from "../../tenant/schema/tenant";
33
33
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
34
34
  import type { TierMap } from "../../tier-engine/compose-app";
35
- import { TierEngineHandlers } from "../../tier-engine/constants";
35
+ import { TierEngineQueries } from "../../tier-engine/constants";
36
36
  import { tierAssignmentEntity } from "../../tier-engine/entity";
37
37
  import { createTierEngineFeature } from "../../tier-engine/feature";
38
38
  import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
39
39
  import { billingFoundationFeature } from "../feature";
40
- import {
41
- createSubscriptionTierSync,
42
- SUBSCRIPTION_WEBHOOK_PATH,
43
- type SystemWriteResult,
44
- } from "../subscription-tier-sync";
40
+ import { createSubscriptionTierSync } from "../subscription-tier-sync";
45
41
  import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
46
42
 
47
43
  type TestTier = "free" | "pro";
@@ -51,98 +47,42 @@ const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
51
47
  pro: { features: [], caps: { maxItems: 100 } },
52
48
  };
53
49
 
54
- const mockProviderFeature = defineFeature("test-mock-tier-sync-provider", (r) => {
55
- r.requires("billing-foundation");
56
- const plugin: SubscriptionProviderPlugin = {
57
- verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
58
- };
59
- r.useExtension("subscriptionProvider", "mock", plugin);
60
- });
61
-
62
- // Boundary-cast: SubscriptionTierSyncDeps deliberately erases the entity's
63
- // concrete field types to EntityDefinition (generic factory), which TS
64
- // can't structurally widen to on its own.
65
- const tierAssignmentTable = buildEntityTable(
66
- "tier-assignment",
67
- tierAssignmentEntity,
68
- ) as unknown as EntityTable<EntityDefinition>;
69
-
70
- let stack: TestStack;
71
- let forceTierSyncFailure = false;
72
-
73
- async function dispatchSystemWrite(args: {
74
- readonly handlerQn: string;
75
- readonly payload: unknown;
76
- readonly tenantId: TenantId;
77
- }): Promise<SystemWriteResult> {
78
- if (
79
- forceTierSyncFailure &&
80
- (args.handlerQn === TierEngineHandlers.create || args.handlerQn === TierEngineHandlers.update)
81
- ) {
82
- return {
83
- isSuccess: false,
84
- error: { code: "test_forced_failure", message: "forced tier-sync failure" },
50
+ function mockProviderFeature() {
51
+ return defineFeature("test-mock-tier-sync-provider", (r) => {
52
+ r.requires("billing-foundation");
53
+ const plugin: SubscriptionProviderPlugin = {
54
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
85
55
  };
86
- }
87
- const systemUser = createTestUser({ id: 1, tenantId: args.tenantId, roles: ["SystemAdmin"] });
88
- const res = await stack.http.write(args.handlerQn, args.payload, systemUser);
89
- const body = (await res.json()) as {
90
- isSuccess: boolean;
91
- data?: unknown;
92
- error?: { readonly code?: string; readonly message?: string };
93
- };
94
- return body.isSuccess
95
- ? { isSuccess: true, data: body.data }
96
- : { isSuccess: false, error: body.error };
56
+ r.useExtension("subscriptionProvider", "mock", plugin);
57
+ });
97
58
  }
98
59
 
99
- let logApp: Hono;
100
- let failApp: Hono;
101
-
102
- beforeAll(async () => {
103
- stack = await setupTestStack({
104
- features: [
105
- createConfigFeature(),
106
- createTenantFeature(),
107
- createComplianceProfilesFeature(),
108
- createTenantLifecycleFeature(),
109
- billingFoundationFeature,
110
- createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
111
- mockProviderFeature,
112
- ],
113
- });
60
+ const sharedBaseFeatures = () => [
61
+ createConfigFeature(),
62
+ createTenantFeature(),
63
+ createComplianceProfilesFeature(),
64
+ createTenantLifecycleFeature(),
65
+ billingFoundationFeature,
66
+ mockProviderFeature(),
67
+ ];
68
+
69
+ async function bootStack(
70
+ features: ReturnType<typeof sharedBaseFeatures>,
71
+ extraRoutes: NonNullable<Parameters<typeof setupTestStack>[0]["extraRoutes"]>,
72
+ ): Promise<TestStack> {
73
+ const stack = await setupTestStack({ features, extraRoutes });
114
74
  await unsafeCreateEntityTable(stack.db, tenantEntity);
115
75
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
116
- await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
76
+ await unsafeCreateEntityTable(stack.db, tierAssignmentEntity);
117
77
  configurePiiSubjectKms(new InMemoryKmsAdapter());
78
+ return stack;
79
+ }
118
80
 
119
- const sharedDeps = {
120
- db: stack.db,
121
- registry: stack.registry,
122
- dispatchSystemWrite,
123
- tierAssignmentTable,
124
- isTierName,
125
- defaultTier: "free" as const,
126
- };
127
-
128
- logApp = new Hono();
129
- createSubscriptionTierSync<TestTier>(sharedDeps).wireSubscriptionWebhookRoute(logApp);
130
-
131
- failApp = new Hono();
132
- createSubscriptionTierSync<TestTier>({
133
- ...sharedDeps,
134
- onSyncError: "fail-webhook",
135
- }).wireSubscriptionWebhookRoute(failApp);
136
- });
137
-
138
- afterAll(async () => {
139
- await stack.cleanup();
140
- resetPiiSubjectKmsForTests();
141
- });
142
-
143
- const webhookPath = SUBSCRIPTION_WEBHOOK_PATH.replace(":providerName", "mock");
144
-
145
- function buildEvent(tenantId: string, providerEventId: string): SubscriptionEvent {
81
+ function buildEvent(
82
+ tenantId: string,
83
+ providerEventId: string,
84
+ overrides: Partial<SubscriptionEvent> = {},
85
+ ): SubscriptionEvent {
146
86
  return {
147
87
  providerEventId,
148
88
  providerName: "mock",
@@ -154,33 +94,133 @@ function buildEvent(tenantId: string, providerEventId: string): SubscriptionEven
154
94
  tier: "pro",
155
95
  currentPeriodEnd: "2026-06-01T00:00:00Z",
156
96
  rawPayload: "{}",
97
+ ...overrides,
157
98
  };
158
99
  }
159
100
 
101
+ async function postWebhook(stack: TestStack, path: string, event: SubscriptionEvent) {
102
+ return stack.app.request(path.replace(":providerName", "mock"), {
103
+ method: "POST",
104
+ body: JSON.stringify(event),
105
+ });
106
+ }
107
+
108
+ // =============================================================================
109
+ // onSyncError — tier-engine deliberately NOT mounted, so the sync's
110
+ // dispatchSystemQuery(TierEngineQueries.list) throws a real NotFoundError.
111
+ // =============================================================================
112
+
160
113
  describe("createSubscriptionTierSync onSyncError", () => {
161
- test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
162
- forceTierSyncFailure = true;
163
- const tenantId = testTenantId(9001);
164
- const res = await logApp.request(webhookPath, {
165
- method: "POST",
166
- body: JSON.stringify(buildEvent(tenantId, "evt_log_1")),
114
+ let logStack: TestStack;
115
+ let failStack: TestStack;
116
+ let logPath: string;
117
+ let failPath: string;
118
+
119
+ beforeAll(async () => {
120
+ const logSync = createSubscriptionTierSync<TestTier>({ isTierName, defaultTier: "free" });
121
+ const logRoute = logSync.createWebhookRoute();
122
+ logStack = await bootStack(sharedBaseFeatures(), [logRoute]);
123
+ logPath = logRoute.path;
124
+
125
+ const failSync = createSubscriptionTierSync<TestTier>({
126
+ isTierName,
127
+ defaultTier: "free",
128
+ onSyncError: "fail-webhook",
167
129
  });
168
- forceTierSyncFailure = false;
130
+ const failRoute = failSync.createWebhookRoute();
131
+ failStack = await bootStack(sharedBaseFeatures(), [failRoute]);
132
+ failPath = failRoute.path;
133
+ });
134
+
135
+ afterAll(async () => {
136
+ await logStack.cleanup();
137
+ await failStack.cleanup();
138
+ resetPiiSubjectKmsForTests();
139
+ });
169
140
 
141
+ test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
142
+ const tenantId = testTenantId(9001);
143
+ const res = await postWebhook(logStack, logPath, buildEvent(tenantId, "evt_log_1"));
170
144
  expect(res.status).toBe(200);
171
145
  });
172
146
 
173
147
  test("'fail-webhook': a tier-sync failure also fails the webhook response", async () => {
174
- forceTierSyncFailure = true;
175
148
  const tenantId = testTenantId(9002);
176
- const res = await failApp.request(webhookPath, {
177
- method: "POST",
178
- body: JSON.stringify(buildEvent(tenantId, "evt_fail_1")),
179
- });
180
- forceTierSyncFailure = false;
181
-
149
+ const res = await postWebhook(failStack, failPath, buildEvent(tenantId, "evt_fail_1"));
182
150
  expect(res.status).toBe(500);
183
151
  const body = (await res.json()) as { error: { code: string } };
184
152
  expect(body.error.code).toBe("subscription_webhook_processing_failed");
185
153
  });
186
154
  });
155
+
156
+ // =============================================================================
157
+ // Happy path — tier-engine mounted, tier-sync effect visible through
158
+ // TierEngineQueries.list.
159
+ // =============================================================================
160
+
161
+ describe("createSubscriptionTierSync — tier-sync effect", () => {
162
+ let stack: TestStack;
163
+ let webhookPath: string;
164
+
165
+ beforeAll(async () => {
166
+ const sync = createSubscriptionTierSync<TestTier>({ isTierName, defaultTier: "free" });
167
+ const route = sync.createWebhookRoute();
168
+ webhookPath = route.path;
169
+ stack = await bootStack(
170
+ [
171
+ ...sharedBaseFeatures(),
172
+ createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
173
+ ],
174
+ [route],
175
+ );
176
+ });
177
+
178
+ afterAll(async () => {
179
+ await stack.cleanup();
180
+ resetPiiSubjectKmsForTests();
181
+ });
182
+
183
+ test("active subscription → tier-assignment created with the subscription's tier", async () => {
184
+ const tenantId = testTenantId(9101);
185
+ const res = await postWebhook(
186
+ stack,
187
+ webhookPath,
188
+ buildEvent(tenantId, "evt_create_1", { status: SubscriptionStatuses.active, tier: "pro" }),
189
+ );
190
+ expect(res.status).toBe(200);
191
+
192
+ const admin = createTestUser({ id: 9101, tenantId, roles: ["SystemAdmin"] });
193
+ const listed = (await stack.http.queryOk(TierEngineQueries.list, {}, admin)) as {
194
+ rows: Array<Record<string, unknown>>;
195
+ };
196
+ expect(listed.rows).toHaveLength(1);
197
+ expect(listed.rows[0]?.["tier"]).toBe("pro");
198
+ });
199
+
200
+ test("subsequent canceled event → tier-assignment updated to the default tier", async () => {
201
+ const tenantId = testTenantId(9102);
202
+ await postWebhook(
203
+ stack,
204
+ webhookPath,
205
+ buildEvent(tenantId, "evt_create_2", { status: SubscriptionStatuses.active, tier: "pro" }),
206
+ );
207
+
208
+ const res = await postWebhook(
209
+ stack,
210
+ webhookPath,
211
+ buildEvent(tenantId, "evt_cancel_2", {
212
+ type: SubscriptionEventTypes.canceled,
213
+ status: SubscriptionStatuses.canceled,
214
+ tier: "pro",
215
+ }),
216
+ );
217
+ expect(res.status).toBe(200);
218
+
219
+ const admin = createTestUser({ id: 9102, tenantId, roles: ["SystemAdmin"] });
220
+ const listed = (await stack.http.queryOk(TierEngineQueries.list, {}, admin)) as {
221
+ rows: Array<Record<string, unknown>>;
222
+ };
223
+ expect(listed.rows).toHaveLength(1);
224
+ expect(listed.rows[0]?.["tier"]).toBe("free");
225
+ });
226
+ });
@@ -0,0 +1,174 @@
1
+ // Integration-tests for createSubscriptionWebhookRoute (kumiko-framework
2
+ // #3050). No injected dispatchWrite/resolveProvider stubs anymore — the
3
+ // route is a real `entry:"signature"` extraRoute mounted through
4
+ // setupTestStack, driven with real HTTP against the real dispatcher +
5
+ // registry (never createTestDispatcher, per project rule).
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ setupTestStack,
11
+ type TestStack,
12
+ testTenantId,
13
+ unsafeCreateEntityTable,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import {
16
+ createComplianceProfilesFeature,
17
+ tenantComplianceProfileEntity,
18
+ } from "../../compliance-profiles";
19
+ import { createConfigFeature } from "../../config";
20
+ import { createTenantFeature } from "../../tenant/feature";
21
+ import { tenantEntity } from "../../tenant/schema/tenant";
22
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
23
+ import {
24
+ SubscriptionEventTypes,
25
+ type SubscriptionStatus,
26
+ SubscriptionStatuses,
27
+ } from "../constants";
28
+ import { billingFoundationFeature } from "../feature";
29
+ import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
30
+ import { createSubscriptionWebhookRoute } from "../webhook-handler";
31
+
32
+ const stripeLikePlugin: SubscriptionProviderPlugin = {
33
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent | null,
34
+ };
35
+ const paypalLikePlugin: SubscriptionProviderPlugin = {
36
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent | null,
37
+ };
38
+
39
+ // Test-mock-provider whose `verifyAndParseWebhook` is swapped per test via
40
+ // `resolvedPlugin` — lets each scenario control verify()'s throw/return
41
+ // behavior without touching the wired extraRoute.
42
+ const mockProviderFeature = defineFeature("test-mock-webhook-provider", (r) => {
43
+ r.requires("billing-foundation");
44
+ r.useExtension("subscriptionProvider", "stripe", stripeLikePlugin);
45
+ r.useExtension("subscriptionProvider", "paypal", paypalLikePlugin);
46
+ });
47
+
48
+ let stack: TestStack;
49
+
50
+ beforeAll(async () => {
51
+ stack = await setupTestStack({
52
+ features: [
53
+ createConfigFeature(),
54
+ createTenantFeature(),
55
+ createComplianceProfilesFeature(),
56
+ createTenantLifecycleFeature(),
57
+ billingFoundationFeature,
58
+ mockProviderFeature,
59
+ ],
60
+ extraRoutes: [createSubscriptionWebhookRoute()],
61
+ });
62
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
63
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
64
+ });
65
+
66
+ afterAll(async () => {
67
+ await stack.cleanup();
68
+ });
69
+
70
+ function buildEvent(overrides: Partial<SubscriptionEvent> = {}): SubscriptionEvent {
71
+ return {
72
+ providerEventId: "evt_test_001",
73
+ providerName: "stripe",
74
+ type: SubscriptionEventTypes.created,
75
+ tenantId: testTenantId(5001),
76
+ providerCustomerId: "cus_test",
77
+ providerSubscriptionId: "sub_test",
78
+ status: SubscriptionStatuses.active,
79
+ tier: "pro",
80
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
81
+ rawPayload: '{"raw":"payload"}',
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ async function postWebhook(providerName: string, body: unknown) {
87
+ return stack.app.request(`/api/subscription/webhook/${providerName}`, {
88
+ method: "POST",
89
+ body: JSON.stringify(body),
90
+ headers: { "stripe-signature": "test_sig" },
91
+ });
92
+ }
93
+
94
+ describe("webhook-handler — happy path", () => {
95
+ test("verifyAndParseWebhook → SubscriptionEvent → process-event write → 200 processed", async () => {
96
+ const res = await postWebhook("stripe", buildEvent({ tenantId: testTenantId(5002) }));
97
+ expect(res.status).toBe(200);
98
+ const body = (await res.json()) as { processed: boolean; duplicate: boolean };
99
+ expect(body.processed).toBe(true);
100
+ expect(body.duplicate).toBe(false);
101
+ });
102
+
103
+ test("plugin returns null (unknown event-type) → 200 ignored", async () => {
104
+ const res = await stack.app.request("/api/subscription/webhook/stripe", {
105
+ method: "POST",
106
+ body: "null",
107
+ headers: { "stripe-signature": "test_sig" },
108
+ });
109
+ expect(res.status).toBe(200);
110
+ const body = (await res.json()) as { ignored: boolean };
111
+ expect(body.ignored).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("webhook-handler — error paths", () => {
116
+ test("provider not registered → 404 subscription_provider_not_registered", async () => {
117
+ const res = await postWebhook("ghost-provider", buildEvent());
118
+ expect(res.status).toBe(404);
119
+ const body = (await res.json()) as { error: { code: string; message: string } };
120
+ expect(body.error.code).toBe("subscription_provider_not_registered");
121
+ expect(body.error.message).toMatch(/ghost-provider/);
122
+ });
123
+
124
+ test("verify() throws (sig mismatch) → 401 extra_route_signature_invalid", async () => {
125
+ const res = await stack.app.request("/api/subscription/webhook/stripe", {
126
+ method: "POST",
127
+ body: "not valid json — forces JSON.parse to throw inside verify()",
128
+ headers: { "stripe-signature": "test_sig" },
129
+ });
130
+ expect(res.status).toBe(401);
131
+ const body = (await res.json()) as { error: { code: string } };
132
+ expect(body.error.code).toBe("extra_route_signature_invalid");
133
+ });
134
+
135
+ test("process-event write rejected by schema → 500 subscription_webhook_processing_failed", async () => {
136
+ // status outside the enum trips processEventSchema validation inside
137
+ // process-event.write.ts, so dispatchSystemWrite returns isSuccess:false
138
+ // — a real dispatch failure, not a stubbed one.
139
+ const res = await postWebhook(
140
+ "stripe",
141
+ // @cast-boundary test-only invalid input — deliberately outside the
142
+ // SubscriptionStatus enum to trip processEventSchema validation.
143
+ buildEvent({
144
+ tenantId: testTenantId(5003),
145
+ status: "not-a-real-status" as SubscriptionStatus,
146
+ }),
147
+ );
148
+ expect(res.status).toBe(500);
149
+ const body = (await res.json()) as {
150
+ error: { code: string; message: string; details: unknown };
151
+ };
152
+ expect(body.error.code).toBe("subscription_webhook_processing_failed");
153
+ });
154
+ });
155
+
156
+ describe("webhook-handler — multi-provider mounting", () => {
157
+ test("two different path-segments resolve to two different plugins", async () => {
158
+ const stripeRes = await postWebhook(
159
+ "stripe",
160
+ buildEvent({ tenantId: testTenantId(5004), providerEventId: "evt_multi_stripe" }),
161
+ );
162
+ const paypalRes = await postWebhook(
163
+ "paypal",
164
+ buildEvent({
165
+ tenantId: testTenantId(5005),
166
+ providerEventId: "evt_multi_paypal",
167
+ providerName: "paypal",
168
+ }),
169
+ );
170
+
171
+ expect(stripeRes.status).toBe(200);
172
+ expect(paypalRes.status).toBe(200);
173
+ });
174
+ });
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.301.0",
4
+ "type": "breaking",
5
+ "title": "billing-foundation no longer exports SystemWriteResult (removed in 0.298.0 without a changelog note)",
6
+ "migration": "SystemWriteResult was exported from @cosmicdrift/kumiko-bundled-features/billing-foundation up to 0.297.0 and was dropped in 0.298.0 together with the dispatchSystemWrite dep of createSubscriptionTierSync; there is no alias. Code that still imports it fails with \"has no exported member 'SystemWriteResult'\". If the type is only used for a dispatchSystemWrite dep passed to createSubscriptionTierSync, drop that dep and the type together (see the 0.298.0 webhook-wiring migration: extraRoutes: [createSubscriptionTierSync({ ... }).createWebhookRoute()]). Where a system-write result type is still needed, import type { WriteResult } from \"@cosmicdrift/kumiko-framework/engine\" and use WriteResult<unknown>; it is a discriminated union ({ isSuccess: true; data } | WriteFailure), so narrow on isSuccess before reading data or error."
7
+ },
8
+ {
9
+ "version": "0.298.0",
10
+ "type": "breaking",
11
+ "title": "Billing webhook wiring moves from a raw handler to createSubscriptionWebhookRoute()/createWebhookRoute()",
12
+ "migration": "createSubscriptionWebhookHandler(...) is replaced by createSubscriptionWebhookRoute({ path?, afterDispatch? }), which returns an ExtraRouteDefinition for the extraRoutes array instead of a manually-mounted handler; the route path still defaults to /webhooks/subscription/:providerName. createSubscriptionTierSync(...) deps drop db, registry, dispatchSystemWrite and tierAssignmentTable - it now exposes .createWebhookRoute(), which produces the signature-verified ExtraRouteDefinition to add to extraRoutes. A webhook previously wired as extraRoutes: (app, deps) => { app.post(\"/webhooks/subscription/:providerName\", createSubscriptionWebhookHandler(...)) } becomes extraRoutes: [createSubscriptionTierSync({ ... }).createWebhookRoute()] (or createSubscriptionWebhookRoute({...}) for the lower-level handler), with db/registry/dispatchSystemWrite no longer passed in - the route's signature-entry deps (systemQuery, dispatchSystemWrite, dispatchSystemQuery) cover the read/write access the old handler needed."
13
+ },
2
14
  {
3
15
  "version": "0.288.0",
4
16
  "type": "improvement",
@@ -20,8 +20,8 @@
20
20
  // → read-your-own-write semantics.
21
21
  // 4. **process-event-handler**: programmatic write-handler den der
22
22
  // webhook-handler aufruft, dispatcht zu type-passendem appendEvent.
23
- // 5. **createSubscriptionWebhookHandler**: factory für die HTTP-Route
24
- // `/api/subscription/webhook/:providerName`.
23
+ // 5. **createSubscriptionWebhookRoute**: factory for the `entry:"signature"`
24
+ // extraRoute `/api/subscription/webhook/:providerName`.
25
25
  // 6. **payment-received event + read_payments projection**: one-off-
26
26
  // payments (checkout mode "payment") get their own event, own
27
27
  // per-tenant aggregate, and own `process-payment-event` write-handler
@@ -85,7 +85,7 @@ import { paymentTenantDestroyHook, subscriptionTenantDestroyHook } from "./tenan
85
85
 
86
86
  export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
87
87
  r.describe(
88
- "Plugin host for subscription billing \u2014 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 `createSubscriptionWebhookHandler` factory for the `/api/subscription/webhook/:providerName` route. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
88
+ "Plugin host for subscription billing \u2014 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. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
89
89
  );
90
90
  r.uiHints({
91
91
  displayLabel: "Billing \u00b7 Foundation",