@cosmicdrift/kumiko-bundled-features 0.336.1 → 0.338.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 (186) hide show
  1. package/dist/agent-tools/agent-manifest.js +34 -7
  2. package/dist/agent-tools/tool-catalog.js +58 -19
  3. package/dist/audit/handlers/list.query.js +7 -3
  4. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +1 -0
  5. package/dist/auth-email-password/handlers/invite-accept.write.js +1 -0
  6. package/dist/auth-email-password/handlers/invite-info.query.js +1 -0
  7. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +1 -0
  8. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -0
  9. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  10. package/dist/auth-email-password/seeding.js +6 -0
  11. package/dist/auth-email-password/web/invite-accept-screen.js +4 -1
  12. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -0
  13. package/dist/auth-mfa/handlers/enable-start.write.js +13 -2
  14. package/dist/auth-mfa/hmac-token-codec.js +8 -9
  15. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +2 -0
  16. package/dist/billing-foundation/aggregate-id.d.ts +2 -0
  17. package/dist/billing-foundation/aggregate-id.js +8 -0
  18. package/dist/billing-foundation/checkout-core.d.ts +3 -0
  19. package/dist/billing-foundation/checkout-core.js +3 -0
  20. package/dist/billing-foundation/constants.d.ts +31 -0
  21. package/dist/billing-foundation/constants.js +35 -0
  22. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
  23. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
  24. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
  25. package/dist/billing-foundation/consumer-protection/consent-gate.js +88 -0
  26. package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
  27. package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
  28. package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
  29. package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
  30. package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
  31. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
  32. package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
  33. package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
  34. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
  35. package/dist/billing-foundation/consumer-protection/register-termination.js +12 -0
  36. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +43 -0
  37. package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
  38. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
  39. package/dist/billing-foundation/consumer-protection/termination-pages.js +239 -0
  40. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +51 -0
  41. package/dist/billing-foundation/consumer-protection/termination-record.js +162 -0
  42. package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
  43. package/dist/billing-foundation/consumer-protection/termination-request.js +263 -0
  44. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
  45. package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
  46. package/dist/billing-foundation/events.d.ts +86 -2
  47. package/dist/billing-foundation/events.js +76 -2
  48. package/dist/billing-foundation/feature.js +6 -2
  49. package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
  50. package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
  51. package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
  52. package/dist/billing-foundation/handlers/process-event.write.js +43 -15
  53. package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
  54. package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
  55. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +15 -1
  56. package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
  57. package/dist/billing-foundation/i18n.js +58 -0
  58. package/dist/billing-foundation/index.d.ts +3 -2
  59. package/dist/billing-foundation/index.js +2 -1
  60. package/dist/billing-foundation/types.d.ts +49 -9
  61. package/dist/billing-foundation/validate-options.js +47 -10
  62. package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
  63. package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
  64. package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
  65. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
  66. package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
  67. package/dist/billing-foundation/webhook-handler.js +5 -2
  68. package/dist/cap-counter/with-cap-enforcement.js +7 -3
  69. package/dist/cap-overview/access.d.ts +2 -1
  70. package/dist/cap-overview/access.js +26 -5
  71. package/dist/cap-overview/feature.d.ts +4 -0
  72. package/dist/cap-overview/feature.js +8 -6
  73. package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
  74. package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
  75. package/dist/cap-overview/handlers/tenant-caps-list.query.js +13 -13
  76. package/dist/cap-overview/index.d.ts +2 -2
  77. package/dist/cap-overview/index.js +2 -2
  78. package/dist/cap-overview/screens.d.ts +1 -1
  79. package/dist/cap-overview/screens.js +17 -16
  80. package/dist/config/handlers/set.write.js +18 -2
  81. package/dist/config/write-helpers.js +2 -2
  82. package/dist/crypto-shredding/feature.js +3 -2
  83. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
  84. package/dist/crypto-shredding/handlers/forget-subject.write.js +24 -13
  85. package/dist/custom-fields/feature.js +9 -6
  86. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -0
  87. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -0
  88. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -0
  89. package/dist/custom-fields/lib/field-access.d.ts +3 -2
  90. package/dist/custom-fields/lib/field-access.js +1 -0
  91. package/dist/delivery/delivery-service.js +9 -7
  92. package/dist/delivery/unsubscribe.js +1 -0
  93. package/dist/document-ingest-foundation/feature.js +3 -2
  94. package/dist/files-tenant-data/hooks.js +11 -3
  95. package/dist/folders/web/folder-section.js +8 -1
  96. package/dist/form-draft/handlers/discard.write.js +1 -0
  97. package/dist/form-draft/handlers/save.write.js +1 -0
  98. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -0
  99. package/dist/jobs/constants.d.ts +1 -0
  100. package/dist/jobs/constants.js +1 -0
  101. package/dist/jobs/handlers/trigger.write.js +7 -2
  102. package/dist/jobs/i18n.js +3 -0
  103. package/dist/managed-pages/handlers/set.write.js +1 -0
  104. package/dist/metrics/compute.js +16 -5
  105. package/dist/notes-history/executor.d.ts +2 -0
  106. package/dist/notes-history/executor.js +9 -1
  107. package/dist/notes-history/feature.d.ts +2 -3
  108. package/dist/notes-history/feature.js +2 -1
  109. package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
  110. package/dist/notes-history/handlers/add-note.write.js +20 -3
  111. package/dist/notes-history/web/notes-section.js +6 -1
  112. package/dist/notes-history-user-data/hooks.js +26 -8
  113. package/dist/rate-limiting/handlers/bucket-access.d.ts +2 -5
  114. package/dist/secrets/feature.js +2 -0
  115. package/dist/secrets/handlers/delete.write.js +4 -0
  116. package/dist/secrets/handlers/set.write.js +5 -2
  117. package/dist/secrets/i18n.d.ts +10 -0
  118. package/dist/secrets/i18n.js +10 -0
  119. package/dist/secrets/write-gate.d.ts +10 -0
  120. package/dist/secrets/write-gate.js +36 -0
  121. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -0
  122. package/dist/shared/index.d.ts +0 -1
  123. package/dist/shared/index.js +0 -1
  124. package/dist/shared/session-locale-field.js +1 -2
  125. package/dist/shared/session-timezone-field.js +3 -2
  126. package/dist/step-dispatcher/feature.js +4 -3
  127. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  128. package/dist/step-dispatcher/mail-runner.js +5 -0
  129. package/dist/step-dispatcher/webhook-runner.d.ts +7 -0
  130. package/dist/step-dispatcher/webhook-runner.js +9 -0
  131. package/dist/subscription-mollie/plugin-methods.js +1 -0
  132. package/dist/subscription-mollie/verify-webhook.js +5 -3
  133. package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
  134. package/dist/subscription-stripe/plugin-methods.js +85 -13
  135. package/dist/subscription-stripe/verify-webhook.js +7 -3
  136. package/dist/tags/executor.d.ts +2 -0
  137. package/dist/tags/executor.js +9 -1
  138. package/dist/tags/feature.d.ts +4 -3
  139. package/dist/tags/feature.js +4 -2
  140. package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
  141. package/dist/tags/handlers/assign-tag.write.js +4 -4
  142. package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
  143. package/dist/tags/handlers/remove-tag.write.js +3 -3
  144. package/dist/tenant-handover/handlers/claim.write.js +1 -0
  145. package/dist/tenant-handover/move-entity-graph.js +11 -3
  146. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -0
  147. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -0
  148. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +4 -0
  149. package/dist/tenant-lifecycle/lifecycle-gate.js +26 -4
  150. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -0
  151. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  152. package/dist/user/handlers/detail.query.js +1 -0
  153. package/dist/user/handlers/list.query.js +1 -0
  154. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -0
  155. package/dist/user-data-rights/handlers/deletion-grace-period.js +3 -1
  156. package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
  157. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -0
  158. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -0
  159. package/dist/user-data-rights/handlers/export-status.query.js +1 -0
  160. package/dist/user-data-rights/handlers/lift-restriction.write.js +1 -0
  161. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -0
  162. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -0
  163. package/dist/user-data-rights/handlers/request-export.write.js +1 -0
  164. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -0
  165. package/dist/user-data-rights/run-forget-cleanup.js +30 -15
  166. package/dist/workflow-runner/db/queries/due-runs.d.ts +1 -0
  167. package/dist/workflow-runner/db/queries/due-runs.js +4 -1
  168. package/package.json +9 -9
  169. package/src/agent-tools/changes.json +20 -0
  170. package/src/billing-foundation/changes.json +49 -0
  171. package/src/cap-counter/changes.json +7 -0
  172. package/src/cap-overview/changes.json +14 -0
  173. package/src/config/changes.json +7 -0
  174. package/src/crypto-shredding/changes.json +13 -0
  175. package/src/custom-fields/changes.json +7 -0
  176. package/src/delivery/changes.json +7 -0
  177. package/src/notes-history/changes.json +7 -0
  178. package/src/secrets/changes.json +7 -0
  179. package/src/subscription-mollie/changes.json +13 -0
  180. package/src/subscription-stripe/changes.json +19 -0
  181. package/src/tags/changes.json +7 -0
  182. package/src/tenant-lifecycle/changes.json +8 -1
  183. package/src/user-data-rights/changes.json +6 -0
  184. package/src/workflow-runner/changes.json +6 -0
  185. package/dist/shared/session-field.d.ts +0 -3
  186. package/dist/shared/session-field.js +0 -6
@@ -0,0 +1,125 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+ import { CONSENT_TEXTS, resolveConsentLocale } from "./consent-text.js";
3
+ const LABELS = {
4
+ de: {
5
+ subject: "Vertragsbestätigung",
6
+ intro: "vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
7
+ plan: "Tarif",
8
+ oneOffPayment: "Einmalzahlung",
9
+ price: "Preis",
10
+ oneOff: "einmalig",
11
+ contractStart: "Vertragsbeginn",
12
+ currentPeriodEnd: "Aktueller Abrechnungszeitraum bis",
13
+ consentHeading: "Ihre Zustimmungen",
14
+ consentGivenAt: "Erteilt am",
15
+ termsHeading: "Allgemeine Geschäftsbedingungen",
16
+ contact: "Fragen zu Ihrem Vertrag richten Sie bitte an",
17
+ every: "alle",
18
+ intervals: {
19
+ day: { one: "Tag", many: "Tage" },
20
+ week: { one: "Woche", many: "Wochen" },
21
+ month: { one: "Monat", many: "Monate" },
22
+ year: { one: "Jahr", many: "Jahre" },
23
+ },
24
+ },
25
+ en: {
26
+ subject: "Contract confirmation",
27
+ intro: "thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
28
+ plan: "Plan",
29
+ oneOffPayment: "One-off payment",
30
+ price: "Price",
31
+ oneOff: "one-off",
32
+ contractStart: "Contract start",
33
+ currentPeriodEnd: "Current billing period ends",
34
+ consentHeading: "Your consents",
35
+ consentGivenAt: "Given on",
36
+ termsHeading: "Terms and conditions",
37
+ contact: "Questions about your contract go to",
38
+ every: "every",
39
+ intervals: {
40
+ day: { one: "day", many: "days" },
41
+ week: { one: "week", many: "weeks" },
42
+ month: { one: "month", many: "months" },
43
+ year: { one: "year", many: "years" },
44
+ },
45
+ },
46
+ };
47
+ function formatPrice(consent, locale) {
48
+ const labels = LABELS[locale];
49
+ if (consent.unitAmount === null || consent.currency === null)
50
+ return "-";
51
+ let amount;
52
+ try {
53
+ const format = new Intl.NumberFormat(locale, { style: "currency", currency: consent.currency });
54
+ const fractionDigits = format.resolvedOptions().maximumFractionDigits ?? 2;
55
+ amount = format.format(consent.unitAmount / 10 ** fractionDigits);
56
+ }
57
+ catch {
58
+ // Currency codes come from the provider; an unknown one must not block the mail.
59
+ amount = `${consent.unitAmount} ${consent.currency.toUpperCase()}`;
60
+ }
61
+ if (consent.interval === null)
62
+ return `${amount} (${labels.oneOff})`;
63
+ const unit = labels.intervals[consent.interval];
64
+ const count = consent.intervalCount ?? 1;
65
+ const intervalText = unit === undefined
66
+ ? consent.interval
67
+ : count === 1
68
+ ? unit.one
69
+ : `${labels.every} ${count} ${unit.many}`;
70
+ return count === 1 ? `${amount} / ${intervalText}` : `${amount}, ${intervalText}`;
71
+ }
72
+ function formatDate(iso, locale) {
73
+ let epochMilliseconds;
74
+ try {
75
+ epochMilliseconds = Temporal.Instant.from(iso).epochMilliseconds;
76
+ }
77
+ catch {
78
+ return iso;
79
+ }
80
+ return new Intl.DateTimeFormat(locale, {
81
+ dateStyle: "long",
82
+ timeStyle: "short",
83
+ timeZone: "UTC",
84
+ }).format(epochMilliseconds);
85
+ }
86
+ function paragraphs(text) {
87
+ return text
88
+ .split(/\n{2,}/)
89
+ .map((part) => part.trim())
90
+ .filter((part) => part.length > 0)
91
+ .map((part) => ({ text: part }));
92
+ }
93
+ /** Structured mail content (header/sections/footer) for the email channel's
94
+ * renderer; the renderer owns HTML escaping, this only builds plain text. */
95
+ export function renderContractConfirmation(args) {
96
+ const locale = resolveConsentLocale(args.consent.locale);
97
+ const labels = LABELS[locale];
98
+ const texts = CONSENT_TEXTS[locale];
99
+ const { consent } = args;
100
+ const contractLines = [
101
+ `${labels.plan}: ${consent.tier ?? labels.oneOffPayment}`,
102
+ `${labels.price}: ${formatPrice(consent, locale)}`,
103
+ `${labels.contractStart}: ${formatDate(args.contractStartIso, locale)}`,
104
+ ...(args.currentPeriodEndIso !== undefined
105
+ ? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale)}`]
106
+ : []),
107
+ ];
108
+ const vatNote = args.vatNote[locale] ?? args.vatNote[resolveConsentLocale()];
109
+ return {
110
+ subject: labels.subject,
111
+ header: labels.subject,
112
+ sections: [
113
+ { text: labels.intro },
114
+ ...contractLines.map((text) => ({ text })),
115
+ ...(vatNote !== undefined ? [{ text: vatNote }] : []),
116
+ { text: labels.consentHeading },
117
+ { text: texts.earlyPerformance },
118
+ { text: texts.withdrawalLoss },
119
+ { text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
120
+ { text: labels.termsHeading },
121
+ ...paragraphs(args.termsContent),
122
+ ],
123
+ footer: `${labels.contact} ${args.operatorEmail}`,
124
+ };
125
+ }
@@ -0,0 +1,32 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import type { ConsumerProtectionOptions, ProviderPrice } from "../types.js";
4
+ export type CheckoutMode = "subscription" | "payment";
5
+ export declare const consentPayloadSchema: z.ZodObject<{
6
+ earlyPerformanceRequested: z.ZodLiteral<true>;
7
+ withdrawalLossAcknowledged: z.ZodLiteral<true>;
8
+ consentTextVersion: z.ZodString;
9
+ locale: z.ZodString;
10
+ }, z.core.$strict>;
11
+ export type ConsentPayload = z.infer<typeof consentPayloadSchema>;
12
+ export type PreparedConsent = {
13
+ readonly consentId: string;
14
+ readonly mode: CheckoutMode;
15
+ readonly locale: string;
16
+ readonly submitMessage: string;
17
+ readonly consentTextVersion: string;
18
+ readonly termsHash: string;
19
+ readonly termsTemplateVersion: number;
20
+ };
21
+ export type ConsentPriceDetails = {
22
+ readonly tier: string | null;
23
+ readonly priceId: string;
24
+ readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
25
+ };
26
+ export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload, mode: CheckoutMode): Promise<PreparedConsent>;
27
+ export declare function consentCheckoutFields(prepared: PreparedConsent): {
28
+ readonly consentId: string;
29
+ readonly locale: string;
30
+ readonly submitMessage: string;
31
+ };
32
+ export declare function recordConsent(ctx: HandlerContext, prepared: PreparedConsent, details: ConsentPriceDetails): Promise<void>;
@@ -0,0 +1,88 @@
1
+ // Consent gate shared by start-plan-checkout and create-checkout-session:
2
+ // validate + prepare before the provider call, record only after it succeeded.
3
+ import { createHash } from "node:crypto";
4
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
6
+ import * as z from "zod";
7
+ import { requireTemplateResolver, TEXT_BLOCK_KIND, TemplateNotFoundError, } from "../../template-resolver/index.js";
8
+ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
9
+ import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
10
+ import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./consent-text.js";
11
+ export const consentPayloadSchema = z
12
+ .object({
13
+ earlyPerformanceRequested: z.literal(true),
14
+ withdrawalLossAcknowledged: z.literal(true),
15
+ consentTextVersion: z.string().min(1).max(64),
16
+ locale: z.string().min(2).max(35),
17
+ })
18
+ .strict();
19
+ export async function prepareConsent(ctx, options, consent, mode) {
20
+ const locale = resolveConsentLocale(consent.locale);
21
+ if (consent.consentTextVersion !== consentTextVersion(locale)) {
22
+ throw new UnprocessableError("consent_text_outdated", {
23
+ i18nKey: "billing-foundation.errors.consentTextOutdated",
24
+ message: `billing-foundation: consent text version "${consent.consentTextVersion}" is not current for locale "${locale}"`,
25
+ });
26
+ }
27
+ const resolver = requireTemplateResolver(ctx, "billing-foundation");
28
+ let terms;
29
+ try {
30
+ terms = await resolver.resolveTemplate({
31
+ tenantId: ctx.user.tenantId,
32
+ slug: options.termsTextBlock,
33
+ kind: TEXT_BLOCK_KIND,
34
+ locale,
35
+ });
36
+ }
37
+ catch (error) {
38
+ if (error instanceof TemplateNotFoundError) {
39
+ throw new UnprocessableError("terms_unavailable", {
40
+ i18nKey: "billing-foundation.errors.termsUnavailable",
41
+ message: `billing-foundation: terms text block "${options.termsTextBlock}" could not be resolved`,
42
+ cause: error,
43
+ });
44
+ }
45
+ throw error;
46
+ }
47
+ return {
48
+ consentId: generateId(),
49
+ mode,
50
+ locale,
51
+ submitMessage: submitMessageFor(locale, mode),
52
+ consentTextVersion: consent.consentTextVersion,
53
+ termsHash: createHash("sha256").update(terms.content).digest("hex"),
54
+ termsTemplateVersion: terms.version,
55
+ };
56
+ }
57
+ export function consentCheckoutFields(prepared) {
58
+ return {
59
+ consentId: prepared.consentId,
60
+ locale: prepared.locale,
61
+ submitMessage: prepared.submitMessage,
62
+ };
63
+ }
64
+ export async function recordConsent(ctx, prepared, details) {
65
+ const payload = {
66
+ consentId: prepared.consentId,
67
+ mode: prepared.mode,
68
+ tier: details.tier,
69
+ priceId: details.priceId,
70
+ unitAmount: details.price?.unitAmount ?? null,
71
+ currency: details.price?.currency ?? null,
72
+ interval: details.price?.interval ?? null,
73
+ intervalCount: details.price?.intervalCount ?? null,
74
+ consentTextVersion: prepared.consentTextVersion,
75
+ termsHash: prepared.termsHash,
76
+ termsTemplateVersion: prepared.termsTemplateVersion,
77
+ locale: prepared.locale,
78
+ actorUserId: String(ctx.user.id),
79
+ };
80
+ const tenantId = ctx.user.tenantId;
81
+ const isSubscription = prepared.mode === "subscription";
82
+ await ctx.unsafeAppendEvent({
83
+ aggregateId: isSubscription ? subscriptionAggregateId(tenantId) : paymentAggregateId(tenantId),
84
+ aggregateType: isSubscription ? SUBSCRIPTION_AGGREGATE_TYPE : PAYMENT_AGGREGATE_TYPE,
85
+ type: CHECKOUT_CONSENT_RECORDED_EVENT_QN,
86
+ payload,
87
+ });
88
+ }
@@ -0,0 +1,5 @@
1
+ export declare const CONSENT_LOCALES: readonly ["de", "en"];
2
+ export type ConsentLocale = (typeof CONSENT_LOCALES)[number];
3
+ export declare const FALLBACK_CONSENT_LOCALE: ConsentLocale;
4
+ /** Exact match, else the language part ("de-AT" → "de"), else the fallback. */
5
+ export declare function resolveConsentLocale(input?: string): ConsentLocale;
@@ -0,0 +1,17 @@
1
+ // @runtime client
2
+ // Crypto-free so the browser panel can share the locale rule with the server.
3
+ export const CONSENT_LOCALES = ["de", "en"];
4
+ export const FALLBACK_CONSENT_LOCALE = "de";
5
+ function isConsentLocale(value) {
6
+ return CONSENT_LOCALES.some((locale) => locale === value);
7
+ }
8
+ /** Exact match, else the language part ("de-AT" → "de"), else the fallback. */
9
+ export function resolveConsentLocale(input) {
10
+ if (input === undefined)
11
+ return FALLBACK_CONSENT_LOCALE;
12
+ const lower = input.toLowerCase();
13
+ if (isConsentLocale(lower))
14
+ return lower;
15
+ const language = lower.split("-")[0] ?? "";
16
+ return isConsentLocale(language) ? language : FALLBACK_CONSENT_LOCALE;
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { ConsentLocale } from "./consent-locale.js";
2
+ export { CONSENT_LOCALES, type ConsentLocale, FALLBACK_CONSENT_LOCALE, resolveConsentLocale, } from "./consent-locale.js";
3
+ export type ConsentTexts = {
4
+ readonly earlyPerformance: string;
5
+ readonly withdrawalLoss: string;
6
+ readonly subscriptionSubmitMessage: string;
7
+ readonly paymentSubmitMessage: string;
8
+ };
9
+ export declare const CONSENT_TEXTS: Readonly<Record<ConsentLocale, ConsentTexts>>;
10
+ export declare function consentTextVersion(locale: ConsentLocale): string;
11
+ export declare function submitMessageFor(locale: ConsentLocale, mode: "subscription" | "payment"): string;
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+ export { CONSENT_LOCALES, FALLBACK_CONSENT_LOCALE, resolveConsentLocale, } from "./consent-locale.js";
3
+ export const CONSENT_TEXTS = {
4
+ de: {
5
+ earlyPerformance: "Ich verlange ausdrücklich, dass der Anbieter vor Ablauf der Widerrufsfrist mit der Ausführung des Vertrags beginnt.",
6
+ withdrawalLoss: "Ich nehme zur Kenntnis, dass ich mein Widerrufsrecht verliere, sobald der Vertrag von dem Anbieter vollständig erfüllt ist, bei digitalen Inhalten sobald die Ausführung begonnen hat, nachdem ich ausdrücklich zugestimmt habe und bestätigt habe, dass ich dadurch mein Widerrufsrecht verliere.",
7
+ subscriptionSubmitMessage: "Kostenpflichtiges Abonnement: Es verlängert sich zum Ende jedes Abrechnungszeitraums automatisch, bis Sie es kündigen. Sie können jederzeit zum Ende des laufenden Zeitraums kündigen.",
8
+ paymentSubmitMessage: "Kostenpflichtige Einmalzahlung: Mit Klick auf den Button verpflichten Sie sich zur Zahlung.",
9
+ },
10
+ en: {
11
+ earlyPerformance: "I expressly request that the provider begins performing the contract before the withdrawal period has expired.",
12
+ withdrawalLoss: "I acknowledge that I lose my right of withdrawal once the provider has fully performed the contract, and for digital content once performance has begun, after I have expressly consented and confirmed that I lose my right of withdrawal as a result.",
13
+ subscriptionSubmitMessage: "Paid subscription: it renews automatically at the end of each billing period until you cancel. You can cancel at any time, effective at the end of the current period.",
14
+ paymentSubmitMessage: "Paid one-off payment: by clicking the button you commit to paying.",
15
+ },
16
+ };
17
+ const CONSENT_TEXT_VERSION_LENGTH = 16;
18
+ export function consentTextVersion(locale) {
19
+ const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
20
+ return createHash("sha256")
21
+ .update(`${earlyPerformance}\n${withdrawalLoss}`)
22
+ .digest("hex")
23
+ .slice(0, CONSENT_TEXT_VERSION_LENGTH);
24
+ }
25
+ export function submitMessageFor(locale, mode) {
26
+ const texts = CONSENT_TEXTS[locale];
27
+ return mode === "subscription" ? texts.subscriptionSubmitMessage : texts.paymentSubmitMessage;
28
+ }
@@ -0,0 +1,15 @@
1
+ import { type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
4
+ export declare const issueContractConfirmationSchema: z.ZodObject<{
5
+ consentId: z.ZodString;
6
+ sourceAggregateId: z.ZodUUID;
7
+ currentPeriodEndIso: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strict>;
9
+ export type IssueContractConfirmationResult = {
10
+ readonly issued: true;
11
+ } | {
12
+ readonly issued: false;
13
+ readonly reason: "consent_not_found" | "already_issued" | "recipient_not_found";
14
+ };
15
+ export declare function createIssueContractConfirmationHandler(options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): WriteHandlerDef;
@@ -0,0 +1,149 @@
1
+ // § 312f contract confirmation: one mail per recorded consent, sent by the
2
+ // system-only issue-contract-confirmation handler that the event-triggered
3
+ // jobs (register-consent.ts) call.
4
+ import { access, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import * as z from "zod";
7
+ import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.js";
8
+ import { UserQueries } from "../../user/index.js";
9
+ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
10
+ import { CONTRACT_CONFIRMATION_NOTIFICATION_TYPE } from "../constants.js";
11
+ import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, CONTRACT_CONFIRMATION_ISSUED_EVENT_QN, checkoutConsentRecordedPayloadSchema, contractConfirmationIssuedPayloadSchema, PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
12
+ import { renderContractConfirmation } from "./confirmation-mail.js";
13
+ import { resolveConsentLocale } from "./consent-text.js";
14
+ export const issueContractConfirmationSchema = z
15
+ .object({
16
+ consentId: z.string().min(1).max(100),
17
+ sourceAggregateId: z.uuid(),
18
+ // Only set when the triggering subscription event carries it.
19
+ currentPeriodEndIso: z.string().min(1).optional(),
20
+ })
21
+ .strict();
22
+ const recipientRowSchema = z.object({
23
+ email: z.string().min(1),
24
+ isDeleted: z.boolean().optional(),
25
+ });
26
+ export function createIssueContractConfirmationHandler(options, consumerProtection) {
27
+ return {
28
+ name: "issue-contract-confirmation",
29
+ agent: { expose: false },
30
+ schema: issueContractConfirmationSchema,
31
+ access: { roles: access.system },
32
+ escapeHatch: {
33
+ reason: "reads the consenting buyer's email via UserQueries.findForAuth as a tenant system user; the mail goes only to the user recorded in the consent event",
34
+ },
35
+ // kumiko-lint-ignore complexity-budget one linear pass: stream lookup, consent match, idempotency check, recipient lookup, mail, marker append
36
+ handler: async (event, ctx) => {
37
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
38
+ const payload = event.payload;
39
+ const tenantId = event.user.tenantId;
40
+ // The stream is derived from the caller's tenant: no read of another
41
+ // tenant's stream and no arbitrary aggregate id.
42
+ const isSubscription = payload.sourceAggregateId === subscriptionAggregateId(tenantId);
43
+ if (!isSubscription && payload.sourceAggregateId !== paymentAggregateId(tenantId)) {
44
+ throw new UnprocessableError("invalid_source_aggregate", {
45
+ message: `billing-foundation: ${payload.sourceAggregateId} is not the subscription or payment stream of tenant ${tenantId}`,
46
+ });
47
+ }
48
+ const stream = await ctx.fetchForWriting({
49
+ aggregateId: payload.sourceAggregateId,
50
+ aggregateType: isSubscription ? SUBSCRIPTION_AGGREGATE_TYPE : PAYMENT_AGGREGATE_TYPE,
51
+ });
52
+ let consentEvent;
53
+ let consent;
54
+ for (const e of stream.events) {
55
+ if (e.type !== CHECKOUT_CONSENT_RECORDED_EVENT_QN)
56
+ continue;
57
+ const parsed = checkoutConsentRecordedPayloadSchema.safeParse(e.payload);
58
+ if (parsed.success && parsed.data.consentId === payload.consentId) {
59
+ consentEvent = e;
60
+ consent = parsed.data;
61
+ break;
62
+ }
63
+ }
64
+ if (!consentEvent || !consent) {
65
+ ctx.log?.warn(`[billing-foundation:issue-contract-confirmation] no recorded consent ${payload.consentId} on the stream; no mail sent`);
66
+ return {
67
+ isSuccess: true,
68
+ data: {
69
+ issued: false,
70
+ reason: "consent_not_found",
71
+ },
72
+ };
73
+ }
74
+ const alreadyIssued = stream.events.some((e) => e.type === CONTRACT_CONFIRMATION_ISSUED_EVENT_QN &&
75
+ contractConfirmationIssuedPayloadSchema.safeParse(e.payload).data?.consentId ===
76
+ payload.consentId);
77
+ if (alreadyIssued) {
78
+ return {
79
+ isSuccess: true,
80
+ data: {
81
+ issued: false,
82
+ reason: "already_issued",
83
+ },
84
+ };
85
+ }
86
+ // Everything that can fail runs before the append, so a failed run
87
+ // leaves no marker behind and the job retry starts clean.
88
+ const recipient = recipientRowSchema.safeParse(await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, {
89
+ id: consent.actorUserId,
90
+ }));
91
+ if (!recipient.success || recipient.data.isDeleted === true) {
92
+ ctx.log?.warn(`[billing-foundation:issue-contract-confirmation] recipient of consent ${payload.consentId} not found; no mail sent`);
93
+ return {
94
+ isSuccess: true,
95
+ data: {
96
+ issued: false,
97
+ reason: "recipient_not_found",
98
+ },
99
+ };
100
+ }
101
+ const locale = resolveConsentLocale(consent.locale);
102
+ const terms = await requireTemplateResolver(ctx, "billing-foundation").resolveTemplate({
103
+ tenantId,
104
+ slug: consumerProtection.termsTextBlock,
105
+ kind: TEXT_BLOCK_KIND,
106
+ locale,
107
+ });
108
+ if (terms.version !== consent.termsTemplateVersion) {
109
+ ctx.log?.warn(`[billing-foundation:issue-contract-confirmation] terms text block changed since consent ${payload.consentId} (version ${consent.termsTemplateVersion} -> ${terms.version}); the mail carries the current text`);
110
+ }
111
+ const issuedAtIso = options.now().toString();
112
+ const content = renderContractConfirmation({
113
+ consent,
114
+ consentGivenAtIso: consentEvent.createdAt.toString(),
115
+ contractStartIso: issuedAtIso,
116
+ ...(payload.currentPeriodEndIso !== undefined && {
117
+ currentPeriodEndIso: payload.currentPeriodEndIso,
118
+ }),
119
+ vatNote: consumerProtection.vatNote,
120
+ operatorEmail: consumerProtection.operatorEmail,
121
+ termsContent: terms.content,
122
+ });
123
+ if (!ctx.notify) {
124
+ throw new InternalError({
125
+ message: "billing-foundation:issue-contract-confirmation: ctx.notify unavailable — the delivery feature must be mounted",
126
+ });
127
+ }
128
+ // Append BEFORE notifying: appendOne carries the fetched stream version
129
+ // as expectedVersion, so of two concurrent runs only one insert wins
130
+ // (the other fails with a version conflict) and only the winner mails.
131
+ const issued = {
132
+ consentId: payload.consentId,
133
+ issuedAtIso,
134
+ locale,
135
+ termsTemplateVersion: terms.version,
136
+ };
137
+ await stream.appendOne({ type: CONTRACT_CONFIRMATION_ISSUED_EVENT_QN, payload: issued });
138
+ await ctx.notify(CONTRACT_CONFIRMATION_NOTIFICATION_TYPE, {
139
+ route: { email: recipient.data.email },
140
+ data: content,
141
+ priority: "critical",
142
+ });
143
+ return {
144
+ isSuccess: true,
145
+ data: { issued: true },
146
+ };
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,3 @@
1
+ import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
3
+ export declare function registerConsumerProtection(r: FeatureRegistrar, options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): void;
@@ -0,0 +1,63 @@
1
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
3
+ import { SubscriptionFoundationHandlers, SubscriptionStatuses } from "../constants.js";
4
+ import { CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, CONTRACT_CONFIRMATION_ISSUED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, contractConfirmationIssuedPayloadSchema, INVOICE_PAID_EVENT_QN, PAYMENT_RECEIVED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_QN, } from "../events.js";
5
+ import { createIssueContractConfirmationHandler } from "./issue-confirmation.js";
6
+ import { registerContractTermination } from "./register-termination.js";
7
+ const CONFIRMATION_TRIGGERS = [
8
+ {
9
+ eventQn: SUBSCRIPTION_CREATED_EVENT_QN,
10
+ jobName: "confirm-on-subscription-created",
11
+ stream: "subscription",
12
+ },
13
+ {
14
+ eventQn: SUBSCRIPTION_UPDATED_EVENT_QN,
15
+ jobName: "confirm-on-subscription-updated",
16
+ stream: "subscription",
17
+ },
18
+ { eventQn: INVOICE_PAID_EVENT_QN, jobName: "confirm-on-invoice-paid", stream: "subscription" },
19
+ { eventQn: PAYMENT_RECEIVED_EVENT_QN, jobName: "confirm-on-payment-received", stream: "payment" },
20
+ ];
21
+ function isConfirmableSubscriptionStatus(status) {
22
+ return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing;
23
+ }
24
+ export function registerConsumerProtection(r, options, consumerProtection) {
25
+ r.requires("template-resolver", "delivery", "user", "tenant");
26
+ registerContractTermination(r, options, consumerProtection);
27
+ r.defineEvent(CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, {
28
+ piiFields: "none",
29
+ });
30
+ r.defineEvent(CONTRACT_CONFIRMATION_ISSUED_EVENT_SHORT, contractConfirmationIssuedPayloadSchema, {
31
+ piiFields: "none",
32
+ });
33
+ r.writeHandler(createIssueContractConfirmationHandler(options, consumerProtection));
34
+ // The consent is recorded before the buyer pays; the mail goes out once a
35
+ // later event for the same consentId shows the contract is live. The jobs
36
+ // are at-least-once, the handler's append makes the mail once-only.
37
+ for (const trigger of CONFIRMATION_TRIGGERS) {
38
+ r.job(trigger.jobName, { trigger: { on: trigger.eventQn }, runIn: "worker" }, async (payload, ctx) => {
39
+ const consentId = payload["consentId"];
40
+ // skip: trigger event carries no consent to confirm
41
+ if (typeof consentId !== "string" || consentId.length === 0)
42
+ return;
43
+ const isSubscriptionEvent = trigger.stream === "subscription";
44
+ // skip: subscription not live yet, a later event confirms
45
+ if (isSubscriptionEvent && !isConfirmableSubscriptionStatus(payload["status"]))
46
+ return;
47
+ // The tenant comes from the stored trigger event (the job's system
48
+ // user), never from payload data.
49
+ const tenantId = ctx.systemUser.tenantId;
50
+ const currentPeriodEndIso = payload["currentPeriodEndIso"];
51
+ const result = await ctx.writeAs(createSystemUser(tenantId), SubscriptionFoundationHandlers.issueContractConfirmation, {
52
+ consentId,
53
+ sourceAggregateId: isSubscriptionEvent
54
+ ? subscriptionAggregateId(tenantId)
55
+ : paymentAggregateId(tenantId),
56
+ ...(typeof currentPeriodEndIso === "string" && { currentPeriodEndIso }),
57
+ });
58
+ if (!result.isSuccess) {
59
+ throw new Error(`billing-foundation:${trigger.jobName}: issue-contract-confirmation failed: ${JSON.stringify(result.error)}`);
60
+ }
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,3 @@
1
+ import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
3
+ export declare function registerContractTermination(r: FeatureRegistrar, options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): void;
@@ -0,0 +1,12 @@
1
+ import { CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT, CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT, contractTerminationRequestedPayloadSchema, contractTerminationUnmatchedPayloadSchema, } from "../events.js";
2
+ import { createRecordContractTerminationHandler, createRecordUnmatchedContractTerminationHandler, createTerminableSubscriptionQuery, } from "./termination-record.js";
3
+ import { createRequestContractTerminationHandler, createTerminateContractHandler, } from "./termination-request.js";
4
+ export function registerContractTermination(r, options, consumerProtection) {
5
+ r.defineEvent(CONTRACT_TERMINATION_REQUESTED_EVENT_SHORT, contractTerminationRequestedPayloadSchema, { piiFields: "none" });
6
+ r.defineEvent(CONTRACT_TERMINATION_UNMATCHED_EVENT_SHORT, contractTerminationUnmatchedPayloadSchema, { piiFields: "none" });
7
+ r.queryHandler(createTerminableSubscriptionQuery());
8
+ r.writeHandler(createRecordContractTerminationHandler(options));
9
+ r.writeHandler(createRecordUnmatchedContractTerminationHandler());
10
+ r.writeHandler(createRequestContractTerminationHandler(options, consumerProtection));
11
+ r.writeHandler(createTerminateContractHandler(options, consumerProtection));
12
+ }
@@ -0,0 +1,43 @@
1
+ import type { ContractTerminationDeclarationType, ContractTerminationKind, ProviderCancelOutcome } from "../events.js";
2
+ import type { ConsentLocale } from "./consent-text.js";
3
+ import { type OperatorNoticeReason } from "./termination-texts.js";
4
+ export type TerminationMailContent = {
5
+ readonly subject: string;
6
+ readonly header: string;
7
+ readonly sections: readonly {
8
+ readonly text: string;
9
+ }[];
10
+ readonly footer: string;
11
+ };
12
+ /** What the declarant entered; the only input of the receipt. */
13
+ export type TerminationDeclaration = {
14
+ readonly declarationType: ContractTerminationDeclarationType;
15
+ readonly terminationKind: ContractTerminationKind;
16
+ readonly name?: string;
17
+ readonly email: string;
18
+ readonly customerReference?: string;
19
+ readonly reason?: string;
20
+ };
21
+ export declare function formatReceivedAt(iso: string, locale: ConsentLocale): string;
22
+ export type RenderTerminationReceiptArgs = {
23
+ readonly locale: ConsentLocale;
24
+ readonly requestId: string;
25
+ readonly receivedAtIso: string;
26
+ readonly declaration: TerminationDeclaration;
27
+ readonly operatorEmail: string;
28
+ };
29
+ /** Built from the declarant's own input only: the content must not depend on
30
+ * whether (or how) the declaration matched a contract. */
31
+ export declare function renderTerminationReceipt(args: RenderTerminationReceiptArgs): TerminationMailContent;
32
+ export type RenderOperatorNoticeArgs = {
33
+ readonly locale: ConsentLocale;
34
+ readonly requestId: string;
35
+ readonly receivedAtIso: string;
36
+ readonly channel: "public" | "account";
37
+ readonly declaration: TerminationDeclaration;
38
+ readonly reasons: readonly OperatorNoticeReason[];
39
+ readonly tenantId?: string;
40
+ readonly providerCancel?: ProviderCancelOutcome;
41
+ readonly operatorEmail: string;
42
+ };
43
+ export declare function renderOperatorNotice(args: RenderOperatorNoticeArgs): TerminationMailContent;