@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
@@ -36,17 +36,22 @@ export type ResolvedProvider = {
36
36
  readonly plugin: SubscriptionProviderPlugin;
37
37
  };
38
38
 
39
+ /** Every registered provider plugin, in registration order. */
40
+ function listProviderPlugins(ctx: HandlerContext): readonly ResolvedProvider[] {
41
+ return ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION).map((usage) => ({
42
+ name: usage.entityName,
43
+ // @cast-boundary engine-payload — extension-usage carries unknown options
44
+ plugin: usage.options as SubscriptionProviderPlugin,
45
+ }));
46
+ }
47
+
39
48
  /** Non-throwing provider lookup by name — null when no plugin with that
40
49
  * entityName is registered. */
41
50
  export function findProviderPlugin(
42
51
  ctx: HandlerContext,
43
52
  providerName: string,
44
53
  ): ResolvedProvider | null {
45
- const usage = ctx.registry
46
- .getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
47
- .find((u) => u.entityName === providerName);
48
- // @cast-boundary engine-payload — extension-usage carries unknown options
49
- return usage ? { name: providerName, plugin: usage.options as SubscriptionProviderPlugin } : null;
54
+ return listProviderPlugins(ctx).find((provider) => provider.name === providerName) ?? null;
50
55
  }
51
56
 
52
57
  export function resolveProviderPlugin(ctx: HandlerContext, providerName: string): ResolvedProvider {
@@ -64,17 +69,15 @@ export function resolveProviderPlugin(ctx: HandlerContext, providerName: string)
64
69
  return found;
65
70
  }
66
71
 
67
- /** Picks the catalog's provider — an explicit `catalog.providerName`, or the
68
- * single registered provider exposing `priceToTier` when there is exactly
69
- * one. Returns null when `providerName` is unset and no registered plugin
70
- * exposes `priceToTier` — a state `billing-plans` renders as
71
- * `enabled: false` instead of erroring. An explicit `providerName` that is
72
- * not registered, or more than one candidate, is a config bug and throws
73
- * `UnconfiguredError`. */
74
- export function findCatalogProvider(
72
+ /** The providers a catalog sells through, in registration order: an explicit
73
+ * `catalog.providerName` (only that one), otherwise every registered provider
74
+ * exposing `priceToTier`. Empty when none qualifies, a state `billing-plans`
75
+ * renders as `enabled: false` instead of erroring. An explicit `providerName`
76
+ * that is not registered is a config bug and throws `UnconfiguredError`. */
77
+ export function listCatalogProviders(
75
78
  ctx: HandlerContext,
76
79
  catalog: BillingPlanCatalog,
77
- ): ResolvedProvider | null {
80
+ ): readonly ResolvedProvider[] {
78
81
  if (catalog.providerName) {
79
82
  const found = findProviderPlugin(ctx, catalog.providerName);
80
83
  if (!found) {
@@ -84,40 +87,27 @@ export function findCatalogProvider(
84
87
  hint: `provider "${catalog.providerName}" not registered`,
85
88
  });
86
89
  }
87
- return found;
90
+ return [found];
88
91
  }
89
- const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
90
- const withPriceCatalog = usages.filter(
91
- (u) => (u.options as SubscriptionProviderPlugin).priceToTier !== undefined,
92
+ return listProviderPlugins(ctx).filter(({ plugin }) => plugin.priceToTier !== undefined);
93
+ }
94
+
95
+ export async function filterOfferedProviders(
96
+ ctx: HandlerContext,
97
+ providers: readonly ResolvedProvider[],
98
+ ): Promise<readonly ResolvedProvider[]> {
99
+ const enabledFlags = await Promise.all(
100
+ providers.map(({ plugin }) => isPluginBillingEnabled(ctx, plugin)),
92
101
  );
93
- if (withPriceCatalog.length > 1) {
94
- throw new UnconfiguredError({
95
- feature: "billing-foundation",
96
- key: "catalog.providerName",
97
- hint: `ambiguous — ${withPriceCatalog.length} registered plugins expose priceToTier, set catalog.providerName explicitly`,
98
- });
99
- }
100
- const usage = withPriceCatalog[0];
101
- if (!usage) return null;
102
- // @cast-boundary engine-payload — extension-usage carries unknown options
103
- return { name: usage.entityName, plugin: usage.options as SubscriptionProviderPlugin };
102
+ return providers.filter((_, index) => enabledFlags[index]);
104
103
  }
105
104
 
106
- /** `findCatalogProvider` for callers that require a provider to proceed —
107
- * start-plan-checkout and switch-plan. `FeatureDisabledError` (not
108
- * `UnconfiguredError`) when none is found: an unconfigured provider is
109
- * the same "billing isn't live yet" state as `isPluginBillingEnabled`
110
- * returning false, and the panel already renders both the same way. */
111
- export function resolveCatalogProvider(
105
+ /** The catalog providers that are live right now: what a buyer can choose from. */
106
+ export async function listOfferedCatalogProviders(
112
107
  ctx: HandlerContext,
113
108
  catalog: BillingPlanCatalog,
114
- handlerName: string,
115
- ): ResolvedProvider {
116
- const found = findCatalogProvider(ctx, catalog);
117
- if (!found) {
118
- throw new FeatureDisabledError(BILLING_FOUNDATION_FEATURE, handlerName);
119
- }
120
- return found;
109
+ ): Promise<readonly ResolvedProvider[]> {
110
+ return filterOfferedProviders(ctx, listCatalogProviders(ctx, catalog));
121
111
  }
122
112
 
123
113
  /** `plugin.isBillingEnabled?.(ctx) ?? true` — the one place this fallback is
@@ -166,8 +166,14 @@ inlineHtmlMarkdown.use({
166
166
  blockquote({ tokens }) {
167
167
  return this.parser.parse(tokens);
168
168
  },
169
- list({ items }) {
170
- return items.map((item) => this.parser.parse(item.tokens)).join("");
169
+ list({ items, ordered, start }) {
170
+ const firstNumber = typeof start === "number" ? start : 1;
171
+ return items
172
+ .map((item, index) => {
173
+ const marker = ordered ? `${firstNumber + index}. ` : "";
174
+ return `${marker}${this.parser.parse(item.tokens)}`;
175
+ })
176
+ .join("");
171
177
  },
172
178
  hr: () => "",
173
179
  space: () => "",
@@ -79,6 +79,7 @@ export type ConsentPriceDetails = {
79
79
  "unitAmount" | "currency" | "interval" | "intervalCount"
80
80
  > | null;
81
81
  readonly orderItem?: OrderItem;
82
+ readonly providerName?: string;
82
83
  };
83
84
 
84
85
  export async function prepareConsent(
@@ -163,6 +164,7 @@ export async function recordConsent(
163
164
  termsTemplateVersion: prepared.termsTemplateVersion,
164
165
  locale: prepared.locale,
165
166
  actorUserId: String(ctx.user.id),
167
+ ...(details.providerName !== undefined && { providerName: details.providerName }),
166
168
  ...(details.orderItem && {
167
169
  itemLabelKey: details.orderItem.labelKey,
168
170
  ...(details.orderItem.params && { itemLabelParams: details.orderItem.params }),
@@ -12,6 +12,7 @@ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework
12
12
  import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
13
13
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
14
14
  import * as z from "zod";
15
+ import { decryptStoredUserEmail } from "../../shared/index.ts";
15
16
  import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.ts";
16
17
  import { UserQueries } from "../../user/index.ts";
17
18
  import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.ts";
@@ -29,7 +30,6 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
29
30
  import { CONTRACT_CONFIRMATION_NOTIFICATION_TYPE } from "../web/constants.ts";
30
31
  import { contractConfirmationVariables } from "./confirmation-mail.ts";
31
32
  import { resolveConsentLocale } from "./consent-text.ts";
32
- import { openStoredUserEmail } from "./stored-user-email.ts";
33
33
 
34
34
  export const issueContractConfirmationSchema = z
35
35
  .object({
@@ -58,6 +58,17 @@ function translatedOrUndefined(translation: string, key: string): string | undef
58
58
  return translation === key ? undefined : translation;
59
59
  }
60
60
 
61
+ function vatNoteFor(
62
+ consumerProtection: ConsumerProtectionOptions,
63
+ providerName: string | undefined,
64
+ ): Readonly<Record<string, string>> {
65
+ const providerNote =
66
+ providerName === undefined
67
+ ? undefined
68
+ : consumerProtection.providerTexts?.[providerName]?.vatNote;
69
+ return providerNote ?? consumerProtection.vatNote;
70
+ }
71
+
61
72
  function pickTimeZone(userZone: string | null | undefined, tenantZone: string): string {
62
73
  if (userZone && isValidIanaTimeZone(userZone)) return userZone;
63
74
  return isValidIanaTimeZone(tenantZone) ? tenantZone : "UTC";
@@ -145,7 +156,7 @@ export function createIssueContractConfirmationHandler(
145
156
  );
146
157
  const recipientEmail =
147
158
  recipient.success && recipient.data.isDeleted !== true
148
- ? await openStoredUserEmail(
159
+ ? await decryptStoredUserEmail(
149
160
  recipient.data.email,
150
161
  "billing-foundation:issue-contract-confirmation",
151
162
  )
@@ -203,7 +214,7 @@ export function createIssueContractConfirmationHandler(
203
214
  ...(payload.currentPeriodEndIso !== undefined && {
204
215
  currentPeriodEndIso: payload.currentPeriodEndIso,
205
216
  }),
206
- vatNote: consumerProtection.vatNote,
217
+ vatNote: vatNoteFor(consumerProtection, consent.providerName),
207
218
  operatorEmail: consumerProtection.operatorEmail,
208
219
  termsContent: terms.content,
209
220
  });
@@ -69,10 +69,9 @@ export function terminationReceiptTemplate(): NotificationLocalizedTemplate {
69
69
  mailRepeatedRows("details", "text"),
70
70
  mailParagraph(texts.receiptNextSteps),
71
71
  mailParagraph(texts.receiptRevoke(OPERATOR_EMAIL_SLOT)),
72
- mailParagraphWithLink(texts.receiptNotMe, texts.receiptNotMeLink, "revokeUrl").replace(
73
- "<p>",
74
- '<p data-if="revokeUrl">',
75
- ),
72
+ mailParagraphWithLink(texts.receiptNotMe, texts.receiptNotMeLink, "revokeUrl", {
73
+ showIf: "revokeUrl",
74
+ }),
76
75
  mailParagraph(`${texts.contact} {operatorEmail}`),
77
76
  ].join(""),
78
77
  };
@@ -14,28 +14,34 @@ function receiptBucketKey(ctx: HandlerContext, email: string): string | undefine
14
14
  return digest === undefined ? undefined : `${RECEIPT_BUCKET_PREFIX}:${digest}`;
15
15
  }
16
16
 
17
+ export type ReceiptMailVerdict = "allowed" | "limited" | "unavailable";
18
+
17
19
  /** Consumes one receipt token for the recipient. Fails closed when the limiter
18
- * errors or no digest key is wired: the declaration is already recorded, a missing receipt is the lesser harm. */
19
- export async function mayMailReceipt(ctx: HandlerContext, email: string): Promise<boolean> {
20
+ * errors or no digest key is wired ("unavailable"): the declaration is already
21
+ * recorded, a missing receipt is the lesser harm. */
22
+ export async function mayMailReceipt(
23
+ ctx: HandlerContext,
24
+ email: string,
25
+ ): Promise<ReceiptMailVerdict> {
20
26
  // skip: no rate limiter wired, nothing to count against
21
- if (!ctx.rateLimit) return true;
27
+ if (!ctx.rateLimit) return "allowed";
22
28
  try {
23
29
  const bucketKey = receiptBucketKey(ctx, email);
24
30
  if (bucketKey === undefined) {
25
31
  ctx.log?.warn(
26
32
  "[billing-foundation:termination] no rate-limit digest available, receipt mail withheld",
27
33
  );
28
- return false;
34
+ return "unavailable";
29
35
  }
30
36
  const decision = await ctx.rateLimit.check(bucketKey, {
31
37
  limit: RECEIPT_MAILS_PER_RECIPIENT,
32
38
  windowSeconds: RECEIPT_WINDOW_SECONDS,
33
39
  });
34
- return decision.allowed;
40
+ return decision.allowed ? "allowed" : "limited";
35
41
  } catch (error) {
36
42
  ctx.log?.warn(
37
43
  `[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`,
38
44
  );
39
- return false;
45
+ return "unavailable";
40
46
  }
41
47
  }
@@ -249,25 +249,8 @@ export function createRecordContractTerminationHandler(
249
249
 
250
250
  // The public job runs at least once: a second run must not call the
251
251
  // provider or append again.
252
- const alreadyRecorded = stream.events
253
- .map((e) =>
254
- e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN
255
- ? contractTerminationRequestedPayloadSchema.safeParse(e.payload).data
256
- : undefined,
257
- )
258
- .find((recordedPayload) => recordedPayload?.requestId === payload.requestId);
259
- if (alreadyRecorded) {
260
- return {
261
- isSuccess: true as const,
262
- data: {
263
- receivedAtIso: alreadyRecorded.receivedAtIso,
264
- effectiveAtIso: alreadyRecorded.effectiveAtIso,
265
- providerCancel: alreadyRecorded.providerCancel,
266
- noticeReason: null,
267
- withdrawalPeriodExpired: false,
268
- } satisfies RecordContractTerminationResult,
269
- };
270
- }
252
+ const alreadyRecorded = findRecordedTermination(stream.events, payload.requestId);
253
+ if (alreadyRecorded) return recordedTerminationResult(alreadyRecorded);
271
254
 
272
255
  // A revoked public declaration never reaches the provider.
273
256
  if (hasRevocation(stream.events, payload.requestId)) {
@@ -301,6 +284,13 @@ export function createRecordContractTerminationHandler(
301
284
  aggregateId: subscriptionAggregateId(tenantId),
302
285
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
303
286
  });
287
+ // A concurrent run with the same requestId may have recorded while we were
288
+ // at the provider; a second append would duplicate the event and the notice.
289
+ const recordedMeanwhile = findRecordedTermination(
290
+ streamAfterProviderCall.events,
291
+ payload.requestId,
292
+ );
293
+ if (recordedMeanwhile) return recordedTerminationResult(recordedMeanwhile);
304
294
  // A revocation that landed during the provider call found no cancel to take back.
305
295
  if (hasRevocation(streamAfterProviderCall.events, payload.requestId)) {
306
296
  await takeBackLateRevokedCancel(ctx, consumerProtection, {
@@ -348,6 +338,32 @@ export function createRecordContractTerminationHandler(
348
338
  };
349
339
  }
350
340
 
341
+ export function findRecordedTermination(
342
+ events: readonly { type: string; payload: unknown }[],
343
+ requestId: string,
344
+ ): ContractTerminationRequestedPayload | undefined {
345
+ return events
346
+ .map((e) =>
347
+ e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN
348
+ ? contractTerminationRequestedPayloadSchema.safeParse(e.payload).data
349
+ : undefined,
350
+ )
351
+ .find((recordedPayload) => recordedPayload?.requestId === requestId);
352
+ }
353
+
354
+ function recordedTerminationResult(recorded: ContractTerminationRequestedPayload) {
355
+ return {
356
+ isSuccess: true as const,
357
+ data: {
358
+ receivedAtIso: recorded.receivedAtIso,
359
+ effectiveAtIso: recorded.effectiveAtIso,
360
+ providerCancel: recorded.providerCancel,
361
+ noticeReason: null,
362
+ withdrawalPeriodExpired: false,
363
+ } satisfies RecordContractTerminationResult,
364
+ };
365
+ }
366
+
351
367
  export function hasRevocation(
352
368
  events: readonly { type: string; payload: unknown }[],
353
369
  requestId: string,
@@ -13,6 +13,7 @@ import {
13
13
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
14
14
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
15
15
  import * as z from "zod";
16
+ import { decryptStoredUserEmail } from "../../shared/index.ts";
16
17
  import { TenantQueries } from "../../tenant/index.ts";
17
18
  import { UserQueries } from "../../user/index.ts";
18
19
  import {
@@ -31,10 +32,9 @@ import {
31
32
  SubscriptionFoundationQueries,
32
33
  } from "../web/constants.ts";
33
34
  import { resolveConsentLocale } from "./consent-text.ts";
34
- import { openStoredUserEmail } from "./stored-user-email.ts";
35
35
  import { type TerminationDeclaration, terminationReceiptVariables } from "./termination-mail.ts";
36
36
  import { notifyOperator, type OperatorNoticePlan, requireNotify } from "./termination-notify.ts";
37
- import { mayMailReceipt } from "./termination-receipt-limit.ts";
37
+ import { mayMailReceipt, type ReceiptMailVerdict } from "./termination-receipt-limit.ts";
38
38
  import type { RecordContractTerminationResult } from "./termination-record.ts";
39
39
  import { createRevocationToken, revocationUrl } from "./termination-revoke.ts";
40
40
  import type { OperatorNoticeReason } from "./termination-texts.ts";
@@ -109,13 +109,19 @@ type TerminationMailPlan = OperatorNoticePlan & {
109
109
  readonly revokeUrl?: string;
110
110
  };
111
111
 
112
+ function receiptWithheldReasons(verdict: ReceiptMailVerdict): readonly OperatorNoticeReason[] {
113
+ if (verdict === "limited") return ["receipt_rate_limited"];
114
+ if (verdict === "unavailable") return ["receipt_not_sent"];
115
+ return [];
116
+ }
117
+
112
118
  async function sendTerminationMails(
113
119
  ctx: HandlerContext,
114
120
  consumerProtection: ConsumerProtectionOptions,
115
121
  plan: TerminationMailPlan,
116
122
  ): Promise<void> {
117
- const receiptAllowed = await mayMailReceipt(ctx, plan.declaration.email);
118
- if (receiptAllowed) {
123
+ const receiptVerdict = await mayMailReceipt(ctx, plan.declaration.email);
124
+ if (receiptVerdict === "allowed") {
119
125
  await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
120
126
  route: { email: plan.declaration.email },
121
127
  data: terminationReceiptVariables({
@@ -133,9 +139,7 @@ async function sendTerminationMails(
133
139
  priority: "critical",
134
140
  });
135
141
  }
136
- const reasons = receiptAllowed
137
- ? plan.reasons
138
- : [...plan.reasons, "receipt_rate_limited" as const];
142
+ const reasons = [...plan.reasons, ...receiptWithheldReasons(receiptVerdict)];
139
143
  // skip: no operator notice needed for this declaration
140
144
  if (reasons.length === 0) return;
141
145
  const { revokeUrl: _revokeUrl, ...noticePlan } = plan;
@@ -320,7 +324,7 @@ export function createTerminateContractHandler(
320
324
  );
321
325
  const callerEmail =
322
326
  caller.success && caller.data.email && caller.data.isDeleted !== true
323
- ? await openStoredUserEmail(caller.data.email, "billing-foundation:terminate-contract")
327
+ ? await decryptStoredUserEmail(caller.data.email, "billing-foundation:terminate-contract")
324
328
  : null;
325
329
  if (callerEmail === null) {
326
330
  throw new InternalError({
@@ -14,11 +14,9 @@ import * as z from "zod";
14
14
  import { subscriptionAggregateId } from "../aggregate-id.ts";
15
15
  import {
16
16
  CONTRACT_TERMINATION_DECLARED_EVENT_QN,
17
- CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
18
17
  CONTRACT_TERMINATION_REVOKED_EVENT_QN,
19
18
  type ContractTerminationRevokedPayload,
20
19
  contractTerminationDeclaredPayloadSchema,
21
- contractTerminationRequestedPayloadSchema,
22
20
  SUBSCRIPTION_AGGREGATE_TYPE,
23
21
  } from "../events.ts";
24
22
  import { getSubscriptionForTenant } from "../get-subscription-for-tenant.ts";
@@ -26,7 +24,7 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
26
24
  import { SubscriptionFoundationHandlers } from "../web/constants.ts";
27
25
  import type { ConsentLocale } from "./consent-text.ts";
28
26
  import { notifyOperator } from "./termination-notify.ts";
29
- import { hasRevocation, resumeAtProvider } from "./termination-record.ts";
27
+ import { findRecordedTermination, hasRevocation, resumeAtProvider } from "./termination-record.ts";
30
28
  import type { OperatorNoticeReason } from "./termination-texts.ts";
31
29
 
32
30
  export const REVOCATION_PATHS: Readonly<Record<ConsentLocale, string>> = {
@@ -152,10 +150,7 @@ export function createRecordContractTerminationRevocationHandler(
152
150
  }
153
151
  if (hasRevocation(stream.events, payload.requestId)) return outcome("already-revoked");
154
152
 
155
- const requested = stream.events
156
- .filter((e) => e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN)
157
- .map((e) => contractTerminationRequestedPayloadSchema.safeParse(e.payload).data)
158
- .find((candidate) => candidate?.requestId === payload.requestId);
153
+ const requested = findRecordedTermination(stream.events, payload.requestId);
159
154
  const subscription = await getSubscriptionForTenant(ctx, tenantId);
160
155
  const effectiveAtIso = requested?.effectiveAtIso ?? subscription?.currentPeriodEnd.toString();
161
156
  const now = options.now();
@@ -175,6 +170,17 @@ export function createRecordContractTerminationRevocationHandler(
175
170
  aggregateId: subscriptionAggregateId(tenantId),
176
171
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
177
172
  });
173
+ // A cancel recorded between the first fetch and now was never resumed above.
174
+ const requestedMeanwhile =
175
+ requested === undefined
176
+ ? findRecordedTermination(streamAfterProviderCall.events, payload.requestId)
177
+ : undefined;
178
+ if (requestedMeanwhile?.providerCancel === "period-end") {
179
+ reasons.push(
180
+ ...(await resumeAtProvider(ctx, await getSubscriptionForTenant(ctx, tenantId))),
181
+ );
182
+ }
183
+ const providerCancel = (requested ?? requestedMeanwhile)?.providerCancel;
178
184
  const revoked: ContractTerminationRevokedPayload = {
179
185
  requestId: payload.requestId,
180
186
  revokedAtIso: now.toString(),
@@ -194,7 +200,7 @@ export function createRecordContractTerminationRevocationHandler(
194
200
  },
195
201
  reasons,
196
202
  tenantId,
197
- ...(requested !== undefined && { providerCancel: requested.providerCancel }),
203
+ ...(providerCancel !== undefined && { providerCancel }),
198
204
  });
199
205
  return outcome("revoked");
200
206
  },
@@ -91,6 +91,7 @@ export const OPERATOR_NOTICE_REASONS = [
91
91
  "recording_failed",
92
92
  "withdrawal_period_expired",
93
93
  "receipt_rate_limited",
94
+ "receipt_not_sent",
94
95
  "revoked",
95
96
  "revocation_resume_manual",
96
97
  "revocation_resume_failed",
@@ -145,6 +146,8 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
145
146
  "Widerruf nach Ablauf der Widerrufsfrist: zum Periodenende gekündigt, Anspruch prüfen",
146
147
  receipt_rate_limited:
147
148
  "Keine Eingangsbestätigung versendet: Limit pro Empfängeradresse erreicht",
149
+ receipt_not_sent:
150
+ "Keine Eingangsbestätigung versendet: Limit-Prüfung nicht verfügbar (Redis-Fehler oder fehlende Konfiguration)",
148
151
  revoked:
149
152
  "Die Erklärung wurde vom Postfachinhaber über „Diese Kündigung war ich nicht“ zurückgenommen",
150
153
  revocation_resume_manual:
@@ -256,6 +259,8 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
256
259
  withdrawal_period_expired:
257
260
  "Withdrawal after the withdrawal period: cancelled at period end, review the claim",
258
261
  receipt_rate_limited: "No receipt mail sent: the per-recipient limit was reached",
262
+ receipt_not_sent:
263
+ "No receipt mail sent: the limit check was unavailable (limiter error or missing configuration)",
259
264
  revoked:
260
265
  'The declaration was taken back by the mailbox owner ("I did not request this cancellation")',
261
266
  revocation_resume_manual:
@@ -1,26 +1,24 @@
1
1
  import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
- import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_QN } from "../events.ts";
2
+ import { SUBSCRIPTION_CREATED_EVENT_QN } from "../events.ts";
3
3
 
4
4
  // § 355 BGB: the withdrawal period is 14 days from conclusion of the contract.
5
5
  export const WITHDRAWAL_PERIOD_DAYS = 14;
6
6
  const HOURS_PER_DAY = 24;
7
7
 
8
- const PERIOD_START_EVENT_TYPES: ReadonlySet<string> = new Set([
9
- CHECKOUT_CONSENT_RECORDED_EVENT_QN,
10
- SUBSCRIPTION_CREATED_EVENT_QN,
11
- ]);
12
-
13
8
  type PeriodStartEvent = { readonly type: string; readonly createdAt: Temporal.Instant };
14
9
 
15
- /** The period runs from the latest consent or subscription start on the stream.
16
- * A stream with neither has no provable start and counts as expired. */
10
+ /** The period runs from the latest subscription start on the stream. A checkout
11
+ * consent alone never restarts it: it is written before the buyer reaches the
12
+ * provider, so an abandoned plan-change checkout would otherwise reopen the
13
+ * period of a long-concluded contract. A stream without a subscription start
14
+ * has no provable start and counts as expired. */
17
15
  export function isWithinWithdrawalPeriod(
18
16
  events: readonly PeriodStartEvent[],
19
17
  now: Temporal.Instant,
20
18
  ): boolean {
21
19
  let latestStart: Temporal.Instant | undefined;
22
20
  for (const event of events) {
23
- if (!PERIOD_START_EVENT_TYPES.has(event.type)) continue;
21
+ if (event.type !== SUBSCRIPTION_CREATED_EVENT_QN) continue;
24
22
  if (
25
23
  latestStart === undefined ||
26
24
  event.createdAt.epochMilliseconds > latestStart.epochMilliseconds
@@ -28,7 +26,7 @@ export function isWithinWithdrawalPeriod(
28
26
  latestStart = event.createdAt;
29
27
  }
30
28
  }
31
- // skip: no consent or subscription-created event, nothing proves a running period
29
+ // skip: no subscription-created event, nothing proves a running period
32
30
  if (latestStart === undefined) return false;
33
31
  const periodEnd = latestStart.add({ hours: WITHDRAWAL_PERIOD_DAYS * HOURS_PER_DAY });
34
32
  return now.epochMilliseconds <= periodEnd.epochMilliseconds;
@@ -141,6 +141,7 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
141
141
  termsTemplateVersion: z.number(),
142
142
  locale: z.string().min(2).max(35),
143
143
  actorUserId: z.string().min(1),
144
+ providerName: z.string().min(1).max(100).optional(),
144
145
  itemLabelKey: z.string().min(1).max(200).optional(),
145
146
  itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
146
147
  });
@@ -79,6 +79,7 @@ import { createBillingPlansQuery } from "./handlers/billing-plans.query.ts";
79
79
  import { createCheckoutSessionHandler } from "./handlers/create-checkout-session.write.ts";
80
80
  import { createPortalSessionHandler } from "./handlers/create-portal-session.write.ts";
81
81
  import { listSubscriptionsQuery } from "./handlers/list-subscriptions.query.ts";
82
+ import { createPlanSwitchPreviewQuery } from "./handlers/plan-switch-preview.query.ts";
82
83
  import { createProcessEventHandler } from "./handlers/process-event.write.ts";
83
84
  import { processPaymentEventHandler } from "./handlers/process-payment-event.write.ts";
84
85
  import { createStartPlanCheckoutHandler } from "./handlers/start-plan-checkout.write.ts";
@@ -137,7 +138,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
137
138
  BILLING_FOUNDATION_FEATURE,
138
139
  (r) => {
139
140
  r.describe(
140
- "Plugin host for subscription billing — manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers and a dormant billing-plans dashboard screen/panel from the catalog. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event — catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block — use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
141
+ "Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.",
141
142
  );
142
143
  r.uiHints({
143
144
  displayLabel: "Billing · Foundation",
@@ -239,6 +240,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
239
240
  if (widened.catalog) {
240
241
  const { catalog } = widened;
241
242
  r.queryHandler(createBillingPlansQuery(widened, catalog));
243
+ r.queryHandler(createPlanSwitchPreviewQuery(catalog));
242
244
  r.writeHandler(createStartPlanCheckoutHandler(widened, catalog));
243
245
  r.writeHandler(createSwitchPlanHandler(widened, catalog));
244
246
  r.screen(createBillingPlansScreen(catalog.viewRoles));