@cosmicdrift/kumiko-bundled-features 0.317.0 → 0.319.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 (54) hide show
  1. package/package.json +11 -10
  2. package/src/auth-email-password/constants.ts +44 -44
  3. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +159 -8
  4. package/src/billing-foundation/__tests__/billing-plans-admin-only.integration.test.ts +158 -0
  5. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +1034 -0
  6. package/src/billing-foundation/__tests__/checkout-core.test.ts +153 -0
  7. package/src/billing-foundation/__tests__/constants.test.ts +72 -0
  8. package/src/billing-foundation/__tests__/feature.test.ts +139 -1
  9. package/src/billing-foundation/changes.json +54 -0
  10. package/src/billing-foundation/checkout-core.ts +382 -0
  11. package/src/billing-foundation/constants.ts +98 -0
  12. package/src/billing-foundation/entities.ts +3 -1
  13. package/src/billing-foundation/events.ts +5 -0
  14. package/src/billing-foundation/feature.ts +155 -87
  15. package/src/billing-foundation/get-subscription-for-tenant.ts +11 -0
  16. package/src/billing-foundation/handlers/billing-plans.query.ts +32 -0
  17. package/src/billing-foundation/handlers/create-checkout-session.write.ts +42 -44
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +76 -64
  19. package/src/billing-foundation/handlers/process-event.write.ts +2 -0
  20. package/src/billing-foundation/handlers/start-plan-checkout.write.ts +75 -0
  21. package/src/billing-foundation/handlers/switch-plan.write.ts +108 -0
  22. package/src/billing-foundation/i18n.ts +66 -0
  23. package/src/billing-foundation/index.ts +27 -5
  24. package/src/billing-foundation/plan-catalog.ts +218 -0
  25. package/src/billing-foundation/projection.ts +48 -13
  26. package/src/billing-foundation/screens.ts +25 -0
  27. package/src/billing-foundation/types.ts +175 -1
  28. package/src/billing-foundation/validate-options.ts +66 -0
  29. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +365 -0
  30. package/src/billing-foundation/web/billing-plans-panel.tsx +283 -0
  31. package/src/billing-foundation/web/client-plugin.tsx +21 -0
  32. package/src/billing-foundation/web/index.ts +23 -0
  33. package/src/billing-foundation/webhook-handler.ts +1 -0
  34. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +88 -0
  35. package/src/cap-counter/index.ts +1 -0
  36. package/src/cap-counter/stock-cap-guard.ts +17 -5
  37. package/src/cap-overview/__tests__/cap-limit-from-config.integration.test.ts +141 -0
  38. package/src/cap-overview/changes.json +6 -0
  39. package/src/cap-overview/handlers/caps-usage.query.ts +1 -1
  40. package/src/cap-overview/handlers/tenant-caps-list.query.ts +26 -1
  41. package/src/cap-overview/types.ts +6 -1
  42. package/src/subscription-mollie/changes.json +8 -1
  43. package/src/subscription-mollie/feature.ts +1 -0
  44. package/src/subscription-stripe/__tests__/feature.test.ts +25 -1
  45. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +541 -3
  46. package/src/subscription-stripe/__tests__/runtime.test.ts +35 -0
  47. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +74 -8
  48. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +39 -2
  49. package/src/subscription-stripe/changes.json +24 -0
  50. package/src/subscription-stripe/feature.ts +40 -1
  51. package/src/subscription-stripe/plugin-methods.ts +352 -1
  52. package/src/subscription-stripe/runtime.ts +23 -4
  53. package/src/subscription-stripe/verify-webhook.ts +24 -1
  54. package/src/tier-engine/tier-resolver.ts +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.317.0",
3
+ "version": "0.319.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>",
@@ -55,6 +55,7 @@
55
55
  "./ledger": "./src/ledger/index.ts",
56
56
  "./ledger/web": "./src/ledger/web/index.ts",
57
57
  "./billing-foundation": "./src/billing-foundation/index.ts",
58
+ "./billing-foundation/web": "./src/billing-foundation/web/index.ts",
58
59
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
59
60
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
60
61
  "./foundation-shared": "./src/foundation-shared/index.ts",
@@ -133,12 +134,12 @@
133
134
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
135
  },
135
136
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.317.0",
137
- "@cosmicdrift/kumiko-framework": "0.317.0",
138
- "@cosmicdrift/kumiko-headless": "0.317.0",
139
- "@cosmicdrift/kumiko-renderer": "0.317.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.317.0",
141
- "@cosmicdrift/kumiko-types": "0.317.0",
137
+ "@cosmicdrift/kumiko-dispatcher-live": "0.319.0",
138
+ "@cosmicdrift/kumiko-framework": "0.319.0",
139
+ "@cosmicdrift/kumiko-headless": "0.319.0",
140
+ "@cosmicdrift/kumiko-renderer": "0.319.0",
141
+ "@cosmicdrift/kumiko-renderer-web": "0.319.0",
142
+ "@cosmicdrift/kumiko-types": "0.319.0",
142
143
  "@mollie/api-client": "^4.5.0",
143
144
  "@node-rs/argon2": "^2.0.2",
144
145
  "@types/mailparser": "^3.4.6",
@@ -153,7 +154,7 @@
153
154
  "qrcode": "^1.5.4",
154
155
  "react": "^19.2.6",
155
156
  "sharp": "^0.35.4",
156
- "stripe": "^22.1.1",
157
+ "stripe": "^22.6.2",
157
158
  "tailwind-merge": "^3.6.0"
158
159
  },
159
160
  "publishConfig": {
@@ -167,8 +168,8 @@
167
168
  ],
168
169
  "devDependencies": {
169
170
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.317.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.317.0",
171
+ "@cosmicdrift/kumiko-locale-de": "0.319.0",
172
+ "@cosmicdrift/kumiko-locale-es": "0.319.0",
172
173
  "jsqr": "^1.4.0"
173
174
  }
174
175
  }
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
- // Pure string-Konstanten — keine DB/Node-builtins. Mit `@runtime client`
3
- // markiert damit auch Browser-Code (Members-Screen etc.) sie importieren
4
- // kann ohne dass die runtime-isolation-Guard schreit. Runtime darf
5
- // "client"-Files importieren (siehe RUNTIME_RULES), also bleibt auch
6
- // der server-side Zugriff (handlers, dispatcher) erhalten.
2
+ // Pure string constants — no DB/Node builtins. Marked `@runtime client` so
3
+ // browser code (Members screen etc.) can import them too without the
4
+ // runtime-isolation guard complaining. Runtime may import "client" files
5
+ // (see RUNTIME_RULES), so server-side access (handlers, dispatcher) stays
6
+ // intact as well.
7
7
  export const AUTH_EMAIL_PASSWORD_FEATURE = "auth-email-password" as const;
8
8
 
9
9
  // Minimum length for reset/verify hmacSecret — mirrors the ≥32-char
@@ -25,20 +25,20 @@ export const AuthHandlers = {
25
25
  // Redis lockout state.
26
26
  requestAccountUnlock: "auth-email-password:write:request-account-unlock",
27
27
  confirmAccountUnlock: "auth-email-password:write:confirm-account-unlock",
28
- // Magic-Link Self-Signup (Pre-Activation-Token-Pattern). request mintet
29
- // einen opaken Random-Token, speichert ihn bidirektional in Redis und
30
- // sendet eine Aktivierungs-Mail. confirm löst den Token ein und legt
31
- // user + tenant + Admin-Membership atomar an. emailVerified=true ab
32
- // Sekunde 0 — der Klick auf den Mail-Link IST der Beweis.
28
+ // Magic-link self-signup (pre-activation-token pattern). Request mints an
29
+ // opaque random token, stores it bidirectionally in Redis and sends an
30
+ // activation email. Confirm redeems the token and creates
31
+ // user + tenant + Admin membership atomically. emailVerified=true from
32
+ // second 0 — clicking the mail link IS the proof.
33
33
  signupRequest: "auth-email-password:write:signup-request",
34
34
  signupConfirm: "auth-email-password:write:signup-confirm",
35
- // Tenant-Invite Magic-Link (Admin lädt User in existing Tenant ein).
36
- // Drei separate accept-Endpoints für klare Branch-Separation:
37
- // inviteCreate: Admin → POST email + role
38
- // inviteAccept: logged-in User → POST token (membership-add)
39
- // inviteAcceptWithLogin: anon User mit existing email → POST token + email + password
40
- // inviteSignupComplete: anon User mit neuer email → POST token + password
41
- // inviteCancel: Admin cancelt pending invite
35
+ // Tenant-invite magic-link (admin invites a user into an existing tenant).
36
+ // Three separate accept endpoints for clear branch separation:
37
+ // inviteCreate: admin → POST email + role
38
+ // inviteAccept: logged-in user → POST token (membership-add)
39
+ // inviteAcceptWithLogin: anon user with existing email → POST token + email + password
40
+ // inviteSignupComplete: anon user with new email → POST token + password
41
+ // inviteCancel: admin cancels a pending invite
42
42
  inviteCreate: "auth-email-password:write:invite-create",
43
43
  inviteAccept: "auth-email-password:write:invite-accept",
44
44
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
@@ -76,20 +76,20 @@ export const AuthErrors = {
76
76
  // code so a probing client can't distinguish tampered from stale.
77
77
  invalidUnlockToken: "invalid_unlock_token",
78
78
  unlockNotConfigured: "account_unlock_not_configured",
79
- // Self-Signup: alle confirm-Failures (unbekannter Token, schon
80
- // konsumiert, abgelaufen) collapsen auf diesen Code — gleicher
81
- // anti-enumeration-Trade-off wie reset/verify.
79
+ // Self-signup: all confirm failures (unknown token, already
80
+ // consumed, expired) collapse onto this code — same
81
+ // anti-enumeration trade-off as reset/verify.
82
82
  invalidSignupToken: "invalid_signup_token",
83
83
  signupNotConfigured: "signup_not_configured",
84
- // Self-Signup: confirm lehnt eine bereits registrierte Email ab statt den
85
- // bestehenden User wiederzuverwenden (Account-Takeover, #365). KEIN
86
- // anti-enumeration-collapse wie invalidSignupToken: wer hier ankommt,
87
- // kontrolliert die Inbox (hat den Magic-Link), das Reveal "Email existiert"
88
- // ist also keine neue Info.
84
+ // Self-signup: confirm rejects an already-registered email instead of
85
+ // reusing the existing user (account takeover, #365). NO
86
+ // anti-enumeration collapse like invalidSignupToken: whoever gets here
87
+ // controls the inbox (has the magic link), so revealing "email exists"
88
+ // is not new information.
89
89
  signupEmailAlreadyRegistered: "signup_email_already_registered",
90
- // Invite-Flow: alle Token-Failures collapsen auf invalidInviteToken
91
- // (anti-enumeration). emailMismatch wenn der invitee versucht den
92
- // Link mit einer anderen Email zu accepten als die eingeladene.
90
+ // Invite flow: all token failures collapse onto invalidInviteToken
91
+ // (anti-enumeration). emailMismatch when the invitee tries to accept the
92
+ // link with a different email than the one invited.
93
93
  invalidInviteToken: "invalid_invite_token",
94
94
  inviteEmailMismatch: "invite_email_mismatch",
95
95
  inviteAlreadyMember: "invite_already_member",
@@ -100,17 +100,17 @@ export const AuthErrors = {
100
100
  // deliberate enumeration trade-off: the lockout event itself is already
101
101
  // observable to the attacker, and legit users benefit from a clear signal.
102
102
  accountLocked: "account_locked",
103
- // S2.U6 (DSGVO Art. 18) — Account ist im Restricted-Status. Login wird
104
- // explicit verweigert mit eigenem Code (nicht zu invalid_credentials
105
- // collapsen) damit UI sagen kann "Account ist aktuell pausiert, hier
106
- // klicken zum Aufheben". Enumeration-leak akzeptiert: Restriction ist
107
- // user-initiiert, der User weiss dass sein Konto restricted ist.
103
+ // S2.U6 (GDPR Art. 18) — account is in Restricted status. Login is
104
+ // explicitly refused with its own code (not collapsed into
105
+ // invalid_credentials) so the UI can say "account is currently paused,
106
+ // click here to lift it". Enumeration leak accepted: restriction is
107
+ // user-initiated, the user already knows their account is restricted.
108
108
  accountRestricted: "account_restricted",
109
- // Account ist im DeletionRequested- oder Deleted-Status. Anders als
110
- // Restricted ist das nicht reversibel via Login → wir collapsen auf
111
- // invalid_credentials damit Forget-Pfad nicht via Login enumerierbar
112
- // wird (User der "Konto loeschen" geklickt hat soll nicht erneut sehen
113
- // dass die Email-Adresse noch in der DB existiert).
109
+ // Account is in DeletionRequested or Deleted status. Unlike
110
+ // Restricted, this is not reversible via login → we collapse onto
111
+ // invalid_credentials so the forget-path isn't enumerable via login
112
+ // (a user who clicked "delete account" shouldn't see again that their
113
+ // email address still exists in the DB).
114
114
  } as const;
115
115
 
116
116
  // Account-lockout defaults — overridable via
@@ -132,12 +132,12 @@ export const AUTH_RESET_DEFAULT_TTL_MINUTES = 15;
132
132
  // replaying the same token re-sets the same flag.
133
133
  export const AUTH_VERIFY_DEFAULT_TTL_MINUTES = 24 * 60;
134
134
 
135
- // Self-Signup: 24h Default. Lang genug damit User nicht denken muss
136
- // "schnell aktivieren" — ein Mail-Link der morgen früh noch geht ist
137
- // User-Friendly. Kürzere TTLs werfen Resend-Spam weil User vergessen.
135
+ // Self-signup: 24h default. Long enough that the user doesn't have to
136
+ // think "activate quickly" — a mail link that still works tomorrow morning
137
+ // is user-friendly. Shorter TTLs cause resend-spam because users forget.
138
138
  export const AUTH_SIGNUP_DEFAULT_TTL_MINUTES = 24 * 60;
139
139
 
140
- // Tenant-Invite: 7 Tage Default. Industry-Standard (GitHub, Linear,
141
- // Slack); invitees brauchen oft länger zum Reagieren als bei Self-
142
- // Signup wo die User-Intention frisch ist.
140
+ // Tenant invite: 7 days default. Industry standard (GitHub, Linear,
141
+ // Slack); invitees often need longer to respond than in self-signup,
142
+ // where the user's intent is fresh.
143
143
  export const AUTH_INVITE_DEFAULT_TTL_MINUTES = 7 * 24 * 60;
@@ -47,7 +47,7 @@ import {
47
47
  SubscriptionFoundationHandlers,
48
48
  SubscriptionStatuses,
49
49
  } from "../constants";
50
- import { billingFoundationFeature } from "../feature";
50
+ import { createBillingFoundationFeature } from "../feature";
51
51
  import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
52
52
  import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
53
53
  import { createSubscriptionWebhookRoute } from "../webhook-handler";
@@ -73,6 +73,11 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
73
73
  r.requires("billing-foundation");
74
74
  const plugin: SubscriptionProviderPlugin = {
75
75
  verifyAndParseWebhook: async () => null,
76
+ // Only the two tiers scenario 6's subscription-mode checkouts actually
77
+ // exercise — the hardened create-checkout-session now requires every
78
+ // mode:"subscription" priceId to resolve through priceToTier.
79
+ priceToTier: { price_pro_test: "pro", price_business_test: "business" },
80
+ oneOffPriceIds: ["price_topup_test"],
76
81
  createCheckoutSession: async (_ctx, options) => {
77
82
  mockCheckoutCalls.push({
78
83
  priceId: options.priceId,
@@ -131,7 +136,7 @@ beforeAll(async () => {
131
136
  createTenantFeature(),
132
137
  createComplianceProfilesFeature(),
133
138
  createTenantLifecycleFeature(),
134
- billingFoundationFeature,
139
+ createBillingFoundationFeature({ baseUrl: "https://example.com" }),
135
140
  mockProviderFeature,
136
141
  mockPaymentProviderFeature,
137
142
  ],
@@ -174,6 +179,7 @@ function buildEvent(
174
179
  providerCustomerId: string;
175
180
  providerSubscriptionId: string;
176
181
  currentPeriodEndIso: string;
182
+ cancelAtIso: string | null;
177
183
  rawPayload: string;
178
184
  }> = {},
179
185
  ) {
@@ -186,6 +192,12 @@ function buildEvent(
186
192
  status: overrides.status ?? SubscriptionStatuses.active,
187
193
  tier: overrides.tier ?? "pro",
188
194
  currentPeriodEndIso: overrides.currentPeriodEndIso ?? "2026-06-01T00:00:00Z",
195
+ // `cancelAtIso` stays entirely absent from the payload unless an override
196
+ // sets it — undefined (key omitted) vs. null (explicit clear) is the
197
+ // 3-way distinction cancelAtSetFromPayload relies on; a `?? undefined`
198
+ // default here would collapse "omitted" into "explicit null" and hide a
199
+ // regression where a later event without the field wipes a prior value.
200
+ ...(overrides.cancelAtIso !== undefined && { cancelAtIso: overrides.cancelAtIso }),
189
201
  rawPayload: overrides.rawPayload ?? '{"raw":"payload"}',
190
202
  };
191
203
  }
@@ -480,7 +492,10 @@ describe("scenario 5: Provider-Wechsel mid-period (Disney+-Pattern)", () => {
480
492
  describe("scenario 6: create-checkout-session — Plugin-routing", () => {
481
493
  test("happy-path: valid provider → URL durchgereicht + plugin mit korrekten args aufgerufen", async () => {
482
494
  mockCheckoutCalls.length = 0;
483
- const admin = adminFor(3009);
495
+ // Not 3009 — scenario 5 reuses that tenant number and leaves it with a
496
+ // non-terminal "pro" subscription; openCheckout's new existing-
497
+ // subscription conflict-gate would reject this checkout for it.
498
+ const admin = adminFor(3016);
484
499
  const result = (await stack.http.writeOk(
485
500
  "billing-foundation:write:create-checkout-session",
486
501
  {
@@ -525,7 +540,26 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
525
540
 
526
541
  test("optional providerCustomerId wird durchgereicht (Plan-Wechsel-Flow)", async () => {
527
542
  mockCheckoutCalls.length = 0;
528
- const admin = adminFor(3012);
543
+ // Not 3012 — that tenant already gets its own subscription in the
544
+ // crypto-shredding test further down and would collide.
545
+ const admin = adminFor(3018);
546
+
547
+ // The provider customer-id must belong to the caller's own subscription
548
+ // (even if canceled) — otherwise the foreign_provider_customer guard fires.
549
+ await stack.http.writeOk(
550
+ SubscriptionFoundationHandlers.processEvent,
551
+ {
552
+ ...buildEvent({
553
+ providerEventId: "evt_3018_create",
554
+ providerCustomerId: "cus_existing_xyz",
555
+ providerSubscriptionId: "sub_3018",
556
+ status: SubscriptionStatuses.canceled,
557
+ }),
558
+ providerName: "mock",
559
+ },
560
+ admin,
561
+ );
562
+
529
563
  await stack.http.writeOk(
530
564
  "billing-foundation:write:create-checkout-session",
531
565
  {
@@ -540,6 +574,59 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
540
574
  expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
541
575
  });
542
576
 
577
+ test("fremde providerCustomerId wird abgelehnt (foreign_provider_customer)", async () => {
578
+ mockCheckoutCalls.length = 0;
579
+ const other = adminFor(3019);
580
+ await stack.http.writeOk(
581
+ SubscriptionFoundationHandlers.processEvent,
582
+ {
583
+ ...buildEvent({
584
+ providerEventId: "evt_3019_create",
585
+ providerCustomerId: "cus_belongs_to_tenant_3019",
586
+ providerSubscriptionId: "sub_3019",
587
+ }),
588
+ providerName: "mock",
589
+ },
590
+ other,
591
+ );
592
+
593
+ const admin = adminFor(3017);
594
+ const error = await stack.http.writeErr(
595
+ "billing-foundation:write:create-checkout-session",
596
+ {
597
+ providerName: "mock",
598
+ priceId: "price_pro_test",
599
+ successUrl: "https://example.com/s",
600
+ cancelUrl: "https://example.com/c",
601
+ providerCustomerId: "cus_belongs_to_tenant_3019",
602
+ },
603
+ admin,
604
+ );
605
+ expect(error.httpStatus).toBe(422);
606
+ expect(error.i18nKey).toBe("billing-foundation.errors.foreignProviderCustomer");
607
+ expect(mockCheckoutCalls).toHaveLength(0);
608
+ });
609
+
610
+ test("fremde providerCustomerId wird auch im mode:payment abgelehnt", async () => {
611
+ mockCheckoutCalls.length = 0;
612
+ const admin = adminFor(3020);
613
+ const error = await stack.http.writeErr(
614
+ "billing-foundation:write:create-checkout-session",
615
+ {
616
+ providerName: "mock",
617
+ priceId: "price_topup_test",
618
+ successUrl: "https://example.com/s",
619
+ cancelUrl: "https://example.com/c",
620
+ providerCustomerId: "cus_belongs_to_tenant_3019",
621
+ mode: "payment",
622
+ },
623
+ admin,
624
+ );
625
+ expect(error.httpStatus).toBe(422);
626
+ expect(error.i18nKey).toBe("billing-foundation.errors.foreignProviderCustomer");
627
+ expect(mockCheckoutCalls).toHaveLength(0);
628
+ });
629
+
543
630
  test("optional mode wird durchgereicht (One-off-Payment-Flow, fw#2755)", async () => {
544
631
  mockCheckoutCalls.length = 0;
545
632
  const admin = adminFor(3014);
@@ -598,7 +685,7 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
598
685
 
599
686
  const result = (await stack.http.writeOk(
600
687
  "billing-foundation:write:create-portal-session",
601
- { returnUrl: "https://example.com/return" },
688
+ {},
602
689
  admin,
603
690
  )) as Record<string, unknown>;
604
691
 
@@ -608,11 +695,13 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
608
695
  // Drift-pin: portal-handler liest providerCustomerId AUS DER DB
609
696
  // (subscription-row), nicht aus der payload. Wenn ein Refactor das
610
697
  // umstellt (= Tenant könnte fremde portal-sessions öffnen), würde
611
- // mockPortalCalls den falschen customer-id sehen.
698
+ // mockPortalCalls den falschen customer-id sehen. returnUrl is now
699
+ // server-computed (portalReturnUrl) — no catalog mounted here, so it
700
+ // collapses onto baseUrl itself.
612
701
  expect(mockPortalCalls).toHaveLength(1);
613
702
  expect(mockPortalCalls[0]).toEqual({
614
703
  providerCustomerId: "cus_3013",
615
- returnUrl: "https://example.com/return",
704
+ returnUrl: "https://example.com",
616
705
  });
617
706
  });
618
707
 
@@ -620,7 +709,7 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
620
709
  const admin = adminFor(3011);
621
710
  const error = await stack.http.writeErr(
622
711
  "billing-foundation:write:create-portal-session",
623
- { returnUrl: "https://example.com/return" },
712
+ {},
624
713
  admin,
625
714
  );
626
715
  expect(JSON.stringify(error)).toMatch(/no active subscription/);
@@ -667,6 +756,68 @@ describe("scenario 8: cancel-event setzt status auf canceled, behält subscripti
667
756
  });
668
757
  });
669
758
 
759
+ describe("scenario 8b: cancelAt — set / leave-unchanged / clear across events", () => {
760
+ test("event without cancelAtIso key keeps a prior value; explicit null clears it", async () => {
761
+ const admin = adminFor(3009);
762
+
763
+ // 1. create with cancelAt set — a Stripe cancel_at_period_end webhook.
764
+ await stack.http.writeOk(
765
+ SubscriptionFoundationHandlers.processEvent,
766
+ buildEvent({
767
+ providerEventId: "evt_3009_create",
768
+ providerCustomerId: "cus_3009",
769
+ providerSubscriptionId: "sub_3009",
770
+ cancelAtIso: "2026-07-01T00:00:00Z",
771
+ }),
772
+ admin,
773
+ );
774
+ let subs = (await stack.http.queryOk(
775
+ "billing-foundation:query:subscription:list",
776
+ {},
777
+ admin,
778
+ )) as { rows: Array<Record<string, unknown>> };
779
+ expect(subs.rows[0]?.["cancelAt"]).toBe("2026-07-01T00:00:00Z");
780
+
781
+ // 2. an unrelated update event that never mentions cancelAtIso (e.g. a
782
+ // price/tier change) must NOT wipe the previously recorded cancelAt.
783
+ await stack.http.writeOk(
784
+ SubscriptionFoundationHandlers.processEvent,
785
+ buildEvent({
786
+ providerEventId: "evt_3009_update",
787
+ type: SubscriptionEventTypes.updated,
788
+ providerCustomerId: "cus_3009",
789
+ providerSubscriptionId: "sub_3009",
790
+ tier: "business",
791
+ }),
792
+ admin,
793
+ );
794
+ subs = (await stack.http.queryOk("billing-foundation:query:subscription:list", {}, admin)) as {
795
+ rows: Array<Record<string, unknown>>;
796
+ };
797
+ expect(subs.rows[0]?.["tier"]).toBe("business");
798
+ expect(subs.rows[0]?.["cancelAt"]).toBe("2026-07-01T00:00:00Z");
799
+
800
+ // 3. reactivation — an update event with cancelAtIso explicitly null
801
+ // clears it.
802
+ await stack.http.writeOk(
803
+ SubscriptionFoundationHandlers.processEvent,
804
+ buildEvent({
805
+ providerEventId: "evt_3009_reactivate",
806
+ type: SubscriptionEventTypes.updated,
807
+ providerCustomerId: "cus_3009",
808
+ providerSubscriptionId: "sub_3009",
809
+ tier: "business",
810
+ cancelAtIso: null,
811
+ }),
812
+ admin,
813
+ );
814
+ subs = (await stack.http.queryOk("billing-foundation:query:subscription:list", {}, admin)) as {
815
+ rows: Array<Record<string, unknown>>;
816
+ };
817
+ expect(subs.rows[0]?.["cancelAt"]).toBeNull();
818
+ });
819
+ });
820
+
670
821
  // scenario 9 (subscriptionTenantDestroyHook direct-call, default+HGB branches) removed:
671
822
  // covered end-to-end by billing-foundation/__tests__/tenant-destroy.integration.test.ts,
672
823
  // which drives the real tenant-lifecycle sweep instead of hand-feeding the hook.
@@ -0,0 +1,158 @@
1
+ // Admin-only billing-plans catalog — a separate stack (own viewRoles/
2
+ // purchaseRoles: ["Admin"]) instead of a describe-block inside
3
+ // billing-plans.integration.test.ts, matching how show-pony pins a distinct
4
+ // role-shape against its own setupTestStack rather than layering it onto a
5
+ // shared one. Proves the catalog's role-gate isn't hardcoded to
6
+ // TenantAdmin/SystemAdmin: an app that restricts billing to a custom "Admin"
7
+ // role locks TenantAdmin out of both the query and the purchase-handlers.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
11
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ createTestUser,
14
+ setupTestStack,
15
+ type TestStack,
16
+ testTenantId,
17
+ unsafeCreateEntityTable,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import {
20
+ createComplianceProfilesFeature,
21
+ tenantComplianceProfileEntity,
22
+ } from "../../compliance-profiles";
23
+ import { createConfigFeature } from "../../config";
24
+ import { createTenantFeature } from "../../tenant/feature";
25
+ import { tenantEntity } from "../../tenant/schema/tenant";
26
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
27
+ import { subscriptionAggregateId } from "../aggregate-id";
28
+ import { SubscriptionFoundationHandlers, SubscriptionStatuses } from "../constants";
29
+ import { createBillingFoundationFeature } from "../feature";
30
+ import { subscriptionsProjectionTable } from "../projection";
31
+ import type { BillingPlanCatalog, ProviderPrice, SubscriptionProviderPlugin } from "../types";
32
+
33
+ const PRICE_TO_TIER: Readonly<Record<string, string>> = { price_pro: "pro" };
34
+
35
+ const PRICES: readonly ProviderPrice[] = [
36
+ {
37
+ priceId: "price_pro",
38
+ unitAmount: 1900,
39
+ currency: "usd",
40
+ interval: "month",
41
+ intervalCount: 1,
42
+ active: true,
43
+ metadata: {},
44
+ },
45
+ ];
46
+
47
+ const checkoutCalls: Array<{ priceId: string }> = [];
48
+
49
+ const adminOnlyProviderFeature = defineFeature("test-mock-admin-only-provider", (r) => {
50
+ r.requires("billing-foundation");
51
+ const plugin: SubscriptionProviderPlugin = {
52
+ verifyAndParseWebhook: async () => null,
53
+ priceToTier: PRICE_TO_TIER,
54
+ retrievePrices: async (_ctx, priceIds) => PRICES.filter((p) => priceIds.includes(p.priceId)),
55
+ createCheckoutSession: async (_ctx, options) => {
56
+ checkoutCalls.push({ priceId: options.priceId });
57
+ return { url: `https://mock.example/checkout/${options.priceId}` };
58
+ },
59
+ };
60
+ r.useExtension("subscriptionProvider", "mock-admin-only-provider", plugin);
61
+ });
62
+
63
+ async function resolveCurrentTier(db: TenantDb, tenantId: string): Promise<string> {
64
+ const rows = await db.selectMany(
65
+ subscriptionsProjectionTable,
66
+ { id: subscriptionAggregateId(tenantId) },
67
+ { limit: 1 },
68
+ );
69
+ const row = rows[0];
70
+ if (!row) return "free";
71
+ const status = row["status"] as string;
72
+ return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing
73
+ ? (row["tier"] as string)
74
+ : "free";
75
+ }
76
+
77
+ const adminOnlyCatalog: BillingPlanCatalog<"pro"> = {
78
+ plans: ["pro"],
79
+ tierLabelKey: (tier) => `plan.${tier}.label`,
80
+ benefits: () => [],
81
+ resolveCurrentTier,
82
+ viewRoles: ["Admin"],
83
+ purchaseRoles: ["Admin"],
84
+ successPath: "/billing/success",
85
+ cancelPath: "/billing/cancel",
86
+ providerName: "mock-admin-only-provider",
87
+ };
88
+
89
+ let stack: TestStack;
90
+
91
+ beforeAll(async () => {
92
+ stack = await setupTestStack({
93
+ features: [
94
+ createConfigFeature(),
95
+ createTenantFeature(),
96
+ createComplianceProfilesFeature(),
97
+ createTenantLifecycleFeature(),
98
+ createBillingFoundationFeature({
99
+ baseUrl: "https://app.example.com",
100
+ catalog: adminOnlyCatalog,
101
+ }),
102
+ adminOnlyProviderFeature,
103
+ ],
104
+ });
105
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
106
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await stack.cleanup();
111
+ });
112
+
113
+ describe("billing-plans catalog with viewRoles/purchaseRoles restricted to a custom Admin role", () => {
114
+ test("an Admin can query the catalog and start a checkout through to the mock provider", async () => {
115
+ const admin = createTestUser({ id: 8001, tenantId: testTenantId(8001), roles: ["Admin"] });
116
+
117
+ const result = (await stack.http.queryOk(
118
+ "billing-foundation:query:billing-plans",
119
+ {},
120
+ admin,
121
+ )) as {
122
+ canPurchase: boolean;
123
+ plans: Array<{ tier: string; action: string }>;
124
+ };
125
+ expect(result.canPurchase).toBe(true);
126
+ expect(result.plans.find((p) => p.tier === "pro")?.action).toBe("checkout");
127
+
128
+ const checkout = (await stack.http.writeOk(
129
+ SubscriptionFoundationHandlers.startPlanCheckout,
130
+ { tier: "pro" },
131
+ admin,
132
+ )) as { url: string };
133
+ expect(checkout.url).toBe("https://mock.example/checkout/price_pro");
134
+ expect(checkoutCalls).toEqual([{ priceId: "price_pro" }]);
135
+ });
136
+
137
+ test("a TenantAdmin without the Admin role is rejected on both query and start-plan-checkout", async () => {
138
+ const tenantAdmin = createTestUser({
139
+ id: 8002,
140
+ tenantId: testTenantId(8002),
141
+ roles: ["TenantAdmin"],
142
+ });
143
+
144
+ const queryError = await stack.http.queryErr(
145
+ "billing-foundation:query:billing-plans",
146
+ {},
147
+ tenantAdmin,
148
+ );
149
+ expect(queryError.httpStatus).toBe(403);
150
+
151
+ const writeError = await stack.http.writeErr(
152
+ SubscriptionFoundationHandlers.startPlanCheckout,
153
+ { tier: "pro" },
154
+ tenantAdmin,
155
+ );
156
+ expect(writeError.httpStatus).toBe(403);
157
+ });
158
+ });