@cosmicdrift/kumiko-samples 0.387.0 → 0.388.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 (173) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
  5. package/packages/bundled-features/src/audit/changes.json +12 -0
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  7. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  9. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  10. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  11. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  12. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  13. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  14. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  16. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  28. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  29. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  30. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  31. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  32. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  34. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  35. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  36. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  37. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  38. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  39. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  40. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  41. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  42. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  43. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  44. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  45. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  46. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  47. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  48. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
  49. package/packages/bundled-features/src/cap-counter/changes.json +18 -0
  50. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  51. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  52. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  53. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  54. package/packages/bundled-features/src/config/changes.json +12 -0
  55. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  56. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  57. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  58. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  59. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
  60. package/packages/bundled-features/src/delivery/changes.json +30 -0
  61. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  62. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  64. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  65. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  66. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  67. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  68. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  69. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  70. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  71. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  72. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  73. package/packages/bundled-features/src/files/README.md +1 -1
  74. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  75. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  76. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  77. package/packages/bundled-features/src/host-routing/README.md +1 -1
  78. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  79. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  80. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  83. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  85. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  86. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  87. package/packages/bundled-features/src/jobs/changes.json +13 -0
  88. package/packages/bundled-features/src/jobs/feature.ts +14 -4
  89. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  90. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  92. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  93. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  94. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  95. package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
  96. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  97. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  98. package/packages/bundled-features/src/sessions/feature.ts +7 -2
  99. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  100. package/packages/bundled-features/src/shared/index.ts +1 -1
  101. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  102. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  103. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  104. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  105. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  106. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  107. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  108. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  109. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  110. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  111. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  112. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  113. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  114. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  115. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  116. package/packages/bundled-features/src/template-resolver/changes.json +48 -0
  117. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  118. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  119. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  120. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  121. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  122. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  123. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  125. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
  126. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  127. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  128. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  129. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  131. package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
  132. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
  133. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  134. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  135. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  136. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  137. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  138. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  139. package/packages/bundled-features/src/tenant/changes.json +6 -0
  140. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  141. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  145. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  146. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  147. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  148. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  149. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  150. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  151. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  152. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
  153. package/packages/bundled-features/src/waitlist/import.ts +5 -0
  154. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
  155. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  156. package/packages/bundled-features/src/waitlist/options.ts +3 -0
  157. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  158. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  161. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  162. package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
  163. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  164. package/samples/recipes/auth-mfa/README.md +5 -0
  165. package/samples/recipes/auth-mfa/package.json +5 -2
  166. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  167. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  168. package/samples/recipes/env-composition/README.md +3 -1
  169. package/samples/recipes/env-composition/src/env.ts +2 -0
  170. package/samples/recipes/legal-pages/README.md +1 -1
  171. package/samples/recipes/rate-limiting/README.md +18 -0
  172. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  173. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -6,12 +6,14 @@
6
6
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { isPluginBillingEnabled } from "./checkout-core.ts";
8
8
  import { getSubscriptionForTenant } from "./get-subscription-for-tenant.ts";
9
- import type {
10
- BillingPlanCatalog,
11
- BillingPlansResult,
12
- BillingPlanView,
13
- ProviderPrice,
14
- SubscriptionProviderPlugin,
9
+ import {
10
+ type BillingPlanCatalog,
11
+ type BillingPlansResult,
12
+ type BillingPlanView,
13
+ canSwitchPlans,
14
+ canSwitchPlansInApp,
15
+ type ProviderPrice,
16
+ type SubscriptionProviderPlugin,
15
17
  } from "./types.ts";
16
18
  import {
17
19
  BillingPlanActions,
@@ -51,7 +53,7 @@ function invertPriceToTier(
51
53
  return byTier;
52
54
  }
53
55
 
54
- type ResolvedPlanPrice = { readonly priceId: string; readonly price: ProviderPrice } | null;
56
+ export type ResolvedPlanPrice = { readonly priceId: string; readonly price: ProviderPrice } | null;
55
57
 
56
58
  /** Resolves one active, flat-amount price per plan tier. 0 or >1 active
57
59
  * flat-amount prices for a tier is ambiguous — the caller can't tell which
@@ -120,7 +122,7 @@ function canSwitchToTier(
120
122
  isSwitchableSubscriptionStatus(subscription.status) &&
121
123
  tier !== subscription.tier &&
122
124
  subscription.cancelAt === null &&
123
- plugin?.createPlanSwitchSession !== undefined
125
+ canSwitchPlans(plugin)
124
126
  );
125
127
  }
126
128
 
@@ -157,37 +159,60 @@ function resolvePlanAction(
157
159
  : BillingPlanActions.unavailable;
158
160
  }
159
161
 
160
- /** `plugin === null` — no provider is registered/resolvable for this
161
- * catalog (see checkout-core's `findCatalogProvider`) — collapses onto the
162
- * same "enabled: false" result a disabled provider produces, rather than
163
- * a separate error shape the panel would need to special-case. */
164
- export async function buildBillingPlans(
162
+ /** Tenant-level state every provider's plan list is measured against; loaded
163
+ * once so a multi-provider result does not repeat the reads per provider. */
164
+ export type BillingPlansBase = {
165
+ readonly currentTierValue: string;
166
+ readonly subscriptionView: Awaited<ReturnType<typeof getSubscriptionForTenant>>;
167
+ readonly subscription: BillingPlansResult["subscription"];
168
+ readonly blockingSubscription: boolean;
169
+ readonly canPurchase: boolean;
170
+ };
171
+
172
+ export async function loadBillingPlansBase(
165
173
  ctx: HandlerContext,
166
- plugin: SubscriptionProviderPlugin | null,
167
174
  catalog: BillingPlanCatalog,
168
175
  now: () => Temporal.Instant,
169
- ): Promise<BillingPlansResult> {
176
+ ): Promise<BillingPlansBase> {
170
177
  const currentTierValue = await catalog.resolveCurrentTier(ctx.db, ctx.user.tenantId);
171
- const enabled = plugin !== null && (await isPluginBillingEnabled(ctx, plugin));
172
- const resolvedPrices =
173
- enabled && plugin
174
- ? await resolvePlanPrices(ctx, plugin, catalog)
175
- : new Map<string, ResolvedPlanPrice>(catalog.plans.map((tier) => [tier, null]));
176
-
177
178
  const subscriptionView = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
178
- const nowInstant = now();
179
+ const blockingSubscription =
180
+ subscriptionView !== null && isSubscriptionBlockingCheckout(subscriptionView, now());
179
181
  const subscription = subscriptionView
180
182
  ? {
181
183
  status: subscriptionView.status,
182
184
  tier: subscriptionView.tier,
183
- terminal: !isSubscriptionBlockingCheckout(subscriptionView, nowInstant),
185
+ terminal: !blockingSubscription,
184
186
  currentPeriodEnd: subscriptionView.currentPeriodEnd.toString(),
185
187
  cancelAt: subscriptionView.cancelAt?.toString() ?? null,
186
188
  }
187
189
  : null;
190
+ const canPurchase = userHasAnyRole(ctx.user.roles, purchaseRolesOf(catalog));
191
+ return { currentTierValue, subscriptionView, subscription, blockingSubscription, canPurchase };
192
+ }
193
+
194
+ export type ProviderPlanViews = {
195
+ readonly enabled: boolean;
196
+ readonly inAppSwitch: boolean;
197
+ readonly plans: readonly BillingPlanView[];
198
+ };
188
199
 
189
- const purchaseRoles = purchaseRolesOf(catalog);
190
- const canPurchase = userHasAnyRole(ctx.user.roles, purchaseRoles);
200
+ /** `plugin === null` — no provider is registered/resolvable for this
201
+ * catalog (see checkout-core's `listCatalogProviders`) — collapses onto the
202
+ * same "enabled: false" result a disabled provider produces, rather than
203
+ * a separate error shape the panel would need to special-case. */
204
+ export async function buildProviderPlanViews(
205
+ ctx: HandlerContext,
206
+ plugin: SubscriptionProviderPlugin | null,
207
+ catalog: BillingPlanCatalog,
208
+ base: BillingPlansBase,
209
+ ): Promise<ProviderPlanViews> {
210
+ const { currentTierValue, subscription, canPurchase } = base;
211
+ const enabled = plugin !== null && (await isPluginBillingEnabled(ctx, plugin));
212
+ const resolvedPrices =
213
+ enabled && plugin
214
+ ? await resolvePlanPrices(ctx, plugin, catalog)
215
+ : new Map<string, ResolvedPlanPrice>(catalog.plans.map((tier) => [tier, null]));
191
216
 
192
217
  const plans: BillingPlanView[] = catalog.plans.map((tier) => {
193
218
  const isCurrent = tier === currentTierValue;
@@ -221,8 +246,17 @@ export async function buildBillingPlans(
221
246
  };
222
247
  });
223
248
 
249
+ return { enabled, inAppSwitch: plugin !== null && canSwitchPlansInApp(plugin), plans };
250
+ }
251
+
252
+ export function toBillingPlansResult(
253
+ catalog: BillingPlanCatalog,
254
+ base: BillingPlansBase,
255
+ views: ProviderPlanViews,
256
+ ): BillingPlansResult {
257
+ const { currentTierValue, subscription, canPurchase } = base;
224
258
  return {
225
- enabled,
259
+ enabled: views.enabled,
226
260
  currentTier: {
227
261
  tier: currentTierValue,
228
262
  labelKey: catalog.tierLabelKey(currentTierValue),
@@ -230,7 +264,8 @@ export async function buildBillingPlans(
230
264
  },
231
265
  subscription,
232
266
  canPurchase,
233
- plans,
267
+ plans: views.plans,
268
+ ...(views.inAppSwitch && { inAppPlanSwitch: true as const }),
234
269
  ...(catalog.contact !== undefined && { contact: catalog.contact }),
235
270
  };
236
271
  }
@@ -0,0 +1,84 @@
1
+ // Pre-checks shared by switch-plan and plan-switch-preview: both must agree on
2
+ // which subscription, provider and target price a plan switch means.
3
+ // Internal — not re-exported from index.ts.
4
+
5
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
7
+ import {
8
+ assertBillingEnabled,
9
+ findProviderPlugin,
10
+ listCatalogProviders,
11
+ type ResolvedProvider,
12
+ } from "./checkout-core.ts";
13
+ import { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant.ts";
14
+ import { type ResolvedPlanPrice, resolvePlanPrices } from "./plan-catalog.ts";
15
+ import { type BillingPlanCatalog, canSwitchPlans } from "./types.ts";
16
+ import { isSwitchableSubscriptionStatus } from "./web/constants.ts";
17
+
18
+ export type PreparedPlanSwitch = {
19
+ readonly provider: ResolvedProvider;
20
+ readonly subscription: SubscriptionView;
21
+ readonly target: NonNullable<ResolvedPlanPrice>;
22
+ readonly allowedPriceIds: readonly string[];
23
+ };
24
+
25
+ export async function preparePlanSwitch(
26
+ ctx: HandlerContext,
27
+ catalog: BillingPlanCatalog,
28
+ tier: string,
29
+ handlerName: string,
30
+ ): Promise<PreparedPlanSwitch> {
31
+ const subscription = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
32
+ if (!subscription || !isSwitchableSubscriptionStatus(subscription.status)) {
33
+ throw new ConflictError({
34
+ i18nKey: "billing-foundation.errors.noActiveSubscription",
35
+ message:
36
+ "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
37
+ });
38
+ }
39
+ // providerSubscriptionId only exists at the subscription's own provider; a catalog that
40
+ // no longer sells through that provider cannot switch it.
41
+ const provider = findProviderPlugin(ctx, subscription.providerName);
42
+ const isCatalogProvider =
43
+ provider !== null &&
44
+ listCatalogProviders(ctx, catalog).some((candidate) => candidate.name === provider.name);
45
+ if (!provider || !isCatalogProvider) {
46
+ throw new ConflictError({
47
+ i18nKey: "billing-foundation.errors.providerMismatch",
48
+ message: `tenant's active subscription is on provider "${subscription.providerName}", which the plan catalog does not sell through — switch-plan cannot switch across providers`,
49
+ });
50
+ }
51
+ await assertBillingEnabled(ctx, provider.plugin, handlerName);
52
+ if (subscription.cancelAt !== null) {
53
+ throw new ConflictError({
54
+ i18nKey: "billing-foundation.errors.cancellationScheduled",
55
+ message:
56
+ "subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
57
+ });
58
+ }
59
+ if (tier === subscription.tier) {
60
+ throw new ConflictError({
61
+ i18nKey: "billing-foundation.errors.alreadyOnPlan",
62
+ message: `tenant is already on plan "${tier}"`,
63
+ });
64
+ }
65
+ if (!canSwitchPlans(provider.plugin)) {
66
+ throw new UnprocessableError("plan_switch_not_supported", {
67
+ i18nKey: "billing-foundation.errors.planSwitchNotSupported",
68
+ message: `subscription-foundation: provider "${subscription.providerName}" supports neither createPlanSwitchSession nor previewPlanSwitch + applyPlanSwitch`,
69
+ });
70
+ }
71
+
72
+ const prices = await resolvePlanPrices(ctx, provider.plugin, catalog);
73
+ const target = prices.get(tier);
74
+ if (!target) {
75
+ throw new UnprocessableError("price_unavailable", {
76
+ i18nKey: "billing-foundation.errors.priceUnavailable",
77
+ message: `subscription-foundation: no live price resolved for tier "${tier}"`,
78
+ });
79
+ }
80
+ const allowedPriceIds = [...prices.values()]
81
+ .filter((p): p is NonNullable<typeof p> => p !== null)
82
+ .map((p) => p.priceId);
83
+ return { provider, subscription, target, allowedPriceIds };
84
+ }
@@ -136,6 +136,9 @@ export type PaymentEvent = {
136
136
  // =============================================================================
137
137
 
138
138
  export type SubscriptionProviderPlugin = {
139
+ /** Human-readable provider name for the plan panel's provider switch. */
140
+ readonly displayName?: string;
141
+
139
142
  /**
140
143
  * Verify webhook signature + parse provider-event into normalized
141
144
  * form. **Pre-tenant-resolution** — no HandlerContext, because at
@@ -313,6 +316,32 @@ export type SubscriptionProviderPlugin = {
313
316
  },
314
317
  ) => Promise<{ readonly url: string }>;
315
318
 
319
+ /**
320
+ * In-app plan switch, step 1: what the switch would cost right now. Together
321
+ * with `applyPlanSwitch` an alternative to the hosted `createPlanSwitchSession`.
322
+ */
323
+ readonly previewPlanSwitch?: (
324
+ ctx: HandlerContext,
325
+ options: {
326
+ readonly providerSubscriptionId: string;
327
+ readonly targetPriceId: string;
328
+ },
329
+ ) => Promise<PlanSwitchPreview>;
330
+
331
+ /**
332
+ * In-app plan switch, step 2: performs the switch at the provider. The
333
+ * state change itself arrives later through the webhook.
334
+ */
335
+ readonly applyPlanSwitch?: (
336
+ ctx: HandlerContext,
337
+ options: {
338
+ readonly providerSubscriptionId: string;
339
+ readonly targetPriceId: string;
340
+ readonly tenantId: string;
341
+ readonly consentId?: string;
342
+ },
343
+ ) => Promise<void>;
344
+
316
345
  /**
317
346
  * Fetches the live provider-side state of one subscription, for the
318
347
  * `sync-subscriptions` backfill job — catches drift (e.g. a `cancel_at`
@@ -330,6 +359,18 @@ export type SubscriptionProviderPlugin = {
330
359
  ) => Promise<ProviderSubscriptionSnapshot | null>;
331
360
  };
332
361
 
362
+ export type PlanSwitchPreview = {
363
+ /** Charged immediately, smallest currency unit, tax included. */
364
+ readonly amountDueNow: number;
365
+ /** Tax part of `amountDueNow`; null when the provider reports none. */
366
+ readonly tax: number | null;
367
+ /** Lower-case ISO currency code. */
368
+ readonly currency: string;
369
+ /** Credit for the unused time on the old plan, smallest currency unit. */
370
+ readonly credit: number;
371
+ readonly effectiveAt: string;
372
+ };
373
+
333
374
  export type ProviderSubscriptionSnapshot = {
334
375
  readonly providerCustomerId: string;
335
376
  readonly providerSubscriptionId: string;
@@ -399,7 +440,8 @@ export type BillingPlanCatalog<TTier extends string = string> = {
399
440
  * `baseUrl` itself when unset) and by `switch-plan` after a plan switch
400
441
  * (defaults to `successPath` there instead). */
401
442
  readonly returnPath?: string;
402
- /** Defaults to the only registered provider exposing `priceToTier`. */
443
+ /** Restricts the catalog to one provider; defaults to every registered
444
+ * provider exposing `priceToTier`. */
403
445
  readonly providerName?: string;
404
446
  /** Rendered as the last card, no price, visible to `viewRoles` regardless of `purchaseRoles`. */
405
447
  readonly contact?: BillingPlanContactCard;
@@ -429,6 +471,19 @@ export type ConsumerProtectionOptions = {
429
471
  readonly termsTextBlock: string;
430
472
  /** Per-locale VAT note; must carry at least `de` and `en`. */
431
473
  readonly vatNote: Readonly<Record<string, string>>;
474
+ /** Per-provider texts keyed by provider name, e.g. for a merchant-of-record
475
+ * provider that issues the invoice and VAT itself. `vatNote` here replaces
476
+ * the global one in that provider's confirmation mail; `checkoutNote` is
477
+ * shown under the provider choice. de and en are required for each text. */
478
+ readonly providerTexts?: Readonly<
479
+ Record<
480
+ string,
481
+ {
482
+ readonly vatNote?: Readonly<Record<string, string>>;
483
+ readonly checkoutNote?: Readonly<Record<string, string>>;
484
+ }
485
+ >
486
+ >;
432
487
  readonly operatorEmail: string;
433
488
  /** Where the public § 312k declaration is reachable. `"tenant-host"`
434
489
  * (default): on a host that resolves a tenant. `"platform"`: also on a
@@ -490,6 +545,23 @@ export type BillingPlanView = {
490
545
  readonly action: BillingPlanAction;
491
546
  };
492
547
 
548
+ export type BillingProviderView = {
549
+ readonly name: string;
550
+ readonly displayName: string;
551
+ readonly note?: Readonly<Record<string, string>>;
552
+ readonly plans: readonly BillingPlanView[];
553
+ };
554
+
555
+ export function canSwitchPlansInApp(plugin: SubscriptionProviderPlugin): boolean {
556
+ return plugin.previewPlanSwitch !== undefined && plugin.applyPlanSwitch !== undefined;
557
+ }
558
+
559
+ /** A provider switches plans via a hosted session or via in-app preview + apply. */
560
+ export function canSwitchPlans(plugin: SubscriptionProviderPlugin | null): boolean {
561
+ if (plugin === null) return false;
562
+ return plugin.createPlanSwitchSession !== undefined || canSwitchPlansInApp(plugin);
563
+ }
564
+
493
565
  export type BillingPlansResult = {
494
566
  readonly enabled: boolean;
495
567
  readonly currentTier: {
@@ -506,6 +578,13 @@ export type BillingPlansResult = {
506
578
  } | null;
507
579
  readonly canPurchase: boolean;
508
580
  readonly plans: readonly BillingPlanView[];
581
+ /** Present when more than one provider is offered, or the single offered
582
+ * provider has a `providerTexts` checkout note. `plans` above are the first
583
+ * entry's plans. */
584
+ readonly providers?: readonly BillingProviderView[];
585
+ /** True when the subscription's provider switches plans in-app (preview +
586
+ * confirm) instead of on a hosted page. */
587
+ readonly inAppPlanSwitch?: true;
509
588
  /** Present only when `catalog.contact` is configured. */
510
589
  readonly contact?: BillingPlanContactCard;
511
590
  /** Present only when `consumerProtection` is configured. */
@@ -28,6 +28,24 @@ function isRootRelativeOrHttpsUrl(link: string): boolean {
28
28
  }
29
29
  }
30
30
 
31
+ function validateProviderTexts(cp: ConsumerProtectionOptions): void {
32
+ for (const [providerName, texts] of Object.entries(cp.providerTexts ?? {})) {
33
+ for (const [textName, text] of [
34
+ ["vatNote", texts.vatNote],
35
+ ["checkoutNote", texts.checkoutNote],
36
+ ] as const) {
37
+ if (text === undefined) continue;
38
+ for (const locale of ["de", "en"] as const) {
39
+ if (!text[locale]?.trim()) {
40
+ throw new Error(
41
+ `createBillingFoundationFeature: consumerProtection.providerTexts.${providerName}.${textName} needs a non-empty "${locale}" entry.`,
42
+ );
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+
31
49
  function validateConsumerProtection(cp: ConsumerProtectionOptions): void {
32
50
  if (cp.termsTextBlock.trim() === "") {
33
51
  throw new Error("createBillingFoundationFeature: consumerProtection.termsTextBlock is empty.");
@@ -39,6 +57,7 @@ function validateConsumerProtection(cp: ConsumerProtectionOptions): void {
39
57
  );
40
58
  }
41
59
  }
60
+ validateProviderTexts(cp);
42
61
  if (!z.email().safeParse(cp.operatorEmail).success) {
43
62
  throw new Error(
44
63
  `createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`,
@@ -24,24 +24,13 @@ import {
24
24
  SubscriptionFoundationQueries,
25
25
  SubscriptionStatuses,
26
26
  } from "./constants.ts";
27
+ import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
27
28
  import { localizedParams } from "./localized-params.ts";
29
+ import { cheapestPrice, intervalKey, pricesDiffer, renewalKey, tierPrices } from "./plan-price.ts";
30
+ import { PlanSwitchConfirmDialog } from "./plan-switch-confirm-dialog.tsx";
28
31
 
29
32
  type UseTranslation = ReturnType<typeof useTranslation>;
30
33
 
31
- function intervalKey(price: BillingPlanView["price"]): string | undefined {
32
- if (price === null || price.interval === null) return undefined;
33
- return price.intervalCount !== null && price.intervalCount > 1
34
- ? `billing-foundation.plans.everyInterval.${price.interval}`
35
- : `billing-foundation.plans.perInterval.${price.interval}`;
36
- }
37
-
38
- function renewalKey(price: BillingPlanView["price"]): string | undefined {
39
- if (price === null || price.interval === null) return undefined;
40
- return price.intervalCount !== null && price.intervalCount > 1
41
- ? `billing-foundation.consent.renewsEvery.${price.interval}`
42
- : `billing-foundation.consent.renews.${price.interval}`;
43
- }
44
-
45
34
  /** Which action the plan "would" offer if it weren't blocked — mirrors
46
35
  * plan-catalog.ts's own decision tree, since `unavailable` collapses both
47
36
  * "would checkout" and "would switch" into one action and the disabled CTA
@@ -135,12 +124,25 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
135
124
  const [redirecting, setRedirecting] = useState(false);
136
125
  const [consentTier, setConsentTier] = useState<string | null>(null);
137
126
  const [cancelOpen, setCancelOpen] = useState(false);
127
+ const [chosenProvider, setChosenProvider] = useState<string | null>(null);
128
+ const [switchTier, setSwitchTier] = useState<string | null>(null);
129
+ const [switchRequested, setSwitchRequested] = useState(false);
130
+
131
+ // Only present when the tenant can choose (several offered providers) or
132
+ // the single offered provider has a note; absent means the server decides.
133
+ const providers = query.data?.providers;
134
+ const activeProvider = providers?.find((p) => p.name === chosenProvider) ?? providers?.[0];
135
+ const providerArg = activeProvider ? { providerName: activeProvider.name } : {};
138
136
 
139
137
  async function orderWithConsent(
140
138
  tier: string,
141
- consent: CheckoutConsentPayload,
139
+ consent: CheckoutConsentPayload | undefined,
142
140
  ): Promise<WriteResult<{ readonly url: string }>> {
143
- const result = await checkoutMutation.mutate({ tier, consent });
141
+ const result = await checkoutMutation.mutate({
142
+ tier,
143
+ ...(consent !== undefined && { consent }),
144
+ ...providerArg,
145
+ });
144
146
  if (result.isSuccess) {
145
147
  setRedirecting(true);
146
148
  window.location.assign(result.data.url);
@@ -149,13 +151,23 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
149
151
  }
150
152
 
151
153
  async function handleAction(tier: string, kind: "checkout" | "switch"): Promise<void> {
152
- if (kind === "checkout" && query.data?.consumerProtection !== undefined) {
154
+ if (
155
+ kind === "checkout" &&
156
+ (query.data?.consumerProtection !== undefined || (providers?.length ?? 0) > 1)
157
+ ) {
158
+ setChosenProvider(null);
153
159
  setConsentTier(tier);
154
160
  return;
155
161
  }
162
+ if (kind === "switch" && query.data?.inAppPlanSwitch) {
163
+ setSwitchTier(tier);
164
+ return;
165
+ }
156
166
  setRedirecting(true);
157
- const mutation = kind === "checkout" ? checkoutMutation : switchMutation;
158
- const result = await mutation.mutate({ tier });
167
+ const result =
168
+ kind === "checkout"
169
+ ? await checkoutMutation.mutate({ tier, ...providerArg })
170
+ : await switchMutation.mutate({ tier });
159
171
  if (result.isSuccess) {
160
172
  window.location.assign(result.data.url);
161
173
  } else {
@@ -189,6 +201,9 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
189
201
  }
190
202
  if (!query.data) return null;
191
203
  const result: BillingPlansResult = query.data;
204
+ // Cards show the first provider's price; the dialog then follows the chosen one.
205
+ const shownPlans = providers?.[0]?.plans ?? result.plans;
206
+ const dialogPlans = activeProvider?.plans ?? result.plans;
192
207
 
193
208
  // The consent dialog shows the checkout error itself.
194
209
  const mutationError =
@@ -199,7 +214,11 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
199
214
  result.subscription !== null && !result.subscription.terminal && result.canPurchase;
200
215
 
201
216
  function toCardProps(plan: BillingPlanView) {
202
- const key = intervalKey(plan.price);
217
+ const comparable =
218
+ providers !== undefined && providers.length > 1 ? tierPrices(providers, plan.tier) : [];
219
+ const fromPrice = pricesDiffer(comparable) ? cheapestPrice(comparable) : undefined;
220
+ const shownPrice = fromPrice ?? plan.price;
221
+ const key = intervalKey(shownPrice);
203
222
  return {
204
223
  title: t(plan.labelKey),
205
224
  description:
@@ -214,13 +233,14 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
214
233
  price:
215
234
  !result.enabled && plan.isCurrent
216
235
  ? undefined
217
- : plan.price === null
236
+ : shownPrice === null
218
237
  ? null
219
238
  : {
220
- amount: formatMoney(plan.price.unitAmount, plan.price.currency, locale),
239
+ amount: formatMoney(shownPrice.unitAmount, shownPrice.currency, locale),
240
+ ...(fromPrice !== undefined && { prefix: t("billing-foundation.plans.priceFrom") }),
221
241
  ...(key !== undefined && {
222
242
  period: t(key, {
223
- count: new Intl.NumberFormat(locale).format(plan.price.intervalCount ?? 1),
243
+ count: new Intl.NumberFormat(locale).format(shownPrice.intervalCount ?? 1),
224
244
  }),
225
245
  }),
226
246
  },
@@ -239,12 +259,41 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
239
259
  // rendered plans is the current one, a synthetic current-tier card (no
240
260
  // price, no cta) keeps it visible regardless of enabled/disabled.
241
261
  const consentPlan =
242
- consentTier !== null ? result.plans.find((plan) => plan.tier === consentTier) : undefined;
262
+ consentTier !== null ? dialogPlans.find((plan) => plan.tier === consentTier) : undefined;
263
+ const switchPlan =
264
+ switchTier !== null ? shownPlans.find((plan) => plan.tier === switchTier) : undefined;
243
265
  const consumerProtection = result.consumerProtection;
244
- const currentTierInPlans = result.plans.some((plan) => plan.isCurrent);
245
- const visiblePlans = result.enabled
246
- ? result.plans
247
- : result.plans.filter((plan) => plan.isCurrent);
266
+ const noteLocale = resolveConsentLocale(locale);
267
+ const noteOf = (provider: { readonly note?: Readonly<Record<string, string>> }) =>
268
+ provider.note?.[noteLocale] ?? provider.note?.["en"];
269
+ const singleProviderNote =
270
+ providers !== undefined && providers.length === 1 && providers[0] !== undefined
271
+ ? noteOf(providers[0])
272
+ : undefined;
273
+ function providerChoiceFor(tier: string) {
274
+ if (providers === undefined || providers.length < 2 || activeProvider === undefined) {
275
+ return undefined;
276
+ }
277
+ const prices = tierPrices(providers, tier);
278
+ const showPrices = pricesDiffer(prices);
279
+ return {
280
+ value: activeProvider.name,
281
+ onChange: setChosenProvider,
282
+ options: providers.map((provider) => {
283
+ const price = provider.plans.find((plan) => plan.tier === tier)?.price;
284
+ const note = noteOf(provider);
285
+ return {
286
+ value: provider.name,
287
+ title: provider.displayName,
288
+ ...(note !== undefined && { description: note }),
289
+ ...(showPrices &&
290
+ price && { aside: formatMoney(price.unitAmount, price.currency, locale) }),
291
+ };
292
+ }),
293
+ };
294
+ }
295
+ const currentTierInPlans = shownPlans.some((plan) => plan.isCurrent);
296
+ const visiblePlans = result.enabled ? shownPlans : shownPlans.filter((plan) => plan.isCurrent);
248
297
 
249
298
  return (
250
299
  <div className="flex flex-col gap-4" data-testid="billing-plans-panel">
@@ -258,6 +307,11 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
258
307
  {t("billing-foundation.plans.purchaseNotAllowed")}
259
308
  </Banner>
260
309
  )}
310
+ {switchRequested && (
311
+ <Banner variant="info" testId="billing-plans-panel-switch-requested">
312
+ {t("billing-foundation.plans.planSwitchRequested")}
313
+ </Banner>
314
+ )}
261
315
  {mutationError !== null && (
262
316
  <Banner variant="error" testId="billing-plans-panel-mutation-error">
263
317
  {t(mutationError.i18nKey, mutationError.i18nParams)}
@@ -318,25 +372,43 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
318
372
  ))}
319
373
  {result.contact !== undefined && <BillingContactPlan contact={result.contact} />}
320
374
  </PlanGrid>
321
- {consumerProtection !== undefined &&
322
- consentPlan !== undefined &&
323
- consentPlan.price !== null && (
324
- <CheckoutConsentDialog
325
- open
326
- onOpenChange={(open) => {
327
- if (!open) setConsentTier(null);
328
- }}
329
- planName={t(consentPlan.labelKey)}
330
- price={{
331
- amount: formatMoney(consentPlan.price.unitAmount, consentPlan.price.currency, locale),
332
- renewalKey: renewalKey(consentPlan.price),
333
- count: new Intl.NumberFormat(locale).format(consentPlan.price.intervalCount ?? 1),
334
- }}
335
- consumerProtection={consumerProtection}
336
- onOrder={(consent) => orderWithConsent(consentPlan.tier, consent)}
337
- onConsentTextOutdated={() => void query.refetch()}
338
- />
339
- )}
375
+ {consentPlan !== undefined && consentPlan.price !== null && (
376
+ <CheckoutConsentDialog
377
+ open
378
+ onOpenChange={(open) => {
379
+ if (!open) setConsentTier(null);
380
+ }}
381
+ planName={t(consentPlan.labelKey)}
382
+ price={{
383
+ amount: formatMoney(consentPlan.price.unitAmount, consentPlan.price.currency, locale),
384
+ renewalKey: renewalKey(consentPlan.price),
385
+ count: new Intl.NumberFormat(locale).format(consentPlan.price.intervalCount ?? 1),
386
+ }}
387
+ consumerProtection={consumerProtection}
388
+ providerChoice={providerChoiceFor(consentPlan.tier)}
389
+ note={singleProviderNote}
390
+ onOrder={(consent) => orderWithConsent(consentPlan.tier, consent)}
391
+ onConsentTextOutdated={() => void query.refetch()}
392
+ />
393
+ )}
394
+ {switchPlan !== undefined && (
395
+ <PlanSwitchConfirmDialog
396
+ open
397
+ onOpenChange={(open) => {
398
+ if (!open) setSwitchTier(null);
399
+ }}
400
+ tier={switchPlan.tier}
401
+ fromPlanName={t(result.currentTier.labelKey)}
402
+ toPlanName={t(switchPlan.labelKey)}
403
+ consumerProtection={consumerProtection}
404
+ onApplied={() => {
405
+ setSwitchTier(null);
406
+ setSwitchRequested(true);
407
+ void query.refetch();
408
+ }}
409
+ onConsentTextOutdated={() => void query.refetch()}
410
+ />
411
+ )}
340
412
  {consumerProtection !== undefined && canManage && (
341
413
  <CancelContractDialog
342
414
  open={cancelOpen}