@cosmicdrift/kumiko-samples 0.338.0 → 0.340.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 (147) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +21 -1
  3. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  4. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -6
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +2 -6
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +3 -5
  7. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -8
  8. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +35 -3
  11. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +60 -0
  12. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +10 -0
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +8 -0
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +15 -2
  15. package/packages/bundled-features/src/auth-mfa/feature.ts +1 -1
  16. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -7
  17. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +1 -5
  18. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -7
  19. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +1 -5
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +1 -7
  21. package/packages/bundled-features/src/billing-foundation/changes.json +41 -0
  22. package/packages/bundled-features/src/billing-foundation/constants.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +3 -3
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +2 -2
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +48 -2
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +10 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +44 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +32 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +129 -33
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +13 -59
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +20 -21
  33. package/packages/bundled-features/src/billing-foundation/events.ts +18 -0
  34. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +4 -5
  35. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +6 -5
  36. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  37. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  38. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +4 -4
  39. package/packages/bundled-features/src/billing-foundation/types.ts +6 -0
  40. package/packages/bundled-features/src/billing-foundation/validate-options.ts +6 -0
  41. package/packages/bundled-features/src/channel-discord/discord-channel.ts +32 -0
  42. package/packages/bundled-features/src/channel-discord/feature.ts +40 -0
  43. package/packages/bundled-features/src/channel-discord/index.ts +2 -0
  44. package/packages/bundled-features/src/channel-slack/feature.ts +38 -0
  45. package/packages/bundled-features/src/channel-slack/index.ts +2 -0
  46. package/packages/bundled-features/src/channel-slack/slack-channel.ts +29 -0
  47. package/packages/bundled-features/src/channel-teams/feature.ts +42 -0
  48. package/packages/bundled-features/src/channel-teams/index.ts +2 -0
  49. package/packages/bundled-features/src/channel-teams/teams-channel.ts +50 -0
  50. package/packages/bundled-features/src/channel-telegram/feature.ts +35 -0
  51. package/packages/bundled-features/src/channel-telegram/index.ts +2 -0
  52. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +74 -0
  53. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +6 -18
  54. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  55. package/packages/bundled-features/src/custom-fields/handlers/define-tenant-field.write.ts +1 -4
  56. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +1 -1
  58. package/packages/bundled-features/src/data-retention/feature.ts +2 -3
  59. package/packages/bundled-features/src/delivery/changes.json +7 -0
  60. package/packages/bundled-features/src/delivery/channel-context.ts +3 -1
  61. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +76 -0
  62. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +135 -0
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +3 -0
  64. package/packages/bundled-features/src/delivery/index.ts +15 -0
  65. package/packages/bundled-features/src/delivery/jobs.ts +1 -1
  66. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  67. package/packages/bundled-features/src/delivery/types.ts +8 -3
  68. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +1 -1
  69. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  70. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  71. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  72. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +1 -5
  73. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +14 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +29 -13
  75. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +1 -1
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +7 -0
  78. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +201 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  80. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +1 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +17 -1
  82. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +74 -36
  83. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +1 -1
  84. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +7 -10
  85. package/packages/bundled-features/src/renderer-simple/changes.json +9 -1
  86. package/packages/bundled-features/src/renderer-simple/feature.ts +14 -4
  87. package/packages/bundled-features/src/renderer-simple/index.ts +2 -2
  88. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +94 -31
  89. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  90. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  91. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +1 -1
  92. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -4
  93. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -3
  94. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +3 -5
  95. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  96. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  97. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -1
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -1
  99. package/packages/bundled-features/src/user/changes.json +13 -6
  100. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  101. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -6
  102. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
  103. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -3
  104. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +1 -1
  105. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  106. package/packages/bundled-features/src/user-data-rights/handlers/export-status.query.ts +1 -1
  107. package/packages/bundled-features/src/user-data-rights/handlers/lift-restriction.write.ts +2 -6
  108. package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +1 -1
  109. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  110. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  111. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +1 -1
  112. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +2 -8
  113. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +1 -1
  114. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +1 -1
  115. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  116. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +1 -1
  117. package/packages/bundled-features/src/workflow-runner/changes.json +14 -0
  118. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  119. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +7 -2
  120. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +5 -0
  121. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  122. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +231 -77
  123. package/packages/bundled-features/src/workflow-runner/pending-projection.ts +1 -0
  124. package/packages/bundled-features/src/workflow-runner/runner.ts +12 -2
  125. package/packages/bundled-features/src/workflow-runner/tables.ts +8 -1
  126. package/samples/apps/styleguide/src/features/widgets/i18n.ts +5 -0
  127. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +11 -0
  128. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +16 -0
  129. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +152 -0
  130. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +82 -0
  131. package/samples/apps/use-all-bundled/feature-manifest.json +146 -1
  132. package/samples/apps/use-all-bundled/package.json +1 -0
  133. package/samples/apps/use-all-bundled/playwright.billing-gallery.config.ts +14 -0
  134. package/samples/apps/use-all-bundled/schema/generate.ts +20 -0
  135. package/samples/apps/use-all-bundled/scripts/gen-feature-manifest.ts +1 -0
  136. package/samples/apps/use-all-bundled/src/run-config.ts +10 -0
  137. package/samples/apps/use-all-bundled/tsconfig.json +1 -0
  138. package/samples/recipes/chat-channels/README.md +19 -0
  139. package/samples/recipes/chat-channels/package.json +9 -0
  140. package/samples/recipes/chat-channels/src/feature.ts +40 -0
  141. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  142. package/samples/recipes/workflow-engine/src/event-trigger.ts +5 -0
  143. package/samples/recipes/workflow-engine/src/postgres-resume-loop.ts +50 -9
  144. package/samples/recipes/workflow-engine/src/workflow-runner.ts +12 -2
  145. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  146. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  147. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -1,12 +1,17 @@
1
- import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { createSystemUser, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { SubscriptionCancelTimings, SubscriptionFoundationHandlers } from "../constants.js";
2
3
  import {
4
+ CONTRACT_TERMINATION_DECLARED_EVENT_QN,
5
+ CONTRACT_TERMINATION_DECLARED_EVENT_SHORT,
3
6
  CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT,
4
7
  CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT,
8
+ contractTerminationDeclaredPayloadSchema,
5
9
  contractTerminationRequestedPayloadSchema,
6
10
  contractTerminationUnmatchedPayloadSchema,
7
11
  } from "../events.js";
8
12
  import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
9
13
  import {
14
+ createDeclareContractTerminationHandler,
10
15
  createRecordContractTerminationHandler,
11
16
  createRecordUnmatchedContractTerminationHandler,
12
17
  createTerminableSubscriptionQuery,
@@ -26,14 +31,55 @@ export function registerContractTermination(
26
31
  contractTerminationRequestedPayloadSchema,
27
32
  { piiFields: "none" },
28
33
  );
34
+ r.defineEvent(
35
+ CONTRACT_TERMINATION_DECLARED_EVENT_SHORT,
36
+ contractTerminationDeclaredPayloadSchema,
37
+ { piiFields: "none" },
38
+ );
29
39
  r.defineEvent(
30
40
  CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT,
31
41
  contractTerminationUnmatchedPayloadSchema,
32
42
  { piiFields: "none" },
33
43
  );
34
44
  r.queryHandler(createTerminableSubscriptionQuery());
35
- r.writeHandler(createRecordContractTerminationHandler(options));
45
+ r.writeHandler(createDeclareContractTerminationHandler());
46
+ r.writeHandler(createRecordContractTerminationHandler(options, consumerProtection));
36
47
  r.writeHandler(createRecordUnmatchedContractTerminationHandler());
37
48
  r.writeHandler(createRequestContractTerminationHandler(options, consumerProtection));
38
49
  r.writeHandler(createTerminateContractHandler(options, consumerProtection));
50
+
51
+ // The provider cancel of a public declaration runs here, not in the
52
+ // request: matched and unmatched requests then do the same work and the
53
+ // response time does not tell whether the email belongs to a customer.
54
+ // The tenant comes from the stored trigger event (the job's system user).
55
+ r.job(
56
+ "cancel-on-public-termination-declared",
57
+ { trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN }, runIn: "worker" },
58
+ async (payload, ctx) => {
59
+ const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
60
+ // skip: trigger event carries no usable declaration
61
+ if (!declared.success) return;
62
+ const result = await ctx.writeAs(
63
+ createSystemUser(ctx.systemUser.tenantId),
64
+ SubscriptionFoundationHandlers.recordContractTermination,
65
+ {
66
+ requestId: declared.data.requestId,
67
+ declarationType: declared.data.declarationType,
68
+ terminationKind: declared.data.terminationKind,
69
+ channel: "public",
70
+ when:
71
+ declared.data.declarationType === "withdrawal"
72
+ ? "none"
73
+ : SubscriptionCancelTimings.periodEnd,
74
+ receivedAtIso: declared.data.receivedAtIso,
75
+ locale: declared.data.locale,
76
+ },
77
+ );
78
+ if (!result.isSuccess) {
79
+ throw new Error(
80
+ `billing-foundation:cancel-on-public-termination-declared: record-contract-termination failed: ${JSON.stringify(result.error)}`,
81
+ );
82
+ }
83
+ },
84
+ );
39
85
  }
@@ -24,6 +24,13 @@ export type TerminationDeclaration = {
24
24
  readonly reason?: string;
25
25
  };
26
26
 
27
+ /** The operator notice for a provider problem has no declarant data at all. */
28
+ export type OperatorNoticeDeclaration = Pick<
29
+ TerminationDeclaration,
30
+ "declarationType" | "terminationKind"
31
+ > &
32
+ Partial<Omit<TerminationDeclaration, "declarationType" | "terminationKind">>;
33
+
27
34
  const TIME_ZONE_BY_LOCALE: Readonly<Record<ConsentLocale, string>> = {
28
35
  de: "Europe/Berlin",
29
36
  en: "UTC",
@@ -44,7 +51,7 @@ export function formatReceivedAt(iso: string, locale: ConsentLocale): string {
44
51
  }
45
52
 
46
53
  function declarationLines(
47
- declaration: TerminationDeclaration,
54
+ declaration: OperatorNoticeDeclaration,
48
55
  locale: ConsentLocale,
49
56
  ): readonly { readonly text: string }[] {
50
57
  const texts = TERMINATION_TEXTS[locale];
@@ -52,7 +59,7 @@ function declarationLines(
52
59
  `${texts.declarationType}: ${texts.declarationTypeLabel[declaration.declarationType]}`,
53
60
  `${texts.terminationKind}: ${texts.terminationKindLabel[declaration.terminationKind]}`,
54
61
  ...(declaration.name !== undefined ? [`${texts.name}: ${declaration.name}`] : []),
55
- `${texts.email}: ${declaration.email}`,
62
+ ...(declaration.email !== undefined ? [`${texts.email}: ${declaration.email}`] : []),
56
63
  ...(declaration.customerReference
57
64
  ? [`${texts.customerReference}: ${declaration.customerReference}`]
58
65
  : []),
@@ -96,7 +103,7 @@ export type RenderOperatorNoticeArgs = {
96
103
  readonly requestId: string;
97
104
  readonly receivedAtIso: string;
98
105
  readonly channel: "public" | "account";
99
- readonly declaration: TerminationDeclaration;
106
+ readonly declaration: OperatorNoticeDeclaration;
100
107
  readonly reasons: readonly OperatorNoticeReason[];
101
108
  readonly tenantId?: string;
102
109
  readonly providerCancel?: ProviderCancelOutcome;
@@ -0,0 +1,44 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE } from "../constants.js";
4
+ import type { ProviderCancelOutcome } from "../events.js";
5
+ import type { ConsumerProtectionOptions } from "../types.js";
6
+ import type { ConsentLocale } from "./consent-text.js";
7
+ import { type OperatorNoticeDeclaration, renderOperatorNotice } from "./termination-mail.js";
8
+ import type { OperatorNoticeReason } from "./termination-texts.js";
9
+
10
+ export type OperatorNoticePlan = {
11
+ readonly locale: ConsentLocale;
12
+ readonly requestId: string;
13
+ readonly receivedAtIso: string;
14
+ readonly channel: "public" | "account";
15
+ readonly declaration: OperatorNoticeDeclaration;
16
+ readonly reasons: readonly OperatorNoticeReason[];
17
+ readonly tenantId?: string;
18
+ readonly providerCancel?: ProviderCancelOutcome;
19
+ };
20
+
21
+ export function requireNotify(ctx: HandlerContext): NonNullable<HandlerContext["notify"]> {
22
+ if (!ctx.notify) {
23
+ throw new InternalError({
24
+ message:
25
+ "billing-foundation:termination: ctx.notify unavailable — the delivery feature must be mounted",
26
+ });
27
+ }
28
+ return ctx.notify;
29
+ }
30
+
31
+ export async function notifyOperator(
32
+ ctx: HandlerContext,
33
+ consumerProtection: ConsumerProtectionOptions,
34
+ plan: OperatorNoticePlan,
35
+ ): Promise<void> {
36
+ await requireNotify(ctx)(CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE, {
37
+ route: { email: consumerProtection.operatorEmail },
38
+ data: renderOperatorNotice({
39
+ ...plan,
40
+ operatorEmail: consumerProtection.operatorEmail,
41
+ }),
42
+ priority: "critical",
43
+ });
44
+ }
@@ -4,6 +4,7 @@
4
4
  import type { AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
5
5
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
6
6
  import type { Context } from "hono";
7
+ import { getCookie } from "hono/cookie";
7
8
  import { securePageHeaders, wrapInLayout } from "../../page-render/index.js";
8
9
  import { SubscriptionFoundationHandlers } from "../constants.js";
9
10
  import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS } from "../events.js";
@@ -247,6 +248,33 @@ type WriteResponseBody = {
247
248
 
248
249
  const RATE_LIMITED_CODE = "rate_limited";
249
250
 
251
+ const TENANT_HEADER = "x-tenant";
252
+ const TENANT_COOKIE = "kumiko_tenant";
253
+ // Exactly what the anonymous tenant resolution reads. The resolver and its
254
+ // trust rules still decide; the re-entry only sees what a direct /api/write
255
+ // from this client would see. Never the session cookie or Authorization (the
256
+ // handler is anonymous) and never X-Forwarded-For (the resolved clientIp
257
+ // travels as env).
258
+ const FORWARDED_TENANT_HEADERS = [
259
+ "host",
260
+ "x-forwarded-host",
261
+ "x-forwarded-proto",
262
+ TENANT_HEADER,
263
+ ] as const;
264
+
265
+ function reentryHeaders(c: Context): Headers {
266
+ const headers = new Headers({ "content-type": "application/json" });
267
+ for (const name of FORWARDED_TENANT_HEADERS) {
268
+ const value = c.req.header(name);
269
+ if (value !== undefined) headers.set(name, value);
270
+ }
271
+ const tenantCookie = getCookie(c, TENANT_COOKIE);
272
+ if (tenantCookie !== undefined) {
273
+ headers.set("cookie", `${TENANT_COOKIE}=${encodeURIComponent(tenantCookie)}`);
274
+ }
275
+ return headers;
276
+ }
277
+
250
278
  async function submitDeclaration(
251
279
  c: Context,
252
280
  app: { fetch: (request: Request, env?: unknown) => Response | Promise<Response> },
@@ -261,11 +289,13 @@ async function submitDeclaration(
261
289
  // deps.write only resolves a session user under /api/*; this page lives
262
290
  // outside it, so it re-enters the dispatcher through the app like the
263
291
  // user-export by-token route. The resolved clientIp as env keeps the
264
- // handler's per-IP rate limit per visitor instead of one shared bucket.
292
+ // handler's per-IP rate limit per visitor instead of one shared bucket;
293
+ // the forwarded host headers let a host-based tenant resolver see this
294
+ // visitor's host.
265
295
  const response = await app.fetch(
266
296
  new Request(`${new URL(c.req.url).origin}/api/write`, {
267
297
  method: "POST",
268
- headers: { "content-type": "application/json" },
298
+ headers: reentryHeaders(c),
269
299
  body: JSON.stringify({
270
300
  type: SubscriptionFoundationHandlers.requestContractTermination,
271
301
  payload: payloadOf(values, locale),
@@ -5,6 +5,7 @@
5
5
 
6
6
  import {
7
7
  access,
8
+ type HandlerContext,
8
9
  type QueryHandlerDef,
9
10
  SYSTEM_ROLE,
10
11
  type WriteHandlerDef,
@@ -17,17 +18,22 @@ import { isTerminalSubscriptionStatus, SubscriptionCancelTimings } from "../cons
17
18
  import {
18
19
  CONTRACT_TERMINATION_CHANNELS,
19
20
  CONTRACT_TERMINATION_DECLARATION_TYPES,
21
+ CONTRACT_TERMINATION_DECLARED_EVENT_QN,
20
22
  CONTRACT_TERMINATION_KINDS,
21
23
  CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
22
24
  CONTRACT_TERMINATION_UNMATCHED_AGGREGATE_TYPE,
23
25
  CONTRACT_TERMINATION_UNMATCHED_EVENT_QN,
26
+ type ContractTerminationDeclaredPayload,
24
27
  type ContractTerminationRequestedPayload,
25
28
  type ContractTerminationUnmatchedPayload,
29
+ contractTerminationRequestedPayloadSchema,
26
30
  type ProviderCancelOutcome,
27
31
  SUBSCRIPTION_AGGREGATE_TYPE,
28
32
  } from "../events.js";
29
33
  import { getSubscriptionForTenant } from "../get-subscription-for-tenant.js";
30
- import type { ResolvedBillingFoundationOptions } from "../types.js";
34
+ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
35
+ import { CONSENT_LOCALES, FALLBACK_CONSENT_LOCALE } from "./consent-locale.js";
36
+ import { notifyOperator } from "./termination-notify.js";
31
37
  import type { OperatorNoticeReason } from "./termination-texts.js";
32
38
 
33
39
  const REQUESTED_CANCEL_TIMINGS = [
@@ -43,6 +49,20 @@ export const recordContractTerminationSchema = z
43
49
  terminationKind: z.enum(CONTRACT_TERMINATION_KINDS),
44
50
  channel: z.enum(CONTRACT_TERMINATION_CHANNELS),
45
51
  when: z.enum(REQUESTED_CANCEL_TIMINGS),
52
+ // The public job passes the time the declaration arrived and the declarant's
53
+ // locale, so the record and a provider-problem notice do not take the job's.
54
+ receivedAtIso: z.string().min(1).optional(),
55
+ locale: z.enum(CONSENT_LOCALES).optional(),
56
+ })
57
+ .strict();
58
+
59
+ export const declareContractTerminationSchema = z
60
+ .object({
61
+ requestId: z.string().min(1).max(100),
62
+ declarationType: z.enum(CONTRACT_TERMINATION_DECLARATION_TYPES),
63
+ terminationKind: z.enum(CONTRACT_TERMINATION_KINDS),
64
+ receivedAtIso: z.string().min(1),
65
+ locale: z.enum(CONSENT_LOCALES),
46
66
  })
47
67
  .strict();
48
68
 
@@ -85,8 +105,71 @@ export function createTerminableSubscriptionQuery(): QueryHandlerDef {
85
105
  };
86
106
  }
87
107
 
108
+ type ProviderCancelResult = Pick<
109
+ RecordContractTerminationResult,
110
+ "providerCancel" | "effectiveAtIso" | "noticeReason"
111
+ >;
112
+
113
+ async function cancelAtProvider(
114
+ ctx: HandlerContext,
115
+ tenantId: string,
116
+ when: (typeof REQUESTED_CANCEL_TIMINGS)[number],
117
+ receivedAtIso: string,
118
+ ): Promise<ProviderCancelResult> {
119
+ // skip: a withdrawal over the public channel makes no provider call
120
+ if (when === "none") return { providerCancel: "none", effectiveAtIso: null, noticeReason: null };
121
+ const subscription = await getSubscriptionForTenant(ctx, tenantId);
122
+ if (subscription === null || isTerminalSubscriptionStatus(subscription.status)) {
123
+ return { providerCancel: "none", effectiveAtIso: null, noticeReason: "no_active_subscription" };
124
+ }
125
+ const plugin = findPlugin(ctx, subscription.providerName);
126
+ if (!plugin?.cancelSubscription) {
127
+ return { providerCancel: "none", effectiveAtIso: null, noticeReason: "provider_cannot_cancel" };
128
+ }
129
+ try {
130
+ await plugin.cancelSubscription(ctx, {
131
+ providerSubscriptionId: subscription.providerSubscriptionId,
132
+ when,
133
+ });
134
+ } catch (error) {
135
+ ctx.log?.warn(
136
+ `[billing-foundation:record-contract-termination] provider cancel failed for tenant ${tenantId}: ${error instanceof Error ? error.message : String(error)}`,
137
+ );
138
+ return { providerCancel: "none", effectiveAtIso: null, noticeReason: "provider_error" };
139
+ }
140
+ return {
141
+ providerCancel: when,
142
+ effectiveAtIso:
143
+ when === SubscriptionCancelTimings.immediately
144
+ ? receivedAtIso
145
+ : subscription.currentPeriodEnd.toString(),
146
+ noticeReason: null,
147
+ };
148
+ }
149
+
150
+ export function createDeclareContractTerminationHandler(): WriteHandlerDef {
151
+ return {
152
+ name: "declare-contract-termination",
153
+ agent: { expose: false },
154
+ schema: declareContractTerminationSchema,
155
+ access: { roles: access.system },
156
+ handler: async (event, ctx) => {
157
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
158
+ const payload = event.payload as z.infer<typeof declareContractTerminationSchema>;
159
+ const stream = await ctx.fetchForWriting({
160
+ aggregateId: subscriptionAggregateId(event.user.tenantId),
161
+ aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
162
+ });
163
+ const declared: ContractTerminationDeclaredPayload = payload;
164
+ await stream.appendOne({ type: CONTRACT_TERMINATION_DECLARED_EVENT_QN, payload: declared });
165
+ return { isSuccess: true as const, data: { declared: true } };
166
+ },
167
+ };
168
+ }
169
+
88
170
  export function createRecordContractTerminationHandler(
89
171
  options: ResolvedBillingFoundationOptions,
172
+ consumerProtection: ConsumerProtectionOptions,
90
173
  ): WriteHandlerDef {
91
174
  return {
92
175
  name: "record-contract-termination",
@@ -104,45 +187,40 @@ export function createRecordContractTerminationHandler(
104
187
  });
105
188
  }
106
189
 
107
- const receivedAtIso = options.now().toString();
190
+ const receivedAtIso = payload.receivedAtIso ?? options.now().toString();
108
191
  const stream = await ctx.fetchForWriting({
109
192
  aggregateId: subscriptionAggregateId(tenantId),
110
193
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
111
194
  });
112
195
 
113
- let providerCancel: ProviderCancelOutcome = "none";
114
- let effectiveAtIso: string | null = null;
115
- let noticeReason: RecordContractTerminationResult["noticeReason"] = null;
116
-
117
- if (payload.when !== "none") {
118
- const subscription = await getSubscriptionForTenant(ctx, tenantId);
119
- if (subscription === null || isTerminalSubscriptionStatus(subscription.status)) {
120
- noticeReason = "no_active_subscription";
121
- } else {
122
- const plugin = findPlugin(ctx, subscription.providerName);
123
- if (!plugin?.cancelSubscription) {
124
- noticeReason = "provider_cannot_cancel";
125
- } else {
126
- try {
127
- await plugin.cancelSubscription(ctx, {
128
- providerSubscriptionId: subscription.providerSubscriptionId,
129
- when: payload.when,
130
- });
131
- providerCancel = payload.when;
132
- effectiveAtIso =
133
- payload.when === SubscriptionCancelTimings.immediately
134
- ? receivedAtIso
135
- : subscription.currentPeriodEnd.toString();
136
- } catch (error) {
137
- ctx.log?.warn(
138
- `[billing-foundation:record-contract-termination] provider cancel failed for tenant ${tenantId}: ${error instanceof Error ? error.message : String(error)}`,
139
- );
140
- noticeReason = "provider_error";
141
- }
142
- }
143
- }
196
+ // The public job runs at least once: a second run must not call the
197
+ // provider or append again.
198
+ const alreadyRecorded = stream.events
199
+ .map((e) =>
200
+ e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN
201
+ ? contractTerminationRequestedPayloadSchema.safeParse(e.payload).data
202
+ : undefined,
203
+ )
204
+ .find((recordedPayload) => recordedPayload?.requestId === payload.requestId);
205
+ if (alreadyRecorded) {
206
+ return {
207
+ isSuccess: true as const,
208
+ data: {
209
+ receivedAtIso: alreadyRecorded.receivedAtIso,
210
+ effectiveAtIso: alreadyRecorded.effectiveAtIso,
211
+ providerCancel: alreadyRecorded.providerCancel,
212
+ noticeReason: null,
213
+ } satisfies RecordContractTerminationResult,
214
+ };
144
215
  }
145
216
 
217
+ const { providerCancel, effectiveAtIso, noticeReason } = await cancelAtProvider(
218
+ ctx,
219
+ tenantId,
220
+ payload.when,
221
+ receivedAtIso,
222
+ );
223
+
146
224
  const recorded: ContractTerminationRequestedPayload = {
147
225
  requestId: payload.requestId,
148
226
  declarationType: payload.declarationType,
@@ -157,6 +235,24 @@ export function createRecordContractTerminationHandler(
157
235
  payload: recorded,
158
236
  });
159
237
 
238
+ // The account path reports the provider outcome to its caller and the
239
+ // operator in its own mail; the public path has nobody else to tell.
240
+ if (payload.channel === "public" && noticeReason !== null) {
241
+ await notifyOperator(ctx, consumerProtection, {
242
+ locale: payload.locale ?? FALLBACK_CONSENT_LOCALE,
243
+ requestId: payload.requestId,
244
+ receivedAtIso,
245
+ channel: "public",
246
+ declaration: {
247
+ declarationType: payload.declarationType,
248
+ terminationKind: payload.terminationKind,
249
+ },
250
+ reasons: [noticeReason],
251
+ tenantId,
252
+ providerCancel,
253
+ });
254
+ }
255
+
160
256
  return {
161
257
  isSuccess: true as const,
162
258
  data: {
@@ -16,7 +16,6 @@ import * as z from "zod";
16
16
  import { TenantQueries } from "../../tenant/index.js";
17
17
  import { UserQueries } from "../../user/index.js";
18
18
  import {
19
- CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE,
20
19
  CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE,
21
20
  SubscriptionCancelTimings,
22
21
  SubscriptionFoundationHandlers,
@@ -31,12 +30,9 @@ import {
31
30
  } from "../events.js";
32
31
  import { purchaseRolesOf } from "../plan-catalog.js";
33
32
  import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
34
- import { type ConsentLocale, resolveConsentLocale } from "./consent-text.js";
35
- import {
36
- renderOperatorNotice,
37
- renderTerminationReceipt,
38
- type TerminationDeclaration,
39
- } from "./termination-mail.js";
33
+ import { resolveConsentLocale } from "./consent-text.js";
34
+ import { renderTerminationReceipt, type TerminationDeclaration } from "./termination-mail.js";
35
+ import { notifyOperator, type OperatorNoticePlan, requireNotify } from "./termination-notify.js";
40
36
  import type { RecordContractTerminationResult } from "./termination-record.js";
41
37
  import type { OperatorNoticeReason } from "./termination-texts.js";
42
38
 
@@ -106,29 +102,14 @@ function needsOperatorNotice(declaration: {
106
102
  ];
107
103
  }
108
104
 
109
- type TerminationMailPlan = {
110
- readonly locale: ConsentLocale;
111
- readonly requestId: string;
112
- readonly receivedAtIso: string;
113
- readonly channel: "public" | "account";
114
- readonly declaration: TerminationDeclaration;
115
- readonly reasons: readonly OperatorNoticeReason[];
116
- readonly tenantId?: string;
117
- readonly providerCancel?: ProviderCancelOutcome;
118
- };
105
+ type TerminationMailPlan = OperatorNoticePlan & { readonly declaration: TerminationDeclaration };
119
106
 
120
107
  async function sendTerminationMails(
121
108
  ctx: HandlerContext,
122
109
  consumerProtection: ConsumerProtectionOptions,
123
110
  plan: TerminationMailPlan,
124
111
  ): Promise<void> {
125
- if (!ctx.notify) {
126
- throw new InternalError({
127
- message:
128
- "billing-foundation:termination: ctx.notify unavailable — the delivery feature must be mounted",
129
- });
130
- }
131
- await ctx.notify(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
112
+ await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
132
113
  route: { email: plan.declaration.email },
133
114
  data: renderTerminationReceipt({
134
115
  locale: plan.locale,
@@ -141,21 +122,7 @@ async function sendTerminationMails(
141
122
  });
142
123
  // skip: no operator notice needed for this declaration
143
124
  if (plan.reasons.length === 0) return;
144
- await ctx.notify(CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE, {
145
- route: { email: consumerProtection.operatorEmail },
146
- data: renderOperatorNotice({
147
- locale: plan.locale,
148
- requestId: plan.requestId,
149
- receivedAtIso: plan.receivedAtIso,
150
- channel: plan.channel,
151
- declaration: plan.declaration,
152
- reasons: plan.reasons,
153
- operatorEmail: consumerProtection.operatorEmail,
154
- ...(plan.tenantId !== undefined && { tenantId: plan.tenantId }),
155
- ...(plan.providerCancel !== undefined && { providerCancel: plan.providerCancel }),
156
- }),
157
- priority: "critical",
158
- });
125
+ await notifyOperator(ctx, consumerProtection, plan);
159
126
  }
160
127
 
161
128
  // Tenants the entered email can terminate a contract for: tenants where the
@@ -212,6 +179,7 @@ export function createRequestContractTerminationHandler(
212
179
  schema: requestContractTerminationSchema,
213
180
  access: { roles: ["anonymous"] },
214
181
  rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
182
+ ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
215
183
  escapeHatch: {
216
184
  reason:
217
185
  "Anonymous declarant has no session — finds the contract by email via ctx.queryAs(system, user:find-for-auth) and tenant:memberships; the tenant written to comes only from those lookups.",
@@ -223,32 +191,19 @@ export function createRequestContractTerminationHandler(
223
191
  const receivedAtIso = options.now().toString();
224
192
  const { declarationType, terminationKind } = payload;
225
193
  const reasons: OperatorNoticeReason[] = [];
194
+ const locale = resolveConsentLocale(payload.locale);
226
195
  let matchedTenantId: string | undefined;
227
- let providerCancel: ProviderCancelOutcome | undefined;
228
196
 
229
197
  const candidates = await findTerminableTenantIds(ctx, event.user.tenantId, payload.email);
230
198
  const [onlyCandidate] = candidates;
231
199
  if (candidates.length === 1 && onlyCandidate !== undefined) {
232
200
  matchedTenantId = onlyCandidate;
233
- const recorded = await ctx.writeAs(
201
+ const declared = await ctx.writeAs(
234
202
  createSystemUser(onlyCandidate),
235
- SubscriptionFoundationHandlers.recordContractTermination,
236
- {
237
- requestId,
238
- declarationType,
239
- terminationKind,
240
- channel: "public",
241
- when: declarationType === "withdrawal" ? "none" : SubscriptionCancelTimings.periodEnd,
242
- },
203
+ SubscriptionFoundationHandlers.declareContractTermination,
204
+ { requestId, declarationType, terminationKind, receivedAtIso, locale },
243
205
  );
244
- if (recorded.isSuccess) {
245
- // @cast-boundary engine-payload — our own record handler's return shape
246
- const data = recorded.data as RecordContractTerminationResult;
247
- providerCancel = data.providerCancel;
248
- if (data.noticeReason !== null) reasons.push(data.noticeReason);
249
- } else {
250
- reasons.push("recording_failed");
251
- }
206
+ if (!declared.isSuccess) reasons.push("recording_failed");
252
207
  } else {
253
208
  reasons.push(candidates.length === 0 ? "unmatched" : "ambiguous");
254
209
  const recorded = await ctx.writeAs(
@@ -267,7 +222,7 @@ export function createRequestContractTerminationHandler(
267
222
  reasons.push(...needsOperatorNotice(payload));
268
223
 
269
224
  await sendTerminationMails(ctx, consumerProtection, {
270
- locale: resolveConsentLocale(payload.locale),
225
+ locale,
271
226
  requestId,
272
227
  receivedAtIso,
273
228
  channel: "public",
@@ -283,7 +238,6 @@ export function createRequestContractTerminationHandler(
283
238
  },
284
239
  reasons,
285
240
  ...(matchedTenantId !== undefined && { tenantId: matchedTenantId }),
286
- ...(providerCancel !== undefined && { providerCancel }),
287
241
  });
288
242
 
289
243
  return {
@@ -90,21 +90,21 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
90
90
  customerReference: "Kundennummer / Referenz",
91
91
  reasonFieldLabel: "Begründung",
92
92
  receiptSubject: {
93
- termination: "Eingangsbestätigung Ihrer Kündigung",
94
- withdrawal: "Eingangsbestätigung Ihres Widerrufs",
93
+ termination: "Eingangsbestätigung deiner Kündigung",
94
+ withdrawal: "Eingangsbestätigung deines Widerrufs",
95
95
  },
96
96
  receiptIntro:
97
- "wir bestätigen den Eingang Ihrer Erklärung mit folgendem Inhalt. Diese E-Mail ist eine Eingangsbestätigung, keine Bestätigung der Wirksamkeit.",
97
+ "wir bestätigen den Eingang deiner Erklärung mit folgendem Inhalt. Diese E-Mail ist eine Eingangsbestätigung, keine Bestätigung der Wirksamkeit.",
98
98
  receiptNextSteps:
99
- "Wir prüfen Ihre Erklärung und melden uns, sobald sie bearbeitet ist oder wir Rückfragen haben.",
99
+ "Wir prüfen deine Erklärung und melden uns, sobald sie bearbeitet ist oder wir Rückfragen haben.",
100
100
  receiptRevoke: (operatorEmail) =>
101
- `Haben Sie diese Erklärung nicht abgegeben oder möchten Sie sie zurücknehmen? Schreiben Sie uns bitte umgehend an ${operatorEmail}.`,
102
- contact: "Fragen richten Sie bitte an",
101
+ `Hast du diese Erklärung nicht abgegeben oder möchtest du sie zurücknehmen? Schreib uns bitte umgehend an ${operatorEmail}.`,
102
+ contact: "Fragen richtest du bitte an",
103
103
  operatorSubject: {
104
104
  termination: "Kündigung: Bearbeitung nötig",
105
105
  withdrawal: "Widerruf: Bearbeitung nötig",
106
106
  },
107
- operatorIntro: "Es ist eine Erklärung eingegangen, die Ihre Bearbeitung braucht.",
107
+ operatorIntro: "Es ist eine Erklärung eingegangen, die bearbeitet werden muss.",
108
108
  operatorReasons: {
109
109
  unmatched: "Kein passender Vertrag zur angegebenen E-Mail gefunden",
110
110
  ambiguous: "Die angegebene E-Mail passt zu mehreren Verträgen",
@@ -127,8 +127,8 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
127
127
  },
128
128
  page: {
129
129
  title: "Vertrag kündigen oder widerrufen",
130
- lead: "Mit diesem Formular erklären Sie die Kündigung oder den Widerruf Ihres Vertrags. Sie brauchen dafür kein Kundenkonto.",
131
- declarationTypeHeading: "Was möchten Sie erklären?",
130
+ lead: "Mit diesem Formular erklärst du die Kündigung oder den Widerruf deines Vertrags. Du brauchst dafür kein Kundenkonto.",
131
+ declarationTypeHeading: "Was möchtest du erklären?",
132
132
  kindHeading: "Art der Kündigung",
133
133
  reasonHint: "Pflicht bei außerordentlicher Kündigung.",
134
134
  earliestDate: "Die Kündigung gilt zum nächstmöglichen Zeitpunkt.",
@@ -137,25 +137,24 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
137
137
  customerReferenceLabel: "Kundennummer (optional)",
138
138
  reasonLabel: "Begründung",
139
139
  reviewButton: "Weiter zur Prüfung",
140
- reviewTitle: "Bitte prüfen Sie Ihre Angaben",
141
- reviewLead: "Erst mit dem Klick auf den Button unten wird Ihre Erklärung abgegeben.",
140
+ reviewTitle: "Bitte prüfe deine Angaben",
141
+ reviewLead: "Erst mit dem Klick auf den Button unten wird deine Erklärung abgegeben.",
142
142
  confirmButton: { termination: "Jetzt kündigen", withdrawal: "Jetzt widerrufen" },
143
143
  backButton: "Angaben ändern",
144
- resultTitle: "Ihre Erklärung ist eingegangen",
145
- resultLead: "Wir haben Ihre Erklärung erhalten und senden Ihnen eine Eingangsbestätigung.",
144
+ resultTitle: "Deine Erklärung ist eingegangen",
145
+ resultLead: "Wir haben deine Erklärung erhalten und senden dir eine Eingangsbestätigung.",
146
146
  resultReceivedAt: "Eingegangen am",
147
147
  resultRequestId: "Vorgangsnummer",
148
- resultMailNote: "Die Eingangsbestätigung geht an die von Ihnen angegebene E-Mail-Adresse.",
149
- errorsHeading: "Bitte korrigieren Sie Ihre Angaben",
150
- errorName: "Bitte geben Sie Ihren Namen an.",
151
- errorEmail: "Bitte geben Sie eine gültige E-Mail-Adresse an.",
148
+ resultMailNote: "Die Eingangsbestätigung geht an die von dir angegebene E-Mail-Adresse.",
149
+ errorsHeading: "Bitte korrigiere deine Angaben",
150
+ errorName: "Bitte gib deinen Namen an.",
151
+ errorEmail: "Bitte gib eine gültige E-Mail-Adresse an.",
152
152
  errorReason: "Bei einer außerordentlichen Kündigung ist eine Begründung nötig.",
153
- errorChoice: "Bitte wählen Sie eine Erklärung und die Art der Kündigung.",
154
- errorGeneric:
155
- "Ihre Erklärung konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut.",
153
+ errorChoice: "Bitte wähle eine Erklärung und die Art der Kündigung.",
154
+ errorGeneric: "Deine Erklärung konnte nicht verarbeitet werden. Bitte versuch es erneut.",
156
155
  rateLimitedTitle: "Zu viele Anfragen",
157
156
  rateLimitedBody:
158
- "Von Ihrer Adresse kamen zu viele Anfragen. Bitte versuchen Sie es später erneut.",
157
+ "Von deiner Adresse kamen zu viele Anfragen. Bitte versuch es später erneut.",
159
158
  },
160
159
  },
161
160
  en: {