@cosmicdrift/kumiko-samples 0.362.0 → 0.364.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 (321) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +23 -6
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
  5. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
  6. package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
  7. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
  8. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
  9. package/packages/bundled-features/src/audit/changes.json +6 -0
  10. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  11. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  12. package/packages/bundled-features/src/auth-email-password/changes.json +51 -0
  13. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  14. package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
  15. package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
  16. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
  18. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  19. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
  20. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
  21. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
  23. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  24. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +5 -13
  25. package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
  26. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
  27. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  30. package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
  32. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
  33. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
  34. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
  35. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
  36. package/packages/bundled-features/src/billing-foundation/changes.json +43 -0
  37. package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +90 -44
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +118 -0
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +39 -33
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +36 -20
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  51. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  52. package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
  53. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
  55. package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +12 -6
  57. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
  58. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  59. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +20 -11
  60. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  61. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  62. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  63. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  64. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  65. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  66. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  67. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
  68. package/packages/bundled-features/src/channel-email/changes.json +19 -0
  69. package/packages/bundled-features/src/channel-email/email-channel.ts +135 -29
  70. package/packages/bundled-features/src/channel-email/feature.ts +6 -3
  71. package/packages/bundled-features/src/channel-email/index.ts +7 -1
  72. package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
  73. package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
  74. package/packages/bundled-features/src/channel-email/types.ts +23 -0
  75. package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
  76. package/packages/bundled-features/src/config/changes.json +6 -0
  77. package/packages/bundled-features/src/config/handlers/set.write.ts +3 -7
  78. package/packages/bundled-features/src/config/resolver.ts +15 -12
  79. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  80. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  81. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  82. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  83. package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
  84. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  85. package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
  86. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
  87. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  88. package/packages/bundled-features/src/data-retention/index.ts +1 -0
  89. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
  90. package/packages/bundled-features/src/delivery/attachment-ref.ts +105 -0
  91. package/packages/bundled-features/src/delivery/changes.json +66 -0
  92. package/packages/bundled-features/src/delivery/channel-context.ts +152 -4
  93. package/packages/bundled-features/src/delivery/constants.ts +2 -0
  94. package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
  95. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  96. package/packages/bundled-features/src/delivery/index.ts +19 -2
  97. package/packages/bundled-features/src/delivery/jobs.ts +97 -17
  98. package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
  99. package/packages/bundled-features/src/delivery/testing.ts +9 -1
  100. package/packages/bundled-features/src/delivery/types.ts +34 -18
  101. package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
  102. package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
  103. package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
  104. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
  105. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  106. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  107. package/packages/bundled-features/src/file-foundation/index.ts +7 -0
  108. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +89 -0
  109. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  110. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  111. package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
  112. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
  113. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
  114. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
  115. package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
  116. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  118. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  120. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
  122. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  123. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
  124. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
  125. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
  126. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
  127. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +42 -25
  128. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
  129. package/packages/bundled-features/src/jobs/changes.json +19 -0
  130. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  131. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  132. package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
  133. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  134. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  135. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  136. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  137. package/packages/bundled-features/src/ledger/changes.json +7 -0
  138. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  139. package/packages/bundled-features/src/ledger/recurring.ts +1 -3
  140. package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
  141. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  142. package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
  143. package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
  144. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
  145. package/packages/bundled-features/src/page-render/branding.ts +3 -0
  146. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  147. package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
  148. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
  149. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
  150. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
  151. package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
  152. package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
  153. package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
  154. package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
  155. package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
  156. package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
  157. package/packages/bundled-features/src/renderer-foundation/types.ts +134 -35
  158. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  159. package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
  160. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  161. package/packages/bundled-features/src/secrets/changes.json +6 -0
  162. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  163. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  164. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
  165. package/packages/bundled-features/src/sessions/feature.ts +2 -4
  166. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
  167. package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
  168. package/packages/bundled-features/src/shared/changes.json +6 -0
  169. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  170. package/packages/bundled-features/src/shared/index.ts +6 -0
  171. package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
  172. package/packages/bundled-features/src/shared/mail-template-body.ts +42 -0
  173. package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
  174. package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
  175. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  176. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  177. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  178. package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
  179. package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
  180. package/packages/bundled-features/src/template-resolver/README.md +66 -8
  181. package/packages/bundled-features/src/template-resolver/api.ts +85 -9
  182. package/packages/bundled-features/src/template-resolver/changes.json +186 -0
  183. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +278 -0
  184. package/packages/bundled-features/src/template-resolver/constants.ts +49 -11
  185. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
  186. package/packages/bundled-features/src/template-resolver/document-schema.ts +345 -0
  187. package/packages/bundled-features/src/template-resolver/feature.ts +59 -6
  188. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
  189. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
  190. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +160 -0
  191. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +33 -0
  192. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  193. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +138 -0
  194. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +9 -6
  195. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
  196. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
  197. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +151 -0
  198. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +19 -18
  199. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  200. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +323 -0
  201. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
  202. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
  203. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
  204. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
  205. package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
  206. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
  207. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +135 -0
  208. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
  209. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
  210. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +196 -0
  211. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +233 -0
  212. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +137 -0
  213. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +91 -17
  214. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
  215. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +16 -77
  216. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +31 -0
  217. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
  218. package/packages/bundled-features/src/template-resolver/index.ts +111 -0
  219. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  220. package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
  221. package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
  222. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  223. package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
  224. package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
  225. package/packages/bundled-features/src/template-resolver/qualified-names.ts +34 -0
  226. package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
  227. package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
  228. package/packages/bundled-features/src/template-resolver/seeding.ts +171 -20
  229. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
  230. package/packages/bundled-features/src/template-resolver/table.ts +27 -1
  231. package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
  232. package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
  233. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  234. package/packages/bundled-features/src/template-resolver/template-meta.ts +192 -0
  235. package/packages/bundled-features/src/template-resolver/template-preview.ts +676 -0
  236. package/packages/bundled-features/src/template-resolver/template-resources.ts +114 -0
  237. package/packages/bundled-features/src/template-resolver/template-usage.ts +68 -0
  238. package/packages/bundled-features/src/template-resolver/template-validation.ts +230 -0
  239. package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
  240. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +20 -13
  241. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +215 -0
  242. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +37 -0
  243. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1065 -0
  244. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +72 -0
  245. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
  246. package/packages/bundled-features/src/template-resolver/web/i18n.ts +121 -0
  247. package/packages/bundled-features/src/template-resolver/web/index.ts +26 -0
  248. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
  249. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +176 -0
  250. package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
  251. package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
  252. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
  253. package/packages/bundled-features/src/tenant/seeding.ts +41 -7
  254. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
  255. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
  256. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  257. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  258. package/packages/bundled-features/src/user/changes.json +6 -0
  259. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
  260. package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
  261. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  262. package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
  263. package/packages/bundled-features/src/user/seeding.ts +19 -5
  264. package/packages/bundled-features/src/user-data-rights/changes.json +13 -0
  265. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
  266. package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
  267. package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
  268. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
  269. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
  270. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
  271. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  272. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
  273. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  274. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  275. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  276. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
  277. package/packages/bundled-features/src/waitlist/changes.json +7 -0
  278. package/packages/bundled-features/src/waitlist/feature.ts +17 -0
  279. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
  280. package/packages/bundled-features/src/waitlist/mail.ts +69 -18
  281. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  282. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  283. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +3 -3
  284. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  285. package/samples/apps/ui-walkthrough/package.json +11 -0
  286. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  287. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
  288. package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
  289. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +239 -0
  290. package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
  291. package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
  292. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +570 -0
  293. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +72 -0
  294. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
  295. package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
  296. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
  297. package/samples/apps/ui-walkthrough/src/run-config.ts +41 -1
  298. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
  299. package/samples/apps/use-all-bundled/feature-manifest.json +118 -21
  300. package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
  301. package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
  302. package/samples/apps/use-all-bundled/package.json +2 -0
  303. package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
  304. package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
  305. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  306. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
  307. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
  308. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
  309. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
  310. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
  311. package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
  312. package/samples/recipes/delivery-notifications/README.md +26 -25
  313. package/samples/recipes/delivery-notifications/package.json +2 -1
  314. package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
  315. package/samples/recipes/seed-migration/README.md +1 -1
  316. package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
  317. package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
  318. package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
  319. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
  320. package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
  321. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
@@ -13,6 +13,7 @@ import {
13
13
  } from "../events.js";
14
14
  import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
15
15
  import { createIssueContractConfirmationHandler } from "./issue-confirmation.js";
16
+ import { registerConsumerProtectionMails } from "./mail-notifications.js";
16
17
  import { registerContractTermination } from "./register-termination.js";
17
18
 
18
19
  type ConfirmationTrigger = {
@@ -46,6 +47,7 @@ export function registerConsumerProtection(
46
47
  consumerProtection: ConsumerProtectionOptions,
47
48
  ): void {
48
49
  r.requires("template-resolver", "delivery", "user", "tenant");
50
+ registerConsumerProtectionMails(r);
49
51
  registerContractTermination(r, options, consumerProtection);
50
52
  r.defineEvent(CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, {
51
53
  piiFields: "none",
@@ -52,9 +52,19 @@ export function registerContractTermination(
52
52
  // request: matched and unmatched requests then do the same work and the
53
53
  // response time does not tell whether the email belongs to a customer.
54
54
  // The tenant comes from the stored trigger event (the job's system user).
55
+ // Every declaration appends to the same subscription stream, so parallel runs
56
+ // and a declaration arriving mid-run hit version conflicts. Sequential runs
57
+ // rule out the first; a retry re-reads the stream and the requestId check in
58
+ // record-contract-termination keeps it from recording twice.
55
59
  r.job(
56
60
  "cancel-on-public-termination-declared",
57
- { trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN }, runIn: "worker" },
61
+ {
62
+ trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN },
63
+ runIn: "worker",
64
+ concurrency: "sequential",
65
+ retries: 3,
66
+ backoff: "exponential",
67
+ },
58
68
  async (payload, ctx) => {
59
69
  const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
60
70
  // skip: trigger event carries no usable declaration
@@ -7,11 +7,22 @@ import type {
7
7
  import type { ConsentLocale } from "./consent-text.js";
8
8
  import { type OperatorNoticeReason, TERMINATION_TEXTS } from "./termination-texts.js";
9
9
 
10
- export type TerminationMailContent = {
10
+ type TextRow = { readonly text: string };
11
+
12
+ /** Variables of the termination receipt template. */
13
+ export type TerminationReceiptVariables = {
11
14
  readonly subject: string;
12
- readonly header: string;
13
- readonly sections: readonly { readonly text: string }[];
14
- readonly footer: string;
15
+ readonly receivedAt: string;
16
+ readonly requestId: string;
17
+ readonly details: readonly TextRow[];
18
+ readonly operatorEmail: string;
19
+ };
20
+
21
+ /** Variables of the operator notice template. */
22
+ export type OperatorNoticeVariables = {
23
+ readonly subject: string;
24
+ readonly lines: readonly TextRow[];
25
+ readonly operatorEmail: string;
15
26
  };
16
27
 
17
28
  /** What the declarant entered; the only input of the receipt. */
@@ -53,7 +64,7 @@ export function formatReceivedAt(iso: string, locale: ConsentLocale): string {
53
64
  function declarationLines(
54
65
  declaration: OperatorNoticeDeclaration,
55
66
  locale: ConsentLocale,
56
- ): readonly { readonly text: string }[] {
67
+ ): readonly TextRow[] {
57
68
  const texts = TERMINATION_TEXTS[locale];
58
69
  const lines = [
59
70
  `${texts.declarationType}: ${texts.declarationTypeLabel[declaration.declarationType]}`,
@@ -68,38 +79,36 @@ function declarationLines(
68
79
  return lines.map((text) => ({ text }));
69
80
  }
70
81
 
71
- export type RenderTerminationReceiptArgs = {
82
+ /** The receipt carries enum fields only: free text from an anonymous caller
83
+ * must not be echoed to an arbitrary address. */
84
+ export type ReceiptDeclaration = Pick<
85
+ TerminationDeclaration,
86
+ "declarationType" | "terminationKind"
87
+ >;
88
+
89
+ export type TerminationReceiptArgs = {
72
90
  readonly locale: ConsentLocale;
73
91
  readonly requestId: string;
74
92
  readonly receivedAtIso: string;
75
- readonly declaration: TerminationDeclaration;
93
+ readonly declaration: ReceiptDeclaration;
76
94
  readonly operatorEmail: string;
77
95
  };
78
96
 
79
- /** Built from the declarant's own input only: the content must not depend on
80
- * whether (or how) the declaration matched a contract. */
81
- export function renderTerminationReceipt(
82
- args: RenderTerminationReceiptArgs,
83
- ): TerminationMailContent {
97
+ /** The content must not depend on whether (or how) the declaration matched a contract. */
98
+ export function terminationReceiptVariables(
99
+ args: TerminationReceiptArgs,
100
+ ): TerminationReceiptVariables {
84
101
  const texts = TERMINATION_TEXTS[args.locale];
85
- const subject = texts.receiptSubject[args.declaration.declarationType];
86
102
  return {
87
- subject,
88
- header: subject,
89
- sections: [
90
- { text: texts.receiptGreeting },
91
- { text: texts.receiptIntro },
92
- { text: `${texts.receivedAt}: ${formatReceivedAt(args.receivedAtIso, args.locale)}` },
93
- { text: `${texts.requestId}: ${args.requestId}` },
94
- ...declarationLines(args.declaration, args.locale),
95
- { text: texts.receiptNextSteps },
96
- { text: texts.receiptRevoke(args.operatorEmail) },
97
- ],
98
- footer: `${texts.contact} ${args.operatorEmail}`,
103
+ subject: texts.receiptSubject[args.declaration.declarationType],
104
+ receivedAt: formatReceivedAt(args.receivedAtIso, args.locale),
105
+ requestId: args.requestId,
106
+ details: declarationLines(args.declaration, args.locale),
107
+ operatorEmail: args.operatorEmail,
99
108
  };
100
109
  }
101
110
 
102
- export type RenderOperatorNoticeArgs = {
111
+ export type OperatorNoticeArgs = {
103
112
  readonly locale: ConsentLocale;
104
113
  readonly requestId: string;
105
114
  readonly receivedAtIso: string;
@@ -111,14 +120,11 @@ export type RenderOperatorNoticeArgs = {
111
120
  readonly operatorEmail: string;
112
121
  };
113
122
 
114
- export function renderOperatorNotice(args: RenderOperatorNoticeArgs): TerminationMailContent {
123
+ export function operatorNoticeVariables(args: OperatorNoticeArgs): OperatorNoticeVariables {
115
124
  const texts = TERMINATION_TEXTS[args.locale];
116
- const subject = texts.operatorSubject[args.declaration.declarationType];
117
125
  return {
118
- subject,
119
- header: subject,
120
- sections: [
121
- { text: texts.operatorIntro },
126
+ subject: texts.operatorSubject[args.declaration.declarationType],
127
+ lines: [
122
128
  ...args.reasons.map((reason) => ({
123
129
  text: `${texts.noticeReasons}: ${texts.operatorReasons[reason]}`,
124
130
  })),
@@ -131,6 +137,6 @@ export function renderOperatorNotice(args: RenderOperatorNoticeArgs): Terminatio
131
137
  : []),
132
138
  ...declarationLines(args.declaration, args.locale),
133
139
  ],
134
- footer: args.operatorEmail,
140
+ operatorEmail: args.operatorEmail,
135
141
  };
136
142
  }
@@ -4,7 +4,7 @@ import { CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE } from "../constants.js
4
4
  import type { ProviderCancelOutcome } from "../events.js";
5
5
  import type { ConsumerProtectionOptions } from "../types.js";
6
6
  import type { ConsentLocale } from "./consent-text.js";
7
- import { type OperatorNoticeDeclaration, renderOperatorNotice } from "./termination-mail.js";
7
+ import { type OperatorNoticeDeclaration, operatorNoticeVariables } from "./termination-mail.js";
8
8
  import type { OperatorNoticeReason } from "./termination-texts.js";
9
9
 
10
10
  export type OperatorNoticePlan = {
@@ -35,7 +35,7 @@ export async function notifyOperator(
35
35
  ): Promise<void> {
36
36
  await requireNotify(ctx)(CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE, {
37
37
  route: { email: consumerProtection.operatorEmail },
38
- data: renderOperatorNotice({
38
+ data: operatorNoticeVariables({
39
39
  ...plan,
40
40
  operatorEmail: consumerProtection.operatorEmail,
41
41
  }),
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { type AnonymousExtraRoute, getSessionCookies } from "@cosmicdrift/kumiko-framework/api";
5
5
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
6
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
6
7
  import type { Context } from "hono";
7
8
  import { getCookie } from "hono/cookie";
8
9
  import {
@@ -98,10 +99,6 @@ function htmlResponse(
98
99
  );
99
100
  }
100
101
 
101
- function isOneOf<T extends string>(options: readonly T[], value: string): value is T {
102
- return (options as readonly string[]).includes(value);
103
- }
104
-
105
102
  async function readForm(c: Context): Promise<{ step: string; values: FormValues }> {
106
103
  const body = await c.req.parseBody();
107
104
  const text = (key: string): string => {
@@ -0,0 +1,41 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { normalizePayloadBucketValue } from "@cosmicdrift/kumiko-framework/pipeline";
3
+
4
+ // The declaration itself is a statutory duty and is never refused. Only the
5
+ // receipt mail is capped, so the endpoint cannot be used to mail one inbox
6
+ // from the operator's sender domain.
7
+ export const RECEIPT_MAILS_PER_RECIPIENT = 3;
8
+ export const RECEIPT_WINDOW_SECONDS = 24 * 60 * 60;
9
+
10
+ const RECEIPT_BUCKET_PREFIX = "termination-receipt";
11
+
12
+ function receiptBucketKey(ctx: HandlerContext, email: string): string | undefined {
13
+ const digest = ctx.rateLimitDigest?.(normalizePayloadBucketValue(email, "email"));
14
+ return digest === undefined ? undefined : `${RECEIPT_BUCKET_PREFIX}:${digest}`;
15
+ }
16
+
17
+ /** 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
+ // skip: no rate limiter wired, nothing to count against
21
+ if (!ctx.rateLimit) return true;
22
+ try {
23
+ const bucketKey = receiptBucketKey(ctx, email);
24
+ if (bucketKey === undefined) {
25
+ ctx.log?.warn(
26
+ "[billing-foundation:termination] no rate-limit digest available, receipt mail withheld",
27
+ );
28
+ return false;
29
+ }
30
+ const decision = await ctx.rateLimit.check(bucketKey, {
31
+ limit: RECEIPT_MAILS_PER_RECIPIENT,
32
+ windowSeconds: RECEIPT_WINDOW_SECONDS,
33
+ });
34
+ return decision.allowed;
35
+ } catch (error) {
36
+ ctx.log?.warn(
37
+ `[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`,
38
+ );
39
+ return false;
40
+ }
41
+ }
@@ -35,6 +35,7 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
35
35
  import { CONSENT_LOCALES, FALLBACK_CONSENT_LOCALE } from "./consent-locale.js";
36
36
  import { notifyOperator } from "./termination-notify.js";
37
37
  import type { OperatorNoticeReason } from "./termination-texts.js";
38
+ import { isWithinWithdrawalPeriod } from "./withdrawal-period.js";
38
39
 
39
40
  const REQUESTED_CANCEL_TIMINGS = [
40
41
  SubscriptionCancelTimings.periodEnd,
@@ -74,6 +75,8 @@ export type RecordContractTerminationResult = {
74
75
  OperatorNoticeReason,
75
76
  "provider_cannot_cancel" | "provider_error" | "no_active_subscription"
76
77
  > | null;
78
+ /** An account withdrawal arrived after the period: cancelled at period end instead. */
79
+ readonly withdrawalPeriodExpired: boolean;
77
80
  };
78
81
 
79
82
  export const recordUnmatchedContractTerminationSchema = z
@@ -210,14 +213,19 @@ export function createRecordContractTerminationHandler(
210
213
  effectiveAtIso: alreadyRecorded.effectiveAtIso,
211
214
  providerCancel: alreadyRecorded.providerCancel,
212
215
  noticeReason: null,
216
+ withdrawalPeriodExpired: false,
213
217
  } satisfies RecordContractTerminationResult,
214
218
  };
215
219
  }
216
220
 
221
+ const withdrawalPeriodExpired =
222
+ payload.declarationType === "withdrawal" &&
223
+ payload.when === SubscriptionCancelTimings.immediately &&
224
+ !isWithinWithdrawalPeriod(stream.events, options.now());
217
225
  const { providerCancel, effectiveAtIso, noticeReason } = await cancelAtProvider(
218
226
  ctx,
219
227
  tenantId,
220
- payload.when,
228
+ withdrawalPeriodExpired ? SubscriptionCancelTimings.periodEnd : payload.when,
221
229
  receivedAtIso,
222
230
  );
223
231
 
@@ -260,6 +268,7 @@ export function createRecordContractTerminationHandler(
260
268
  effectiveAtIso,
261
269
  providerCancel,
262
270
  noticeReason,
271
+ withdrawalPeriodExpired,
263
272
  } satisfies RecordContractTerminationResult,
264
273
  };
265
274
  },
@@ -31,12 +31,12 @@ import {
31
31
  import { purchaseRolesOf } from "../plan-catalog.js";
32
32
  import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
33
33
  import { resolveConsentLocale } from "./consent-text.js";
34
- import { renderTerminationReceipt, type TerminationDeclaration } from "./termination-mail.js";
34
+ import { type TerminationDeclaration, terminationReceiptVariables } from "./termination-mail.js";
35
35
  import { notifyOperator, type OperatorNoticePlan, requireNotify } from "./termination-notify.js";
36
+ import { mayMailReceipt } from "./termination-receipt-limit.js";
36
37
  import type { RecordContractTerminationResult } from "./termination-record.js";
37
38
  import type { OperatorNoticeReason } from "./termination-texts.js";
38
39
 
39
- const TENANT_ADMIN_ROLE = "TenantAdmin";
40
40
  const EXTRAORDINARY_REASON_MESSAGE = "reason is required for an extraordinary termination";
41
41
 
42
42
  const authUserRowSchema = z.object({
@@ -109,28 +109,38 @@ async function sendTerminationMails(
109
109
  consumerProtection: ConsumerProtectionOptions,
110
110
  plan: TerminationMailPlan,
111
111
  ): Promise<void> {
112
- await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
113
- route: { email: plan.declaration.email },
114
- data: renderTerminationReceipt({
112
+ const receiptAllowed = await mayMailReceipt(ctx, plan.declaration.email);
113
+ if (receiptAllowed) {
114
+ await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
115
+ route: { email: plan.declaration.email },
116
+ data: terminationReceiptVariables({
117
+ locale: plan.locale,
118
+ requestId: plan.requestId,
119
+ receivedAtIso: plan.receivedAtIso,
120
+ declaration: {
121
+ declarationType: plan.declaration.declarationType,
122
+ terminationKind: plan.declaration.terminationKind,
123
+ },
124
+ operatorEmail: consumerProtection.operatorEmail,
125
+ }),
115
126
  locale: plan.locale,
116
- requestId: plan.requestId,
117
- receivedAtIso: plan.receivedAtIso,
118
- declaration: plan.declaration,
119
- operatorEmail: consumerProtection.operatorEmail,
120
- }),
121
- locale: plan.locale,
122
- priority: "critical",
123
- });
127
+ priority: "critical",
128
+ });
129
+ }
130
+ const reasons = receiptAllowed
131
+ ? plan.reasons
132
+ : [...plan.reasons, "receipt_rate_limited" as const];
124
133
  // skip: no operator notice needed for this declaration
125
- if (plan.reasons.length === 0) return;
126
- await notifyOperator(ctx, consumerProtection, plan);
134
+ if (reasons.length === 0) return;
135
+ await notifyOperator(ctx, consumerProtection, { ...plan, reasons });
127
136
  }
128
137
 
129
138
  // Tenants the entered email can terminate a contract for: tenants where the
130
- // user is TenantAdmin and the subscription is not terminal. Every identity
139
+ // user holds a purchase role and the subscription is not terminal. Every identity
131
140
  // below comes from the trusted lookups, never from the payload.
132
141
  async function findTerminableTenantIds(
133
142
  ctx: HandlerContext,
143
+ options: ResolvedBillingFoundationOptions,
134
144
  requestTenantId: string,
135
145
  email: string,
136
146
  ): Promise<readonly string[]> {
@@ -148,10 +158,11 @@ async function findTerminableTenantIds(
148
158
  }),
149
159
  );
150
160
  if (!memberships.success) return [];
161
+ const purchaseRoles = purchaseRolesOf(options.catalog);
151
162
  const adminTenantIds = [
152
163
  ...new Set(
153
164
  memberships.data
154
- .filter((membership) => membership.roles.includes(TENANT_ADMIN_ROLE))
165
+ .filter((membership) => membership.roles.some((role) => purchaseRoles.includes(role)))
155
166
  .map((membership) => membership.tenantId),
156
167
  ),
157
168
  ];
@@ -180,7 +191,6 @@ export function createRequestContractTerminationHandler(
180
191
  schema: requestContractTerminationSchema,
181
192
  access: { roles: ["anonymous"] },
182
193
  rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
183
- additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
184
194
  ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
185
195
  escapeHatch: {
186
196
  reason:
@@ -196,7 +206,12 @@ export function createRequestContractTerminationHandler(
196
206
  const locale = resolveConsentLocale(payload.locale);
197
207
  let matchedTenantId: string | undefined;
198
208
 
199
- const candidates = await findTerminableTenantIds(ctx, event.user.tenantId, payload.email);
209
+ const candidates = await findTerminableTenantIds(
210
+ ctx,
211
+ options,
212
+ event.user.tenantId,
213
+ payload.email,
214
+ );
200
215
  const [onlyCandidate] = candidates;
201
216
  if (candidates.length === 1 && onlyCandidate !== undefined) {
202
217
  matchedTenantId = onlyCandidate;
@@ -257,7 +272,7 @@ export function createTerminateContractHandler(
257
272
  return {
258
273
  name: "terminate-contract",
259
274
  description:
260
- "Terminates the tenant's subscription at the end of the billing period, or withdraws from the contract and cancels it immediately; sends a receipt to the caller's email.",
275
+ "Terminates the tenant's subscription at the end of the billing period, or withdraws from the contract and cancels it immediately within the 14-day withdrawal period (at period end after it); sends a receipt to the caller's email.",
261
276
  schema: terminateContractSchema,
262
277
  access: { roles: purchaseRolesOf(options.catalog) },
263
278
  escapeHatch: {
@@ -313,6 +328,7 @@ export function createTerminateContractHandler(
313
328
  },
314
329
  reasons: [
315
330
  ...(data.noticeReason !== null ? [data.noticeReason] : []),
331
+ ...(data.withdrawalPeriodExpired ? (["withdrawal_period_expired"] as const) : []),
316
332
  ...needsOperatorNotice(payload),
317
333
  ],
318
334
  tenantId,
@@ -72,6 +72,8 @@ export const OPERATOR_NOTICE_REASONS = [
72
72
  "provider_error",
73
73
  "no_active_subscription",
74
74
  "recording_failed",
75
+ "withdrawal_period_expired",
76
+ "receipt_rate_limited",
75
77
  ] as const;
76
78
  export type OperatorNoticeReason = (typeof OPERATOR_NOTICE_REASONS)[number];
77
79
 
@@ -116,6 +118,10 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
116
118
  provider_error: "Die Kündigung beim Zahlungsanbieter ist fehlgeschlagen",
117
119
  no_active_subscription: "Keine laufende Subscription beim Anbieter",
118
120
  recording_failed: "Die Erklärung konnte nicht gespeichert werden",
121
+ withdrawal_period_expired:
122
+ "Widerruf nach Ablauf der Widerrufsfrist: zum Periodenende gekündigt, Anspruch prüfen",
123
+ receipt_rate_limited:
124
+ "Keine Eingangsbestätigung versendet: Limit pro Empfängeradresse erreicht",
119
125
  },
120
126
  noticeReasons: "Grund der Benachrichtigung",
121
127
  channel: "Kanal",
@@ -199,6 +205,9 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
199
205
  provider_error: "Cancelling at the payment provider failed",
200
206
  no_active_subscription: "No running subscription at the provider",
201
207
  recording_failed: "The declaration could not be stored",
208
+ withdrawal_period_expired:
209
+ "Withdrawal after the withdrawal period: cancelled at period end, review the claim",
210
+ receipt_rate_limited: "No receipt mail sent: the per-recipient limit was reached",
202
211
  },
203
212
  noticeReasons: "Reason for this notice",
204
213
  channel: "Channel",
@@ -0,0 +1,35 @@
1
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_QN } from "../events.js";
3
+
4
+ // § 355 BGB: the withdrawal period is 14 days from conclusion of the contract.
5
+ export const WITHDRAWAL_PERIOD_DAYS = 14;
6
+ const HOURS_PER_DAY = 24;
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
+ type PeriodStartEvent = { readonly type: string; readonly createdAt: Temporal.Instant };
14
+
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. */
17
+ export function isWithinWithdrawalPeriod(
18
+ events: readonly PeriodStartEvent[],
19
+ now: Temporal.Instant,
20
+ ): boolean {
21
+ let latestStart: Temporal.Instant | undefined;
22
+ for (const event of events) {
23
+ if (!PERIOD_START_EVENT_TYPES.has(event.type)) continue;
24
+ if (
25
+ latestStart === undefined ||
26
+ event.createdAt.epochMilliseconds > latestStart.epochMilliseconds
27
+ ) {
28
+ latestStart = event.createdAt;
29
+ }
30
+ }
31
+ // skip: no consent or subscription-created event, nothing proves a running period
32
+ if (latestStart === undefined) return false;
33
+ const periodEnd = latestStart.add({ hours: WITHDRAWAL_PERIOD_DAYS * HOURS_PER_DAY });
34
+ return now.epochMilliseconds <= periodEnd.epochMilliseconds;
35
+ }
@@ -40,10 +40,10 @@ export async function getSubscriptionForTenant(
40
40
  if (!row) return null;
41
41
  const piiKms = configuredPiiSubjectKms();
42
42
  const decrypted = piiKms
43
- ? await decryptPiiFieldValues(row as Record<string, unknown>, SUBSCRIPTION_PII_FIELDS, piiKms, {
43
+ ? await decryptPiiFieldValues(row, SUBSCRIPTION_PII_FIELDS, piiKms, {
44
44
  requestId: `billing-foundation:get-subscription:${tenantId}`,
45
45
  })
46
- : (row as Record<string, unknown>);
46
+ : row;
47
47
  // @cast-boundary db-row — drizzle-row carries column-as-unknown
48
48
  return {
49
49
  tier: decrypted["tier"] as string,
@@ -23,7 +23,7 @@
23
23
  // hand-picked priceId.
24
24
 
25
25
  import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
26
- import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
26
+ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import * as z from "zod";
28
28
  import {
29
29
  assertCheckoutAllowed,
@@ -32,6 +32,7 @@ import {
32
32
  startProviderCheckout,
33
33
  } from "../checkout-core.js";
34
34
  import {
35
+ assertValidOrderItem,
35
36
  type ConsentPayload,
36
37
  consentCheckoutFields,
37
38
  consentPayloadSchema,
@@ -104,11 +105,12 @@ export function createCheckoutSessionHandler(
104
105
 
105
106
  const mode = payload.mode ?? "subscription";
106
107
  const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
107
- const price = await lookupPrice(ctx, plugin, payload.priceId);
108
+ const price = await lookupPrice(ctx, plugin, payload.providerName, payload.priceId);
108
109
  const orderItem =
109
110
  mode === "payment"
110
111
  ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
111
112
  : undefined;
113
+ assertValidOrderItem(orderItem, payload.priceId);
112
114
  const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
113
115
  const result = await startProviderCheckout(ctx, plugin, {
114
116
  ...checkoutInput,
@@ -134,11 +136,17 @@ export function createCheckoutSessionHandler(
134
136
  async function lookupPrice(
135
137
  ctx: HandlerContext,
136
138
  plugin: SubscriptionProviderPlugin,
139
+ providerName: string,
137
140
  priceId: string,
138
141
  ): Promise<ProviderPrice> {
142
+ if (!plugin.retrievePrices) {
143
+ throw new InternalError({
144
+ message: `billing-foundation: provider "${providerName}" lacks retrievePrices, required by consumerProtection`,
145
+ });
146
+ }
139
147
  let price: ProviderPrice | undefined;
140
148
  try {
141
- const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
149
+ const prices = await plugin.retrievePrices(ctx, [priceId]);
142
150
  price = prices.find((p) => p.priceId === priceId);
143
151
  } catch (cause) {
144
152
  throw priceUnavailable(priceId, cause);
@@ -52,15 +52,10 @@ export function createPortalSessionHandler(options: BillingFoundationOptions): W
52
52
  }
53
53
  const piiKms = configuredPiiSubjectKms();
54
54
  const decrypted = piiKms
55
- ? await decryptPiiFieldValues(
56
- row as Record<string, unknown>,
57
- SUBSCRIPTION_PII_FIELDS,
58
- piiKms,
59
- {
60
- requestId: `billing-foundation:create-portal-session:${tenantId}`,
61
- },
62
- )
63
- : (row as Record<string, unknown>);
55
+ ? await decryptPiiFieldValues(row, SUBSCRIPTION_PII_FIELDS, piiKms, {
56
+ requestId: `billing-foundation:create-portal-session:${tenantId}`,
57
+ })
58
+ : row;
64
59
  const providerName = row["providerName"] as string; // @cast-boundary db-row
65
60
  const providerCustomerId = decrypted["providerCustomerId"] as string; // @cast-boundary db-row
66
61
 
@@ -27,7 +27,7 @@ export const listSubscriptionsQuery: QueryHandlerDef = {
27
27
  if (!piiKms) return { rows };
28
28
  const decryptedRows = await Promise.all(
29
29
  rows.map((row) =>
30
- decryptPiiFieldValues(row as Record<string, unknown>, SUBSCRIPTION_PII_FIELDS, piiKms, {
30
+ decryptPiiFieldValues(row, SUBSCRIPTION_PII_FIELDS, piiKms, {
31
31
  requestId: `billing-foundation:list-subscriptions:${ctx.user.tenantId}`,
32
32
  }),
33
33
  ),
@@ -21,6 +21,7 @@ import * as z from "zod";
21
21
  import { subscriptionAggregateId } from "../aggregate-id.js";
22
22
  import {
23
23
  isSubscriptionBlockingCheckout,
24
+ isSwitchableSubscriptionStatus,
24
25
  SubscriptionEventTypes,
25
26
  SubscriptionStatuses,
26
27
  } from "../constants.js";
@@ -167,8 +168,8 @@ export async function appendSubscriptionEvent(
167
168
  // ---------------------------------------------------------------
168
169
  const eventPayload: SubscriptionEventPayload = {
169
170
  providerName: payload.providerName,
170
- providerCustomerId: encryptedFields["providerCustomerId"] as string,
171
- providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
171
+ providerCustomerId: encryptedFields.providerCustomerId,
172
+ providerSubscriptionId: encryptedFields.providerSubscriptionId,
172
173
  status: payload.status,
173
174
  tier: payload.tier,
174
175
  currentPeriodEndIso: payload.currentPeriodEndIso,
@@ -199,19 +200,24 @@ export async function appendSubscriptionEvent(
199
200
  // Tenant-Admin direkt.
200
201
  // One subscription aggregate exists per tenant, but a tenant can legitimately hold two provider
201
202
  // subscriptions over time (a stale `incomplete` one superseded by a fresh checkout). A late or
202
- // retried event of the old subscription must not overwrite the row of the live one, so only a
203
- // `created` event may replace a blocking row that belongs to a different provider subscription.
203
+ // retried event of the old subscription must not overwrite the row of the live one, so an event of a
204
+ // different provider subscription only replaces a row that no longer blocks a checkout. The exception
205
+ // is a `created` of a subscription that is itself live (a provider switch); a late `created` of a
206
+ // never-completed `incomplete` one stays stale, since delivery order is not guaranteed.
204
207
  function isStaleSubscriptionEvent(
205
208
  current: SubscriptionView | null,
206
209
  payload: ProcessEventPayload,
207
210
  now: Temporal.Instant,
208
211
  ): boolean {
209
212
  if (current === null) return false;
210
- if (payload.type === SubscriptionEventTypes.created) return false;
211
213
  const sameSubscription =
212
214
  current.providerName === payload.providerName &&
213
215
  current.providerSubscriptionId === payload.providerSubscriptionId;
214
- return !sameSubscription && isSubscriptionBlockingCheckout(current, now);
216
+ if (sameSubscription || !isSubscriptionBlockingCheckout(current, now)) return false;
217
+ const supersedesCurrent =
218
+ payload.type === SubscriptionEventTypes.created &&
219
+ isSwitchableSubscriptionStatus(payload.status);
220
+ return !supersedesCurrent;
215
221
  }
216
222
 
217
223
  export function createProcessEventHandler(now: () => Temporal.Instant): WriteHandlerDef {
@@ -95,7 +95,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
95
95
  // ---------------------------------------------------------------
96
96
  const eventPayload: PaymentEventPayload = {
97
97
  providerName: payload.providerName,
98
- providerCustomerId: encryptedFields["providerCustomerId"] as string,
98
+ providerCustomerId: encryptedFields.providerCustomerId,
99
99
  priceId: payload.priceId,
100
100
  ...(payload.consentId !== undefined && { consentId: payload.consentId }),
101
101
  };
@@ -95,6 +95,9 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
95
95
  "billing-foundation.errors.redirectOriginNotAllowed": {
96
96
  en: "This redirect URL is not allowed for this app.",
97
97
  },
98
+ "billing-foundation.errors.providerCustomerUnknown": {
99
+ en: "The billing account for this subscription no longer exists. Subscribe again to continue.",
100
+ },
98
101
  "billing-foundation.errors.providerHasNoPriceCatalog": {
99
102
  en: "This provider has no known prices configured.",
100
103
  },