@cosmicdrift/kumiko-bundled-features 0.248.0 → 0.249.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.248.0",
3
+ "version": "0.249.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>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.248.0",
134
- "@cosmicdrift/kumiko-framework": "0.248.0",
135
- "@cosmicdrift/kumiko-headless": "0.248.0",
136
- "@cosmicdrift/kumiko-renderer": "0.248.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.248.0",
138
- "@cosmicdrift/kumiko-types": "0.248.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.249.0",
134
+ "@cosmicdrift/kumiko-framework": "0.249.0",
135
+ "@cosmicdrift/kumiko-headless": "0.249.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.249.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.249.0",
138
+ "@cosmicdrift/kumiko-types": "0.249.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.248.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.248.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.249.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.249.0"
169
169
  }
170
170
  }
@@ -68,6 +68,7 @@ const mockCheckoutCalls: Array<{
68
68
  successUrl: string;
69
69
  cancelUrl: string;
70
70
  providerCustomerId?: string;
71
+ mode?: "subscription" | "payment";
71
72
  }> = [];
72
73
  const mockPortalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
73
74
 
@@ -82,6 +83,7 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
82
83
  successUrl: options.successUrl,
83
84
  cancelUrl: options.cancelUrl,
84
85
  ...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
86
+ ...(options.mode && { mode: options.mode }),
85
87
  });
86
88
  return { url: `https://mock.example/checkout/${options.priceId}` };
87
89
  },
@@ -513,6 +515,41 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
513
515
  );
514
516
  expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
515
517
  });
518
+
519
+ test("optional mode wird durchgereicht (One-off-Payment-Flow, fw#2755)", async () => {
520
+ mockCheckoutCalls.length = 0;
521
+ const admin = adminFor(3014);
522
+ await stack.http.writeOk(
523
+ "billing-foundation:write:create-checkout-session",
524
+ {
525
+ providerName: "mock",
526
+ priceId: "price_topup_test",
527
+ successUrl: "https://example.com/s",
528
+ cancelUrl: "https://example.com/c",
529
+ mode: "payment",
530
+ },
531
+ admin,
532
+ );
533
+ expect(mockCheckoutCalls).toHaveLength(1);
534
+ expect(mockCheckoutCalls[0]?.mode).toBe("payment");
535
+ });
536
+
537
+ test("mode omitted → plugin receives no mode field (Regression-Pin für bestehende Aufrufer)", async () => {
538
+ mockCheckoutCalls.length = 0;
539
+ const admin = adminFor(3015);
540
+ await stack.http.writeOk(
541
+ "billing-foundation:write:create-checkout-session",
542
+ {
543
+ providerName: "mock",
544
+ priceId: "price_pro_test",
545
+ successUrl: "https://example.com/s",
546
+ cancelUrl: "https://example.com/c",
547
+ },
548
+ admin,
549
+ );
550
+ expect(mockCheckoutCalls).toHaveLength(1);
551
+ expect(mockCheckoutCalls[0]?.mode).toBeUndefined();
552
+ });
516
553
  });
517
554
 
518
555
  describe("scenario 7: create-portal-session — Plugin-routing", () => {
@@ -32,13 +32,16 @@ const createCheckoutSessionSchema = z.object({
32
32
  /** Optional: existierender provider-customer wenn der Tenant schon
33
33
  * einen account beim Provider hat (= Plan-Wechsel statt Neuregistrierung). */
34
34
  providerCustomerId: z.string().max(200).optional(),
35
+ /** Optional: `"payment"` for a one-off checkout (e.g. credit top-up)
36
+ * instead of a recurring subscription. Defaults to `"subscription"`. */
37
+ mode: z.enum(["subscription", "payment"]).optional(),
35
38
  });
36
39
  type CreateCheckoutSessionPayload = z.infer<typeof createCheckoutSessionSchema>;
37
40
 
38
41
  export const createCheckoutSessionHandler: WriteHandlerDef = {
39
42
  name: "create-checkout-session",
40
43
  description:
41
- "Opens a hosted checkout page at the named subscription provider for the caller's tenant and returns its URL; use it when a tenant admin wants to subscribe or switch to a paid plan.",
44
+ 'Opens a hosted checkout page at the named subscription provider for the caller\'s tenant and returns its URL; use it when a tenant admin wants to subscribe, switch plans, or make a one-off payment (mode: "payment").',
42
45
  schema: createCheckoutSessionSchema,
43
46
  // Tenant-Admin-only — der Tenant muss bewusst seine Subscription
44
47
  // konfigurieren. SystemAdmin als Fallback für Operator-Initiated-Flows.
@@ -71,6 +74,7 @@ export const createCheckoutSessionHandler: WriteHandlerDef = {
71
74
  successUrl: payload.successUrl,
72
75
  cancelUrl: payload.cancelUrl,
73
76
  ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
77
+ ...(payload.mode && { mode: payload.mode }),
74
78
  });
75
79
 
76
80
  return {
@@ -129,6 +129,11 @@ export type SubscriptionProviderPlugin = {
129
129
  * schon einen Account beim Provider hat. Sonst legt der Provider
130
130
  * beim checkout einen neuen customer an. */
131
131
  readonly providerCustomerId?: string;
132
+ /** Checkout mode. `"subscription"` (default) starts a recurring
133
+ * billing flow; `"payment"` a one-off checkout (e.g. pay-per-use
134
+ * credit top-ups). Optional with default `"subscription"` — existing
135
+ * callers are unaffected. */
136
+ readonly mode?: "subscription" | "payment";
132
137
  },
133
138
  ) => Promise<{ readonly url: string }>;
134
139
 
@@ -158,6 +158,55 @@ describe("cap-overview tenant isolation", () => {
158
158
  });
159
159
  });
160
160
 
161
+ describe("cap-overview my-caps for regular tenant members", () => {
162
+ test("a regular User(A) reads A's own usage, not a 403", async () => {
163
+ const memberA = createTestUser({ id: 90041, tenantId: TENANT_A, roles: ["User"] });
164
+ const result = await stack.http.queryOk<{
165
+ rows: readonly { id: string; used: number | null }[];
166
+ }>(CapOverviewQueries.capsUsage, {}, memberA);
167
+ const widgetsRow = result.rows.find((row) => row.id === testCap.id);
168
+ expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_A]);
169
+ });
170
+
171
+ test("an Editor(B) reads B's own usage, not A's", async () => {
172
+ const editorB = createTestUser({ id: 90042, tenantId: TENANT_B, roles: ["Editor"] });
173
+ const result = await stack.http.queryOk<{
174
+ rows: readonly { id: string; used: number | null }[];
175
+ }>(CapOverviewQueries.capsUsage, {}, editorB);
176
+ const widgetsRow = result.rows.find((row) => row.id === testCap.id);
177
+ expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_B]);
178
+ expect(widgetsRow?.used).not.toBe(USAGE_BY_TENANT[TENANT_A]);
179
+ });
180
+
181
+ test("a regular User(A) cannot read tenant B's usage via override", async () => {
182
+ const memberA = createTestUser({ id: 90043, tenantId: TENANT_A, roles: ["User"] });
183
+ const res = await stack.http.query(
184
+ CapOverviewQueries.capsUsage,
185
+ { tenantId: TENANT_B },
186
+ memberA,
187
+ );
188
+ expect(res.status).toBe(403);
189
+ });
190
+
191
+ test("a regular User(A) cannot list all tenants", async () => {
192
+ const memberA = createTestUser({ id: 90044, tenantId: TENANT_A, roles: ["User"] });
193
+ const res = await stack.http.query(CapOverviewQueries.tenantCapsList, {}, memberA);
194
+ expect(res.status).toBe(403);
195
+ });
196
+
197
+ test("a regular User(A) cannot enumerate tenant names via the platform picker", async () => {
198
+ const memberA = createTestUser({ id: 90045, tenantId: TENANT_A, roles: ["User"] });
199
+ const res = await stack.http.query(CapOverviewQueries.tenantOptions, {}, memberA);
200
+ expect(res.status).toBe(403);
201
+ });
202
+
203
+ test("an unranked app role still cannot reach my-caps", async () => {
204
+ const viewerA = createTestUser({ id: 90046, tenantId: TENANT_A, roles: ["Viewer"] });
205
+ const res = await stack.http.query(CapOverviewQueries.capsUsage, {}, viewerA);
206
+ expect(res.status).toBe(403);
207
+ });
208
+ });
209
+
161
210
  describe("cap-overview tier filter operators", () => {
162
211
  test("op:ne excludes tenants matching the value, not just those matching it", async () => {
163
212
  const sysAdmin = createTestUser({ id: 90017, tenantId: TENANT_A, roles: ["SystemAdmin"] });
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
3
4
  import { billingFoundationFeature } from "../../billing-foundation";
4
5
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
5
6
  import { createConfigFeature } from "../../config/feature";
6
7
  import { createTenantFeature } from "../../tenant";
7
8
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
8
9
  import { tierEngineFeature } from "../../tier-engine";
10
+ import { MY_CAPS_ACCESS_ROLES } from "../access";
9
11
  import {
10
12
  MY_CAPS_SCREEN_ID,
11
13
  PLATFORM_TENANT_CAPS_SCREEN_ID,
@@ -58,6 +60,29 @@ describe("cap-overview boot", () => {
58
60
  expect(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.type).toBe("dashboard");
59
61
  });
60
62
 
63
+ test("my-caps screen and the query filling its cards carry the SAME rule", () => {
64
+ const feature = createCapOverviewFeature({ caps: [testCap] });
65
+ expect(rolesOf(feature.screens[MY_CAPS_SCREEN_ID]?.access)).toEqual([...MY_CAPS_ACCESS_ROLES]);
66
+ expect(rolesOf(feature.queryHandlers["caps:usage"]?.access)).toEqual([...MY_CAPS_ACCESS_ROLES]);
67
+ });
68
+
69
+ test("my-caps stays reachable for every role that reached it before", () => {
70
+ for (const role of access.admin) {
71
+ expect(MY_CAPS_ACCESS_ROLES).toContain(role);
72
+ }
73
+ expect(MY_CAPS_ACCESS_ROLES).toContain("User");
74
+ });
75
+
76
+ test("the platform-wide screens and their queries stay SystemAdmin-only", () => {
77
+ const feature = createCapOverviewFeature({ caps: [testCap] });
78
+ expect(rolesOf(feature.screens[TENANT_CAP_LIST_SCREEN_ID]?.access)).toEqual(["SystemAdmin"]);
79
+ expect(rolesOf(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.access)).toEqual([
80
+ "SystemAdmin",
81
+ ]);
82
+ expect(rolesOf(feature.queryHandlers["tenant-caps:list"]?.access)).toEqual(["SystemAdmin"]);
83
+ expect(rolesOf(feature.queryHandlers["tenant-options"]?.access)).toEqual(["SystemAdmin"]);
84
+ });
85
+
61
86
  test("tenant-cap-list facet field is a declared column and the list handler schema accepts filters", () => {
62
87
  const feature = createCapOverviewFeature({ caps: [testCap], tiers: ["free", "pro"] });
63
88
  const screen = feature.screens[TENANT_CAP_LIST_SCREEN_ID];
@@ -0,0 +1,9 @@
1
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // Every built-in membership rank (role-assignment.ts: User < Editor < Admin <
4
+ // TenantAdmin < SystemAdmin), so a regular team member can see their own
5
+ // tenant's usage. Deliberately not `access.authenticated` — that preset omits
6
+ // TenantAdmin and would revoke the access TenantAdmins already have. The
7
+ // screen and the query that fills its cards must carry the SAME rule, or the
8
+ // dashboard renders and every card 403s.
9
+ export const MY_CAPS_ACCESS_ROLES = access.roles("User", "Editor", ...access.admin);
@@ -5,8 +5,8 @@
5
5
  // platform-wide table of every tenant's tier, billing status, and
6
6
  // usage against a configurable set of caps.
7
7
  // 2. caps:usage query + my-caps / platform-tenant-caps dashboards —
8
- // per-tenant usage cards. TenantAdmin sees their own tenant;
9
- // SystemAdmin can additionally view any tenant via `tenantId`.
8
+ // per-tenant usage cards. Every member of a tenant sees their own
9
+ // tenant; SystemAdmin can additionally view any tenant via `tenantId`.
10
10
  //
11
11
  // **What this feature does NOT do:**
12
12
  // - No writes. Reads tier-engine's read_tier_assignments, billing-
@@ -52,7 +52,7 @@ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): Featur
52
52
 
53
53
  return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
54
54
  r.describe(
55
- "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; TenantAdmin gets their own usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
55
+ "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; every member of a tenant gets their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
56
56
  );
57
57
  r.uiHints({
58
58
  displayLabel: "Cap Overview · Tier & Usage Visibility",
@@ -1,7 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
3
  import {
4
- access,
5
4
  crossTenantOverrideDenied,
6
5
  defineQueryHandler,
7
6
  type QueryHandlerDefinition,
@@ -9,6 +8,7 @@ import {
9
8
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
9
  import { z } from "zod";
11
10
  import { tierAssignmentEntity } from "../../tier-engine";
11
+ import { MY_CAPS_ACCESS_ROLES } from "../access";
12
12
  import type { CapSpec, CapUsageWithMeta } from "../types";
13
13
  import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math";
14
14
 
@@ -20,9 +20,9 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
20
20
  return defineQueryHandler({
21
21
  name: "caps:usage",
22
22
  description:
23
- "Returns one tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (SystemAdmin may target another tenant via tenantId).",
23
+ "Returns the calling user's own tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (only SystemAdmin may target another tenant via tenantId).",
24
24
  schema: z.object({ tenantId: z.string().min(1).optional() }),
25
- access: { roles: access.admin },
25
+ access: { roles: MY_CAPS_ACCESS_ROLES },
26
26
  handler: async (query, ctx) => {
27
27
  if (!ctx.systemDb) {
28
28
  throw new InternalError({
@@ -1,5 +1,6 @@
1
1
  // Public API of the cap-overview bundled-feature.
2
2
 
3
+ export { MY_CAPS_ACCESS_ROLES } from "./access";
3
4
  export {
4
5
  CAP_CARDS_PANEL_COMPONENT,
5
6
  CAP_OVERVIEW_FEATURE,
@@ -1,4 +1,5 @@
1
- import { access, type ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { MY_CAPS_ACCESS_ROLES } from "./access";
2
3
  import {
3
4
  CAP_CARDS_PANEL_COMPONENT,
4
5
  CAP_USAGE_CELL_COMPONENT,
@@ -78,7 +79,7 @@ export const myCapsScreen: ScreenDefinition = {
78
79
  component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
79
80
  },
80
81
  ],
81
- access: { roles: access.admin },
82
+ access: { roles: MY_CAPS_ACCESS_ROLES },
82
83
  };
83
84
 
84
85
  export const platformTenantCapsScreen: ScreenDefinition = {
@@ -0,0 +1,86 @@
1
+ // Unit tests for the Mollie plugin methods (createCheckoutSession only —
2
+ // createPortalSession/cancelSubscription are not implemented, see
3
+ // plugin-methods.ts). Mollie-SDK calls are mocked via spyOn — we test our
4
+ // sequenceType/metadata mapping, NOT Mollie itself.
5
+
6
+ import { describe, expect, spyOn, test } from "bun:test";
7
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
8
+ import type { MollieClient } from "@mollie/api-client";
9
+ import { createMollieCheckoutSession, type MolliePriceConfig } from "../plugin-methods";
10
+
11
+ const PRICE_CONFIG: Readonly<Record<string, MolliePriceConfig>> = {
12
+ plan_pro: {
13
+ amountValue: "9.99",
14
+ amountCurrency: "EUR",
15
+ interval: "1 month",
16
+ description: "Pro Plan",
17
+ },
18
+ };
19
+
20
+ const WEBHOOK_URL = "https://app.example.com/api/subscription/webhook/mollie";
21
+ const stubCtx = {} as HandlerContext;
22
+
23
+ // No `: MollieClient` return-type annotation — keeping the inferred narrow
24
+ // shape lets spyOn resolve a non-overloaded signature for `payments.create`
25
+ // (MollieClient's real signature is Promise|void-overloaded, which makes
26
+ // spyOn(...).mockResolvedValue infer `never`, see plugin-methods.ts's own
27
+ // `@cast-boundary` comment on the same overload).
28
+ function buildClient() {
29
+ return {
30
+ customers: {
31
+ create: async () => ({ id: "cus_mock" }),
32
+ },
33
+ payments: {
34
+ create: async () => ({ getCheckoutUrl: () => "https://www.mollie.com/checkout/mock" }),
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("createMollieCheckoutSession", () => {
40
+ test("mode omitted defaults to sequenceType 'first' (mandate-setup, Regression-Pin für bestehende Aufrufer)", async () => {
41
+ const client = buildClient();
42
+ const createMock = spyOn(client.payments, "create").mockResolvedValue({
43
+ getCheckoutUrl: () => "https://www.mollie.com/checkout/mock",
44
+ });
45
+
46
+ const checkout = createMollieCheckoutSession(
47
+ client as unknown as MollieClient,
48
+ PRICE_CONFIG,
49
+ WEBHOOK_URL,
50
+ );
51
+ const result = await checkout(stubCtx, {
52
+ priceId: "plan_pro",
53
+ tenantId: "tenant-001",
54
+ successUrl: "https://example.com/success",
55
+ cancelUrl: "https://example.com/cancel",
56
+ providerCustomerId: "cus_existing",
57
+ });
58
+
59
+ expect(result).toEqual({ url: "https://www.mollie.com/checkout/mock" });
60
+ expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ sequenceType: "first" }));
61
+ });
62
+
63
+ test("mode='payment': sequenceType wird zu 'oneoff' (One-off-Top-up, kein Mandate-setup)", async () => {
64
+ const client = buildClient();
65
+ const createMock = spyOn(client.payments, "create").mockResolvedValue({
66
+ getCheckoutUrl: () => "https://www.mollie.com/checkout/topup",
67
+ });
68
+
69
+ const checkout = createMollieCheckoutSession(
70
+ client as unknown as MollieClient,
71
+ PRICE_CONFIG,
72
+ WEBHOOK_URL,
73
+ );
74
+ const result = await checkout(stubCtx, {
75
+ priceId: "plan_pro",
76
+ tenantId: "tenant-002",
77
+ successUrl: "https://example.com/success",
78
+ cancelUrl: "https://example.com/cancel",
79
+ providerCustomerId: "cus_existing",
80
+ mode: "payment",
81
+ });
82
+
83
+ expect(result).toEqual({ url: "https://www.mollie.com/checkout/topup" });
84
+ expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ sequenceType: "oneoff" }));
85
+ });
86
+ });
@@ -52,12 +52,20 @@ export function createMollieCheckoutSession(
52
52
  customerId = customer.id;
53
53
  }
54
54
 
55
+ // mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
56
+ // setup. verify-webhook.ts's ensureSubscriptionForMandate already
57
+ // gates on sequenceType === "first" — a oneoff payment falls into
58
+ // its "not our domain" branch and creates NO Mollie subscription.
59
+ // Webhook resolution for oneoff payments is separate follow-up
60
+ // work (offlot#109), not part of this contract.
61
+ const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
62
+
55
63
  // payments.create ist overloaded (Promise OR void mit callback);
56
64
  // explicit cast auf Promise<Payment>-overload.
57
65
  const payment = (await (client.payments.create({
58
66
  amount: { currency: priceCfg.amountCurrency, value: priceCfg.amountValue },
59
67
  description: priceCfg.description,
60
- sequenceType: SequenceType.first,
68
+ sequenceType,
61
69
  customerId,
62
70
  redirectUrl: options.successUrl,
63
71
  cancelUrl: options.cancelUrl,
@@ -116,6 +116,55 @@ describe("createStripeCheckoutSession", () => {
116
116
  );
117
117
  });
118
118
 
119
+ test("mode='payment': ruft stripe.checkout.sessions.create mit mode=payment + payment_intent_data.metadata (KEIN subscription_data)", async () => {
120
+ const stripe = buildStripe();
121
+ const createMock = spyOn(stripe.checkout.sessions, "create")
122
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
123
+ .mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/topup" } as any);
124
+
125
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
126
+ const result = await checkout(stubCtx, {
127
+ priceId: "price_credits_topup",
128
+ tenantId: "tenant-003",
129
+ successUrl: "https://example.com/success",
130
+ cancelUrl: "https://example.com/cancel",
131
+ mode: "payment",
132
+ });
133
+
134
+ expect(result).toEqual({ url: "https://checkout.stripe.com/c/pay/topup" });
135
+ expect(createMock).toHaveBeenCalledWith({
136
+ mode: "payment",
137
+ line_items: [{ price: "price_credits_topup", quantity: 1 }],
138
+ success_url: "https://example.com/success",
139
+ cancel_url: "https://example.com/cancel",
140
+ // Drift-Pin: payment-mode carries tenantId via payment_intent_data,
141
+ // NOT subscription_data — Stripe rejects subscription_data outside
142
+ // subscription-mode.
143
+ payment_intent_data: {
144
+ metadata: { tenantId: "tenant-003" },
145
+ },
146
+ });
147
+ });
148
+
149
+ test("mode omitted defaults to 'subscription' (Regression-Pin für bestehende Aufrufer)", async () => {
150
+ const stripe = buildStripe();
151
+ const createMock = spyOn(stripe.checkout.sessions, "create")
152
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
153
+ .mockResolvedValue({ url: "https://x" } as any);
154
+
155
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
156
+ await checkout(stubCtx, {
157
+ priceId: "price_x",
158
+ tenantId: "tenant-004",
159
+ successUrl: "https://x/s",
160
+ cancelUrl: "https://x/c",
161
+ });
162
+
163
+ expect(createMock).toHaveBeenCalledWith(
164
+ expect.objectContaining({ mode: "subscription", subscription_data: expect.anything() }),
165
+ );
166
+ });
167
+
119
168
  test("throws wenn Stripe keine url returnt (defensive — sollte nie passieren bei mode=subscription)", async () => {
120
169
  const stripe = buildStripe();
121
170
  spyOn(stripe.checkout.sessions, "create")
@@ -41,24 +41,26 @@ export function createStripeCheckoutSession(runtime: StripeCtxRuntime) {
41
41
  // billing-live-config-key write-side.
42
42
  await runtime.assertBillingLive(ctx);
43
43
  const stripe = await runtime.clientForCtx(ctx);
44
+ const mode = options.mode ?? "subscription";
44
45
 
45
46
  const session = await stripe.checkout.sessions.create({
46
- mode: "subscription",
47
+ mode,
47
48
  line_items: [{ price: options.priceId, quantity: 1 }],
48
49
  success_url: options.successUrl,
49
50
  cancel_url: options.cancelUrl,
50
- // metadata.tenantId landet auf der subscription die durch diese
51
- // checkout-session entsteht — beim subsequent webhook lesen wir's
52
- // aus subscription.metadata.tenantId zurück.
53
- subscription_data: {
54
- metadata: { tenantId: options.tenantId },
55
- },
51
+ // subscription_data is subscription-mode-only (Stripe rejects it in
52
+ // payment-mode) — the subsequent webhook reads metadata.tenantId off
53
+ // the subscription it creates. payment-mode has no subscription, so
54
+ // tenantId travels via payment_intent_data.metadata instead.
55
+ ...(mode === "subscription"
56
+ ? { subscription_data: { metadata: { tenantId: options.tenantId } } }
57
+ : { payment_intent_data: { metadata: { tenantId: options.tenantId } } }),
56
58
  ...(options.providerCustomerId && { customer: options.providerCustomerId }),
57
59
  });
58
60
 
59
61
  if (!session.url) {
60
- // Stripe garantiert url für mode: subscription. Defensive für
61
- // zukünftige API-Drift.
62
+ // Defensive: Stripe returns a hosted url for both modes today —
63
+ // guards against future API drift.
62
64
  throw new Error("subscription-stripe: checkout.sessions.create returned no url");
63
65
  }
64
66
  return { url: session.url };