@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.
- package/package.json +10 -10
- package/src/auth-email-password/constants.ts +44 -44
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +76 -4
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +235 -1
- package/src/billing-foundation/__tests__/checkout-core.test.ts +4 -0
- package/src/billing-foundation/__tests__/feature.test.ts +20 -0
- package/src/billing-foundation/changes.json +36 -0
- package/src/billing-foundation/checkout-core.ts +163 -40
- package/src/billing-foundation/entities.ts +3 -1
- package/src/billing-foundation/events.ts +5 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +6 -0
- package/src/billing-foundation/handlers/billing-plans.query.ts +3 -3
- package/src/billing-foundation/handlers/create-portal-session.write.ts +9 -18
- package/src/billing-foundation/handlers/process-event.write.ts +2 -0
- package/src/billing-foundation/handlers/start-plan-checkout.write.ts +8 -8
- package/src/billing-foundation/handlers/switch-plan.write.ts +9 -16
- package/src/billing-foundation/i18n.ts +4 -0
- package/src/billing-foundation/index.ts +18 -11
- package/src/billing-foundation/plan-catalog.ts +26 -12
- package/src/billing-foundation/projection.ts +31 -6
- package/src/billing-foundation/types.ts +32 -4
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +22 -9
- package/src/billing-foundation/web/billing-plans-panel.tsx +51 -9
- package/src/billing-foundation/web/index.ts +15 -0
- package/src/billing-foundation/webhook-handler.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +20 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +24 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +20 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +37 -0
- package/src/subscription-stripe/changes.json +6 -0
- package/src/subscription-stripe/feature.ts +25 -0
- package/src/subscription-stripe/plugin-methods.ts +12 -4
- 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.
|
|
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.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@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",
|
|
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.
|
|
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.
|
|
172
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
|
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;
|
|
@@ -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
|
-
{
|
|
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
|
|
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
|
-
{
|
|
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
|
+
});
|