@cosmicdrift/kumiko-bundled-features 0.247.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 +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +37 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +5 -1
- package/src/billing-foundation/types.ts +5 -0
- package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +49 -0
- package/src/cap-overview/__tests__/cap-overview.boot.test.ts +26 -1
- package/src/cap-overview/access.ts +9 -0
- package/src/cap-overview/feature.ts +3 -3
- package/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/src/cap-overview/index.ts +1 -0
- package/src/cap-overview/screens.ts +3 -2
- package/src/subscription-mollie/__tests__/plugin-methods.test.ts +86 -0
- package/src/subscription-mollie/plugin-methods.ts +9 -1
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +49 -0
- package/src/subscription-stripe/plugin-methods.ts +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
-
|
|
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.
|
|
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;
|
|
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
|
|
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:
|
|
25
|
+
access: { roles: MY_CAPS_ACCESS_ROLES },
|
|
26
26
|
handler: async (query, ctx) => {
|
|
27
27
|
if (!ctx.systemDb) {
|
|
28
28
|
throw new InternalError({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|
|
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
|
|
47
|
+
mode,
|
|
47
48
|
line_items: [{ price: options.priceId, quantity: 1 }],
|
|
48
49
|
success_url: options.successUrl,
|
|
49
50
|
cancel_url: options.cancelUrl,
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
61
|
-
//
|
|
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 };
|