@cosmicdrift/kumiko-bundled-features 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 (212) hide show
  1. package/dist/agent-tools/tool-dispatch.d.ts +7 -7
  2. package/dist/agent-tools/tool-dispatch.js +9 -9
  3. package/dist/audit/handlers/audit-events.js +1 -1
  4. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  5. package/dist/auth-mfa/feature.js +8 -0
  6. package/dist/auth-mfa/web/i18n.js +5 -1
  7. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  8. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  9. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  10. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  11. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  12. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  13. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  14. package/dist/billing-foundation/checkout-core.js +24 -39
  15. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  16. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  17. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  18. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  19. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  20. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  21. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  22. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  23. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  24. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  25. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  26. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  27. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  28. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  29. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  30. package/dist/billing-foundation/events.d.ts +1 -0
  31. package/dist/billing-foundation/events.js +1 -0
  32. package/dist/billing-foundation/feature.js +3 -1
  33. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  34. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  35. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  36. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  37. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  38. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  39. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  40. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  42. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  43. package/dist/billing-foundation/i18n.js +22 -0
  44. package/dist/billing-foundation/index.d.ts +1 -1
  45. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  46. package/dist/billing-foundation/plan-catalog.js +25 -16
  47. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  48. package/dist/billing-foundation/plan-switch.js +60 -0
  49. package/dist/billing-foundation/types.d.ts +57 -1
  50. package/dist/billing-foundation/types.js +9 -0
  51. package/dist/billing-foundation/validate-options.js +17 -0
  52. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  53. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  54. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  55. package/dist/billing-foundation/web/constants.d.ts +4 -0
  56. package/dist/billing-foundation/web/constants.js +4 -0
  57. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  58. package/dist/billing-foundation/web/plan-price.js +32 -0
  59. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  60. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  61. package/dist/cap-counter/cap-reservation.js +5 -1
  62. package/dist/cap-counter/enforce-cap.js +2 -1
  63. package/dist/cap-counter/stock-cap-guard.js +7 -1
  64. package/dist/channel-email/feature.js +1 -1
  65. package/dist/config/handlers/cascade.query.d.ts +5 -0
  66. package/dist/config/handlers/cascade.query.js +4 -3
  67. package/dist/config/handlers/values.query.d.ts +5 -0
  68. package/dist/config/handlers/values.query.js +4 -3
  69. package/dist/config/write-helpers.d.ts +1 -0
  70. package/dist/config/write-helpers.js +18 -9
  71. package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
  72. package/dist/delivery/channel-context.js +6 -3
  73. package/dist/delivery/db/queries/preferences.js +2 -2
  74. package/dist/delivery/delivery-service.js +22 -7
  75. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  76. package/dist/delivery/handlers/preferences.query.js +20 -1
  77. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  78. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  79. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  80. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  81. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  82. package/dist/delivery/unsubscribe-page.js +5 -0
  83. package/dist/delivery/unsubscribe.js +25 -7
  84. package/dist/file-foundation/index.d.ts +1 -1
  85. package/dist/file-foundation/index.js +1 -1
  86. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  87. package/dist/file-foundation/read-file-ref-content.js +6 -1
  88. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  89. package/dist/form-draft/db/queries/cleanup.js +2 -2
  90. package/dist/form-draft/decrypt-draft.js +5 -1
  91. package/dist/host-routing/dispatch.js +11 -20
  92. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  93. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  94. package/dist/inbound-mail-foundation/i18n.js +0 -1
  95. package/dist/inbound-mail-foundation/screens.js +0 -3
  96. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  97. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  98. package/dist/jobs/feature.d.ts +1 -0
  99. package/dist/jobs/feature.js +8 -4
  100. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  101. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  102. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  103. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  104. package/dist/jobs/job-run-logger.js +5 -4
  105. package/dist/mail-foundation/feature.d.ts +3 -3
  106. package/dist/mail-foundation/feature.js +3 -0
  107. package/dist/personal-access-tokens/feature.d.ts +1 -1
  108. package/dist/personal-access-tokens/feature.js +4 -0
  109. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  110. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  111. package/dist/sessions/feature.d.ts +2 -2
  112. package/dist/sessions/feature.js +4 -0
  113. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  114. package/dist/shared/decrypt-stored-pii.js +6 -1
  115. package/dist/shared/index.d.ts +1 -1
  116. package/dist/shared/index.js +1 -1
  117. package/dist/shared/mail-template-body.d.ts +6 -2
  118. package/dist/shared/mail-template-body.js +7 -4
  119. package/dist/step-dispatcher/feature.js +1 -0
  120. package/dist/subscription-dodo/client.d.ts +17 -0
  121. package/dist/subscription-dodo/client.js +31 -0
  122. package/dist/subscription-dodo/constants.d.ts +12 -0
  123. package/dist/subscription-dodo/constants.js +19 -0
  124. package/dist/subscription-dodo/feature.d.ts +37 -0
  125. package/dist/subscription-dodo/feature.js +144 -0
  126. package/dist/subscription-dodo/index.d.ts +2 -0
  127. package/dist/subscription-dodo/index.js +4 -0
  128. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  129. package/dist/subscription-dodo/plugin-methods.js +260 -0
  130. package/dist/subscription-dodo/runtime.d.ts +27 -0
  131. package/dist/subscription-dodo/runtime.js +89 -0
  132. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  133. package/dist/subscription-dodo/verify-webhook.js +189 -0
  134. package/dist/subscription-stripe/feature.js +1 -0
  135. package/dist/subscription-stripe/runtime.js +7 -6
  136. package/dist/template-resolver/api.d.ts +3 -2
  137. package/dist/template-resolver/api.js +24 -2
  138. package/dist/template-resolver/compile-document-body.js +1 -0
  139. package/dist/template-resolver/feature.js +2 -2
  140. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  141. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  142. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  143. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  144. package/dist/template-resolver/handlers/document-base.js +3 -2
  145. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  146. package/dist/template-resolver/handlers/list.query.js +5 -6
  147. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  148. package/dist/template-resolver/handlers/overview.query.js +6 -3
  149. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  150. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  151. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  152. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  153. package/dist/template-resolver/handlers/usage.query.js +26 -16
  154. package/dist/template-resolver/handlers/validate.query.js +7 -2
  155. package/dist/template-resolver/seeding.js +26 -10
  156. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  157. package/dist/template-resolver/store-template-resource.js +19 -4
  158. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  159. package/dist/template-resolver/template-delivery-policy.js +0 -6
  160. package/dist/template-resolver/template-meta.js +7 -4
  161. package/dist/template-resolver/template-preview.js +4 -3
  162. package/dist/template-resolver/web/constants.d.ts +2 -2
  163. package/dist/template-resolver/web/constants.js +3 -3
  164. package/dist/template-resolver/web/index.d.ts +1 -0
  165. package/dist/template-resolver/web/index.js +1 -0
  166. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  167. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +10 -3
  170. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  171. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  172. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  173. package/dist/tenant-lifecycle/constants.js +4 -0
  174. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  175. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  176. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  177. package/dist/tier-engine/web/constants.js +2 -2
  178. package/dist/user-data-rights/run-export-jobs.js +3 -4
  179. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  180. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  181. package/dist/waitlist/handlers/submit.write.js +5 -5
  182. package/dist/waitlist/import.d.ts +1 -0
  183. package/dist/waitlist/import.js +4 -0
  184. package/dist/waitlist/legacy-migrate.js +17 -6
  185. package/dist/waitlist/link-on-signup.js +18 -6
  186. package/dist/waitlist/options.d.ts +3 -0
  187. package/package.json +19 -9
  188. package/src/agent-tools/changes.json +6 -0
  189. package/src/audit/changes.json +12 -0
  190. package/src/auth-mfa/changes.json +6 -0
  191. package/src/billing-foundation/changes.json +55 -0
  192. package/src/cap-counter/changes.json +18 -0
  193. package/src/channel-email/changes.json +6 -0
  194. package/src/config/changes.json +12 -0
  195. package/src/crypto-shredding/changes.json +6 -0
  196. package/src/delivery/changes.json +30 -0
  197. package/src/form-draft/changes.json +6 -0
  198. package/src/host-routing/changes.json +6 -0
  199. package/src/inbound-mail-foundation/changes.json +18 -0
  200. package/src/jobs/changes.json +13 -0
  201. package/src/mail-foundation/changes.json +6 -0
  202. package/src/renderer-pdf/changes.json +6 -0
  203. package/src/step-dispatcher/changes.json +6 -0
  204. package/src/subscription-dodo/changes.json +14 -0
  205. package/src/subscription-stripe/changes.json +12 -0
  206. package/src/template-resolver/changes.json +48 -0
  207. package/src/tenant/changes.json +6 -0
  208. package/src/tenant-lifecycle/changes.json +6 -0
  209. package/src/tier-engine/changes.json +6 -0
  210. package/src/waitlist/changes.json +18 -0
  211. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  212. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -1,83 +1,75 @@
1
1
  // switch-plan — a tenant-admin/purchase-role switches an existing,
2
- // non-terminal subscription to a different plan tier via the provider's
3
- // own confirmation page. Only registered when `createBillingFoundationFeature`
4
- // gets a `catalog`.
5
- import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
2
+ // non-terminal subscription to a different plan tier. A provider with a hosted
3
+ // switch session answers `{ url }`; a provider that switches in-app (preview +
4
+ // apply) applies the switch right here and answers `{ applied: true }`.
5
+ // Only registered when `createBillingFoundationFeature` gets a `catalog`.
6
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
7
  import * as z from "zod";
7
- import { assertBillingEnabled, assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl, resolveCatalogProvider, } from "../checkout-core.js";
8
- import { getSubscriptionForTenant } from "../get-subscription-for-tenant.js";
9
- import { purchaseRolesOf, resolvePlanPrices } from "../plan-catalog.js";
10
- import { isSwitchableSubscriptionStatus } from "../web/constants.js";
8
+ import { assertRedirectOrigins, isNonEmptyStringArray, joinBaseUrl } from "../checkout-core.js";
9
+ import { consentPayloadSchema, prepareConsent, recordConsent, } from "../consumer-protection/consent-gate.js";
10
+ import { purchaseRolesOf } from "../plan-catalog.js";
11
+ import { preparePlanSwitch } from "../plan-switch.js";
11
12
  export function createSwitchPlanHandler(options, catalog) {
12
13
  const plans = catalog.plans;
13
14
  if (!isNonEmptyStringArray(plans)) {
14
15
  throw new Error("switch-plan: catalog.plans must not be empty");
15
16
  }
16
- const schema = z.object({ tier: z.enum(plans) }).strict();
17
+ const consumerProtection = options.consumerProtection;
18
+ const tierSchema = z.object({ tier: z.enum(plans) });
19
+ const schema = (consumerProtection
20
+ ? tierSchema.extend({ consent: consentPayloadSchema.optional() })
21
+ : tierSchema).strict();
17
22
  return {
18
23
  name: "switch-plan",
19
- description: "Opens the provider's confirmation page for switching the existing subscription to another plan tier.",
24
+ description: "Switches the existing subscription to another plan tier: opens the provider's confirmation page (answers { url }), or applies the switch in-app for providers that preview and apply themselves (answers { applied: true }; send consent when consumer protection is configured).",
20
25
  schema,
21
26
  access: { roles: purchaseRolesOf(catalog) },
22
27
  handler: async (event, ctx) => {
23
28
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
24
29
  const payload = event.payload;
25
- const { name: catalogProviderName, plugin } = resolveCatalogProvider(ctx, catalog, "switch-plan");
26
- await assertBillingEnabled(ctx, plugin, "switch-plan");
27
- const sub = await getSubscriptionForTenant(ctx, event.user.tenantId);
28
- if (!sub || !isSwitchableSubscriptionStatus(sub.status)) {
29
- throw new ConflictError({
30
- i18nKey: "billing-foundation.errors.noActiveSubscription",
31
- message: "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
30
+ const prepared = await preparePlanSwitch(ctx, catalog, payload.tier, "switch-plan");
31
+ const { plugin } = prepared.provider;
32
+ if (plugin.createPlanSwitchSession) {
33
+ const baseUrl = options.baseUrl ?? "";
34
+ const returnUrl = joinBaseUrl(baseUrl, catalog.returnPath ?? catalog.successPath);
35
+ assertRedirectOrigins([returnUrl], options.baseUrl);
36
+ const result = await plugin.createPlanSwitchSession(ctx, {
37
+ providerSubscriptionId: prepared.subscription.providerSubscriptionId,
38
+ targetPriceId: prepared.target.priceId,
39
+ allowedPriceIds: prepared.allowedPriceIds,
40
+ returnUrl,
32
41
  });
42
+ return { isSuccess: true, data: { url: result.url } };
33
43
  }
34
- if (sub.cancelAt !== null) {
35
- throw new ConflictError({
36
- i18nKey: "billing-foundation.errors.cancellationScheduled",
37
- message: "subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
38
- });
39
- }
40
- if (payload.tier === sub.tier) {
41
- throw new ConflictError({
42
- i18nKey: "billing-foundation.errors.alreadyOnPlan",
43
- message: `tenant is already on plan "${payload.tier}"`,
44
- });
45
- }
46
- // providerSubscriptionId only exists at the subscription's own provider; after a
47
- // provider migration the catalog provider can differ, which is not a plan switch.
48
- if (sub.providerName !== catalogProviderName) {
49
- throw new ConflictError({
50
- i18nKey: "billing-foundation.errors.providerMismatch",
51
- message: `tenant's active subscription is on provider "${sub.providerName}", the catalog resolves provider "${catalogProviderName}" — switch-plan cannot switch across providers`,
52
- });
53
- }
54
- if (!plugin.createPlanSwitchSession) {
55
- throw new UnprocessableError("plan_switch_not_supported", {
56
- i18nKey: "billing-foundation.errors.planSwitchNotSupported",
57
- message: `subscription-foundation: provider "${sub.providerName}" has no createPlanSwitchSession-method`,
58
- });
59
- }
60
- const prices = await resolvePlanPrices(ctx, plugin, catalog);
61
- const target = prices.get(payload.tier);
62
- if (!target) {
63
- throw new UnprocessableError("price_unavailable", {
64
- i18nKey: "billing-foundation.errors.priceUnavailable",
65
- message: `subscription-foundation: no live price resolved for tier "${payload.tier}"`,
66
- });
67
- }
68
- const allowedPriceIds = [...prices.values()]
69
- .filter((p) => p !== null)
70
- .map((p) => p.priceId);
71
- const baseUrl = options.baseUrl ?? "";
72
- const returnUrl = joinBaseUrl(baseUrl, catalog.returnPath ?? catalog.successPath);
73
- assertRedirectOrigins([returnUrl], options.baseUrl);
74
- const result = await plugin.createPlanSwitchSession(ctx, {
75
- providerSubscriptionId: sub.providerSubscriptionId,
76
- targetPriceId: target.priceId,
77
- allowedPriceIds,
78
- returnUrl,
79
- });
80
- return { isSuccess: true, data: { url: result.url } };
44
+ return applyInApp(prepared, payload, ctx, options);
81
45
  },
82
46
  };
83
47
  }
48
+ // Consent is recorded before the provider call: the apply carries the consentId
49
+ // as provider metadata, and a failing apply rolls the whole handler back.
50
+ async function applyInApp(prepared, payload, ctx, options) {
51
+ const { plugin, name: providerName } = prepared.provider;
52
+ if (!plugin.applyPlanSwitch) {
53
+ throw new InternalError({
54
+ message: `billing-foundation: provider "${providerName}" has neither createPlanSwitchSession nor applyPlanSwitch`,
55
+ });
56
+ }
57
+ const consent = options.consumerProtection
58
+ ? await prepareConsent(ctx, options.consumerProtection, payload.consent, "subscription")
59
+ : undefined;
60
+ if (consent) {
61
+ await recordConsent(ctx, consent, {
62
+ tier: payload.tier,
63
+ priceId: prepared.target.priceId,
64
+ price: prepared.target.price,
65
+ providerName,
66
+ });
67
+ }
68
+ await plugin.applyPlanSwitch(ctx, {
69
+ providerSubscriptionId: prepared.subscription.providerSubscriptionId,
70
+ targetPriceId: prepared.target.priceId,
71
+ tenantId: ctx.user.tenantId,
72
+ ...(consent && { consentId: consent.consentId }),
73
+ });
74
+ return { isSuccess: true, data: { applied: true } };
75
+ }
@@ -48,6 +48,16 @@ function syncProviderEventId(snapshot, lastChangedAt) {
48
48
  ].join("|");
49
49
  return `sync:${createHash("sha256").update(hashInput).digest("hex")}`;
50
50
  }
51
+ // The retrieval time stands in for the provider's event time, so a webhook emitted before
52
+ // this snapshot cannot roll the synced state back later. Provider webhook times are
53
+ // whole-second and come from the provider's clock, so the stamp is floored and shifted back:
54
+ // a genuine webhook right after the sync must not look older than the snapshot.
55
+ const SYNC_SNAPSHOT_EVENT_TIME_MARGIN_SECONDS = 5;
56
+ function snapshotEventTime() {
57
+ return Temporal.Now.instant()
58
+ .round({ smallestUnit: "second", roundingMode: "floor" })
59
+ .subtract({ seconds: SYNC_SNAPSHOT_EVENT_TIME_MARGIN_SECONDS });
60
+ }
51
61
  export const syncSubscriptionHandler = {
52
62
  name: "sync-subscription",
53
63
  agent: { expose: false },
@@ -113,10 +123,11 @@ export const syncSubscriptionHandler = {
113
123
  tier: snapshot.tier,
114
124
  currentPeriodEndIso: snapshot.currentPeriodEnd,
115
125
  cancelAtIso: snapshot.cancelAt,
126
+ providerEventAtIso: snapshotEventTime().toString(),
116
127
  });
117
128
  return {
118
129
  isSuccess: true,
119
- data: (appendResult.duplicate
130
+ data: (appendResult.duplicate || appendResult.stale
120
131
  ? { synced: false, reason: "unchanged" }
121
132
  : { synced: true }),
122
133
  };
@@ -20,6 +20,20 @@ export const BILLING_FOUNDATION_I18N = {
20
20
  "billing-foundation.plans.everyInterval.year": { en: "every {count} years" },
21
21
  "billing-foundation.plans.choose": { en: "Choose {plan}" },
22
22
  "billing-foundation.plans.switch": { en: "Switch to {plan}" },
23
+ "billing-foundation.plans.planSwitchRequested": {
24
+ en: "Plan switch requested. It becomes active once the payment is confirmed.",
25
+ },
26
+ "billing-foundation.planSwitch.title": { en: "Confirm plan switch" },
27
+ "billing-foundation.planSwitch.summary": { en: "{from} to {to}" },
28
+ "billing-foundation.planSwitch.dueNow": { en: "Due now: {amount}" },
29
+ "billing-foundation.planSwitch.includesTax": { en: "including {tax} VAT" },
30
+ "billing-foundation.planSwitch.credit": { en: "Credit for the unused time: {amount}" },
31
+ "billing-foundation.planSwitch.recurring": { en: "New recurring price: {amount}" },
32
+ "billing-foundation.planSwitch.effective": { en: "Effective immediately" },
33
+ "billing-foundation.planSwitch.pendingNote": {
34
+ en: "The switch becomes active after payment confirmation.",
35
+ },
36
+ "billing-foundation.planSwitch.order": { en: "Switch with obligation to pay" },
23
37
  "billing-foundation.plans.manage": { en: "Manage subscription" },
24
38
  "billing-foundation.plans.reactivate": { en: "Reactivate subscription" },
25
39
  "billing-foundation.plans.cancelScheduled": {
@@ -65,6 +79,8 @@ export const BILLING_FOUNDATION_I18N = {
65
79
  "billing-foundation.consent.link.withdrawal": { en: "Withdrawal policy" },
66
80
  "billing-foundation.consent.link.privacy": { en: "Privacy policy" },
67
81
  "billing-foundation.consent.order": { en: "Order with obligation to pay" },
82
+ "billing-foundation.plans.priceFrom": { en: "from" },
83
+ "billing-foundation.consent.chooseProvider": { en: "Payment provider" },
68
84
  "billing-foundation.consent.back": { en: "Back" },
69
85
  "billing-foundation.cancel.open": { en: "Cancel contract here" },
70
86
  "billing-foundation.cancel.title": { en: "Cancel contract" },
@@ -125,6 +141,12 @@ export const BILLING_FOUNDATION_I18N = {
125
141
  "billing-foundation.errors.providerMismatch": {
126
142
  en: "This tenant's subscription is on a different provider than the plan catalog. Contact support to migrate.",
127
143
  },
144
+ "billing-foundation.errors.providerNotOffered": {
145
+ en: "This payment provider is not available right now.",
146
+ },
147
+ "billing-foundation.errors.providerRequired": {
148
+ en: "Choose a payment provider to continue.",
149
+ },
128
150
  "billing-foundation.errors.priceUnavailable": {
129
151
  en: "This plan's price is temporarily unavailable.",
130
152
  },
@@ -10,7 +10,7 @@ export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscript
10
10
  export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.ts";
11
11
  export { billingPlansPanel, createBillingPlansScreen } from "./screens.ts";
12
12
  export { createSubscriptionTierSync, effectiveTierFromSubscription, SUBSCRIPTION_WEBHOOK_PATH, type SubscriptionTierSyncDeps, type TierSyncResult, } from "./subscription-tier-sync.ts";
13
- export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanContactCard, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type LegalLinkSet, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.ts";
13
+ export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanContactCard, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type BillingProviderView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type LegalLinkSet, type PaymentEvent, type PlanSwitchPreview, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.ts";
14
14
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, type BillingEventKind, BillingEventKinds, type BillingPlanAction, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, SubscriptionCancelTimings, type SubscriptionEventType, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "./web/constants.ts";
15
15
  export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "./web/consumer-protection/consent-locale.ts";
16
16
  export { createSubscriptionWebhookRoute, type SubscriptionWebhookRouteOptions, } from "./webhook-handler.ts";
@@ -1,12 +1,13 @@
1
1
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { BillingPlanCatalog, BillingPlansResult, ProviderPrice, SubscriptionProviderPlugin } from "./types.ts";
2
+ import { getSubscriptionForTenant } from "./get-subscription-for-tenant.ts";
3
+ import { type BillingPlanCatalog, type BillingPlansResult, type BillingPlanView, type ProviderPrice, type SubscriptionProviderPlugin } from "./types.ts";
3
4
  /** `catalog?.purchaseRoles ?? DEFAULT_PURCHASE_ROLES` — the one place this
4
5
  * fallback is spelled out; plan-catalog.ts and the start-plan-checkout/
5
6
  * switch-plan/create-portal-session handlers all call this instead of
6
7
  * repeating the literal. Accepts `undefined` so create-portal-session
7
8
  * (mounted with or without a catalog) doesn't need its own fallback. */
8
9
  export declare function purchaseRolesOf(catalog: Pick<BillingPlanCatalog, "purchaseRoles"> | undefined): readonly string[];
9
- type ResolvedPlanPrice = {
10
+ export type ResolvedPlanPrice = {
10
11
  readonly priceId: string;
11
12
  readonly price: ProviderPrice;
12
13
  } | null;
@@ -15,9 +16,24 @@ type ResolvedPlanPrice = {
15
16
  * one the plan means — and resolves to null with a warning instead of an
16
17
  * arbitrary pick. */
17
18
  export declare function resolvePlanPrices(ctx: HandlerContext, plugin: SubscriptionProviderPlugin, catalog: BillingPlanCatalog): Promise<ReadonlyMap<string, ResolvedPlanPrice>>;
19
+ /** Tenant-level state every provider's plan list is measured against; loaded
20
+ * once so a multi-provider result does not repeat the reads per provider. */
21
+ export type BillingPlansBase = {
22
+ readonly currentTierValue: string;
23
+ readonly subscriptionView: Awaited<ReturnType<typeof getSubscriptionForTenant>>;
24
+ readonly subscription: BillingPlansResult["subscription"];
25
+ readonly blockingSubscription: boolean;
26
+ readonly canPurchase: boolean;
27
+ };
28
+ export declare function loadBillingPlansBase(ctx: HandlerContext, catalog: BillingPlanCatalog, now: () => Temporal.Instant): Promise<BillingPlansBase>;
29
+ export type ProviderPlanViews = {
30
+ readonly enabled: boolean;
31
+ readonly inAppSwitch: boolean;
32
+ readonly plans: readonly BillingPlanView[];
33
+ };
18
34
  /** `plugin === null` — no provider is registered/resolvable for this
19
- * catalog (see checkout-core's `findCatalogProvider`) — collapses onto the
35
+ * catalog (see checkout-core's `listCatalogProviders`) — collapses onto the
20
36
  * same "enabled: false" result a disabled provider produces, rather than
21
37
  * a separate error shape the panel would need to special-case. */
22
- export declare function buildBillingPlans(ctx: HandlerContext, plugin: SubscriptionProviderPlugin | null, catalog: BillingPlanCatalog, now: () => Temporal.Instant): Promise<BillingPlansResult>;
23
- export {};
38
+ export declare function buildProviderPlanViews(ctx: HandlerContext, plugin: SubscriptionProviderPlugin | null, catalog: BillingPlanCatalog, base: BillingPlansBase): Promise<ProviderPlanViews>;
39
+ export declare function toBillingPlansResult(catalog: BillingPlanCatalog, base: BillingPlansBase, views: ProviderPlanViews): BillingPlansResult;
@@ -4,6 +4,7 @@
4
4
  // Internal — not re-exported from index.ts.
5
5
  import { isPluginBillingEnabled } from "./checkout-core.js";
6
6
  import { getSubscriptionForTenant } from "./get-subscription-for-tenant.js";
7
+ import { canSwitchPlans, canSwitchPlansInApp, } from "./types.js";
7
8
  import { BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSubscriptionBlockingCheckout, isSwitchableSubscriptionStatus, SubscriptionStatuses, } from "./web/constants.js";
8
9
  function userHasAnyRole(userRoles, allowedRoles) {
9
10
  return allowedRoles.some((role) => userRoles.includes(role));
@@ -80,7 +81,7 @@ function canSwitchToTier(tier, subscription, plugin) {
80
81
  return (isSwitchableSubscriptionStatus(subscription.status) &&
81
82
  tier !== subscription.tier &&
82
83
  subscription.cancelAt === null &&
83
- plugin?.createPlanSwitchSession !== undefined);
84
+ canSwitchPlans(plugin));
84
85
  }
85
86
  /** One plan-row's action — pulled out of `buildBillingPlans`' map callback so
86
87
  * that function's own complexity stays under the guard's budget. paymentPending
@@ -105,29 +106,32 @@ function resolvePlanAction(tier, isCurrent, price, enabled, canPurchase, subscri
105
106
  ? BillingPlanActions.switch
106
107
  : BillingPlanActions.unavailable;
107
108
  }
108
- /** `plugin === null` — no provider is registered/resolvable for this
109
- * catalog (see checkout-core's `findCatalogProvider`) — collapses onto the
110
- * same "enabled: false" result a disabled provider produces, rather than
111
- * a separate error shape the panel would need to special-case. */
112
- export async function buildBillingPlans(ctx, plugin, catalog, now) {
109
+ export async function loadBillingPlansBase(ctx, catalog, now) {
113
110
  const currentTierValue = await catalog.resolveCurrentTier(ctx.db, ctx.user.tenantId);
114
- const enabled = plugin !== null && (await isPluginBillingEnabled(ctx, plugin));
115
- const resolvedPrices = enabled && plugin
116
- ? await resolvePlanPrices(ctx, plugin, catalog)
117
- : new Map(catalog.plans.map((tier) => [tier, null]));
118
111
  const subscriptionView = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
119
- const nowInstant = now();
112
+ const blockingSubscription = subscriptionView !== null && isSubscriptionBlockingCheckout(subscriptionView, now());
120
113
  const subscription = subscriptionView
121
114
  ? {
122
115
  status: subscriptionView.status,
123
116
  tier: subscriptionView.tier,
124
- terminal: !isSubscriptionBlockingCheckout(subscriptionView, nowInstant),
117
+ terminal: !blockingSubscription,
125
118
  currentPeriodEnd: subscriptionView.currentPeriodEnd.toString(),
126
119
  cancelAt: subscriptionView.cancelAt?.toString() ?? null,
127
120
  }
128
121
  : null;
129
- const purchaseRoles = purchaseRolesOf(catalog);
130
- const canPurchase = userHasAnyRole(ctx.user.roles, purchaseRoles);
122
+ const canPurchase = userHasAnyRole(ctx.user.roles, purchaseRolesOf(catalog));
123
+ return { currentTierValue, subscriptionView, subscription, blockingSubscription, canPurchase };
124
+ }
125
+ /** `plugin === null` — no provider is registered/resolvable for this
126
+ * catalog (see checkout-core's `listCatalogProviders`) — collapses onto the
127
+ * same "enabled: false" result a disabled provider produces, rather than
128
+ * a separate error shape the panel would need to special-case. */
129
+ export async function buildProviderPlanViews(ctx, plugin, catalog, base) {
130
+ const { currentTierValue, subscription, canPurchase } = base;
131
+ const enabled = plugin !== null && (await isPluginBillingEnabled(ctx, plugin));
132
+ const resolvedPrices = enabled && plugin
133
+ ? await resolvePlanPrices(ctx, plugin, catalog)
134
+ : new Map(catalog.plans.map((tier) => [tier, null]));
131
135
  const plans = catalog.plans.map((tier) => {
132
136
  const isCurrent = tier === currentTierValue;
133
137
  const resolved = resolvedPrices.get(tier) ?? null;
@@ -149,8 +153,12 @@ export async function buildBillingPlans(ctx, plugin, catalog, now) {
149
153
  action,
150
154
  };
151
155
  });
156
+ return { enabled, inAppSwitch: plugin !== null && canSwitchPlansInApp(plugin), plans };
157
+ }
158
+ export function toBillingPlansResult(catalog, base, views) {
159
+ const { currentTierValue, subscription, canPurchase } = base;
152
160
  return {
153
- enabled,
161
+ enabled: views.enabled,
154
162
  currentTier: {
155
163
  tier: currentTierValue,
156
164
  labelKey: catalog.tierLabelKey(currentTierValue),
@@ -158,7 +166,8 @@ export async function buildBillingPlans(ctx, plugin, catalog, now) {
158
166
  },
159
167
  subscription,
160
168
  canPurchase,
161
- plans,
169
+ plans: views.plans,
170
+ ...(views.inAppSwitch && { inAppPlanSwitch: true }),
162
171
  ...(catalog.contact !== undefined && { contact: catalog.contact }),
163
172
  };
164
173
  }
@@ -0,0 +1,12 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type ResolvedProvider } from "./checkout-core.ts";
3
+ import { type SubscriptionView } from "./get-subscription-for-tenant.ts";
4
+ import { type ResolvedPlanPrice } from "./plan-catalog.ts";
5
+ import { type BillingPlanCatalog } from "./types.ts";
6
+ export type PreparedPlanSwitch = {
7
+ readonly provider: ResolvedProvider;
8
+ readonly subscription: SubscriptionView;
9
+ readonly target: NonNullable<ResolvedPlanPrice>;
10
+ readonly allowedPriceIds: readonly string[];
11
+ };
12
+ export declare function preparePlanSwitch(ctx: HandlerContext, catalog: BillingPlanCatalog, tier: string, handlerName: string): Promise<PreparedPlanSwitch>;
@@ -0,0 +1,60 @@
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
+ import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { assertBillingEnabled, findProviderPlugin, listCatalogProviders, } from "./checkout-core.js";
6
+ import { getSubscriptionForTenant } from "./get-subscription-for-tenant.js";
7
+ import { resolvePlanPrices } from "./plan-catalog.js";
8
+ import { canSwitchPlans } from "./types.js";
9
+ import { isSwitchableSubscriptionStatus } from "./web/constants.js";
10
+ export async function preparePlanSwitch(ctx, catalog, tier, handlerName) {
11
+ const subscription = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
12
+ if (!subscription || !isSwitchableSubscriptionStatus(subscription.status)) {
13
+ throw new ConflictError({
14
+ i18nKey: "billing-foundation.errors.noActiveSubscription",
15
+ message: "tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
16
+ });
17
+ }
18
+ // providerSubscriptionId only exists at the subscription's own provider; a catalog that
19
+ // no longer sells through that provider cannot switch it.
20
+ const provider = findProviderPlugin(ctx, subscription.providerName);
21
+ const isCatalogProvider = provider !== null &&
22
+ listCatalogProviders(ctx, catalog).some((candidate) => candidate.name === provider.name);
23
+ if (!provider || !isCatalogProvider) {
24
+ throw new ConflictError({
25
+ i18nKey: "billing-foundation.errors.providerMismatch",
26
+ message: `tenant's active subscription is on provider "${subscription.providerName}", which the plan catalog does not sell through — switch-plan cannot switch across providers`,
27
+ });
28
+ }
29
+ await assertBillingEnabled(ctx, provider.plugin, handlerName);
30
+ if (subscription.cancelAt !== null) {
31
+ throw new ConflictError({
32
+ i18nKey: "billing-foundation.errors.cancellationScheduled",
33
+ message: "subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
34
+ });
35
+ }
36
+ if (tier === subscription.tier) {
37
+ throw new ConflictError({
38
+ i18nKey: "billing-foundation.errors.alreadyOnPlan",
39
+ message: `tenant is already on plan "${tier}"`,
40
+ });
41
+ }
42
+ if (!canSwitchPlans(provider.plugin)) {
43
+ throw new UnprocessableError("plan_switch_not_supported", {
44
+ i18nKey: "billing-foundation.errors.planSwitchNotSupported",
45
+ message: `subscription-foundation: provider "${subscription.providerName}" supports neither createPlanSwitchSession nor previewPlanSwitch + applyPlanSwitch`,
46
+ });
47
+ }
48
+ const prices = await resolvePlanPrices(ctx, provider.plugin, catalog);
49
+ const target = prices.get(tier);
50
+ if (!target) {
51
+ throw new UnprocessableError("price_unavailable", {
52
+ i18nKey: "billing-foundation.errors.priceUnavailable",
53
+ message: `subscription-foundation: no live price resolved for tier "${tier}"`,
54
+ });
55
+ }
56
+ const allowedPriceIds = [...prices.values()]
57
+ .filter((p) => p !== null)
58
+ .map((p) => p.priceId);
59
+ return { provider, subscription, target, allowedPriceIds };
60
+ }
@@ -75,6 +75,8 @@ export type PaymentEvent = {
75
75
  readonly priceId: string;
76
76
  };
77
77
  export type SubscriptionProviderPlugin = {
78
+ /** Human-readable provider name for the plan panel's provider switch. */
79
+ readonly displayName?: string;
78
80
  /**
79
81
  * Verify webhook signature + parse provider-event into normalized
80
82
  * form. **Pre-tenant-resolution** — no HandlerContext, because at
@@ -228,6 +230,24 @@ export type SubscriptionProviderPlugin = {
228
230
  }) => Promise<{
229
231
  readonly url: string;
230
232
  }>;
233
+ /**
234
+ * In-app plan switch, step 1: what the switch would cost right now. Together
235
+ * with `applyPlanSwitch` an alternative to the hosted `createPlanSwitchSession`.
236
+ */
237
+ readonly previewPlanSwitch?: (ctx: HandlerContext, options: {
238
+ readonly providerSubscriptionId: string;
239
+ readonly targetPriceId: string;
240
+ }) => Promise<PlanSwitchPreview>;
241
+ /**
242
+ * In-app plan switch, step 2: performs the switch at the provider. The
243
+ * state change itself arrives later through the webhook.
244
+ */
245
+ readonly applyPlanSwitch?: (ctx: HandlerContext, options: {
246
+ readonly providerSubscriptionId: string;
247
+ readonly targetPriceId: string;
248
+ readonly tenantId: string;
249
+ readonly consentId?: string;
250
+ }) => Promise<void>;
231
251
  /**
232
252
  * Fetches the live provider-side state of one subscription, for the
233
253
  * `sync-subscriptions` backfill job — catches drift (e.g. a `cancel_at`
@@ -241,6 +261,17 @@ export type SubscriptionProviderPlugin = {
241
261
  */
242
262
  readonly retrieveSubscription?: (ctx: HandlerContext, providerSubscriptionId: string) => Promise<ProviderSubscriptionSnapshot | null>;
243
263
  };
264
+ export type PlanSwitchPreview = {
265
+ /** Charged immediately, smallest currency unit, tax included. */
266
+ readonly amountDueNow: number;
267
+ /** Tax part of `amountDueNow`; null when the provider reports none. */
268
+ readonly tax: number | null;
269
+ /** Lower-case ISO currency code. */
270
+ readonly currency: string;
271
+ /** Credit for the unused time on the old plan, smallest currency unit. */
272
+ readonly credit: number;
273
+ readonly effectiveAt: string;
274
+ };
244
275
  export type ProviderSubscriptionSnapshot = {
245
276
  readonly providerCustomerId: string;
246
277
  readonly providerSubscriptionId: string;
@@ -296,7 +327,8 @@ export type BillingPlanCatalog<TTier extends string = string> = {
296
327
  * `baseUrl` itself when unset) and by `switch-plan` after a plan switch
297
328
  * (defaults to `successPath` there instead). */
298
329
  readonly returnPath?: string;
299
- /** Defaults to the only registered provider exposing `priceToTier`. */
330
+ /** Restricts the catalog to one provider; defaults to every registered
331
+ * provider exposing `priceToTier`. */
300
332
  readonly providerName?: string;
301
333
  /** Rendered as the last card, no price, visible to `viewRoles` regardless of `purchaseRoles`. */
302
334
  readonly contact?: BillingPlanContactCard;
@@ -319,6 +351,14 @@ export type ConsumerProtectionOptions = {
319
351
  readonly termsTextBlock: string;
320
352
  /** Per-locale VAT note; must carry at least `de` and `en`. */
321
353
  readonly vatNote: Readonly<Record<string, string>>;
354
+ /** Per-provider texts keyed by provider name, e.g. for a merchant-of-record
355
+ * provider that issues the invoice and VAT itself. `vatNote` here replaces
356
+ * the global one in that provider's confirmation mail; `checkoutNote` is
357
+ * shown under the provider choice. de and en are required for each text. */
358
+ readonly providerTexts?: Readonly<Record<string, {
359
+ readonly vatNote?: Readonly<Record<string, string>>;
360
+ readonly checkoutNote?: Readonly<Record<string, string>>;
361
+ }>>;
322
362
  readonly operatorEmail: string;
323
363
  /** Where the public § 312k declaration is reachable. `"tenant-host"`
324
364
  * (default): on a host that resolves a tenant. `"platform"`: also on a
@@ -372,6 +412,15 @@ export type BillingPlanView = {
372
412
  readonly isCurrent: boolean;
373
413
  readonly action: BillingPlanAction;
374
414
  };
415
+ export type BillingProviderView = {
416
+ readonly name: string;
417
+ readonly displayName: string;
418
+ readonly note?: Readonly<Record<string, string>>;
419
+ readonly plans: readonly BillingPlanView[];
420
+ };
421
+ export declare function canSwitchPlansInApp(plugin: SubscriptionProviderPlugin): boolean;
422
+ /** A provider switches plans via a hosted session or via in-app preview + apply. */
423
+ export declare function canSwitchPlans(plugin: SubscriptionProviderPlugin | null): boolean;
375
424
  export type BillingPlansResult = {
376
425
  readonly enabled: boolean;
377
426
  readonly currentTier: {
@@ -388,6 +437,13 @@ export type BillingPlansResult = {
388
437
  } | null;
389
438
  readonly canPurchase: boolean;
390
439
  readonly plans: readonly BillingPlanView[];
440
+ /** Present when more than one provider is offered, or the single offered
441
+ * provider has a `providerTexts` checkout note. `plans` above are the first
442
+ * entry's plans. */
443
+ readonly providers?: readonly BillingProviderView[];
444
+ /** True when the subscription's provider switches plans in-app (preview +
445
+ * confirm) instead of on a hosted page. */
446
+ readonly inAppPlanSwitch?: true;
391
447
  /** Present only when `catalog.contact` is configured. */
392
448
  readonly contact?: BillingPlanContactCard;
393
449
  /** Present only when `consumerProtection` is configured. */
@@ -33,3 +33,12 @@ export const KNOWN_RECURRING_INTERVALS = ["day", "week", "month", "year"];
33
33
  export function isLegalLinkSet(links) {
34
34
  return "terms" in links;
35
35
  }
36
+ export function canSwitchPlansInApp(plugin) {
37
+ return plugin.previewPlanSwitch !== undefined && plugin.applyPlanSwitch !== undefined;
38
+ }
39
+ /** A provider switches plans via a hosted session or via in-app preview + apply. */
40
+ export function canSwitchPlans(plugin) {
41
+ if (plugin === null)
42
+ return false;
43
+ return plugin.createPlanSwitchSession !== undefined || canSwitchPlansInApp(plugin);
44
+ }
@@ -20,6 +20,22 @@ function isRootRelativeOrHttpsUrl(link) {
20
20
  return false;
21
21
  }
22
22
  }
23
+ function validateProviderTexts(cp) {
24
+ for (const [providerName, texts] of Object.entries(cp.providerTexts ?? {})) {
25
+ for (const [textName, text] of [
26
+ ["vatNote", texts.vatNote],
27
+ ["checkoutNote", texts.checkoutNote],
28
+ ]) {
29
+ if (text === undefined)
30
+ continue;
31
+ for (const locale of ["de", "en"]) {
32
+ if (!text[locale]?.trim()) {
33
+ throw new Error(`createBillingFoundationFeature: consumerProtection.providerTexts.${providerName}.${textName} needs a non-empty "${locale}" entry.`);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
23
39
  function validateConsumerProtection(cp) {
24
40
  if (cp.termsTextBlock.trim() === "") {
25
41
  throw new Error("createBillingFoundationFeature: consumerProtection.termsTextBlock is empty.");
@@ -29,6 +45,7 @@ function validateConsumerProtection(cp) {
29
45
  throw new Error(`createBillingFoundationFeature: consumerProtection.vatNote needs a non-empty "${locale}" entry.`);
30
46
  }
31
47
  }
48
+ validateProviderTexts(cp);
32
49
  if (!z.email().safeParse(cp.operatorEmail).success) {
33
50
  throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
34
51
  }