@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -77,24 +77,28 @@ export async function dispatchMagicLinkMail(
77
77
  // mailT already root-falls-back; appUrl must use the same negotiated tag
78
78
  // or language-in-path apps 404 on e.g. /de-AT/activate.
79
79
  const urlLocale = resolveMailLocale(locale);
80
+ // Template, expiry formatting and app-name fallback must share one locale,
81
+ // otherwise an English template shows e.g. French "3 heures".
82
+ const templateLocale = authMailTemplateLocale(spec.notificationType, locale);
80
83
  const { validFor, validUntil } = tokenMailExpiry(
81
84
  {
82
85
  expiresAt: params.expiresAt,
83
86
  ...(params.issuedAt !== undefined && { issuedAt: params.issuedAt }),
84
87
  ...(params.timeZone !== undefined && { timeZone: params.timeZone }),
85
88
  },
86
- locale,
89
+ templateLocale,
87
90
  );
88
91
  await notify(spec.notificationType, {
89
92
  route: { email: params.email },
90
93
  data: {
91
- appName: params.appName ?? spec.fallbackAppName ?? mailT(locale, "auth.mail.appNameDefault"),
94
+ appName:
95
+ params.appName ?? spec.fallbackAppName ?? mailT(templateLocale, "auth.mail.appNameDefault"),
92
96
  actionUrl: appendToken(resolveAppUrl(params.appUrl, urlLocale), params.token),
93
97
  validFor,
94
98
  validUntil,
95
99
  ...(params.role !== undefined && { role: params.role }),
96
100
  },
97
- locale: authMailTemplateLocale(spec.notificationType, locale),
101
+ locale: templateLocale,
98
102
  priority: "critical",
99
103
  });
100
104
  }
@@ -0,0 +1,10 @@
1
+ // @runtime client
2
+ // Server-registered so the document designer names the seeded auth mails.
3
+ export const AUTH_EMAIL_PASSWORD_MAIL_I18N: Readonly<Record<string, { readonly en: string }>> = {
4
+ "auth-email-password:feature.label": { en: "Sign-in" },
5
+ "auth-email-password:notify.password-reset.label": { en: "Password reset" },
6
+ "auth-email-password:notify.account-unlock.label": { en: "Account unlock" },
7
+ "auth-email-password:notify.email-verification.label": { en: "Email verification" },
8
+ "auth-email-password:notify.signup-activation.label": { en: "Sign-up activation" },
9
+ "auth-email-password:notify.invite.label": { en: "Invitation" },
10
+ };
@@ -10,7 +10,7 @@
10
10
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
11
11
 
12
12
  import { useLocale } from "@cosmicdrift/kumiko-renderer";
13
- import { type ComponentType, type ReactNode, useEffect, useState } from "react";
13
+ import { type ComponentType, type ReactNode, useEffect, useMemo, useState } from "react";
14
14
  import {
15
15
  assertNavigableUrl,
16
16
  buildLoginRedirectUrl,
@@ -204,6 +204,21 @@ export function resolveLoginRedirect(
204
204
  };
205
205
  }
206
206
 
207
+ // A throwing loginUrl builder must not take the whole app tree down in render;
208
+ // null falls through to the built-in login screen.
209
+ function resolveLoginRedirectOrNull(
210
+ loginUrl: string | LoginUrlBuilder,
211
+ locale: string,
212
+ location: Pick<Location, "origin" | "pathname" | "search" | "hash">,
213
+ ): LoginRedirect | null {
214
+ try {
215
+ return resolveLoginRedirect(loginUrl, locale, location);
216
+ } catch (error) {
217
+ console.error("Auth gate: loginUrl could not be resolved, showing the built-in login", error);
218
+ return null;
219
+ }
220
+ }
221
+
207
222
  export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
208
223
  children: ReactNode;
209
224
  }> {
@@ -213,10 +228,14 @@ export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
213
228
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
214
229
  const { status } = useSession();
215
230
  const locale = useLocale().locale();
216
- const loginRedirect =
217
- loginUrl !== undefined && status === "unauthenticated"
218
- ? resolveLoginRedirect(loginUrl, locale, window.location)
219
- : null;
231
+ const { origin, pathname, search, hash } = window.location;
232
+ const loginRedirect = useMemo(
233
+ () =>
234
+ loginUrl !== undefined && status === "unauthenticated"
235
+ ? resolveLoginRedirectOrNull(loginUrl, locale, { origin, pathname, search, hash })
236
+ : null,
237
+ [status, locale, origin, pathname, search, hash],
238
+ );
220
239
  const redirectUrl = loginRedirect?.kind === "redirect" ? loginRedirect.url : null;
221
240
  // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
222
241
  useEffect(() => {
@@ -1,4 +1,52 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Termination record re-reads the stream after the provider call",
6
+ "detail": "The cancel-at-provider call can take long, so the termination event is appended with a freshly fetched stream version instead of the one read before it."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "improvement",
11
+ "title": "Contract mails carry designer labels",
12
+ "detail": "Feature and notify labels for contract-confirmation, contract-termination-receipt and contract-termination-operator-notice name the contract mails in the document designer."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "Stale subscription created events are ignored and manual-grant skips are logged",
18
+ "detail": "A created event for a subscription that is no longer current no longer replaces the active one while the current row blocks checkout, and the tier sync logs its own code when a manual grant keeps the tier."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "fix",
23
+ "title": "Contract confirmation mail renders the terms' Markdown as formatting",
24
+ "detail": "The terms rows of the contract confirmation mail were plain text, so `**Right to cancel.**` reached the buyer with its asterisks (0.363.0) or lost its emphasis. Each row of `termsParagraphs` is now a richText value: bold, italic and http(s)/mailto/tel links render as HTML, headings become bold rows. Author HTML and images in the terms stay escaped text, and links outside those schemes stay \"label (url)\". Tenant overrides of the template keep working, the variable name and row structure are unchanged."
25
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "Terms in the contract confirmation mail no longer show raw Markdown",
30
+ "detail": "The terms Markdown is reduced to plain text rows: bold and italic markers are dropped, links become \"label (url)\", list items get their own row, and single line breaks inside a paragraph become spaces."
31
+ },
32
+ {
33
+ "version": "0.364.0",
34
+ "type": "fix",
35
+ "title": "The termination receipt rate limit keys Redis with a keyed, handler-bound digest instead of an unkeyed SHA-256 of the email",
36
+ "detail": "HandlerContext gains an optional rateLimitDigest(value), an HMAC bound to the running handler, so a handler can build rate-limit keys without plaintext identifiers and cannot reproduce the dispatcher's payload-bucket digests. normalizePayloadBucketValue is exported from the pipeline entry point. The receipt limit reuses it, and withholds the receipt mail (the declaration is still recorded) when no digest is wired. Payload-bucket normalization now applies Unicode NFKC first, so fullwidth lookalikes share a bucket. Existing receipt buckets restart empty; they expire after the 24 hour window."
37
+ },
38
+ {
39
+ "version": "0.364.0",
40
+ "type": "fix",
41
+ "title": "Checkout validates the one-off order label and price lookup",
42
+ "detail": "An invalid oneOffItemLabel or a provider without retrievePrices fails with a configuration error before the provider is called."
43
+ },
44
+ {
45
+ "version": "0.364.0",
46
+ "type": "fix",
47
+ "title": "Contract termination downgrades late withdrawals and caps receipt mails",
48
+ "detail": "A withdrawal declared after the 14-day period ends at period end (operator notice withdrawal_period_expired); the declaration is always accepted, receipt mails are capped at 3 per recipient per day and carry only enum fields, request id and time; purchaser roles come from the catalog."
49
+ },
2
50
  {
3
51
  "version": "0.363.0",
4
52
  "type": "breaking",
@@ -1,4 +1,10 @@
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
+ import { Marked } from "marked";
4
+ import {
5
+ isAllowedDocumentLinkHref,
6
+ isDocumentPlaceholderHref,
7
+ } from "../../template-resolver/index.js";
2
8
  import type { CheckoutConsentRecordedPayload } from "../events.js";
3
9
  import { type ConsentLocale, resolveConsentLocale } from "./consent-text.js";
4
10
 
@@ -138,13 +144,59 @@ function formatDate(iso: string, locale: ConsentLocale, timeZone: string): strin
138
144
  }).format(epochMilliseconds);
139
145
  }
140
146
 
141
- // The mail template only knows plain text variables, so the Markdown terms
142
- // become one row per paragraph; heading markers are dropped.
147
+ // One richText row of inline HTML per Markdown block. The sanitizer shows a rejected
148
+ // row as escaped text, so author HTML, images and refused links stay plain text.
149
+ const ROW_END = "\n\n";
150
+
151
+ function isMailLinkHref(href: string): boolean {
152
+ return isAllowedDocumentLinkHref(href) && !isDocumentPlaceholderHref(href);
153
+ }
154
+
155
+ const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
156
+ inlineHtmlMarkdown.use({
157
+ renderer: {
158
+ heading({ tokens }) {
159
+ const headingHtml = this.parser.parseInline(tokens);
160
+ return `<strong>${headingHtml}</strong>${ROW_END}`;
161
+ },
162
+ paragraph({ tokens }) {
163
+ return `${this.parser.parseInline(tokens)}${ROW_END}`;
164
+ },
165
+ code: ({ text }) => `${escapeHtml(text)}${ROW_END}`,
166
+ blockquote({ tokens }) {
167
+ return this.parser.parse(tokens);
168
+ },
169
+ list({ items }) {
170
+ return items.map((item) => this.parser.parse(item.tokens)).join("");
171
+ },
172
+ hr: () => "",
173
+ space: () => "",
174
+ del({ tokens }) {
175
+ return this.parser.parseInline(tokens);
176
+ },
177
+ br: () => " ",
178
+ html: ({ text }) => escapeHtml(text),
179
+ image: ({ text }) => escapeHtml(text),
180
+ text(token) {
181
+ // Only block-level text (a list item's own text) carries inline child tokens.
182
+ return "tokens" in token && token.tokens
183
+ ? `${this.parser.parseInline(token.tokens)}${ROW_END}`
184
+ : escapeHtml(token.text);
185
+ },
186
+ link({ href, tokens }) {
187
+ const labelHtml = this.parser.parseInline(tokens);
188
+ if (isMailLinkHref(href)) return `<a href="${escapeHtmlAttr(href)}">${labelHtml}</a>`;
189
+ return labelHtml === escapeHtml(href) ? labelHtml : `${labelHtml} (${escapeHtml(href)})`;
190
+ },
191
+ },
192
+ });
193
+
143
194
  function termsParagraphsOf(markdown: string): readonly TextRow[] {
144
- return markdown
195
+ return inlineHtmlMarkdown
196
+ .parse(markdown, { async: false })
145
197
  .split(/\n\s*\n/)
146
- .map((paragraph) => paragraph.replace(/^#{1,6}\s+/gm, "").trim())
147
- .filter((paragraph) => paragraph.length > 0)
198
+ .map((row) => row.replace(/\s+/g, " ").trim())
199
+ .filter((row) => row.length > 0)
148
200
  .map((text) => ({ text }));
149
201
  }
150
202
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { createHash } from "node:crypto";
5
5
  import { type HandlerContext, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
6
- import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
7
7
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
8
8
  import * as z from "zod";
9
9
  import {
@@ -15,6 +15,7 @@ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js"
15
15
  import {
16
16
  CHECKOUT_CONSENT_RECORDED_EVENT_QN,
17
17
  type CheckoutConsentRecordedPayload,
18
+ checkoutConsentRecordedPayloadSchema,
18
19
  PAYMENT_AGGREGATE_TYPE,
19
20
  SUBSCRIPTION_AGGREGATE_TYPE,
20
21
  } from "../events.js";
@@ -50,6 +51,26 @@ export type OrderItem = {
50
51
  readonly params?: Readonly<Record<string, string | number>>;
51
52
  };
52
53
 
54
+ // Bounds come from the event schema the label ends up in, so an app label that
55
+ // passes here cannot fail the append after the provider session exists.
56
+ const orderItemSchema = z.object({
57
+ labelKey: checkoutConsentRecordedPayloadSchema.shape.itemLabelKey.unwrap(),
58
+ params: checkoutConsentRecordedPayloadSchema.shape.itemLabelParams,
59
+ });
60
+
61
+ /** Throws an InternalError (an app configuration fault, not a buyer error) for a
62
+ * label the consent event could not store. */
63
+ export function assertValidOrderItem(item: OrderItem | undefined, priceId: string): void {
64
+ // skip: subscription checkouts have no one-off order item
65
+ if (item === undefined) return;
66
+ const parsed = orderItemSchema.safeParse(item);
67
+ if (!parsed.success) {
68
+ throw new InternalError({
69
+ message: `billing-foundation: consumerProtection.oneOffItemLabel returned an invalid label for priceId "${priceId}": ${parsed.error.message}`,
70
+ });
71
+ }
72
+ }
73
+
53
74
  export type ConsentPriceDetails = {
54
75
  readonly tier: string | null;
55
76
  readonly priceId: string;
@@ -32,7 +32,10 @@ export const OPERATOR_NOTICE_VARIABLES: TemplateVariables = {
32
32
  export const CONTRACT_CONFIRMATION_VARIABLES: TemplateVariables = {
33
33
  contractLines: textRows(),
34
34
  consentGivenAt: templateVariable.text({ required: true }),
35
- termsParagraphs: textRows(),
35
+ termsParagraphs: templateVariable.list(
36
+ { text: templateVariable.richText({ required: true }) },
37
+ { required: true },
38
+ ),
36
39
  operatorEmail: templateVariable.text({ required: true }),
37
40
  };
38
41
 
@@ -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
@@ -79,16 +79,22 @@ function declarationLines(
79
79
  return lines.map((text) => ({ text }));
80
80
  }
81
81
 
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
+
82
89
  export type TerminationReceiptArgs = {
83
90
  readonly locale: ConsentLocale;
84
91
  readonly requestId: string;
85
92
  readonly receivedAtIso: string;
86
- readonly declaration: TerminationDeclaration;
93
+ readonly declaration: ReceiptDeclaration;
87
94
  readonly operatorEmail: string;
88
95
  };
89
96
 
90
- /** Built from the declarant's own input only: the content must not depend on
91
- * whether (or how) the declaration matched a contract. */
97
+ /** The content must not depend on whether (or how) the declaration matched a contract. */
92
98
  export function terminationReceiptVariables(
93
99
  args: TerminationReceiptArgs,
94
100
  ): TerminationReceiptVariables {
@@ -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
 
@@ -230,7 +238,14 @@ export function createRecordContractTerminationHandler(
230
238
  effectiveAtIso,
231
239
  providerCancel,
232
240
  };
233
- await stream.appendOne({
241
+ // Re-fetch after the external call: a webhook appended meanwhile would
242
+ // otherwise fail the append on a stale expectedVersion although the
243
+ // provider already cancelled.
244
+ const streamAfterProviderCall = await ctx.fetchForWriting({
245
+ aggregateId: subscriptionAggregateId(tenantId),
246
+ aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
247
+ });
248
+ await streamAfterProviderCall.appendOne({
234
249
  type: CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
235
250
  payload: recorded,
236
251
  });
@@ -260,6 +275,7 @@ export function createRecordContractTerminationHandler(
260
275
  effectiveAtIso,
261
276
  providerCancel,
262
277
  noticeReason,
278
+ withdrawalPeriodExpired,
263
279
  } satisfies RecordContractTerminationResult,
264
280
  };
265
281
  },
@@ -33,10 +33,10 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
33
33
  import { resolveConsentLocale } from "./consent-text.js";
34
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: terminationReceiptVariables({
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
+ }