@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,382 @@
|
|
|
1
|
+
// Shared checkout plumbing for create-checkout-session, start-plan-checkout
|
|
2
|
+
// and switch-plan — provider/catalog resolution, redirect-origin hardening,
|
|
3
|
+
// and the price/subscription-state gate that turns a bare priceId-driven
|
|
4
|
+
// checkout into a plan-aware one. Internal — not re-exported from index.ts.
|
|
5
|
+
|
|
6
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
ConflictError,
|
|
9
|
+
FeatureDisabledError,
|
|
10
|
+
UnconfiguredError,
|
|
11
|
+
UnprocessableError,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
13
|
+
import {
|
|
14
|
+
BILLING_FOUNDATION_FEATURE,
|
|
15
|
+
isSubscriptionBlockingCheckout,
|
|
16
|
+
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
17
|
+
} from "./constants";
|
|
18
|
+
import { getSubscriptionForTenant } from "./get-subscription-for-tenant";
|
|
19
|
+
import type { BillingPlanCatalog, SubscriptionProviderPlugin } from "./types";
|
|
20
|
+
|
|
21
|
+
/** Narrows a `readonly string[]` to the non-empty tuple shape `z.enum`
|
|
22
|
+
* needs. Callers only reach this with a catalog's `plans` — the feature
|
|
23
|
+
* factory already rejected an empty `plans` array at mount time, so the
|
|
24
|
+
* `false` branch is unreachable there; kept as a real type guard (not a
|
|
25
|
+
* cast) so a future caller without that guarantee fails loudly instead of
|
|
26
|
+
* producing a `z.enum([])`. */
|
|
27
|
+
export function isNonEmptyStringArray(
|
|
28
|
+
arr: readonly string[],
|
|
29
|
+
): arr is readonly [string, ...string[]] {
|
|
30
|
+
return arr.length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ResolvedProvider = {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly plugin: SubscriptionProviderPlugin;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Non-throwing provider lookup by name — null when no plugin with that
|
|
39
|
+
* entityName is registered. */
|
|
40
|
+
export function findProviderPlugin(
|
|
41
|
+
ctx: HandlerContext,
|
|
42
|
+
providerName: string,
|
|
43
|
+
): ResolvedProvider | null {
|
|
44
|
+
const usage = ctx.registry
|
|
45
|
+
.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
|
|
46
|
+
.find((u) => u.entityName === providerName);
|
|
47
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
48
|
+
return usage ? { name: providerName, plugin: usage.options as SubscriptionProviderPlugin } : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider {
|
|
52
|
+
const found = findProviderPlugin(ctx, providerName);
|
|
53
|
+
if (!found) {
|
|
54
|
+
const known =
|
|
55
|
+
ctx.registry
|
|
56
|
+
.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
|
|
57
|
+
.map((u) => u.entityName)
|
|
58
|
+
.join(", ") || "<none>";
|
|
59
|
+
throw new Error(
|
|
60
|
+
`subscription-foundation: provider "${providerName}" not registered. Known: ${known}.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Picks the catalog's provider — an explicit `catalog.providerName`, or the
|
|
67
|
+
* single registered provider exposing `priceToTier` when there is exactly
|
|
68
|
+
* one. Returns null when no provider is available (unset `providerName` not
|
|
69
|
+
* registered, or no registered plugin exposes `priceToTier`) — a config
|
|
70
|
+
* state `billing-plans` renders as `enabled: false` instead of erroring.
|
|
71
|
+
* More than one candidate is a config bug, not a "no provider yet" state,
|
|
72
|
+
* and still throws `UnconfiguredError`. */
|
|
73
|
+
export function findCatalogProvider(
|
|
74
|
+
ctx: HandlerContext,
|
|
75
|
+
catalog: BillingPlanCatalog,
|
|
76
|
+
): ResolvedProvider | null {
|
|
77
|
+
if (catalog.providerName) {
|
|
78
|
+
const found = findProviderPlugin(ctx, catalog.providerName);
|
|
79
|
+
if (!found) {
|
|
80
|
+
ctx.log?.warn(
|
|
81
|
+
`billing-foundation: catalog.providerName "${catalog.providerName}" is not a registered subscriptionProvider — billing-plans renders as disabled`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
|
|
87
|
+
const withPriceCatalog = usages.filter(
|
|
88
|
+
(u) => (u.options as SubscriptionProviderPlugin).priceToTier !== undefined,
|
|
89
|
+
);
|
|
90
|
+
if (withPriceCatalog.length > 1) {
|
|
91
|
+
throw new UnconfiguredError({
|
|
92
|
+
feature: "billing-foundation",
|
|
93
|
+
key: "catalog.providerName",
|
|
94
|
+
hint: `ambiguous — ${withPriceCatalog.length} registered plugins expose priceToTier, set catalog.providerName explicitly`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const usage = withPriceCatalog[0];
|
|
98
|
+
if (!usage) return null;
|
|
99
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
100
|
+
return { name: usage.entityName, plugin: usage.options as SubscriptionProviderPlugin };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `findCatalogProvider` for callers that require a provider to proceed —
|
|
104
|
+
* start-plan-checkout and switch-plan. `FeatureDisabledError` (not
|
|
105
|
+
* `UnconfiguredError`) when none is found: an unconfigured provider is
|
|
106
|
+
* the same "billing isn't live yet" state as `isPluginBillingEnabled`
|
|
107
|
+
* returning false, and the panel already renders both the same way. */
|
|
108
|
+
export function resolveCatalogProvider(
|
|
109
|
+
ctx: HandlerContext,
|
|
110
|
+
catalog: BillingPlanCatalog,
|
|
111
|
+
handlerName: string,
|
|
112
|
+
): ResolvedProvider {
|
|
113
|
+
const found = findCatalogProvider(ctx, catalog);
|
|
114
|
+
if (!found) {
|
|
115
|
+
throw new FeatureDisabledError(BILLING_FOUNDATION_FEATURE, handlerName);
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `plugin.isBillingEnabled?.(ctx) ?? true` — the one place this fallback is
|
|
121
|
+
* spelled out, so no call-site forgets to await the optional method before
|
|
122
|
+
* applying the `?? true` default. */
|
|
123
|
+
export async function isPluginBillingEnabled(
|
|
124
|
+
ctx: HandlerContext,
|
|
125
|
+
plugin: SubscriptionProviderPlugin,
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
return (await plugin.isBillingEnabled?.(ctx)) ?? true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Throws `FeatureDisabledError` unless the plugin reports billing enabled —
|
|
131
|
+
* shared by every handler that must refuse to act while billing isn't live
|
|
132
|
+
* (`openCheckout`, start-plan-checkout, switch-plan). */
|
|
133
|
+
export async function assertBillingEnabled(
|
|
134
|
+
ctx: HandlerContext,
|
|
135
|
+
plugin: SubscriptionProviderPlugin,
|
|
136
|
+
handlerName: string,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const enabled = await isPluginBillingEnabled(ctx, plugin);
|
|
139
|
+
if (!enabled) {
|
|
140
|
+
throw new FeatureDisabledError(BILLING_FOUNDATION_FEATURE, handlerName);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Public readiness-probe for a named provider — `resolveProviderPlugin` +
|
|
145
|
+
* `isPluginBillingEnabled`, re-exported from index.ts so an app-owner can
|
|
146
|
+
* check billing readiness without reaching into checkout-core internals. */
|
|
147
|
+
export async function isBillingEnabled(
|
|
148
|
+
ctx: HandlerContext,
|
|
149
|
+
providerName: string,
|
|
150
|
+
): Promise<boolean> {
|
|
151
|
+
const { plugin } = resolveProviderPlugin(ctx, providerName);
|
|
152
|
+
return isPluginBillingEnabled(ctx, plugin);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `create-portal-session`'s returnUrl, built server-side (the client no
|
|
156
|
+
* longer supplies one) — `catalog.returnPath` when set, else `baseUrl`
|
|
157
|
+
* itself. Sharing `baseUrl`'s origin is guaranteed by construction
|
|
158
|
+
* (`joinBaseUrl` only ever concatenates onto it), so no separate
|
|
159
|
+
* `assertRedirectOrigins` call is needed here. */
|
|
160
|
+
export function portalReturnUrl(options: {
|
|
161
|
+
readonly baseUrl?: string;
|
|
162
|
+
readonly catalog?: BillingPlanCatalog;
|
|
163
|
+
}): string {
|
|
164
|
+
if (options.baseUrl === undefined) {
|
|
165
|
+
throw new UnconfiguredError({
|
|
166
|
+
feature: "billing-foundation",
|
|
167
|
+
key: "baseUrl",
|
|
168
|
+
hint: "pass createBillingFoundationFeature({ baseUrl }) so create-portal-session can build a returnUrl",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return joinBaseUrl(options.baseUrl, options.catalog?.returnPath ?? "");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Every redirect URL a checkout/portal call carries must share the origin
|
|
175
|
+
* of the configured `baseUrl` — otherwise a caller could redirect a tenant
|
|
176
|
+
* admin's browser (with a fresh Stripe session cookie) to an attacker-
|
|
177
|
+
* controlled origin after checkout. Pure — no ctx, unit-testable. */
|
|
178
|
+
export function assertRedirectOrigins(urls: readonly string[], baseUrl: string | undefined): void {
|
|
179
|
+
if (baseUrl === undefined) {
|
|
180
|
+
throw new UnconfiguredError({
|
|
181
|
+
feature: "billing-foundation",
|
|
182
|
+
key: "baseUrl",
|
|
183
|
+
hint: "pass createBillingFoundationFeature({ baseUrl })",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
let allowedOrigin: string;
|
|
187
|
+
try {
|
|
188
|
+
allowedOrigin = new URL(baseUrl).origin;
|
|
189
|
+
} catch {
|
|
190
|
+
throw new UnconfiguredError({
|
|
191
|
+
feature: "billing-foundation",
|
|
192
|
+
key: "baseUrl",
|
|
193
|
+
hint: `"${baseUrl}" is not a parseable absolute URL`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
for (const url of urls) {
|
|
197
|
+
let origin: string;
|
|
198
|
+
try {
|
|
199
|
+
origin = new URL(url).origin;
|
|
200
|
+
} catch {
|
|
201
|
+
throw new UnprocessableError("redirect_origin_not_allowed", {
|
|
202
|
+
i18nKey: "billing-foundation.errors.redirectOriginNotAllowed",
|
|
203
|
+
message: `"${url}" is not a parseable absolute URL`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (origin !== allowedOrigin) {
|
|
207
|
+
throw new UnprocessableError("redirect_origin_not_allowed", {
|
|
208
|
+
i18nKey: "billing-foundation.errors.redirectOriginNotAllowed",
|
|
209
|
+
message: `redirect url "${url}" has origin "${origin}", expected "${allowedOrigin}"`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Strips a single trailing "/" — a configured `baseUrl` and a plan path
|
|
216
|
+
* both carrying a slash would otherwise concatenate into a protocol-
|
|
217
|
+
* relative "//path". */
|
|
218
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
219
|
+
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Builds an absolute plan-checkout/portal URL by concatenating a
|
|
223
|
+
* normalized `baseUrl` with `path` — per `BillingFoundationOptions.baseUrl`'s
|
|
224
|
+
* own doc comment this is string-concatenation on purpose (not
|
|
225
|
+
* `new URL(path, baseUrl)`, which would drop a path prefix); normalizing
|
|
226
|
+
* first stops a trailing "/" on `baseUrl` from producing "//path". */
|
|
227
|
+
export function joinBaseUrl(baseUrl: string, path: string): string {
|
|
228
|
+
return normalizeBaseUrl(baseUrl) + path;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Shared "no non-terminal subscription yet" gate for `openCheckout`'s
|
|
232
|
+
* mode:"subscription" branch and start-plan-checkout — both need the exact
|
|
233
|
+
* same ConflictError so a bare-priceId caller and a catalog caller can't
|
|
234
|
+
* diverge in wording. Returns the existing subscription (or null) so a
|
|
235
|
+
* caller that needs it afterwards (start-plan-checkout's providerCustomerId
|
|
236
|
+
* reuse) doesn't have to look it up twice. */
|
|
237
|
+
export async function assertNoActiveSubscription(
|
|
238
|
+
ctx: HandlerContext,
|
|
239
|
+
now: Temporal.Instant,
|
|
240
|
+
): Promise<Awaited<ReturnType<typeof getSubscriptionForTenant>>> {
|
|
241
|
+
const existing = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
|
|
242
|
+
if (existing && isSubscriptionBlockingCheckout(existing, now)) {
|
|
243
|
+
throw new ConflictError({
|
|
244
|
+
i18nKey: "billing-foundation.errors.subscriptionExists",
|
|
245
|
+
message:
|
|
246
|
+
"tenant already has a non-terminal subscription; use billing-foundation:write:switch-plan",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return existing;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Pure predicate for `openCheckout`'s providerCustomerId guard — kept
|
|
253
|
+
* standalone so it stays unit-testable without a `ctx`. */
|
|
254
|
+
export function isOwnProviderCustomer(
|
|
255
|
+
ownSubscription: Awaited<ReturnType<typeof getSubscriptionForTenant>>,
|
|
256
|
+
providerName: string,
|
|
257
|
+
providerCustomerId: string,
|
|
258
|
+
): boolean {
|
|
259
|
+
return (
|
|
260
|
+
ownSubscription !== null &&
|
|
261
|
+
ownSubscription.providerName === providerName &&
|
|
262
|
+
ownSubscription.providerCustomerId === providerCustomerId
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type OpenCheckoutOptions = {
|
|
267
|
+
readonly baseUrl?: string;
|
|
268
|
+
readonly catalog?: BillingPlanCatalog;
|
|
269
|
+
readonly now: () => Temporal.Instant;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export type OpenCheckoutInput = {
|
|
273
|
+
readonly providerName: string;
|
|
274
|
+
readonly priceId: string;
|
|
275
|
+
readonly successUrl: string;
|
|
276
|
+
readonly cancelUrl: string;
|
|
277
|
+
readonly providerCustomerId?: string;
|
|
278
|
+
readonly mode?: "subscription" | "payment";
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
function assertSubscriptionPriceAllowed(
|
|
282
|
+
plugin: SubscriptionProviderPlugin,
|
|
283
|
+
catalog: BillingPlanCatalog | undefined,
|
|
284
|
+
input: OpenCheckoutInput,
|
|
285
|
+
): void {
|
|
286
|
+
if (!plugin.priceToTier) {
|
|
287
|
+
throw new UnprocessableError("provider_has_no_price_catalog", {
|
|
288
|
+
i18nKey: "billing-foundation.errors.providerHasNoPriceCatalog",
|
|
289
|
+
message: `subscription-foundation: provider "${input.providerName}" has no priceToTier — cannot verify priceId "${input.priceId}" belongs to a known plan`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const tier = plugin.priceToTier[input.priceId];
|
|
293
|
+
if (!tier) {
|
|
294
|
+
throw new UnprocessableError("unknown_price", {
|
|
295
|
+
i18nKey: "billing-foundation.errors.unknownPrice",
|
|
296
|
+
message: `subscription-foundation: priceId "${input.priceId}" is not in provider "${input.providerName}"'s priceToTier`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (catalog && !catalog.plans.includes(tier)) {
|
|
300
|
+
throw new UnprocessableError("unknown_price", {
|
|
301
|
+
i18nKey: "billing-foundation.errors.unknownPrice",
|
|
302
|
+
message: `subscription-foundation: priceId "${input.priceId}" maps to tier "${tier}", which is not one of the catalog's plans`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Missing/empty oneOffPriceIds rejects every payment checkout: same
|
|
308
|
+
// "no gate configured yet = closed" default as the priceToTier gate.
|
|
309
|
+
function assertOneOffPriceAllowed(
|
|
310
|
+
plugin: SubscriptionProviderPlugin,
|
|
311
|
+
input: OpenCheckoutInput,
|
|
312
|
+
): void {
|
|
313
|
+
const oneOffPriceIds = plugin.oneOffPriceIds ?? [];
|
|
314
|
+
if (!oneOffPriceIds.includes(input.priceId)) {
|
|
315
|
+
throw new UnprocessableError("unknown_price", {
|
|
316
|
+
i18nKey: "billing-foundation.errors.unknownPrice",
|
|
317
|
+
message: `subscription-foundation: priceId "${input.priceId}" is not in provider "${input.providerName}"'s oneOffPriceIds`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The one checkout entry-point create-checkout-session and
|
|
323
|
+
* start-plan-checkout both funnel through. `mode: "payment"` (one-off
|
|
324
|
+
* top-ups etc.) skips the price/subscription gates entirely — those apply
|
|
325
|
+
* to recurring subscriptions only. */
|
|
326
|
+
export async function openCheckout(
|
|
327
|
+
ctx: HandlerContext,
|
|
328
|
+
options: OpenCheckoutOptions,
|
|
329
|
+
input: OpenCheckoutInput,
|
|
330
|
+
): Promise<{ readonly url: string; readonly providerName: string }> {
|
|
331
|
+
const { plugin } = resolveProviderPlugin(ctx, input.providerName);
|
|
332
|
+
if (!plugin.createCheckoutSession) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`subscription-foundation: provider "${input.providerName}" has no createCheckoutSession-method (e.g. Apple-IAP-only providers). Use the provider's native checkout flow.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Checked before the redirect-origin hardening for every mode — a
|
|
339
|
+
// disabled provider must reject a mode:"payment" checkout just as much
|
|
340
|
+
// as a mode:"subscription" one.
|
|
341
|
+
await assertBillingEnabled(ctx, plugin, "create-checkout-session");
|
|
342
|
+
assertRedirectOrigins([input.successUrl, input.cancelUrl], options.baseUrl);
|
|
343
|
+
|
|
344
|
+
const mode = input.mode ?? "subscription";
|
|
345
|
+
// Reused by the providerCustomerId check below to avoid a second lookup
|
|
346
|
+
// when mode:"subscription" already ran assertNoActiveSubscription.
|
|
347
|
+
let ownSubscription: Awaited<ReturnType<typeof getSubscriptionForTenant>> | undefined;
|
|
348
|
+
|
|
349
|
+
if (mode === "subscription") {
|
|
350
|
+
assertSubscriptionPriceAllowed(plugin, options.catalog, input);
|
|
351
|
+
ownSubscription = await assertNoActiveSubscription(ctx, options.now());
|
|
352
|
+
} else {
|
|
353
|
+
assertOneOffPriceAllowed(plugin, input);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Rejects a foreign tenant's provider-customer id — otherwise the checkout
|
|
357
|
+
// would attach to that customer's stored payment methods and invoices.
|
|
358
|
+
if (input.providerCustomerId) {
|
|
359
|
+
if (ownSubscription === undefined) {
|
|
360
|
+
ownSubscription = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
|
|
361
|
+
}
|
|
362
|
+
if (!isOwnProviderCustomer(ownSubscription, input.providerName, input.providerCustomerId)) {
|
|
363
|
+
throw new UnprocessableError("foreign_provider_customer", {
|
|
364
|
+
i18nKey: "billing-foundation.errors.foreignProviderCustomer",
|
|
365
|
+
message: `subscription-foundation: providerCustomerId "${input.providerCustomerId}" does not belong to this tenant's own subscription at provider "${input.providerName}"`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const result = await plugin.createCheckoutSession(ctx, {
|
|
371
|
+
priceId: input.priceId,
|
|
372
|
+
tenantId: ctx.user.tenantId,
|
|
373
|
+
successUrl: input.successUrl,
|
|
374
|
+
cancelUrl: input.cancelUrl,
|
|
375
|
+
...(input.providerCustomerId && { providerCustomerId: input.providerCustomerId }),
|
|
376
|
+
// Only forwarded when the caller set it explicitly — preserves the
|
|
377
|
+
// pre-hardening wire-contract for callers that never sent `mode`
|
|
378
|
+
// (create-checkout-session's own regression pin).
|
|
379
|
+
...(input.mode && { mode: input.mode }),
|
|
380
|
+
});
|
|
381
|
+
return { url: result.url, providerName: input.providerName };
|
|
382
|
+
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// temporal-polyfill is a plain npm dependency (not a framework runtime
|
|
3
|
+
// module), so importing it here doesn't break browser bundles. Aliased —
|
|
4
|
+
// same reason as event-store.ts's own import: the un-aliased name would
|
|
5
|
+
// shadow the ambient global `Temporal` TYPE every `Temporal.Instant`
|
|
6
|
+
// annotation in this file (and its callers, e.g. StoredEvent.createdAt)
|
|
7
|
+
// resolves against, see #1438.
|
|
8
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
9
|
+
|
|
1
10
|
// Feature name
|
|
2
11
|
export const BILLING_FOUNDATION_FEATURE = "billing-foundation" as const;
|
|
3
12
|
|
|
@@ -26,6 +35,15 @@ export const SubscriptionFoundationHandlers = {
|
|
|
26
35
|
* (payment-aggregate), separate from the subscription-aggregate — a
|
|
27
36
|
* payment is not a subscription-state transition. */
|
|
28
37
|
processPaymentEvent: "billing-foundation:write:process-payment-event",
|
|
38
|
+
/** Tenant-Admin/purchase-role picks a plan tier from the catalog with no
|
|
39
|
+
* existing non-terminal subscription — starts a hosted checkout for the
|
|
40
|
+
* matching price. Only registered when `createBillingFoundationFeature`
|
|
41
|
+
* gets a `catalog`. */
|
|
42
|
+
startPlanCheckout: "billing-foundation:write:start-plan-checkout",
|
|
43
|
+
/** Tenant-Admin/purchase-role switches an existing non-terminal
|
|
44
|
+
* subscription to another plan tier via the provider's confirmation
|
|
45
|
+
* page. Only registered when a `catalog` is configured. */
|
|
46
|
+
switchPlan: "billing-foundation:write:switch-plan",
|
|
29
47
|
} as const;
|
|
30
48
|
|
|
31
49
|
// Qualified query handler names.
|
|
@@ -34,6 +52,10 @@ export const SubscriptionFoundationQueries = {
|
|
|
34
52
|
* read_subscriptions-projection. Tenant-Admin sieht via ctx.db
|
|
35
53
|
* tenant-scoping nur die eigene row. */
|
|
36
54
|
listSubscriptions: "billing-foundation:query:subscription:list",
|
|
55
|
+
/** Lists the catalog's plans with live price, benefits, the caller's
|
|
56
|
+
* current tier and per-plan action. Only registered when a `catalog` is
|
|
57
|
+
* configured. */
|
|
58
|
+
billingPlans: "billing-foundation:query:billing-plans",
|
|
37
59
|
} as const;
|
|
38
60
|
|
|
39
61
|
// Normalized subscription-event types — provider-agnostic.
|
|
@@ -73,6 +95,82 @@ export const BillingEventKinds = {
|
|
|
73
95
|
} as const;
|
|
74
96
|
export type BillingEventKind = (typeof BillingEventKinds)[keyof typeof BillingEventKinds];
|
|
75
97
|
|
|
98
|
+
// Billing-plans screen/panel identifiers — the dormant dashboard app-builders
|
|
99
|
+
// mount their own catalog-derived nav entry onto.
|
|
100
|
+
export const BILLING_PLANS_SCREEN_ID = "billing-plans" as const;
|
|
101
|
+
export const BILLING_PLANS_PANEL_COMPONENT = "BillingPlansPanel" as const;
|
|
102
|
+
|
|
103
|
+
// Per-plan action a tenant-admin can take on the billing-plans query result.
|
|
104
|
+
export const BillingPlanActions = {
|
|
105
|
+
checkout: "checkout",
|
|
106
|
+
switch: "switch",
|
|
107
|
+
current: "current",
|
|
108
|
+
unavailable: "unavailable",
|
|
109
|
+
/** A young `incomplete` subscription for this tier — checkout was started
|
|
110
|
+
* but Stripe hasn't confirmed payment yet. No CTA; the panel shows a
|
|
111
|
+
* "still completing" hint instead. */
|
|
112
|
+
paymentPending: "paymentPending",
|
|
113
|
+
} as const;
|
|
114
|
+
export type BillingPlanAction = (typeof BillingPlanActions)[keyof typeof BillingPlanActions];
|
|
115
|
+
|
|
116
|
+
// A canceled subscription is terminal — the tenant has no more billing
|
|
117
|
+
// relationship with the provider and a plan pick starts a fresh checkout
|
|
118
|
+
// instead of a plan-switch. Every other status (including past_due) still
|
|
119
|
+
// has a live provider subscription-object to switch.
|
|
120
|
+
export const TERMINAL_SUBSCRIPTION_STATUSES: ReadonlySet<string> = new Set([
|
|
121
|
+
SubscriptionStatuses.canceled,
|
|
122
|
+
]);
|
|
123
|
+
export function isTerminalSubscriptionStatus(status: string): boolean {
|
|
124
|
+
return TERMINAL_SUBSCRIPTION_STATUSES.has(status);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Stripe auto-expires an "incomplete" subscription (payment never completed)
|
|
128
|
+
// after roughly 23 hours; without a matching staleness cutoff on our side, a
|
|
129
|
+
// stale incomplete row would block that tenant from ever starting a new
|
|
130
|
+
// checkout, since the projection has no expiry-triggered event of its own.
|
|
131
|
+
export const STALE_INCOMPLETE_AFTER = TemporalPolyfill.Duration.from({ hours: 24 });
|
|
132
|
+
|
|
133
|
+
/** Time-aware terminal check for the checkout gate: a canceled subscription
|
|
134
|
+
* is terminal (see `isTerminalSubscriptionStatus`), and so is an
|
|
135
|
+
* `incomplete` one once it's older than `STALE_INCOMPLETE_AFTER` — every
|
|
136
|
+
* other status (including a fresh `incomplete`) still blocks a new
|
|
137
|
+
* checkout. */
|
|
138
|
+
export function isSubscriptionBlockingCheckout(
|
|
139
|
+
sub: { readonly status: string; readonly lastChangedAt: Temporal.Instant },
|
|
140
|
+
now: Temporal.Instant,
|
|
141
|
+
): boolean {
|
|
142
|
+
if (isTerminalSubscriptionStatus(sub.status)) return false;
|
|
143
|
+
if (sub.status !== SubscriptionStatuses.incomplete) return true;
|
|
144
|
+
const staleAt = sub.lastChangedAt.add(STALE_INCOMPLETE_AFTER);
|
|
145
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
146
|
+
// at runtime — row types resolve against ambient Temporal; polyfill Instant
|
|
147
|
+
// is a separate nominal type across the two .d.ts sources.
|
|
148
|
+
return (
|
|
149
|
+
TemporalPolyfill.Instant.compare(
|
|
150
|
+
now as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
|
|
151
|
+
staleAt as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
|
|
152
|
+
) < 0
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A subscription in one of these statuses can be switched to a different
|
|
157
|
+
// plan tier via the provider's confirmation page (Stripe portal
|
|
158
|
+
// subscription_update_confirm and equivalents) — incomplete subscriptions
|
|
159
|
+
// never activated and are excluded.
|
|
160
|
+
export const SWITCHABLE_SUBSCRIPTION_STATUSES: ReadonlySet<string> = new Set([
|
|
161
|
+
SubscriptionStatuses.active,
|
|
162
|
+
SubscriptionStatuses.trialing,
|
|
163
|
+
SubscriptionStatuses.pastDue,
|
|
164
|
+
]);
|
|
165
|
+
export function isSwitchableSubscriptionStatus(status: string): boolean {
|
|
166
|
+
return SWITCHABLE_SUBSCRIPTION_STATUSES.has(status);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Default purchase-roles for a billing-plans catalog when the app doesn't
|
|
170
|
+
// override `purchaseRoles` — matches create-checkout-session's existing
|
|
171
|
+
// TenantAdmin/SystemAdmin-only access.
|
|
172
|
+
export const DEFAULT_PURCHASE_ROLES = ["TenantAdmin", "SystemAdmin"] as const;
|
|
173
|
+
|
|
76
174
|
// **Multi-Provider von Tag 1:** subscription-foundation hat KEIN
|
|
77
175
|
// `provider`-config-key. Alle gemounteten Plugins sind aktiv parallel —
|
|
78
176
|
// der Endkunde wählt beim Subscribe-Klick zwischen Karte/PayPal/
|
|
@@ -27,11 +27,12 @@ import {
|
|
|
27
27
|
// - tier: "free" / "pro" / ... — vom tier-engine konsumiert. Aus
|
|
28
28
|
// price-to-tier-Map resolved im Plugin.
|
|
29
29
|
// - currentPeriodEnd: wann läuft die aktuelle Billing-Period aus.
|
|
30
|
+
// - cancelAt: when a scheduled cancellation ends the subscription;
|
|
31
|
+
// nullable because a renewing subscription has none.
|
|
30
32
|
//
|
|
31
33
|
// **Was hier NICHT ist:**
|
|
32
34
|
// - invoice-history, payment-method, line-items, tax-info → all das
|
|
33
35
|
// fetcht der Tenant via customer-portal-session direkt vom Provider.
|
|
34
|
-
// - cancelAt, cancelAtPeriodEnd → Provider-Sache.
|
|
35
36
|
//
|
|
36
37
|
// **Audit/event-history:** lebt im event-store unter dem `subscription`-
|
|
37
38
|
// stream — KEIN eigene `subscription-event`-Tabelle mehr (= ES ist die
|
|
@@ -79,6 +80,7 @@ export const subscriptionEntity = createEntity({
|
|
|
79
80
|
reason: "catalog_label",
|
|
80
81
|
}),
|
|
81
82
|
currentPeriodEnd: createTimestampField({ required: true }),
|
|
83
|
+
cancelAt: createTimestampField({ required: false }),
|
|
82
84
|
},
|
|
83
85
|
});
|
|
84
86
|
|
|
@@ -60,6 +60,11 @@ export const subscriptionEventPayloadSchema = z.object({
|
|
|
60
60
|
status: statusEnum,
|
|
61
61
|
tier: z.string().min(1).max(50),
|
|
62
62
|
currentPeriodEndIso: z.string().min(1),
|
|
63
|
+
// Optional (not required): existing events (Mollie-style providers, or
|
|
64
|
+
// events appended before this field existed) stay valid without an
|
|
65
|
+
// upcaster. null = the subscription renews; undefined = the provider
|
|
66
|
+
// doesn't report cancelAt at all — projection.ts tells the two apart.
|
|
67
|
+
cancelAtIso: z.string().min(1).nullable().optional(),
|
|
63
68
|
});
|
|
64
69
|
export type SubscriptionEventPayload = z.infer<typeof subscriptionEventPayloadSchema>;
|
|
65
70
|
|