@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
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
// Integration-test for the catalog-derived billing-plans query + the
|
|
2
|
+
// start-plan-checkout/switch-plan write-handlers (createBillingFoundationFeature
|
|
3
|
+
// with a `catalog`). Foundation-level routing/state-machine test — provider-
|
|
4
|
+
// specific behavior (real Stripe price/portal calls) is covered in
|
|
5
|
+
// subscription-stripe's own tests.
|
|
6
|
+
//
|
|
7
|
+
// Mock-provider strategy: same as billing-foundation.integration.test.ts's
|
|
8
|
+
// scenario 6/7 mock, extended with a `priceToTier` catalog + mutable
|
|
9
|
+
// isBillingEnabled/retrievePrices toggles so every spec branch (disabled,
|
|
10
|
+
// price-lookup failure, legacy inactive price, ...) is reachable per-test
|
|
11
|
+
// without a second setupTestStack.
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
18
|
+
import {
|
|
19
|
+
createTestUser,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
testTenantId,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
|
+
// Aliased — an un-aliased `Temporal` would shadow the ambient global
|
|
27
|
+
// `Temporal` TYPE `createBillingFoundationFeature`'s `now` option resolves
|
|
28
|
+
// against.
|
|
29
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
30
|
+
import {
|
|
31
|
+
createComplianceProfilesFeature,
|
|
32
|
+
tenantComplianceProfileEntity,
|
|
33
|
+
} from "../../compliance-profiles";
|
|
34
|
+
import { createConfigFeature } from "../../config";
|
|
35
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
36
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
37
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
38
|
+
import { subscriptionAggregateId } from "../aggregate-id";
|
|
39
|
+
import { SubscriptionFoundationHandlers, SubscriptionStatuses } from "../constants";
|
|
40
|
+
import { createBillingFoundationFeature } from "../feature";
|
|
41
|
+
import { subscriptionsProjectionTable } from "../projection";
|
|
42
|
+
import type { BillingPlanCatalog, ProviderPrice, SubscriptionProviderPlugin } from "../types";
|
|
43
|
+
|
|
44
|
+
type PlanTier = "starter" | "pro";
|
|
45
|
+
|
|
46
|
+
const PRICE_TO_TIER: Readonly<Record<string, string>> = {
|
|
47
|
+
price_starter: "starter",
|
|
48
|
+
price_pro: "pro",
|
|
49
|
+
price_pro_legacy: "pro",
|
|
50
|
+
price_pilot: "pilot",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function stripePrice(
|
|
54
|
+
overrides: Partial<ProviderPrice> & { readonly priceId: string },
|
|
55
|
+
): ProviderPrice {
|
|
56
|
+
return {
|
|
57
|
+
unitAmount: 1900,
|
|
58
|
+
currency: "usd",
|
|
59
|
+
interval: "month",
|
|
60
|
+
intervalCount: 1,
|
|
61
|
+
active: true,
|
|
62
|
+
metadata: {},
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PRICES: readonly ProviderPrice[] = [
|
|
68
|
+
stripePrice({ priceId: "price_starter", unitAmount: 900 }),
|
|
69
|
+
stripePrice({ priceId: "price_pro", unitAmount: 1900 }),
|
|
70
|
+
// Legacy price for the same tier as price_pro — inactive, so resolvePlanPrices
|
|
71
|
+
// still picks exactly one active flat-amount price for "pro" (price_pro).
|
|
72
|
+
stripePrice({ priceId: "price_pro_legacy", unitAmount: 1500, active: false }),
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
let billingEnabled = true;
|
|
76
|
+
let retrievePricesMode: "ok" | "throw" = "ok";
|
|
77
|
+
// Isolated per-test toggle for switch-plan's provider call — reset in
|
|
78
|
+
// beforeEach so a test that sets it never leaks into the next one.
|
|
79
|
+
let switchPlanErrorMode: "ok" | "plan_tiers_share_product" = "ok";
|
|
80
|
+
const checkoutCalls: Array<{
|
|
81
|
+
priceId: string;
|
|
82
|
+
successUrl: string;
|
|
83
|
+
cancelUrl: string;
|
|
84
|
+
providerCustomerId?: string;
|
|
85
|
+
mode?: string;
|
|
86
|
+
}> = [];
|
|
87
|
+
const switchCalls: Array<{
|
|
88
|
+
providerSubscriptionId: string;
|
|
89
|
+
targetPriceId: string;
|
|
90
|
+
allowedPriceIds: readonly string[];
|
|
91
|
+
returnUrl: string;
|
|
92
|
+
}> = [];
|
|
93
|
+
const portalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
|
|
94
|
+
|
|
95
|
+
const mockPlanProviderFeature = defineFeature("test-mock-plan-provider", (r) => {
|
|
96
|
+
r.requires("billing-foundation");
|
|
97
|
+
const plugin: SubscriptionProviderPlugin = {
|
|
98
|
+
verifyAndParseWebhook: async () => null,
|
|
99
|
+
priceToTier: PRICE_TO_TIER,
|
|
100
|
+
oneOffPriceIds: ["price_topup_credits"],
|
|
101
|
+
isBillingEnabled: async () => billingEnabled,
|
|
102
|
+
retrievePrices: async (_ctx, priceIds) => {
|
|
103
|
+
if (retrievePricesMode === "throw") throw new Error("mock provider price lookup failed");
|
|
104
|
+
return PRICES.filter((p) => priceIds.includes(p.priceId));
|
|
105
|
+
},
|
|
106
|
+
createCheckoutSession: async (_ctx, options) => {
|
|
107
|
+
checkoutCalls.push({
|
|
108
|
+
priceId: options.priceId,
|
|
109
|
+
successUrl: options.successUrl,
|
|
110
|
+
cancelUrl: options.cancelUrl,
|
|
111
|
+
...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
|
|
112
|
+
...(options.mode && { mode: options.mode }),
|
|
113
|
+
});
|
|
114
|
+
return { url: `https://mock.example/checkout/${options.priceId}` };
|
|
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
|
+
},
|
|
123
|
+
createPlanSwitchSession: async (_ctx, options) => {
|
|
124
|
+
if (switchPlanErrorMode === "plan_tiers_share_product") {
|
|
125
|
+
throw new UnprocessableError("plan_tiers_share_product", {
|
|
126
|
+
i18nKey: "billing-foundation.errors.planTiersShareProduct",
|
|
127
|
+
message: "mock provider: allowed prices share a Stripe product",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
switchCalls.push(options);
|
|
131
|
+
return { url: `https://mock.example/portal/switch/${options.targetPriceId}` };
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
r.useExtension("subscriptionProvider", "mock-plan-provider", plugin);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Second registered provider — exists only so a subscription can be created
|
|
138
|
+
// on a DIFFERENT provider than the catalog resolves, exercising switch-plan's
|
|
139
|
+
// providerMismatch conflict. Deliberately minimal: no priceToTier, so it
|
|
140
|
+
// never becomes the catalog's ambiguous default.
|
|
141
|
+
const mockOtherProviderFeature = defineFeature("test-mock-other-provider", (r) => {
|
|
142
|
+
r.requires("billing-foundation");
|
|
143
|
+
const plugin: SubscriptionProviderPlugin = {
|
|
144
|
+
verifyAndParseWebhook: async () => null,
|
|
145
|
+
};
|
|
146
|
+
r.useExtension("subscriptionProvider", "mock-other-provider", plugin);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Same shape as `BillingInfoQueryDeps.resolveTier` (spec): reads the
|
|
150
|
+
* subscription projection directly via `TenantDb`, no HandlerContext. A
|
|
151
|
+
* non-active/trialing subscription (or none at all) resolves to "free" —
|
|
152
|
+
* deliberately outside `catalog.plans`, matching a tenant that has never
|
|
153
|
+
* purchased a plan. */
|
|
154
|
+
async function resolveCurrentTier(db: TenantDb, tenantId: string): Promise<string> {
|
|
155
|
+
const rows = await db.selectMany(
|
|
156
|
+
subscriptionsProjectionTable,
|
|
157
|
+
{ id: subscriptionAggregateId(tenantId) },
|
|
158
|
+
{ limit: 1 },
|
|
159
|
+
);
|
|
160
|
+
const row = rows[0];
|
|
161
|
+
if (!row) return "free";
|
|
162
|
+
const status = row["status"] as string;
|
|
163
|
+
const tier = row["tier"] as string;
|
|
164
|
+
return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing
|
|
165
|
+
? tier
|
|
166
|
+
: "free";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function catalog(
|
|
170
|
+
overrides: Partial<BillingPlanCatalog<PlanTier>> = {},
|
|
171
|
+
): BillingPlanCatalog<PlanTier> {
|
|
172
|
+
return {
|
|
173
|
+
plans: ["starter", "pro"],
|
|
174
|
+
tierLabelKey: (tier) => `plan.${tier}.label`,
|
|
175
|
+
benefits: (tier) => [{ labelKey: `plan.${tier}.benefit.core` }],
|
|
176
|
+
resolveCurrentTier,
|
|
177
|
+
viewRoles: ["TenantAdmin", "SystemAdmin"],
|
|
178
|
+
successPath: "/billing/success",
|
|
179
|
+
cancelPath: "/billing/cancel",
|
|
180
|
+
providerName: "mock-plan-provider",
|
|
181
|
+
...overrides,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let stack: TestStack;
|
|
186
|
+
|
|
187
|
+
beforeAll(async () => {
|
|
188
|
+
stack = await setupTestStack({
|
|
189
|
+
features: [
|
|
190
|
+
createConfigFeature(),
|
|
191
|
+
createTenantFeature(),
|
|
192
|
+
createComplianceProfilesFeature(),
|
|
193
|
+
createTenantLifecycleFeature(),
|
|
194
|
+
createBillingFoundationFeature({ baseUrl: "https://app.example.com", catalog: catalog() }),
|
|
195
|
+
mockPlanProviderFeature,
|
|
196
|
+
mockOtherProviderFeature,
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
200
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
201
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
afterAll(async () => {
|
|
205
|
+
await stack.cleanup();
|
|
206
|
+
resetPiiSubjectKmsForTests();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
billingEnabled = true;
|
|
211
|
+
retrievePricesMode = "ok";
|
|
212
|
+
switchPlanErrorMode = "ok";
|
|
213
|
+
checkoutCalls.length = 0;
|
|
214
|
+
switchCalls.length = 0;
|
|
215
|
+
portalCalls.length = 0;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
function adminFor(tenantNumber: number) {
|
|
219
|
+
return createTestUser({
|
|
220
|
+
id: tenantNumber,
|
|
221
|
+
tenantId: testTenantId(tenantNumber),
|
|
222
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function createSubscription(
|
|
227
|
+
tenantId: string,
|
|
228
|
+
overrides: Partial<{
|
|
229
|
+
providerEventId: string;
|
|
230
|
+
providerName: string;
|
|
231
|
+
status: string;
|
|
232
|
+
tier: string;
|
|
233
|
+
providerSubscriptionId: string;
|
|
234
|
+
providerCustomerId: string;
|
|
235
|
+
}> = {},
|
|
236
|
+
onStack: TestStack = stack,
|
|
237
|
+
) {
|
|
238
|
+
const admin = createTestUser({ id: 0, tenantId, roles: ["TenantAdmin", "SystemAdmin"] });
|
|
239
|
+
return onStack.http.writeOk(
|
|
240
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
241
|
+
{
|
|
242
|
+
providerEventId: overrides.providerEventId ?? `evt_${tenantId}_create`,
|
|
243
|
+
providerName: overrides.providerName ?? "mock-plan-provider",
|
|
244
|
+
type: "subscription.created",
|
|
245
|
+
providerCustomerId: overrides.providerCustomerId ?? `cus_${tenantId}`,
|
|
246
|
+
providerSubscriptionId: overrides.providerSubscriptionId ?? `sub_${tenantId}`,
|
|
247
|
+
status: overrides.status ?? SubscriptionStatuses.active,
|
|
248
|
+
tier: overrides.tier ?? "starter",
|
|
249
|
+
currentPeriodEndIso: "2026-06-01T00:00:00Z",
|
|
250
|
+
rawPayload: '{"raw":"payload"}',
|
|
251
|
+
},
|
|
252
|
+
admin,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// =============================================================================
|
|
257
|
+
// 1. No subscription — enabled, priced, checkout for both, canPurchase true.
|
|
258
|
+
// =============================================================================
|
|
259
|
+
|
|
260
|
+
describe("billing-plans query — no subscription", () => {
|
|
261
|
+
test("both plans priced with checkout action, currentTier free", async () => {
|
|
262
|
+
const admin = adminFor(7001);
|
|
263
|
+
const result = (await stack.http.queryOk(
|
|
264
|
+
"billing-foundation:query:billing-plans",
|
|
265
|
+
{},
|
|
266
|
+
admin,
|
|
267
|
+
)) as {
|
|
268
|
+
enabled: boolean;
|
|
269
|
+
currentTier: { tier: string; benefits: Array<{ labelKey: string }> };
|
|
270
|
+
canPurchase: boolean;
|
|
271
|
+
subscription: unknown;
|
|
272
|
+
plans: Array<{ tier: string; action: string; price: { unitAmount: number } | null }>;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
expect(result.enabled).toBe(true);
|
|
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" }]);
|
|
280
|
+
expect(result.subscription).toBeNull();
|
|
281
|
+
expect(result.canPurchase).toBe(true);
|
|
282
|
+
expect(result.plans).toHaveLength(2);
|
|
283
|
+
const starter = result.plans.find((p) => p.tier === "starter");
|
|
284
|
+
const pro = result.plans.find((p) => p.tier === "pro");
|
|
285
|
+
expect(starter?.action).toBe("checkout");
|
|
286
|
+
expect(starter?.price?.unitAmount).toBe(900);
|
|
287
|
+
expect(pro?.action).toBe("checkout");
|
|
288
|
+
expect(pro?.price?.unitAmount).toBe(1900);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// =============================================================================
|
|
293
|
+
// 2. start-plan-checkout — resolves the live (non-legacy) price + builds
|
|
294
|
+
// baseUrl-origin URLs, preserving a baseUrl path-prefix.
|
|
295
|
+
// =============================================================================
|
|
296
|
+
|
|
297
|
+
describe("start-plan-checkout — no existing subscription", () => {
|
|
298
|
+
test("pro tier resolves to price_pro (not the inactive legacy price), URLs use baseUrl + paths", async () => {
|
|
299
|
+
const admin = adminFor(7002);
|
|
300
|
+
const result = (await stack.http.writeOk(
|
|
301
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
302
|
+
{ tier: "pro" },
|
|
303
|
+
admin,
|
|
304
|
+
)) as { url: string };
|
|
305
|
+
|
|
306
|
+
expect(result.url).toBe("https://mock.example/checkout/price_pro");
|
|
307
|
+
expect(checkoutCalls).toHaveLength(1);
|
|
308
|
+
expect(checkoutCalls[0]).toEqual({
|
|
309
|
+
priceId: "price_pro",
|
|
310
|
+
successUrl: "https://app.example.com/billing/success",
|
|
311
|
+
cancelUrl: "https://app.example.com/billing/cancel",
|
|
312
|
+
mode: "subscription",
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// =============================================================================
|
|
318
|
+
// 3. Tier outside catalog.plans is rejected by the zod enum before the handler runs.
|
|
319
|
+
// =============================================================================
|
|
320
|
+
|
|
321
|
+
describe("start-plan-checkout / switch-plan — tier outside the catalog", () => {
|
|
322
|
+
test("start-plan-checkout rejects a tier not in catalog.plans", async () => {
|
|
323
|
+
const admin = adminFor(7003);
|
|
324
|
+
const error = await stack.http.writeErr(
|
|
325
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
326
|
+
{ tier: "pilot" },
|
|
327
|
+
admin,
|
|
328
|
+
);
|
|
329
|
+
// Rejected by the zod z.enum(catalog.plans) schema before the handler runs.
|
|
330
|
+
expect(error.httpStatus).toBe(400);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("switch-plan rejects a tier not in catalog.plans", async () => {
|
|
334
|
+
const admin = adminFor(7004);
|
|
335
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
336
|
+
const error = await stack.http.writeErr(
|
|
337
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
338
|
+
{ tier: "free" },
|
|
339
|
+
admin,
|
|
340
|
+
);
|
|
341
|
+
expect(error.httpStatus).toBe(400);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// =============================================================================
|
|
346
|
+
// 4. Active non-terminal subscription — query shows current/switch, checkout
|
|
347
|
+
// is blocked, payment-mode checkout on create-checkout-session stays open.
|
|
348
|
+
// =============================================================================
|
|
349
|
+
|
|
350
|
+
describe("active subscription — checkout blocked, switch offered", () => {
|
|
351
|
+
test("query: starter is current, pro is switchable", async () => {
|
|
352
|
+
const admin = adminFor(7005);
|
|
353
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
354
|
+
|
|
355
|
+
const result = (await stack.http.queryOk(
|
|
356
|
+
"billing-foundation:query:billing-plans",
|
|
357
|
+
{},
|
|
358
|
+
admin,
|
|
359
|
+
)) as {
|
|
360
|
+
plans: Array<{ tier: string; action: string; isCurrent: boolean }>;
|
|
361
|
+
};
|
|
362
|
+
const starter = result.plans.find((p) => p.tier === "starter");
|
|
363
|
+
const pro = result.plans.find((p) => p.tier === "pro");
|
|
364
|
+
expect(starter?.isCurrent).toBe(true);
|
|
365
|
+
expect(starter?.action).toBe("current");
|
|
366
|
+
expect(pro?.action).toBe("switch");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("start-plan-checkout is rejected with a conflict while a non-terminal subscription exists", async () => {
|
|
370
|
+
const admin = adminFor(7006);
|
|
371
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
372
|
+
const error = await stack.http.writeErr(
|
|
373
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
374
|
+
{ tier: "pro" },
|
|
375
|
+
admin,
|
|
376
|
+
);
|
|
377
|
+
expect(error.httpStatus).toBe(409);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("create-checkout-session mode:subscription is rejected the same way", async () => {
|
|
381
|
+
const admin = adminFor(7007);
|
|
382
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
383
|
+
const error = await stack.http.writeErr(
|
|
384
|
+
"billing-foundation:write:create-checkout-session",
|
|
385
|
+
{
|
|
386
|
+
providerName: "mock-plan-provider",
|
|
387
|
+
priceId: "price_pro",
|
|
388
|
+
successUrl: "https://app.example.com/s",
|
|
389
|
+
cancelUrl: "https://app.example.com/c",
|
|
390
|
+
},
|
|
391
|
+
admin,
|
|
392
|
+
);
|
|
393
|
+
expect(error.httpStatus).toBe(409);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("create-checkout-session mode:payment stays allowed on an active subscription", async () => {
|
|
397
|
+
checkoutCalls.length = 0;
|
|
398
|
+
const admin = adminFor(7008);
|
|
399
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
400
|
+
const result = (await stack.http.writeOk(
|
|
401
|
+
"billing-foundation:write:create-checkout-session",
|
|
402
|
+
{
|
|
403
|
+
providerName: "mock-plan-provider",
|
|
404
|
+
priceId: "price_topup_credits",
|
|
405
|
+
successUrl: "https://app.example.com/s",
|
|
406
|
+
cancelUrl: "https://app.example.com/c",
|
|
407
|
+
mode: "payment",
|
|
408
|
+
},
|
|
409
|
+
admin,
|
|
410
|
+
)) as { url: string };
|
|
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);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// =============================================================================
|
|
457
|
+
// 5. switch-plan — payload passed to createPlanSwitchSession, self-switch and
|
|
458
|
+
// no-subscription both rejected.
|
|
459
|
+
// =============================================================================
|
|
460
|
+
|
|
461
|
+
describe("switch-plan", () => {
|
|
462
|
+
test("pro switch passes providerSubscriptionId, targetPriceId, allowedPriceIds, returnUrl", async () => {
|
|
463
|
+
const admin = adminFor(7009);
|
|
464
|
+
await createSubscription(admin.tenantId, {
|
|
465
|
+
tier: "starter",
|
|
466
|
+
providerSubscriptionId: "sub_switch_7009",
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const result = (await stack.http.writeOk(
|
|
470
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
471
|
+
{ tier: "pro" },
|
|
472
|
+
admin,
|
|
473
|
+
)) as { url: string };
|
|
474
|
+
|
|
475
|
+
expect(result.url).toBe("https://mock.example/portal/switch/price_pro");
|
|
476
|
+
expect(switchCalls).toHaveLength(1);
|
|
477
|
+
expect(switchCalls[0]).toEqual({
|
|
478
|
+
providerSubscriptionId: "sub_switch_7009",
|
|
479
|
+
targetPriceId: "price_pro",
|
|
480
|
+
allowedPriceIds: expect.arrayContaining(["price_starter", "price_pro"]),
|
|
481
|
+
returnUrl: "https://app.example.com/billing/success",
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("switching to the current tier is a conflict", async () => {
|
|
486
|
+
const admin = adminFor(7010);
|
|
487
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
488
|
+
const error = await stack.http.writeErr(
|
|
489
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
490
|
+
{ tier: "starter" },
|
|
491
|
+
admin,
|
|
492
|
+
);
|
|
493
|
+
expect(error.httpStatus).toBe(409);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("switching without any subscription is a conflict", async () => {
|
|
497
|
+
const admin = adminFor(7011);
|
|
498
|
+
const error = await stack.http.writeErr(
|
|
499
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
500
|
+
{ tier: "pro" },
|
|
501
|
+
admin,
|
|
502
|
+
);
|
|
503
|
+
expect(error.httpStatus).toBe(409);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("the provider rejecting the switch because two tiers share one Stripe product surfaces as a 422, not a 500", async () => {
|
|
507
|
+
const admin = adminFor(7024);
|
|
508
|
+
await createSubscription(admin.tenantId, {
|
|
509
|
+
tier: "starter",
|
|
510
|
+
providerSubscriptionId: "sub_switch_7024",
|
|
511
|
+
});
|
|
512
|
+
switchPlanErrorMode = "plan_tiers_share_product";
|
|
513
|
+
|
|
514
|
+
const error = await stack.http.writeErr(
|
|
515
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
516
|
+
{ tier: "pro" },
|
|
517
|
+
admin,
|
|
518
|
+
);
|
|
519
|
+
expect(error.httpStatus).toBe(422);
|
|
520
|
+
expect(error.i18nKey).toBe("billing-foundation.errors.planTiersShareProduct");
|
|
521
|
+
expect(error.details).toMatchObject({ reason: "plan_tiers_share_product" });
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// =============================================================================
|
|
526
|
+
// 6. Canceled (terminal) subscription — treated like "no subscription" for
|
|
527
|
+
// checkout purposes, old provider-customer-id carried forward.
|
|
528
|
+
// =============================================================================
|
|
529
|
+
|
|
530
|
+
describe("canceled subscription — fresh checkout, old customer-id reused", () => {
|
|
531
|
+
test("query offers checkout again; start-plan-checkout succeeds and forwards providerCustomerId", async () => {
|
|
532
|
+
const admin = adminFor(7012);
|
|
533
|
+
await createSubscription(admin.tenantId, {
|
|
534
|
+
providerEventId: "evt_7012_create",
|
|
535
|
+
tier: "starter",
|
|
536
|
+
providerCustomerId: "cus_7012_old",
|
|
537
|
+
});
|
|
538
|
+
await createSubscription(admin.tenantId, {
|
|
539
|
+
providerEventId: "evt_7012_cancel",
|
|
540
|
+
status: SubscriptionStatuses.canceled,
|
|
541
|
+
tier: "starter",
|
|
542
|
+
providerCustomerId: "cus_7012_old",
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const result = (await stack.http.queryOk(
|
|
546
|
+
"billing-foundation:query:billing-plans",
|
|
547
|
+
{},
|
|
548
|
+
admin,
|
|
549
|
+
)) as {
|
|
550
|
+
plans: Array<{ tier: string; action: string }>;
|
|
551
|
+
};
|
|
552
|
+
expect(result.plans.find((p) => p.tier === "starter")?.action).toBe("checkout");
|
|
553
|
+
|
|
554
|
+
const checkout = (await stack.http.writeOk(
|
|
555
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
556
|
+
{ tier: "pro" },
|
|
557
|
+
admin,
|
|
558
|
+
)) as { url: string };
|
|
559
|
+
expect(checkout.url).toBe("https://mock.example/checkout/price_pro");
|
|
560
|
+
expect(checkoutCalls[0]?.providerCustomerId).toBe("cus_7012_old");
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// =============================================================================
|
|
565
|
+
// 7. isBillingEnabled false — disabled, no live prices fetched, checkout gated.
|
|
566
|
+
// =============================================================================
|
|
567
|
+
|
|
568
|
+
describe("isBillingEnabled: false", () => {
|
|
569
|
+
test("query reports disabled with no prices; non-current plans are unavailable", async () => {
|
|
570
|
+
billingEnabled = false;
|
|
571
|
+
const admin = adminFor(7013);
|
|
572
|
+
const result = (await stack.http.queryOk(
|
|
573
|
+
"billing-foundation:query:billing-plans",
|
|
574
|
+
{},
|
|
575
|
+
admin,
|
|
576
|
+
)) as {
|
|
577
|
+
enabled: boolean;
|
|
578
|
+
plans: Array<{ tier: string; action: string; price: unknown }>;
|
|
579
|
+
};
|
|
580
|
+
expect(result.enabled).toBe(false);
|
|
581
|
+
for (const plan of result.plans) {
|
|
582
|
+
expect(plan.price).toBeNull();
|
|
583
|
+
expect(plan.action).toBe("unavailable");
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("start-plan-checkout throws feature_disabled", async () => {
|
|
588
|
+
billingEnabled = false;
|
|
589
|
+
const admin = adminFor(7014);
|
|
590
|
+
const error = await stack.http.writeErr(
|
|
591
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
592
|
+
{ tier: "pro" },
|
|
593
|
+
admin,
|
|
594
|
+
);
|
|
595
|
+
expect(error.httpStatus).toBe(403);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("switch-plan throws feature_disabled", async () => {
|
|
599
|
+
const admin = adminFor(7017);
|
|
600
|
+
await createSubscription(admin.tenantId, { tier: "starter" });
|
|
601
|
+
billingEnabled = false;
|
|
602
|
+
const error = await stack.http.writeErr(
|
|
603
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
604
|
+
{ tier: "pro" },
|
|
605
|
+
admin,
|
|
606
|
+
);
|
|
607
|
+
expect(error.httpStatus).toBe(403);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// =============================================================================
|
|
612
|
+
// 7b. switch-plan across providers — the tenant's active subscription lives
|
|
613
|
+
// on a different provider than the catalog resolves.
|
|
614
|
+
// =============================================================================
|
|
615
|
+
|
|
616
|
+
describe("switch-plan — subscription on a different provider than the catalog", () => {
|
|
617
|
+
test("rejected with a conflict instead of calling the wrong provider's plugin", async () => {
|
|
618
|
+
const admin = adminFor(7018);
|
|
619
|
+
await createSubscription(admin.tenantId, {
|
|
620
|
+
tier: "starter",
|
|
621
|
+
providerName: "mock-other-provider",
|
|
622
|
+
providerSubscriptionId: "sub_other_7018",
|
|
623
|
+
});
|
|
624
|
+
const error = await stack.http.writeErr(
|
|
625
|
+
SubscriptionFoundationHandlers.switchPlan,
|
|
626
|
+
{ tier: "pro" },
|
|
627
|
+
admin,
|
|
628
|
+
);
|
|
629
|
+
expect(error.httpStatus).toBe(409);
|
|
630
|
+
expect(error.i18nKey).toBe("billing-foundation.errors.providerMismatch");
|
|
631
|
+
expect(switchCalls).toHaveLength(0);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// =============================================================================
|
|
636
|
+
// 8. retrievePrices throws — query still resolves, prices null, checkout blocked.
|
|
637
|
+
// =============================================================================
|
|
638
|
+
|
|
639
|
+
describe("retrievePrices throws", () => {
|
|
640
|
+
test("query stays ok with null prices and unavailable actions", async () => {
|
|
641
|
+
retrievePricesMode = "throw";
|
|
642
|
+
const admin = adminFor(7015);
|
|
643
|
+
const result = (await stack.http.queryOk(
|
|
644
|
+
"billing-foundation:query:billing-plans",
|
|
645
|
+
{},
|
|
646
|
+
admin,
|
|
647
|
+
)) as {
|
|
648
|
+
plans: Array<{ tier: string; action: string; price: unknown }>;
|
|
649
|
+
};
|
|
650
|
+
for (const plan of result.plans) {
|
|
651
|
+
expect(plan.price).toBeNull();
|
|
652
|
+
expect(plan.action).toBe("unavailable");
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("start-plan-checkout rejects with price_unavailable", async () => {
|
|
657
|
+
retrievePricesMode = "throw";
|
|
658
|
+
const admin = adminFor(7016);
|
|
659
|
+
const error = await stack.http.writeErr(
|
|
660
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
661
|
+
{ tier: "pro" },
|
|
662
|
+
admin,
|
|
663
|
+
);
|
|
664
|
+
expect(error.httpStatus).toBe(422);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// =============================================================================
|
|
669
|
+
// 9. create-checkout-session hardening — foreign origin, unknown price,
|
|
670
|
+
// a price mapping to a tier outside catalog.plans.
|
|
671
|
+
// =============================================================================
|
|
672
|
+
|
|
673
|
+
describe("create-checkout-session — redirect + price hardening", () => {
|
|
674
|
+
test("a foreign successUrl origin is rejected", async () => {
|
|
675
|
+
const admin = adminFor(7017);
|
|
676
|
+
const error = await stack.http.writeErr(
|
|
677
|
+
"billing-foundation:write:create-checkout-session",
|
|
678
|
+
{
|
|
679
|
+
providerName: "mock-plan-provider",
|
|
680
|
+
priceId: "price_pro",
|
|
681
|
+
successUrl: "https://evil.example/success",
|
|
682
|
+
cancelUrl: "https://app.example.com/cancel",
|
|
683
|
+
},
|
|
684
|
+
admin,
|
|
685
|
+
);
|
|
686
|
+
expect(error.httpStatus).toBe(422);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("an unknown priceId is rejected", async () => {
|
|
690
|
+
const admin = adminFor(7018);
|
|
691
|
+
const error = await stack.http.writeErr(
|
|
692
|
+
"billing-foundation:write:create-checkout-session",
|
|
693
|
+
{
|
|
694
|
+
providerName: "mock-plan-provider",
|
|
695
|
+
priceId: "price_does_not_exist",
|
|
696
|
+
successUrl: "https://app.example.com/success",
|
|
697
|
+
cancelUrl: "https://app.example.com/cancel",
|
|
698
|
+
},
|
|
699
|
+
admin,
|
|
700
|
+
);
|
|
701
|
+
expect(error.httpStatus).toBe(422);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("a priceId mapping to a tier outside catalog.plans is rejected", async () => {
|
|
705
|
+
const admin = adminFor(7019);
|
|
706
|
+
const error = await stack.http.writeErr(
|
|
707
|
+
"billing-foundation:write:create-checkout-session",
|
|
708
|
+
{
|
|
709
|
+
providerName: "mock-plan-provider",
|
|
710
|
+
priceId: "price_pilot",
|
|
711
|
+
successUrl: "https://app.example.com/success",
|
|
712
|
+
cancelUrl: "https://app.example.com/cancel",
|
|
713
|
+
},
|
|
714
|
+
admin,
|
|
715
|
+
);
|
|
716
|
+
expect(error.httpStatus).toBe(422);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// =============================================================================
|
|
721
|
+
// 10. Roles — a view-only role sees the catalog read-only, purchase-actions
|
|
722
|
+
// are 403 for it.
|
|
723
|
+
// =============================================================================
|
|
724
|
+
|
|
725
|
+
describe("roles — view-only access without purchase rights", () => {
|
|
726
|
+
let roleStack: TestStack;
|
|
727
|
+
|
|
728
|
+
beforeAll(async () => {
|
|
729
|
+
roleStack = await setupTestStack({
|
|
730
|
+
features: [
|
|
731
|
+
createConfigFeature(),
|
|
732
|
+
createTenantFeature(),
|
|
733
|
+
createComplianceProfilesFeature(),
|
|
734
|
+
createTenantLifecycleFeature(),
|
|
735
|
+
createBillingFoundationFeature({
|
|
736
|
+
baseUrl: "https://app.example.com",
|
|
737
|
+
catalog: catalog({ viewRoles: ["TenantAdmin", "SystemAdmin", "Member"] }),
|
|
738
|
+
}),
|
|
739
|
+
mockPlanProviderFeature,
|
|
740
|
+
],
|
|
741
|
+
});
|
|
742
|
+
await unsafeCreateEntityTable(roleStack.db, tenantEntity);
|
|
743
|
+
await unsafeCreateEntityTable(roleStack.db, tenantComplianceProfileEntity);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
afterAll(async () => {
|
|
747
|
+
await roleStack.cleanup();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("a Member (view-only) sees canPurchase:false and unavailable actions", async () => {
|
|
751
|
+
const member = createTestUser({ id: 7020, tenantId: testTenantId(7020), roles: ["Member"] });
|
|
752
|
+
const result = (await roleStack.http.queryOk(
|
|
753
|
+
"billing-foundation:query:billing-plans",
|
|
754
|
+
{},
|
|
755
|
+
member,
|
|
756
|
+
)) as { canPurchase: boolean; plans: Array<{ action: string }> };
|
|
757
|
+
expect(result.canPurchase).toBe(false);
|
|
758
|
+
for (const plan of result.plans) expect(plan.action).toBe("unavailable");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("a Member is rejected with 403 on start-plan-checkout", async () => {
|
|
762
|
+
const member = createTestUser({ id: 7021, tenantId: testTenantId(7021), roles: ["Member"] });
|
|
763
|
+
const error = await roleStack.http.writeErr(
|
|
764
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
765
|
+
{ tier: "pro" },
|
|
766
|
+
member,
|
|
767
|
+
);
|
|
768
|
+
expect(error.httpStatus).toBe(403);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// =============================================================================
|
|
773
|
+
// 11. Stale-incomplete checkout gate — a separate stack with an injectable
|
|
774
|
+
// `now` (the event's own modified_at/inserted_at stays real, only the
|
|
775
|
+
// gate's clock is controlled) so both a fresh and a >24h-stale
|
|
776
|
+
// `incomplete` subscription are reachable without waiting real time.
|
|
777
|
+
// =============================================================================
|
|
778
|
+
|
|
779
|
+
describe("stale-incomplete subscription — injected clock", () => {
|
|
780
|
+
let clock: () => Temporal.Instant;
|
|
781
|
+
let clockStack: TestStack;
|
|
782
|
+
|
|
783
|
+
beforeAll(async () => {
|
|
784
|
+
clock = () => TemporalPolyfill.Now.instant() as unknown as Temporal.Instant;
|
|
785
|
+
clockStack = await setupTestStack({
|
|
786
|
+
features: [
|
|
787
|
+
createConfigFeature(),
|
|
788
|
+
createTenantFeature(),
|
|
789
|
+
createComplianceProfilesFeature(),
|
|
790
|
+
createTenantLifecycleFeature(),
|
|
791
|
+
createBillingFoundationFeature({
|
|
792
|
+
baseUrl: "https://app.example.com",
|
|
793
|
+
catalog: catalog(),
|
|
794
|
+
now: () => clock(),
|
|
795
|
+
}),
|
|
796
|
+
mockPlanProviderFeature,
|
|
797
|
+
],
|
|
798
|
+
});
|
|
799
|
+
await unsafeCreateEntityTable(clockStack.db, tenantEntity);
|
|
800
|
+
await unsafeCreateEntityTable(clockStack.db, tenantComplianceProfileEntity);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
afterAll(async () => {
|
|
804
|
+
await clockStack.cleanup();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
beforeEach(() => {
|
|
808
|
+
clock = () => TemporalPolyfill.Now.instant() as unknown as Temporal.Instant;
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("(a) a fresh incomplete subscription still blocks checkout and shows paymentPending", async () => {
|
|
812
|
+
const admin = adminFor(7022);
|
|
813
|
+
await createSubscription(
|
|
814
|
+
admin.tenantId,
|
|
815
|
+
{ tier: "starter", status: SubscriptionStatuses.incomplete },
|
|
816
|
+
clockStack,
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
const result = (await clockStack.http.queryOk(
|
|
820
|
+
"billing-foundation:query:billing-plans",
|
|
821
|
+
{},
|
|
822
|
+
admin,
|
|
823
|
+
)) as { plans: Array<{ tier: string; action: string }> };
|
|
824
|
+
expect(result.plans.find((p) => p.tier === "starter")?.action).toBe("paymentPending");
|
|
825
|
+
|
|
826
|
+
const error = await clockStack.http.writeErr(
|
|
827
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
828
|
+
{ tier: "pro" },
|
|
829
|
+
admin,
|
|
830
|
+
);
|
|
831
|
+
expect(error.httpStatus).toBe(409);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("(b) a stale (>24h) incomplete subscription no longer blocks checkout, and offers a fresh checkout for its own tier", async () => {
|
|
835
|
+
const admin = adminFor(7023);
|
|
836
|
+
await createSubscription(
|
|
837
|
+
admin.tenantId,
|
|
838
|
+
{ tier: "starter", status: SubscriptionStatuses.incomplete },
|
|
839
|
+
clockStack,
|
|
840
|
+
);
|
|
841
|
+
clock = () => TemporalPolyfill.Now.instant().add({ hours: 25 }) as unknown as Temporal.Instant;
|
|
842
|
+
|
|
843
|
+
const result = (await clockStack.http.queryOk(
|
|
844
|
+
"billing-foundation:query:billing-plans",
|
|
845
|
+
{},
|
|
846
|
+
admin,
|
|
847
|
+
)) as { plans: Array<{ tier: string; action: string }> };
|
|
848
|
+
expect(result.plans.find((p) => p.tier === "starter")?.action).toBe("checkout");
|
|
849
|
+
|
|
850
|
+
const checkout = (await clockStack.http.writeOk(
|
|
851
|
+
SubscriptionFoundationHandlers.startPlanCheckout,
|
|
852
|
+
{ tier: "pro" },
|
|
853
|
+
admin,
|
|
854
|
+
)) as { url: string };
|
|
855
|
+
expect(checkout.url).toBe("https://mock.example/checkout/price_pro");
|
|
856
|
+
});
|
|
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
|
+
});
|