@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.
Files changed (54) hide show
  1. package/package.json +11 -10
  2. package/src/auth-email-password/constants.ts +44 -44
  3. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +159 -8
  4. package/src/billing-foundation/__tests__/billing-plans-admin-only.integration.test.ts +158 -0
  5. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +1034 -0
  6. package/src/billing-foundation/__tests__/checkout-core.test.ts +153 -0
  7. package/src/billing-foundation/__tests__/constants.test.ts +72 -0
  8. package/src/billing-foundation/__tests__/feature.test.ts +139 -1
  9. package/src/billing-foundation/changes.json +54 -0
  10. package/src/billing-foundation/checkout-core.ts +382 -0
  11. package/src/billing-foundation/constants.ts +98 -0
  12. package/src/billing-foundation/entities.ts +3 -1
  13. package/src/billing-foundation/events.ts +5 -0
  14. package/src/billing-foundation/feature.ts +155 -87
  15. package/src/billing-foundation/get-subscription-for-tenant.ts +11 -0
  16. package/src/billing-foundation/handlers/billing-plans.query.ts +32 -0
  17. package/src/billing-foundation/handlers/create-checkout-session.write.ts +42 -44
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +76 -64
  19. package/src/billing-foundation/handlers/process-event.write.ts +2 -0
  20. package/src/billing-foundation/handlers/start-plan-checkout.write.ts +75 -0
  21. package/src/billing-foundation/handlers/switch-plan.write.ts +108 -0
  22. package/src/billing-foundation/i18n.ts +66 -0
  23. package/src/billing-foundation/index.ts +27 -5
  24. package/src/billing-foundation/plan-catalog.ts +218 -0
  25. package/src/billing-foundation/projection.ts +48 -13
  26. package/src/billing-foundation/screens.ts +25 -0
  27. package/src/billing-foundation/types.ts +175 -1
  28. package/src/billing-foundation/validate-options.ts +66 -0
  29. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +365 -0
  30. package/src/billing-foundation/web/billing-plans-panel.tsx +283 -0
  31. package/src/billing-foundation/web/client-plugin.tsx +21 -0
  32. package/src/billing-foundation/web/index.ts +23 -0
  33. package/src/billing-foundation/webhook-handler.ts +1 -0
  34. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +88 -0
  35. package/src/cap-counter/index.ts +1 -0
  36. package/src/cap-counter/stock-cap-guard.ts +17 -5
  37. package/src/cap-overview/__tests__/cap-limit-from-config.integration.test.ts +141 -0
  38. package/src/cap-overview/changes.json +6 -0
  39. package/src/cap-overview/handlers/caps-usage.query.ts +1 -1
  40. package/src/cap-overview/handlers/tenant-caps-list.query.ts +26 -1
  41. package/src/cap-overview/types.ts +6 -1
  42. package/src/subscription-mollie/changes.json +8 -1
  43. package/src/subscription-mollie/feature.ts +1 -0
  44. package/src/subscription-stripe/__tests__/feature.test.ts +25 -1
  45. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +541 -3
  46. package/src/subscription-stripe/__tests__/runtime.test.ts +35 -0
  47. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +74 -8
  48. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +39 -2
  49. package/src/subscription-stripe/changes.json +24 -0
  50. package/src/subscription-stripe/feature.ts +40 -1
  51. package/src/subscription-stripe/plugin-methods.ts +352 -1
  52. package/src/subscription-stripe/runtime.ts +23 -4
  53. package/src/subscription-stripe/verify-webhook.ts +24 -1
  54. 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