@cosmicdrift/kumiko-samples 0.387.0 → 0.389.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 (274) 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 +12 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
  6. package/packages/bundled-features/src/audit/changes.json +12 -0
  7. package/packages/bundled-features/src/audit/feature.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  15. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  16. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  17. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  18. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  19. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  20. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  21. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  22. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  23. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  24. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  25. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  26. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  27. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  28. package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
  29. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  30. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  32. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  33. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  37. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  39. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  40. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  51. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  52. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  53. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  55. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  57. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  58. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  59. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  60. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  61. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  62. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  63. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  64. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  65. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  66. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  67. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  68. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  69. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  70. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  71. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
  72. package/packages/bundled-features/src/cap-counter/changes.json +30 -0
  73. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  74. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  75. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  76. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  77. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  78. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  79. package/packages/bundled-features/src/config/changes.json +18 -0
  80. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  81. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  82. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  83. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  84. package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
  85. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
  86. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  87. package/packages/bundled-features/src/delivery/changes.json +42 -0
  88. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  89. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  90. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  91. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  92. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  93. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  94. package/packages/bundled-features/src/delivery/index.ts +5 -0
  95. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  96. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  97. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  98. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  99. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  100. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  101. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  102. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  103. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  104. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  105. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  106. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  107. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  108. package/packages/bundled-features/src/files/README.md +1 -1
  109. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  110. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  111. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  112. package/packages/bundled-features/src/host-routing/README.md +1 -1
  113. package/packages/bundled-features/src/host-routing/changes.json +12 -0
  114. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  115. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  116. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  118. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  120. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  122. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  123. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  124. package/packages/bundled-features/src/jobs/changes.json +13 -0
  125. package/packages/bundled-features/src/jobs/feature.ts +15 -4
  126. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  127. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  128. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  129. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  130. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  131. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  132. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  133. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  134. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  135. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  136. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  137. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  138. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  139. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  140. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  141. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  142. package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
  143. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  144. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  145. package/packages/bundled-features/src/secrets/changes.json +6 -0
  146. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  147. package/packages/bundled-features/src/sessions/changes.json +7 -0
  148. package/packages/bundled-features/src/sessions/feature.ts +8 -2
  149. package/packages/bundled-features/src/sessions/index.ts +1 -1
  150. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  151. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  152. package/packages/bundled-features/src/shared/index.ts +1 -1
  153. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  154. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  155. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  156. package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
  157. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  158. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  159. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  160. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  161. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  162. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  163. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  164. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  165. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  166. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  167. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  168. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  169. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  171. package/packages/bundled-features/src/tags/feature.ts +1 -0
  172. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  173. package/packages/bundled-features/src/template-resolver/changes.json +84 -0
  174. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  175. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  176. package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
  177. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  178. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  179. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  182. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  183. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  184. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
  185. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  186. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  187. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  188. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  189. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  190. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  191. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  192. package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
  193. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
  194. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  195. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  196. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  197. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  198. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  199. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  200. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  201. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  202. package/packages/bundled-features/src/tenant/changes.json +6 -0
  203. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  204. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  205. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  206. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  207. package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
  208. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  209. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  210. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  211. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  212. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  213. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  214. package/packages/bundled-features/src/user/feature.ts +1 -0
  215. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  216. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  217. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  218. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  219. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  220. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  221. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  222. package/packages/bundled-features/src/waitlist/changes.json +36 -0
  223. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  224. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
  226. package/packages/bundled-features/src/waitlist/import.ts +28 -3
  227. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
  228. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  229. package/packages/bundled-features/src/waitlist/options.ts +5 -1
  230. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  231. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  232. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  233. package/samples/apps/styleguide/src/app/server.ts +4 -1
  234. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  235. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  236. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  237. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  238. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  239. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  240. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  241. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  242. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  243. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  244. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  245. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  246. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  247. package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
  248. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  249. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  250. package/samples/apps/use-all-bundled/package.json +2 -1
  251. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  252. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  253. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  254. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  255. package/samples/recipes/auth-mfa/README.md +5 -0
  256. package/samples/recipes/auth-mfa/package.json +5 -2
  257. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  258. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  259. package/samples/recipes/env-composition/README.md +3 -1
  260. package/samples/recipes/env-composition/src/env.ts +2 -0
  261. package/samples/recipes/golden-path/src/feature.ts +2 -1
  262. package/samples/recipes/legal-pages/README.md +1 -1
  263. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  264. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  265. package/samples/recipes/list-table-options/README.md +50 -0
  266. package/samples/recipes/list-table-options/package.json +11 -0
  267. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  268. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  269. package/samples/recipes/rate-limiting/README.md +18 -0
  270. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  271. package/samples/recipes/state-machine/src/events.ts +3 -2
  272. package/samples/recipes/tenant-isolation/README.md +1 -0
  273. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
  274. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -79,6 +79,7 @@ export function MfaSetupPreauthScreen({
79
79
  const [setup, setSetup] = useState<SetupState | null>(null);
80
80
  const [acknowledged, setAcknowledged] = useState(false);
81
81
  const [code, setCode] = useState("");
82
+ const [attempt, setAttempt] = useState(0);
82
83
  const [busy, setBusy] = useState(false);
83
84
  const [error, setError] = useState<string | null>(null);
84
85
  const [retryAfterSeconds, setRetryAfterSeconds] = useState<number | null>(null);
@@ -109,13 +110,13 @@ export function MfaSetupPreauthScreen({
109
110
  }
110
111
  };
111
112
 
112
- const confirmSetup = async (): Promise<void> => {
113
- if (!setup) return;
113
+ const confirmSetup = async (submittedCode: string): Promise<void> => {
114
+ if (!setup || busy) return;
114
115
  setBusy(true);
115
116
  setError(null);
116
117
  setRetryAfterSeconds(null);
117
118
  try {
118
- const res = await confirmMfaSetupPreauth(setup.setupToken, code);
119
+ const res = await confirmMfaSetupPreauth(setup.setupToken, submittedCode);
119
120
  if (res.kind !== "success") {
120
121
  setError(res.error.reason);
121
122
  setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
@@ -130,7 +131,11 @@ export function MfaSetupPreauthScreen({
130
131
  setSetup(null);
131
132
  setAcknowledged(false);
132
133
  setCode("");
134
+ return;
133
135
  }
136
+ // Remount: the field was disabled during confirm and lost focus.
137
+ setCode("");
138
+ setAttempt((n) => n + 1);
134
139
  return;
135
140
  }
136
141
  onSuccess?.(res.data.landingPath);
@@ -143,7 +148,7 @@ export function MfaSetupPreauthScreen({
143
148
 
144
149
  const onSubmit = (e?: FormEvent): void => {
145
150
  e?.preventDefault();
146
- void confirmSetup();
151
+ void confirmSetup(code);
147
152
  };
148
153
 
149
154
  return (
@@ -226,13 +231,17 @@ export function MfaSetupPreauthScreen({
226
231
 
227
232
  <Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
228
233
  <Input
229
- kind="text"
234
+ key={attempt}
235
+ kind="code"
236
+ length={6}
230
237
  id="mfa-setup-preauth-code"
231
238
  name="mfa-setup-preauth-code"
232
239
  value={code}
233
240
  onChange={setCode}
241
+ onComplete={(next) => void confirmSetup(next)}
234
242
  disabled={busy || !acknowledged}
235
- autoComplete="one-time-code"
243
+ hasError={error !== null}
244
+ autoFocus={attempt > 0}
236
245
  />
237
246
  </Field>
238
247
 
@@ -27,6 +27,10 @@ export type MfaVerifyScreenProps = {
27
27
  readonly onCancel?: () => void;
28
28
  };
29
29
 
30
+ type CodeEntryMode = "authenticator" | "recovery";
31
+
32
+ const MFA_CODE_LENGTH = 6;
33
+
30
34
  function reasonToKey(reason: string): string {
31
35
  switch (reason) {
32
36
  case "invalid_totp_code":
@@ -56,14 +60,17 @@ export function MfaVerifyScreen({
56
60
  const t = useTranslation();
57
61
  const { Form, Field, Input, Button, Banner } = usePrimitives();
58
62
  const session = useSession();
63
+ const [mode, setMode] = useState<CodeEntryMode>("authenticator");
59
64
  const [code, setCode] = useState("");
65
+ const [attempt, setAttempt] = useState(0);
60
66
  const [submitting, setSubmitting] = useState(false);
61
67
  const [error, setError] = useState<string | null>(null);
62
68
 
63
- const doSubmit = async (): Promise<void> => {
69
+ const doSubmit = async (submittedCode: string): Promise<void> => {
70
+ if (submitting) return;
64
71
  setSubmitting(true);
65
72
  setError(null);
66
- const res = await verifyMfaChallenge(challengeToken, code);
73
+ const res = await verifyMfaChallenge(challengeToken, submittedCode);
67
74
  if (res.kind === "success") {
68
75
  await session.refresh();
69
76
  setSubmitting(false);
@@ -72,30 +79,65 @@ export function MfaVerifyScreen({
72
79
  }
73
80
  setSubmitting(false);
74
81
  setError(res.error.reason);
82
+ // Remount: the input was disabled during submit and lost focus.
83
+ setAttempt((n) => n + 1);
84
+ if (mode === "authenticator") setCode("");
75
85
  };
76
86
 
77
87
  const onSubmit = (e?: FormEvent): void => {
78
88
  e?.preventDefault();
79
- void doSubmit();
89
+ void doSubmit(code);
80
90
  };
81
91
 
92
+ const toggleMode = (): void => {
93
+ setMode(mode === "authenticator" ? "recovery" : "authenticator");
94
+ setCode("");
95
+ setError(null);
96
+ };
97
+
98
+ const isRecovery = mode === "recovery";
99
+ const fieldId = isRecovery ? "mfa-verify-recovery-code" : "mfa-verify-code";
100
+ const defaultSubtitle = isRecovery
101
+ ? t("auth.mfa.verify.recoverySubtitle")
102
+ : t("auth.mfa.verify.subtitle");
103
+
82
104
  return (
83
- <AuthCard
84
- title={title ?? t("auth.mfa.verify.title")}
85
- subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
86
- >
105
+ <AuthCard title={title ?? t("auth.mfa.verify.title")} subtitle={subtitle ?? defaultSubtitle}>
87
106
  <Form onSubmit={onSubmit}>
88
- <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
89
- <Input
90
- kind="text"
91
- id="mfa-verify-code"
92
- name="mfa-verify-code"
93
- value={code}
94
- onChange={setCode}
95
- disabled={submitting}
96
- required
97
- autoComplete="one-time-code"
98
- />
107
+ <Field
108
+ id={fieldId}
109
+ label={isRecovery ? t("auth.mfa.verify.recoveryCode") : t("auth.mfa.verify.code")}
110
+ required
111
+ >
112
+ {isRecovery ? (
113
+ <Input
114
+ key={`${mode}-${attempt}`}
115
+ kind="text"
116
+ id={fieldId}
117
+ name={fieldId}
118
+ value={code}
119
+ onChange={setCode}
120
+ disabled={submitting}
121
+ required
122
+ autoFocus
123
+ autoComplete="off"
124
+ />
125
+ ) : (
126
+ <Input
127
+ key={`${mode}-${attempt}`}
128
+ kind="code"
129
+ length={MFA_CODE_LENGTH}
130
+ id={fieldId}
131
+ name={fieldId}
132
+ value={code}
133
+ onChange={setCode}
134
+ onComplete={(next) => void doSubmit(next)}
135
+ disabled={submitting}
136
+ hasError={error !== null}
137
+ required
138
+ autoFocus
139
+ />
140
+ )}
99
141
  </Field>
100
142
  {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
101
143
  <Button type="submit" loading={submitting} disabled={submitting}>
@@ -103,6 +145,11 @@ export function MfaVerifyScreen({
103
145
  ? t("auth.mfa.verify.submitting")
104
146
  : (submitLabel ?? t("auth.mfa.verify.submit"))}
105
147
  </Button>
148
+ <Button type="button" variant="link" onClick={toggleMode} disabled={submitting}>
149
+ {isRecovery
150
+ ? t("auth.mfa.verify.useAuthenticatorCode")
151
+ : t("auth.mfa.verify.useRecoveryCode")}
152
+ </Button>
106
153
  {onCancel ? (
107
154
  <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
108
155
  {t("auth.mfa.verify.backToLogin")}
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Concurrent termination runs, revocation resume and sync ordering",
6
+ "detail": "record-contract-termination re-checks the requestId after the provider call so a concurrent run appends nothing. Revocation resumes a period-end cancel that was recorded between its two stream reads. sync-subscription stamps the snapshot time as providerEventAtIso so a late older webhook cannot reactivate a synced cancellation."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Ordered terms lists keep their numbers in the confirmation mail",
12
+ "detail": "The contract confirmation mail prefixes each item of an ordered Markdown list with its number, so references such as \"see no. 3\" in terms and cancellation policies stay intact."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Operator notice tells an unavailable receipt limiter from a reached limit",
18
+ "detail": "When the termination receipt mail is withheld because the rate limiter failed or has no digest key, the operator notice reports \"receipt_not_sent\" instead of the per-recipient limit reason."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "An abandoned plan-change checkout no longer restarts the withdrawal period",
24
+ "detail": "The withdrawal period now runs from the latest subscription start only; a checkout consent without a following subscription start does not reopen it, so a later withdrawal of an old contract is cancelled at period end."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Same-second provider update cannot revive a canceled subscription",
30
+ "detail": "When a non-terminal state event carries the same provider timestamp as a stored cancellation, it is treated as late and discarded."
31
+ },
32
+ {
33
+ "version": "0.388.0",
34
+ "type": "improvement",
35
+ "title": "Several subscription providers in one catalog",
36
+ "detail": "The billing-plans query returns providers (name, displayName, plans, note) when more than one provider is offered, and start-plan-checkout takes providerName. consumerProtection.providerTexts overrides checkoutNote and vatNote per provider, and the consent record carries providerName. switch-plan always uses the provider of the existing subscription."
37
+ },
38
+ {
39
+ "version": "0.388.0",
40
+ "type": "improvement",
41
+ "title": "In-app plan switch with a preview and consent",
42
+ "detail": "A provider plugin that implements previewPlanSwitch and applyPlanSwitch switches plans without a hosted portal. The new plan-switch-preview query returns the proration, the billing-plans result carries inAppPlanSwitch, and the panel shows PlanSwitchConfirmDialog with the withdrawal consent checkboxes. The confirmation mail is sent when the plan_changed webhook arrives with the consent id."
43
+ },
44
+ {
45
+ "version": "0.388.0",
46
+ "type": "breaking",
47
+ "title": "A catalog with several providers no longer throws an ambiguous-provider error",
48
+ "detail": "With more than one subscription provider mounted, billing-plans and start-plan-checkout used to throw. They now list the providers; start-plan-checkout answers with errors.providerRequired when several are offered and no providerName is given, and errors.providerNotOffered for an unknown one. Plugins have to set displayName.",
49
+ "migration": "Set displayName on custom subscriptionProvider plugins. Callers that send start-plan-checkout with several providers mounted must pass providerName."
50
+ },
51
+ {
52
+ "version": "0.388.0",
53
+ "type": "improvement",
54
+ "title": "Provider choice moved into the checkout dialog",
55
+ "detail": "The plan panel no longer shows a provider switch. Cards show the first provider's price, or the cheapest one with a \"from\" prefix when providers disagree. With several providers, or with consumerProtection, choosing a plan opens CheckoutConsentDialog, which now has an optional providerChoice (ChoiceCards with the checkout note and, when prices differ, the price), an optional note, and an optional consumerProtection (without it there are no checkboxes and the order carries no consent). The plan switch dialog shows the recurring price with its interval."
56
+ },
2
57
  {
3
58
  "version": "0.379.0",
4
59
  "type": "fix",
@@ -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({