@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,153 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { UnconfiguredError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
// Aliased — an un-aliased `Temporal` would shadow the ambient global
|
|
4
|
+
// `Temporal` TYPE `SubscriptionView.lastChangedAt` resolves against.
|
|
5
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
6
|
+
import {
|
|
7
|
+
assertRedirectOrigins,
|
|
8
|
+
isNonEmptyStringArray,
|
|
9
|
+
isOwnProviderCustomer,
|
|
10
|
+
joinBaseUrl,
|
|
11
|
+
} from "../checkout-core";
|
|
12
|
+
import type { SubscriptionView } from "../get-subscription-for-tenant";
|
|
13
|
+
|
|
14
|
+
function subscriptionView(overrides: Partial<SubscriptionView> = {}): SubscriptionView {
|
|
15
|
+
return {
|
|
16
|
+
tier: "pro",
|
|
17
|
+
status: "canceled",
|
|
18
|
+
providerName: "mock",
|
|
19
|
+
providerCustomerId: "cus_own",
|
|
20
|
+
providerSubscriptionId: "sub_own",
|
|
21
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
22
|
+
// at runtime — SubscriptionView.lastChangedAt is typed against the
|
|
23
|
+
// ambient Temporal global; temporal-polyfill's own nominal Instant type
|
|
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,
|
|
29
|
+
lastChangedAt: TemporalPolyfill.Instant.from(
|
|
30
|
+
"2024-01-01T00:00:00Z",
|
|
31
|
+
) as unknown as Temporal.Instant,
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("assertRedirectOrigins", () => {
|
|
37
|
+
test("throws UnconfiguredError when baseUrl is undefined, regardless of urls", () => {
|
|
38
|
+
expect(() => assertRedirectOrigins(["https://example.com/success"], undefined)).toThrow(
|
|
39
|
+
UnconfiguredError,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("throws UnconfiguredError when baseUrl is not a parseable absolute URL", () => {
|
|
44
|
+
expect(() => assertRedirectOrigins(["https://example.com/success"], "not-a-url")).toThrow(
|
|
45
|
+
UnconfiguredError,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("passes when every url shares baseUrl's origin", () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
assertRedirectOrigins(
|
|
52
|
+
["https://example.com/success", "https://example.com/cancel"],
|
|
53
|
+
"https://example.com",
|
|
54
|
+
),
|
|
55
|
+
).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("passes when baseUrl carries a path prefix — only the origin is compared", () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
assertRedirectOrigins(["https://example.com/success"], "https://example.com/tenant-x"),
|
|
61
|
+
).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("throws UnprocessableError('redirect_origin_not_allowed') for a different origin", () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
assertRedirectOrigins(["https://attacker.example/success"], "https://example.com"),
|
|
67
|
+
).toThrow(UnprocessableError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("throws UnprocessableError for a lookalike host that merely starts with baseUrl's host", () => {
|
|
71
|
+
expect(() =>
|
|
72
|
+
assertRedirectOrigins(["https://example.com.evil.com/success"], "https://example.com"),
|
|
73
|
+
).toThrow(UnprocessableError);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws UnprocessableError for a different scheme on an otherwise-matching host", () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
assertRedirectOrigins(["http://example.com/success"], "https://example.com"),
|
|
79
|
+
).toThrow(UnprocessableError);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("throws UnprocessableError for a different port", () => {
|
|
83
|
+
expect(() =>
|
|
84
|
+
assertRedirectOrigins(["https://example.com:8443/success"], "https://example.com"),
|
|
85
|
+
).toThrow(UnprocessableError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("throws UnprocessableError for a url that isn't a parseable absolute URL", () => {
|
|
89
|
+
expect(() => assertRedirectOrigins(["/relative/path"], "https://example.com")).toThrow(
|
|
90
|
+
UnprocessableError,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("empty urls list never throws, even with baseUrl set", () => {
|
|
95
|
+
expect(() => assertRedirectOrigins([], "https://example.com")).not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("joinBaseUrl", () => {
|
|
100
|
+
test("concatenates baseUrl and path as-is when baseUrl has no trailing slash", () => {
|
|
101
|
+
expect(joinBaseUrl("https://app.example.com", "/billing/success")).toBe(
|
|
102
|
+
"https://app.example.com/billing/success",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("strips a trailing slash on baseUrl so the result never becomes protocol-relative", () => {
|
|
107
|
+
expect(joinBaseUrl("https://app.example.com/", "/billing/success")).toBe(
|
|
108
|
+
"https://app.example.com/billing/success",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("preserves a path-prefix baseUrl (tenant subdirectory)", () => {
|
|
113
|
+
expect(joinBaseUrl("https://app.example.com/tenant-x", "/billing/success")).toBe(
|
|
114
|
+
"https://app.example.com/tenant-x/billing/success",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("preserves a path-prefix baseUrl with a trailing slash, without doubling the slash", () => {
|
|
119
|
+
expect(joinBaseUrl("https://app.example.com/tenant-x/", "/billing/success")).toBe(
|
|
120
|
+
"https://app.example.com/tenant-x/billing/success",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("isNonEmptyStringArray", () => {
|
|
126
|
+
test("true for a non-empty array", () => {
|
|
127
|
+
expect(isNonEmptyStringArray(["pro"])).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("false for an empty array", () => {
|
|
131
|
+
expect(isNonEmptyStringArray([])).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("isOwnProviderCustomer", () => {
|
|
136
|
+
test("false when the tenant has no subscription at all", () => {
|
|
137
|
+
expect(isOwnProviderCustomer(null, "mock", "cus_own")).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("false for a subscription at a different provider", () => {
|
|
141
|
+
expect(
|
|
142
|
+
isOwnProviderCustomer(subscriptionView({ providerName: "stripe" }), "mock", "cus_own"),
|
|
143
|
+
).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("false for a different customer id at the same provider", () => {
|
|
147
|
+
expect(isOwnProviderCustomer(subscriptionView(), "mock", "cus_other_tenant")).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("true for the tenant's own customer id at the same provider, even canceled", () => {
|
|
151
|
+
expect(isOwnProviderCustomer(subscriptionView(), "mock", "cus_own")).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
// Aliased — an un-aliased `Temporal` would shadow the ambient global
|
|
3
|
+
// `Temporal` TYPE isSubscriptionBlockingCheckout's `now`/`lastChangedAt`
|
|
4
|
+
// params resolve against.
|
|
5
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
6
|
+
import {
|
|
7
|
+
isSubscriptionBlockingCheckout,
|
|
8
|
+
STALE_INCOMPLETE_AFTER,
|
|
9
|
+
SubscriptionStatuses,
|
|
10
|
+
} from "../constants";
|
|
11
|
+
|
|
12
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant at
|
|
13
|
+
// runtime — `isSubscriptionBlockingCheckout`'s params are typed against the
|
|
14
|
+
// ambient Temporal global; temporal-polyfill's own nominal Instant type
|
|
15
|
+
// differs across the two .d.ts sources.
|
|
16
|
+
const LAST_CHANGED = TemporalPolyfill.Instant.from(
|
|
17
|
+
"2024-01-01T00:00:00Z",
|
|
18
|
+
) as unknown as Temporal.Instant;
|
|
19
|
+
|
|
20
|
+
describe("isSubscriptionBlockingCheckout", () => {
|
|
21
|
+
test("STALE_INCOMPLETE_AFTER is 24 hours", () => {
|
|
22
|
+
expect(STALE_INCOMPLETE_AFTER.total("hours")).toBe(24);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a canceled subscription never blocks, regardless of age", () => {
|
|
26
|
+
expect(
|
|
27
|
+
isSubscriptionBlockingCheckout(
|
|
28
|
+
{ status: SubscriptionStatuses.canceled, lastChangedAt: LAST_CHANGED },
|
|
29
|
+
LAST_CHANGED,
|
|
30
|
+
),
|
|
31
|
+
).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("an active subscription always blocks", () => {
|
|
35
|
+
expect(
|
|
36
|
+
isSubscriptionBlockingCheckout(
|
|
37
|
+
{ status: SubscriptionStatuses.active, lastChangedAt: LAST_CHANGED },
|
|
38
|
+
LAST_CHANGED.add({ hours: 1000 }),
|
|
39
|
+
),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a fresh incomplete subscription (23h59) still blocks", () => {
|
|
44
|
+
const now = LAST_CHANGED.add(TemporalPolyfill.Duration.from({ hours: 23, minutes: 59 }));
|
|
45
|
+
expect(
|
|
46
|
+
isSubscriptionBlockingCheckout(
|
|
47
|
+
{ status: SubscriptionStatuses.incomplete, lastChangedAt: LAST_CHANGED },
|
|
48
|
+
now,
|
|
49
|
+
),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a stale incomplete subscription (24h01) no longer blocks", () => {
|
|
54
|
+
const now = LAST_CHANGED.add(TemporalPolyfill.Duration.from({ hours: 24, minutes: 1 }));
|
|
55
|
+
expect(
|
|
56
|
+
isSubscriptionBlockingCheckout(
|
|
57
|
+
{ status: SubscriptionStatuses.incomplete, lastChangedAt: LAST_CHANGED },
|
|
58
|
+
now,
|
|
59
|
+
),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an incomplete subscription exactly at the cutoff no longer blocks", () => {
|
|
64
|
+
const now = LAST_CHANGED.add(STALE_INCOMPLETE_AFTER);
|
|
65
|
+
expect(
|
|
66
|
+
isSubscriptionBlockingCheckout(
|
|
67
|
+
{ status: SubscriptionStatuses.incomplete, lastChangedAt: LAST_CHANGED },
|
|
68
|
+
now,
|
|
69
|
+
),
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -4,12 +4,27 @@ 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,
|
|
10
11
|
SubscriptionStatuses,
|
|
11
12
|
} from "../constants";
|
|
12
|
-
import { billingFoundationFeature } from "../feature";
|
|
13
|
+
import { billingFoundationFeature, createBillingFoundationFeature } from "../feature";
|
|
14
|
+
import type { BillingPlanCatalog } from "../types";
|
|
15
|
+
|
|
16
|
+
function catalog(overrides: Partial<BillingPlanCatalog> = {}): BillingPlanCatalog {
|
|
17
|
+
return {
|
|
18
|
+
plans: ["basic", "pro"],
|
|
19
|
+
tierLabelKey: (tier) => `plan.${tier}.label`,
|
|
20
|
+
benefits: () => [],
|
|
21
|
+
resolveCurrentTier: async () => "basic",
|
|
22
|
+
viewRoles: ["TenantAdmin", "SystemAdmin"],
|
|
23
|
+
successPath: "/billing/success",
|
|
24
|
+
cancelPath: "/billing/cancel",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
describe("billingFoundationFeature — shape", () => {
|
|
15
30
|
test("has the expected name", () => {
|
|
@@ -116,3 +131,126 @@ describe("normalized constants — provider-agnostic event-types + statuses", ()
|
|
|
116
131
|
]);
|
|
117
132
|
});
|
|
118
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("createBillingFoundationFeature — factory validation", () => {
|
|
136
|
+
test("mounts with no options at all", () => {
|
|
137
|
+
expect(() => createBillingFoundationFeature()).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("mounts with only baseUrl, no catalog", () => {
|
|
141
|
+
expect(() => createBillingFoundationFeature({ baseUrl: "https://example.com" })).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("throws when baseUrl is not a parseable absolute URL", () => {
|
|
145
|
+
expect(() => createBillingFoundationFeature({ baseUrl: "not-a-url" })).toThrow(
|
|
146
|
+
/not a parseable absolute URL/,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("throws when baseUrl uses a non-http(s) scheme", () => {
|
|
151
|
+
expect(() => createBillingFoundationFeature({ baseUrl: "ftp://example.com" })).toThrow(
|
|
152
|
+
/must use http or https/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("accepts https and http baseUrls", () => {
|
|
157
|
+
expect(() => createBillingFoundationFeature({ baseUrl: "https://example.com" })).not.toThrow();
|
|
158
|
+
expect(() =>
|
|
159
|
+
createBillingFoundationFeature({ baseUrl: "http://localhost:3000" }),
|
|
160
|
+
).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("throws when catalog is set without baseUrl", () => {
|
|
164
|
+
expect(() => createBillingFoundationFeature({ catalog: catalog() })).toThrow(
|
|
165
|
+
/catalog requires baseUrl/,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("mounts when catalog and baseUrl are both set", () => {
|
|
170
|
+
expect(() =>
|
|
171
|
+
createBillingFoundationFeature({ baseUrl: "https://example.com", catalog: catalog() }),
|
|
172
|
+
).not.toThrow();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("throws when catalog.plans is empty", () => {
|
|
176
|
+
expect(() =>
|
|
177
|
+
createBillingFoundationFeature({
|
|
178
|
+
baseUrl: "https://example.com",
|
|
179
|
+
catalog: catalog({ plans: [] }),
|
|
180
|
+
}),
|
|
181
|
+
).toThrow(/catalog.plans must not be empty/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("throws when catalog.plans has a duplicate tier", () => {
|
|
185
|
+
expect(() =>
|
|
186
|
+
createBillingFoundationFeature({
|
|
187
|
+
baseUrl: "https://example.com",
|
|
188
|
+
catalog: catalog({ plans: ["pro", "pro"] }),
|
|
189
|
+
}),
|
|
190
|
+
).toThrow(/duplicate tier "pro"/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("throws when successPath doesn't start with /", () => {
|
|
194
|
+
expect(() =>
|
|
195
|
+
createBillingFoundationFeature({
|
|
196
|
+
baseUrl: "https://example.com",
|
|
197
|
+
catalog: catalog({ successPath: "billing/success" }),
|
|
198
|
+
}),
|
|
199
|
+
).toThrow(/successPath "billing\/success" must start with "\/"/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("throws when cancelPath is protocol-relative (//)", () => {
|
|
203
|
+
expect(() =>
|
|
204
|
+
createBillingFoundationFeature({
|
|
205
|
+
baseUrl: "https://example.com",
|
|
206
|
+
catalog: catalog({ cancelPath: "//evil.example.com" }),
|
|
207
|
+
}),
|
|
208
|
+
).toThrow(/cancelPath/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("throws when returnPath is set and invalid", () => {
|
|
212
|
+
expect(() =>
|
|
213
|
+
createBillingFoundationFeature({
|
|
214
|
+
baseUrl: "https://example.com",
|
|
215
|
+
catalog: catalog({ returnPath: "not-root-relative" }),
|
|
216
|
+
}),
|
|
217
|
+
).toThrow(/returnPath/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("accepts a valid returnPath", () => {
|
|
221
|
+
expect(() =>
|
|
222
|
+
createBillingFoundationFeature({
|
|
223
|
+
baseUrl: "https://example.com",
|
|
224
|
+
catalog: catalog({ returnPath: "/billing/return" }),
|
|
225
|
+
}),
|
|
226
|
+
).not.toThrow();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("throws when viewRoles is empty", () => {
|
|
230
|
+
expect(() =>
|
|
231
|
+
createBillingFoundationFeature({
|
|
232
|
+
baseUrl: "https://example.com",
|
|
233
|
+
catalog: catalog({ viewRoles: [] }),
|
|
234
|
+
}),
|
|
235
|
+
).toThrow(/viewRoles must not be empty/);
|
|
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
|
+
});
|
|
@@ -1,4 +1,58 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.319.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "billing-plans no longer throws when no provider is mounted",
|
|
6
|
+
"detail": "billing-plans now resolves the catalog's provider through a new\n`findCatalogProvider` (returns `null` instead of throwing) and returns\n`{ enabled: false, ... }` when none is mounted, instead of failing the\nwhole query with UnconfiguredError. Apps that mounted Stripe with an\nempty price map purely to keep this query alive can drop that\nworkaround. An ambiguous catalog (more than one matching provider\nmounted) still throws UnconfiguredError — that case didn't change."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.319.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "start-plan-checkout and switch-plan reject with FeatureDisabledError, not UnconfiguredError, when no provider is mounted",
|
|
12
|
+
"migration": "start-plan-checkout and switch-plan now reject with FeatureDisabledError\n(code \"feature_disabled\", HTTP 403) instead of UnconfiguredError (code\n\"unconfigured\", HTTP 422) when no provider is mounted for the catalog. A\ncaller that specifically catches UnconfiguredError from these two\nhandlers, or branches on the 422 status, must catch FeatureDisabledError\n/ branch on 403 instead."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.319.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "billing-plans now reports a scheduled cancellation and the panel offers reactivation",
|
|
18
|
+
"migration": "SubscriptionView and BillingPlansResult.subscription gain two REQUIRED\nfields: `currentPeriodEnd` (a `Temporal.Instant`, ISO string on\nBillingPlansResult) and `cancelAt` (`Temporal.Instant | null`, ISO\nstring or null on BillingPlansResult). Any test fake that constructs a\nSubscriptionView object literal directly must add both fields.\nBillingPlansResult.currentTier also gains a `benefits` field\n(via `catalog.benefits(currentTier)`), computed even when currentTier is\noutside `catalog.plans` (e.g. a legacy/free tier).\n\nThe Stripe provider now maps `subscription.cancel_at` (or\n`current_period_end` when only `cancel_at_period_end` is set) into a new\n`cancelAtIso` event payload field; process-event applies it with\nset/clear/leave-unchanged semantics, so an event without the field never\nwipes a previously recorded value.\n\nBillingPlansPanel shows an info banner (\"Your subscription is scheduled\nto end on {date}\") whenever `subscription.cancelAt` is set and the\nsubscription isn't terminal, and swaps the manage-subscription button's\nlabel to \"Reactivate subscription\" in the same case. The synthetic\ncurrent-tier card (a tier outside `catalog.plans`, e.g. a legacy/free\ntier) now also renders `catalog.benefits(currentTier)` and the same\nmanage/reactivate action as a catalog card, via a shared helper — no\naction needed unless an app relied on that card being action-less.\n\n`read_subscriptions` gains a nullable `cancel_at` column. Every app that\nmounts billing-foundation — including a plain `billingFoundationFeature()`\nwith no catalog (e.g. kumiko-studio) — must run\n`bun kumiko-schema generate <name>` (offlot-app wraps it as\n`bun run schema:generate <name>`) and commit the resulting migration. Skipping this leaves\nthe app's own `read_subscriptions` table without the column, so the\nprojection's `apply` fails at the next subscription webhook. The column\nis additive and nullable — no rebuild, existing rows keep it `null`\nuntil their next subscription webhook."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.319.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "billing-foundation exports resolveProviderPlugin, isBillingEnabled and the /web QN constants",
|
|
24
|
+
"detail": "The server barrel now exports `resolveProviderPlugin`, `isBillingEnabled`\nand the `ResolvedProvider` type, built on internal\n`isPluginBillingEnabled`/`assertBillingEnabled` helpers already used by\nevery handler that gates on billing being enabled. An app that\nduplicated this provider-lookup-plus-cast itself (offlot did) can call\n`resolveProviderPlugin`/`isBillingEnabled` instead and drop the cast.\n\nThe `/web` barrel now exports `SubscriptionFoundationHandlers`,\n`SubscriptionFoundationQueries`, `BillingPlanActions`,\n`SubscriptionStatuses`, `BILLING_PLANS_SCREEN_ID` and the\n`BillingPlanBenefit`/`BillingPlanCatalog`/`BillingPlansResult`/\n`BillingPlanView` types. An app that hand-copied these QN constants\n(publicstatus did) can import them from `/web` instead."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.319.0",
|
|
28
|
+
"type": "breaking",
|
|
29
|
+
"title": "create-checkout-session's mode:\"payment\" now requires a declared one-off-price allowlist",
|
|
30
|
+
"migration": "SubscriptionProviderPlugin gains an `oneOffPriceIds` field (a readonly\nstring array, defaulting to empty). create-checkout-session's\nmode:\"payment\" branch now rejects any priceId that isn't in the\nresolved provider's `oneOffPriceIds` with an UnprocessableError whose\ndetails.reason is \"unknown_price\" (same shape as the existing\nsubscription-mode price check) — previously any priceId was accepted,\nso a TenantAdmin could start a one-off checkout against an arbitrary\nStripe price. mode:\"payment\" also now goes through the same\nisBillingEnabled gate as mode:\"subscription\", before the redirect-origin\ncheck, and fails with FeatureDisabledError when billing isn't enabled.\n\nEvery provider mount that offers one-off purchases (credit packs,\ntop-ups, ...) must list their priceIds explicitly. offlot-app's\nstart-pack-checkout.write.ts mounts Stripe for credit-pack purchases via\nmode:\"payment\" and needs its pack priceIds added, e.g.:\n`createSubscriptionStripeFeature({ priceToTier, oneOffPriceIds: configuredPackPriceIds() })`\n(or the equivalent option on its provider plugin), or every pack\ncheckout starts failing with \"unknown_price\" after this release.\n`oneOffPriceIds` is validated at mount time — an empty-string entry or a\nduplicate priceId now throws instead of silently widening/narrowing the\nallowlist."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.319.0",
|
|
34
|
+
"type": "breaking",
|
|
35
|
+
"title": "create-portal-session now computes returnUrl server-side and honors the catalog's purchase roles",
|
|
36
|
+
"migration": "create-portal-session's payload schema is now `z.object({}).strict()` — a\nclient that still sends `{ returnUrl }` gets a 400 instead of the URL\nbeing honored. The server now computes returnUrl itself from\n`catalog.returnPath` (joined onto `baseUrl`) or, without a catalog or\nreturnPath, from `baseUrl` directly; a foundation mounted without\n`baseUrl` fails the call with UnconfiguredError on the \"baseUrl\" key,\nsame as create-checkout-session. Update any client call to\n`portalMutation.mutate({})` — BillingPlansPanel already does this and no\nlonger reads `window.location.href`, which used to trigger\nredirect_origin_not_allowed on a `www.`-prefixed origin. A server-internal\ncaller like offlot-app's open-portal.write.ts, which currently does\n`ctx.write(SubscriptionFoundationHandlers.createPortalSession, { returnUrl })`,\nmust drop the `returnUrl` field from that call and instead set the return\ntarget via `catalog.returnPath` (or, without a catalog, the target is\nsimply `baseUrl`).\n\nAccess control also changed: with a catalog configured, the handler's\nallowed roles are now `catalog.purchaseRoles` (falling back to the\ncatalog's default purchase roles) instead of the hardcoded\nTenantAdmin/SystemAdmin pair. An app that lets e.g. a plain Admin role\npurchase plans must add that role to `catalog.purchaseRoles` to keep\nportal access working; conversely, a role that isn't in\n`purchaseRoles` now gets a 403 where it previously reached the handler.\n\nSet `catalog.returnPath` to the app's billing page (e.g.\n\"/host/billing\", or offlot's `${APP_BASE_PATH}/${MY_BILLING_SCREEN_ID}`),\notherwise the portal sends the user back to the bare `baseUrl`."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.318.0",
|
|
40
|
+
"type": "improvement",
|
|
41
|
+
"title": "Add a shared billing-plans catalog (start-plan-checkout, switch-plan, BillingPlansPanel)",
|
|
42
|
+
"detail": "BillingFoundationOptions gains an optional catalog: BillingPlanCatalog<TTier>, describing the app's purchasable tiers, benefits, current-tier resolution and view/purchase roles. Two new write handlers, start-plan-checkout and switch-plan, and a billing-plans query resolve prices via the provider's retrievePrices, decide checkout vs. switch per tier, and enforce viewRoles/purchaseRoles. BillingPlansPanel (web) renders the catalog via the new PlanCard/PlanGrid widgets, handling loading/error/disabled/read-only states and disabling every CTA while a checkout/switch/portal mutation is in flight."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.318.0",
|
|
46
|
+
"type": "breaking",
|
|
47
|
+
"title": "create-checkout-session and create-portal-session are now hardened: origin-checked redirects, known-price validation, plan-switch conflicts",
|
|
48
|
+
"migration": "Mount the foundation via createBillingFoundationFeature({ baseUrl }) (a\npath prefix is fine, e.g. \"https://app.example.com/tenant-x\"). Without\nbaseUrl, every create-checkout-session call now fails with an\nUnconfiguredError on the \"baseUrl\" key, including mode:\"payment\" checkouts.\n\ncreate-checkout-session: successUrl/cancelUrl must share baseUrl's origin,\nor the call fails with an UnprocessableError whose details.reason is\n\"redirect_origin_not_allowed\". For mode:\"subscription\" (the default when\nmode is omitted), priceId must be a price the resolved provider's\npriceToTier map actually knows about, and, if a catalog is configured,\nmust map to one of catalog.plans, or the call fails with reason\n\"unknown_price\". A tenant with an existing non-terminal subscription can\nno longer open a second subscription checkout. A ConflictError with\ni18nKey \"billing-foundation.errors.subscriptionExists\" fires instead; call\nbilling-foundation:write:switch-plan to change plans. mode:\"payment\"\n(one-off top-ups etc.) is exempt from both the price and the\nsubscription-conflict check and keeps working on an active subscription\nunchanged.\n\nProvider plugins written before this release should add a priceToTier map\non their SubscriptionProviderPlugin; without one, every mode:\"subscription\"\ncheckout is rejected with reason \"provider_has_no_price_catalog\".\n\nDirect-priceId callers of create-checkout-session should migrate to a\ncatalog plus billing-foundation:write:start-plan-checkout /\nbilling-foundation:write:switch-plan (or the BillingPlansPanel widget),\nwhich pick the price server-side and never expose a raw priceId to the\nclient.\n\ncreate-portal-session: when baseUrl is set, the payload's returnUrl must\nalso share its origin, or the same redirect_origin_not_allowed error\nfires. Unchanged when baseUrl is not configured.\n\ncreate-checkout-session: an optional providerCustomerId is now only\naccepted if it belongs to the tenant's own subscription at the same\nprovider, including a canceled one. Otherwise the call fails with an\nUnprocessableError whose details.reason is \"foreign_provider_customer\".\nThis closes a gap where a TenantAdmin could pass another tenant's\nprovider customer id and have the checkout attach to that customer's\nstored payment methods and invoices. Checked in both mode:\"subscription\"\nand mode:\"payment\"."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.318.0",
|
|
52
|
+
"type": "improvement",
|
|
53
|
+
"title": "billing-foundation's checkout gate no longer blocks forever on a stale incomplete subscription",
|
|
54
|
+
"detail": "A stale `incomplete` subscription (older than 24h) no longer counts as an active subscription for the checkout gate, so a tenant whose Stripe checkout was abandoned can start a fresh one. `SubscriptionView.lastChangedAt` (required) and `BillingPlanActions.paymentPending` are new; a consumer with an exhaustive switch over `BillingPlanAction` needs a `paymentPending` case. Known limit: two checkouts started in parallel before the provider's first webhook arrives can still create two subscriptions, since the gate only sees subscriptions already projected."
|
|
55
|
+
},
|
|
2
56
|
{
|
|
3
57
|
"version": "0.306.0",
|
|
4
58
|
"type": "fix",
|