@cosmicdrift/kumiko-bundled-features 0.318.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 (33) hide show
  1. package/package.json +10 -10
  2. package/src/auth-email-password/constants.ts +44 -44
  3. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +76 -4
  4. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +235 -1
  5. package/src/billing-foundation/__tests__/checkout-core.test.ts +4 -0
  6. package/src/billing-foundation/__tests__/feature.test.ts +20 -0
  7. package/src/billing-foundation/changes.json +36 -0
  8. package/src/billing-foundation/checkout-core.ts +163 -40
  9. package/src/billing-foundation/entities.ts +3 -1
  10. package/src/billing-foundation/events.ts +5 -0
  11. package/src/billing-foundation/get-subscription-for-tenant.ts +6 -0
  12. package/src/billing-foundation/handlers/billing-plans.query.ts +3 -3
  13. package/src/billing-foundation/handlers/create-portal-session.write.ts +9 -18
  14. package/src/billing-foundation/handlers/process-event.write.ts +2 -0
  15. package/src/billing-foundation/handlers/start-plan-checkout.write.ts +8 -8
  16. package/src/billing-foundation/handlers/switch-plan.write.ts +9 -16
  17. package/src/billing-foundation/i18n.ts +4 -0
  18. package/src/billing-foundation/index.ts +18 -11
  19. package/src/billing-foundation/plan-catalog.ts +26 -12
  20. package/src/billing-foundation/projection.ts +31 -6
  21. package/src/billing-foundation/types.ts +32 -4
  22. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +22 -9
  23. package/src/billing-foundation/web/billing-plans-panel.tsx +51 -9
  24. package/src/billing-foundation/web/index.ts +15 -0
  25. package/src/billing-foundation/webhook-handler.ts +1 -0
  26. package/src/subscription-stripe/__tests__/feature.test.ts +20 -0
  27. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +24 -0
  28. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +20 -7
  29. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +37 -0
  30. package/src/subscription-stripe/changes.json +6 -0
  31. package/src/subscription-stripe/feature.ts +25 -0
  32. package/src/subscription-stripe/plugin-methods.ts +12 -4
  33. package/src/subscription-stripe/verify-webhook.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.318.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>",
@@ -134,12 +134,12 @@
134
134
  "./workflow-runner": "./src/workflow-runner/index.ts"
135
135
  },
136
136
  "dependencies": {
137
- "@cosmicdrift/kumiko-dispatcher-live": "0.318.0",
138
- "@cosmicdrift/kumiko-framework": "0.318.0",
139
- "@cosmicdrift/kumiko-headless": "0.318.0",
140
- "@cosmicdrift/kumiko-renderer": "0.318.0",
141
- "@cosmicdrift/kumiko-renderer-web": "0.318.0",
142
- "@cosmicdrift/kumiko-types": "0.318.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",
143
143
  "@mollie/api-client": "^4.5.0",
144
144
  "@node-rs/argon2": "^2.0.2",
145
145
  "@types/mailparser": "^3.4.6",
@@ -154,7 +154,7 @@
154
154
  "qrcode": "^1.5.4",
155
155
  "react": "^19.2.6",
156
156
  "sharp": "^0.35.4",
157
- "stripe": "^22.1.1",
157
+ "stripe": "^22.6.2",
158
158
  "tailwind-merge": "^3.6.0"
159
159
  },
160
160
  "publishConfig": {
@@ -168,8 +168,8 @@
168
168
  ],
169
169
  "devDependencies": {
170
170
  "@testing-library/user-event": "^14.6.1",
171
- "@cosmicdrift/kumiko-locale-de": "0.318.0",
172
- "@cosmicdrift/kumiko-locale-es": "0.318.0",
171
+ "@cosmicdrift/kumiko-locale-de": "0.319.0",
172
+ "@cosmicdrift/kumiko-locale-es": "0.319.0",
173
173
  "jsqr": "^1.4.0"
174
174
  }
175
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;
@@ -77,6 +77,7 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
77
77
  // exercise — the hardened create-checkout-session now requires every
78
78
  // mode:"subscription" priceId to resolve through priceToTier.
79
79
  priceToTier: { price_pro_test: "pro", price_business_test: "business" },
80
+ oneOffPriceIds: ["price_topup_test"],
80
81
  createCheckoutSession: async (_ctx, options) => {
81
82
  mockCheckoutCalls.push({
82
83
  priceId: options.priceId,
@@ -178,6 +179,7 @@ function buildEvent(
178
179
  providerCustomerId: string;
179
180
  providerSubscriptionId: string;
180
181
  currentPeriodEndIso: string;
182
+ cancelAtIso: string | null;
181
183
  rawPayload: string;
182
184
  }> = {},
183
185
  ) {
@@ -190,6 +192,12 @@ function buildEvent(
190
192
  status: overrides.status ?? SubscriptionStatuses.active,
191
193
  tier: overrides.tier ?? "pro",
192
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 }),
193
201
  rawPayload: overrides.rawPayload ?? '{"raw":"payload"}',
194
202
  };
195
203
  }
@@ -677,7 +685,7 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
677
685
 
678
686
  const result = (await stack.http.writeOk(
679
687
  "billing-foundation:write:create-portal-session",
680
- { returnUrl: "https://example.com/return" },
688
+ {},
681
689
  admin,
682
690
  )) as Record<string, unknown>;
683
691
 
@@ -687,11 +695,13 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
687
695
  // Drift-pin: portal-handler liest providerCustomerId AUS DER DB
688
696
  // (subscription-row), nicht aus der payload. Wenn ein Refactor das
689
697
  // umstellt (= Tenant könnte fremde portal-sessions öffnen), würde
690
- // 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.
691
701
  expect(mockPortalCalls).toHaveLength(1);
692
702
  expect(mockPortalCalls[0]).toEqual({
693
703
  providerCustomerId: "cus_3013",
694
- returnUrl: "https://example.com/return",
704
+ returnUrl: "https://example.com",
695
705
  });
696
706
  });
697
707
 
@@ -699,7 +709,7 @@ describe("scenario 7: create-portal-session — Plugin-routing", () => {
699
709
  const admin = adminFor(3011);
700
710
  const error = await stack.http.writeErr(
701
711
  "billing-foundation:write:create-portal-session",
702
- { returnUrl: "https://example.com/return" },
712
+ {},
703
713
  admin,
704
714
  );
705
715
  expect(JSON.stringify(error)).toMatch(/no active subscription/);
@@ -746,6 +756,68 @@ describe("scenario 8: cancel-event setzt status auf canceled, behält subscripti
746
756
  });
747
757
  });
748
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
+
749
821
  // scenario 9 (subscriptionTenantDestroyHook direct-call, default+HGB branches) removed:
750
822
  // covered end-to-end by billing-foundation/__tests__/tenant-destroy.integration.test.ts,
751
823
  // which drives the real tenant-lifecycle sweep instead of hand-feeding the hook.
@@ -82,6 +82,7 @@ const checkoutCalls: Array<{
82
82
  successUrl: string;
83
83
  cancelUrl: string;
84
84
  providerCustomerId?: string;
85
+ mode?: string;
85
86
  }> = [];
86
87
  const switchCalls: Array<{
87
88
  providerSubscriptionId: string;
@@ -89,12 +90,14 @@ const switchCalls: Array<{
89
90
  allowedPriceIds: readonly string[];
90
91
  returnUrl: string;
91
92
  }> = [];
93
+ const portalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
92
94
 
93
95
  const mockPlanProviderFeature = defineFeature("test-mock-plan-provider", (r) => {
94
96
  r.requires("billing-foundation");
95
97
  const plugin: SubscriptionProviderPlugin = {
96
98
  verifyAndParseWebhook: async () => null,
97
99
  priceToTier: PRICE_TO_TIER,
100
+ oneOffPriceIds: ["price_topup_credits"],
98
101
  isBillingEnabled: async () => billingEnabled,
99
102
  retrievePrices: async (_ctx, priceIds) => {
100
103
  if (retrievePricesMode === "throw") throw new Error("mock provider price lookup failed");
@@ -106,9 +109,17 @@ const mockPlanProviderFeature = defineFeature("test-mock-plan-provider", (r) =>
106
109
  successUrl: options.successUrl,
107
110
  cancelUrl: options.cancelUrl,
108
111
  ...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
112
+ ...(options.mode && { mode: options.mode }),
109
113
  });
110
114
  return { url: `https://mock.example/checkout/${options.priceId}` };
111
115
  },
116
+ createPortalSession: async (_ctx, options) => {
117
+ portalCalls.push({
118
+ providerCustomerId: options.providerCustomerId,
119
+ returnUrl: options.returnUrl,
120
+ });
121
+ return { url: `https://mock.example/portal/${options.providerCustomerId}` };
122
+ },
112
123
  createPlanSwitchSession: async (_ctx, options) => {
113
124
  if (switchPlanErrorMode === "plan_tiers_share_product") {
114
125
  throw new UnprocessableError("plan_tiers_share_product", {
@@ -201,6 +212,7 @@ beforeEach(() => {
201
212
  switchPlanErrorMode = "ok";
202
213
  checkoutCalls.length = 0;
203
214
  switchCalls.length = 0;
215
+ portalCalls.length = 0;
204
216
  });
205
217
 
206
218
  function adminFor(tenantNumber: number) {
@@ -254,7 +266,7 @@ describe("billing-plans query — no subscription", () => {
254
266
  admin,
255
267
  )) as {
256
268
  enabled: boolean;
257
- currentTier: { tier: string };
269
+ currentTier: { tier: string; benefits: Array<{ labelKey: string }> };
258
270
  canPurchase: boolean;
259
271
  subscription: unknown;
260
272
  plans: Array<{ tier: string; action: string; price: { unitAmount: number } | null }>;
@@ -262,6 +274,9 @@ describe("billing-plans query — no subscription", () => {
262
274
 
263
275
  expect(result.enabled).toBe(true);
264
276
  expect(result.currentTier.tier).toBe("free");
277
+ // #3316 P2: "free" is outside catalog.plans (["starter", "pro"]) — the
278
+ // benefits lookup must not be gated on membership in catalog.plans.
279
+ expect(result.currentTier.benefits).toEqual([{ labelKey: "plan.free.benefit.core" }]);
265
280
  expect(result.subscription).toBeNull();
266
281
  expect(result.canPurchase).toBe(true);
267
282
  expect(result.plans).toHaveLength(2);
@@ -294,6 +309,7 @@ describe("start-plan-checkout — no existing subscription", () => {
294
309
  priceId: "price_pro",
295
310
  successUrl: "https://app.example.com/billing/success",
296
311
  cancelUrl: "https://app.example.com/billing/cancel",
312
+ mode: "subscription",
297
313
  });
298
314
  });
299
315
  });
@@ -393,6 +409,47 @@ describe("active subscription — checkout blocked, switch offered", () => {
393
409
  admin,
394
410
  )) as { url: string };
395
411
  expect(result.url).toBe("https://mock.example/checkout/price_topup_credits");
412
+ // #3316 P8: the plugin receives mode:"payment", not silently defaulted
413
+ // to "subscription" or dropped.
414
+ expect(checkoutCalls[0]?.mode).toBe("payment");
415
+ });
416
+
417
+ test("create-checkout-session mode:payment rejects a priceId outside the provider's oneOffPriceIds allowlist", async () => {
418
+ const admin = adminFor(7025);
419
+ const error = await stack.http.writeErr(
420
+ "billing-foundation:write:create-checkout-session",
421
+ {
422
+ providerName: "mock-plan-provider",
423
+ priceId: "price_not_on_allowlist",
424
+ successUrl: "https://app.example.com/s",
425
+ cancelUrl: "https://app.example.com/c",
426
+ mode: "payment",
427
+ },
428
+ admin,
429
+ );
430
+ expect(error.httpStatus).toBe(422);
431
+ expect(error.i18nKey).toBe("billing-foundation.errors.unknownPrice");
432
+ });
433
+
434
+ // Ordering pin: mode:"payment" runs the billing-enabled gate BEFORE the
435
+ // oneOffPriceIds allowlist check, same as mode:"subscription" already did.
436
+ // Without this order, a disabled provider would leak "unknown_price" (a
437
+ // provider-shape detail) instead of the generic "billing disabled" signal.
438
+ test("create-checkout-session mode:payment with billing disabled fails feature_disabled, not unknown_price", async () => {
439
+ billingEnabled = false;
440
+ const admin = adminFor(7026);
441
+ const error = await stack.http.writeErr(
442
+ "billing-foundation:write:create-checkout-session",
443
+ {
444
+ providerName: "mock-plan-provider",
445
+ priceId: "price_not_on_allowlist",
446
+ successUrl: "https://app.example.com/s",
447
+ cancelUrl: "https://app.example.com/c",
448
+ mode: "payment",
449
+ },
450
+ admin,
451
+ );
452
+ expect(error.httpStatus).toBe(403);
396
453
  });
397
454
  });
398
455
 
@@ -798,3 +855,180 @@ describe("stale-incomplete subscription — injected clock", () => {
798
855
  expect(checkout.url).toBe("https://mock.example/checkout/price_pro");
799
856
  });
800
857
  });
858
+
859
+ // =============================================================================
860
+ // 12. create-portal-session — returnUrl computation + strict payload (#3316 P4)
861
+ // =============================================================================
862
+
863
+ describe("create-portal-session — returnUrl computation", () => {
864
+ test("without catalog.returnPath, returnUrl is baseUrl alone", async () => {
865
+ const admin = adminFor(7027);
866
+ await createSubscription(admin.tenantId, { tier: "starter" });
867
+
868
+ const result = (await stack.http.writeOk(
869
+ SubscriptionFoundationHandlers.createPortalSession,
870
+ {},
871
+ admin,
872
+ )) as { url: string };
873
+ expect(result.url).toBe(`https://mock.example/portal/cus_${admin.tenantId}`);
874
+ expect(portalCalls).toHaveLength(1);
875
+ expect(portalCalls[0]?.returnUrl).toBe("https://app.example.com");
876
+ });
877
+
878
+ test("an unknown-key payload (e.g. a client-supplied returnUrl) is rejected — schema is z.object({}).strict()", async () => {
879
+ const admin = adminFor(7028);
880
+ const error = await stack.http.writeErr(
881
+ SubscriptionFoundationHandlers.createPortalSession,
882
+ { returnUrl: "https://evil.example" },
883
+ admin,
884
+ );
885
+ expect(error.httpStatus).toBe(400);
886
+ expect(error.code).toBe("validation_error");
887
+ });
888
+
889
+ describe("with catalog.returnPath configured", () => {
890
+ let returnPathStack: TestStack;
891
+
892
+ beforeAll(async () => {
893
+ returnPathStack = await setupTestStack({
894
+ features: [
895
+ createConfigFeature(),
896
+ createTenantFeature(),
897
+ createComplianceProfilesFeature(),
898
+ createTenantLifecycleFeature(),
899
+ createBillingFoundationFeature({
900
+ baseUrl: "https://app.example.com",
901
+ catalog: catalog({ returnPath: "/billing/portal-return" }),
902
+ }),
903
+ mockPlanProviderFeature,
904
+ ],
905
+ });
906
+ await unsafeCreateEntityTable(returnPathStack.db, tenantEntity);
907
+ await unsafeCreateEntityTable(returnPathStack.db, tenantComplianceProfileEntity);
908
+ });
909
+
910
+ afterAll(async () => {
911
+ await returnPathStack.cleanup();
912
+ });
913
+
914
+ test("returnUrl is baseUrl + returnPath", async () => {
915
+ const admin = adminFor(7029);
916
+ await createSubscription(admin.tenantId, { tier: "starter" }, returnPathStack);
917
+
918
+ await returnPathStack.http.writeOk(
919
+ SubscriptionFoundationHandlers.createPortalSession,
920
+ {},
921
+ admin,
922
+ );
923
+ expect(portalCalls.at(-1)?.returnUrl).toBe("https://app.example.com/billing/portal-return");
924
+ });
925
+ });
926
+ });
927
+
928
+ // =============================================================================
929
+ // 13. create-portal-session — catalog.purchaseRoles gates who may open the
930
+ // portal (#3316 P3): only that role gets in, everyone else is 403.
931
+ // =============================================================================
932
+
933
+ describe("create-portal-session — catalog.purchaseRoles", () => {
934
+ let purchaseRoleStack: TestStack;
935
+
936
+ beforeAll(async () => {
937
+ purchaseRoleStack = await setupTestStack({
938
+ features: [
939
+ createConfigFeature(),
940
+ createTenantFeature(),
941
+ createComplianceProfilesFeature(),
942
+ createTenantLifecycleFeature(),
943
+ createBillingFoundationFeature({
944
+ baseUrl: "https://app.example.com",
945
+ catalog: catalog({ purchaseRoles: ["Admin"] }),
946
+ }),
947
+ mockPlanProviderFeature,
948
+ ],
949
+ });
950
+ await unsafeCreateEntityTable(purchaseRoleStack.db, tenantEntity);
951
+ await unsafeCreateEntityTable(purchaseRoleStack.db, tenantComplianceProfileEntity);
952
+ });
953
+
954
+ afterAll(async () => {
955
+ await purchaseRoleStack.cleanup();
956
+ });
957
+
958
+ test("a user with only the configured purchase-role gets 200", async () => {
959
+ const tenantId = testTenantId(7030);
960
+ await createSubscription(tenantId, { tier: "starter" }, purchaseRoleStack);
961
+ const adminRoleUser = createTestUser({ id: 7030, tenantId, roles: ["Admin"] });
962
+
963
+ const result = (await purchaseRoleStack.http.writeOk(
964
+ SubscriptionFoundationHandlers.createPortalSession,
965
+ {},
966
+ adminRoleUser,
967
+ )) as { url: string };
968
+ expect(result.url).toBeTruthy();
969
+ });
970
+
971
+ test("a user without the configured purchase-role gets 403", async () => {
972
+ const tenantId = testTenantId(7031);
973
+ await createSubscription(tenantId, { tier: "starter" }, purchaseRoleStack);
974
+ const nonPurchaser = createTestUser({ id: 7031, tenantId, roles: ["TenantAdmin"] });
975
+
976
+ const error = await purchaseRoleStack.http.writeErr(
977
+ SubscriptionFoundationHandlers.createPortalSession,
978
+ {},
979
+ nonPurchaser,
980
+ );
981
+ expect(error.httpStatus).toBe(403);
982
+ });
983
+ });
984
+
985
+ // =============================================================================
986
+ // 14. No provider registered at all — billing-plans stays enabled:false,
987
+ // every plan unavailable, start-plan-checkout fails feature_disabled
988
+ // (#3316 P5).
989
+ // =============================================================================
990
+
991
+ describe("no subscription-provider mounted at all", () => {
992
+ let noProviderStack: TestStack;
993
+
994
+ beforeAll(async () => {
995
+ noProviderStack = await setupTestStack({
996
+ features: [
997
+ createConfigFeature(),
998
+ createTenantFeature(),
999
+ createComplianceProfilesFeature(),
1000
+ createTenantLifecycleFeature(),
1001
+ createBillingFoundationFeature({ baseUrl: "https://app.example.com", catalog: catalog() }),
1002
+ // Deliberately no provider plugin mounted.
1003
+ ],
1004
+ });
1005
+ await unsafeCreateEntityTable(noProviderStack.db, tenantEntity);
1006
+ await unsafeCreateEntityTable(noProviderStack.db, tenantComplianceProfileEntity);
1007
+ });
1008
+
1009
+ afterAll(async () => {
1010
+ await noProviderStack.cleanup();
1011
+ });
1012
+
1013
+ test("billing-plans: enabled:false, every plan unavailable", async () => {
1014
+ const admin = adminFor(7032);
1015
+ const result = (await noProviderStack.http.queryOk(
1016
+ "billing-foundation:query:billing-plans",
1017
+ {},
1018
+ admin,
1019
+ )) as { enabled: boolean; plans: Array<{ action: string }> };
1020
+ expect(result.enabled).toBe(false);
1021
+ for (const plan of result.plans) expect(plan.action).toBe("unavailable");
1022
+ });
1023
+
1024
+ test("start-plan-checkout: feature_disabled", async () => {
1025
+ const admin = adminFor(7033);
1026
+ const error = await noProviderStack.http.writeErr(
1027
+ SubscriptionFoundationHandlers.startPlanCheckout,
1028
+ { tier: "pro" },
1029
+ admin,
1030
+ );
1031
+ expect(error.httpStatus).toBe(403);
1032
+ expect(error.i18nKey).toBe("errors.feature.disabled");
1033
+ });
1034
+ });
@@ -22,6 +22,10 @@ function subscriptionView(overrides: Partial<SubscriptionView> = {}): Subscripti
22
22
  // at runtime — SubscriptionView.lastChangedAt is typed against the
23
23
  // ambient Temporal global; temporal-polyfill's own nominal Instant type
24
24
  // differs across the two .d.ts sources.
25
+ currentPeriodEnd: TemporalPolyfill.Instant.from(
26
+ "2024-02-01T00:00:00Z",
27
+ ) as unknown as Temporal.Instant,
28
+ cancelAt: null,
25
29
  lastChangedAt: TemporalPolyfill.Instant.from(
26
30
  "2024-01-01T00:00:00Z",
27
31
  ) as unknown as Temporal.Instant,
@@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test";
4
4
  import { paymentAggregateId, paymentRowId, subscriptionAggregateId } from "../aggregate-id";
5
5
  import {
6
6
  BILLING_FOUNDATION_FEATURE,
7
+ BILLING_PLANS_SCREEN_ID,
7
8
  SUBSCRIPTION_PROVIDER_EXTENSION,
8
9
  SubscriptionEventTypes,
9
10
  SubscriptionFoundationHandlers,
@@ -234,3 +235,22 @@ describe("createBillingFoundationFeature — factory validation", () => {
234
235
  ).toThrow(/viewRoles must not be empty/);
235
236
  });
236
237
  });
238
+
239
+ describe("billingFoundationFeature — dormant billing-plans screen (#3316 P9)", () => {
240
+ test("not registered without a catalog", () => {
241
+ const feature = createBillingFoundationFeature({ baseUrl: "https://example.com" });
242
+ expect(feature.screens[BILLING_PLANS_SCREEN_ID]).toBeUndefined();
243
+ });
244
+
245
+ test("not registered with no options at all", () => {
246
+ expect(billingFoundationFeature.screens[BILLING_PLANS_SCREEN_ID]).toBeUndefined();
247
+ });
248
+
249
+ test("registered once a catalog is configured", () => {
250
+ const feature = createBillingFoundationFeature({
251
+ baseUrl: "https://example.com",
252
+ catalog: catalog(),
253
+ });
254
+ expect(feature.screens[BILLING_PLANS_SCREEN_ID]).toBeDefined();
255
+ });
256
+ });