@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.
- package/package.json +11 -10
- package/src/auth-email-password/constants.ts +44 -44
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +159 -8
- package/src/billing-foundation/__tests__/billing-plans-admin-only.integration.test.ts +158 -0
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +1034 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +153 -0
- package/src/billing-foundation/__tests__/constants.test.ts +72 -0
- package/src/billing-foundation/__tests__/feature.test.ts +139 -1
- package/src/billing-foundation/changes.json +54 -0
- package/src/billing-foundation/checkout-core.ts +382 -0
- package/src/billing-foundation/constants.ts +98 -0
- package/src/billing-foundation/entities.ts +3 -1
- package/src/billing-foundation/events.ts +5 -0
- package/src/billing-foundation/feature.ts +155 -87
- package/src/billing-foundation/get-subscription-for-tenant.ts +11 -0
- package/src/billing-foundation/handlers/billing-plans.query.ts +32 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +42 -44
- package/src/billing-foundation/handlers/create-portal-session.write.ts +76 -64
- package/src/billing-foundation/handlers/process-event.write.ts +2 -0
- package/src/billing-foundation/handlers/start-plan-checkout.write.ts +75 -0
- package/src/billing-foundation/handlers/switch-plan.write.ts +108 -0
- package/src/billing-foundation/i18n.ts +66 -0
- package/src/billing-foundation/index.ts +27 -5
- package/src/billing-foundation/plan-catalog.ts +218 -0
- package/src/billing-foundation/projection.ts +48 -13
- package/src/billing-foundation/screens.ts +25 -0
- package/src/billing-foundation/types.ts +175 -1
- package/src/billing-foundation/validate-options.ts +66 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +365 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +283 -0
- package/src/billing-foundation/web/client-plugin.tsx +21 -0
- package/src/billing-foundation/web/index.ts +23 -0
- package/src/billing-foundation/webhook-handler.ts +1 -0
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +88 -0
- package/src/cap-counter/index.ts +1 -0
- package/src/cap-counter/stock-cap-guard.ts +17 -5
- package/src/cap-overview/__tests__/cap-limit-from-config.integration.test.ts +141 -0
- package/src/cap-overview/changes.json +6 -0
- package/src/cap-overview/handlers/caps-usage.query.ts +1 -1
- package/src/cap-overview/handlers/tenant-caps-list.query.ts +26 -1
- package/src/cap-overview/types.ts +6 -1
- package/src/subscription-mollie/changes.json +8 -1
- package/src/subscription-mollie/feature.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +25 -1
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +541 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +35 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +74 -8
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +39 -2
- package/src/subscription-stripe/changes.json +24 -0
- package/src/subscription-stripe/feature.ts +40 -1
- package/src/subscription-stripe/plugin-methods.ts +352 -1
- package/src/subscription-stripe/runtime.ts +23 -4
- package/src/subscription-stripe/verify-webhook.ts +24 -1
- 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.
|
|
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.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// user + tenant + Admin
|
|
32
|
-
//
|
|
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-
|
|
36
|
-
//
|
|
37
|
-
// inviteCreate:
|
|
38
|
-
// inviteAccept: logged-in
|
|
39
|
-
// inviteAcceptWithLogin: anon
|
|
40
|
-
// inviteSignupComplete: anon
|
|
41
|
-
// inviteCancel:
|
|
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-
|
|
80
|
-
//
|
|
81
|
-
// anti-enumeration-
|
|
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-
|
|
85
|
-
//
|
|
86
|
-
// anti-enumeration
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
|
91
|
-
// (anti-enumeration). emailMismatch
|
|
92
|
-
//
|
|
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 (
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// user-
|
|
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
|
|
110
|
-
// Restricted
|
|
111
|
-
// invalid_credentials
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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-
|
|
136
|
-
// "
|
|
137
|
-
//
|
|
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
|
|
141
|
-
// Slack); invitees
|
|
142
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
{
|
|
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
|
+
});
|