@cosmicdrift/kumiko-samples 0.342.0 → 0.344.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 (84) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/constants.ts +5 -1
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
  7. package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
  8. package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
  12. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
  13. package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
  14. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +35 -7
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +36 -22
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +12 -2
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts +1 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +34 -1
  19. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +14 -5
  21. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +61 -36
  22. package/packages/bundled-features/src/billing-foundation/index.ts +11 -0
  23. package/packages/bundled-features/src/billing-foundation/types.ts +25 -7
  24. package/packages/bundled-features/src/billing-foundation/validate-options.ts +36 -3
  25. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -1
  26. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +1 -2
  27. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +16 -6
  28. package/packages/bundled-features/src/billing-foundation/web/index.ts +12 -0
  29. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  30. package/packages/bundled-features/src/channel-email/feature.ts +1 -0
  31. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-push/feature.ts +1 -0
  33. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  34. package/packages/bundled-features/src/channel-teams/changes.json +8 -0
  35. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  36. package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
  37. package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
  38. package/packages/bundled-features/src/delivery/changes.json +32 -0
  39. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
  40. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
  41. package/packages/bundled-features/src/delivery/constants.ts +4 -0
  42. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
  43. package/packages/bundled-features/src/delivery/events.ts +15 -1
  44. package/packages/bundled-features/src/delivery/feature.ts +32 -4
  45. package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
  46. package/packages/bundled-features/src/delivery/i18n.ts +55 -0
  47. package/packages/bundled-features/src/delivery/index.ts +2 -0
  48. package/packages/bundled-features/src/delivery/jobs.ts +43 -20
  49. package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
  50. package/packages/bundled-features/src/delivery/public-names.ts +4 -0
  51. package/packages/bundled-features/src/delivery/tables.ts +3 -0
  52. package/packages/bundled-features/src/delivery/testing.ts +5 -9
  53. package/packages/bundled-features/src/delivery/types.ts +6 -2
  54. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
  55. package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
  56. package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
  57. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
  58. package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
  59. package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
  60. package/packages/bundled-features/src/jobs/changes.json +6 -0
  61. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  62. package/packages/bundled-features/src/legal-pages/constants.ts +2 -0
  63. package/packages/bundled-features/src/legal-pages/feature.ts +8 -3
  64. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  65. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +10 -0
  66. package/packages/bundled-features/src/secrets/changes.json +6 -0
  67. package/packages/bundled-features/src/secrets/constants.ts +10 -0
  68. package/packages/bundled-features/src/secrets/feature.ts +4 -0
  69. package/packages/bundled-features/src/secrets/i18n.ts +5 -3
  70. package/packages/bundled-features/src/secrets/index.ts +8 -1
  71. package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
  72. package/packages/bundled-features/src/shared/index.ts +6 -0
  73. package/packages/bundled-features/src/shared/redact.ts +27 -0
  74. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
  75. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
  76. package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
  77. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  78. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
  79. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
  80. package/packages/bundled-features/src/user/index.ts +1 -0
  81. package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
  82. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +10 -11
  83. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  84. /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
@@ -8,6 +8,7 @@ type ConfirmationLabels = {
8
8
  readonly subject: string;
9
9
  readonly intro: string;
10
10
  readonly plan: string;
11
+ readonly item: string;
11
12
  readonly oneOffPayment: string;
12
13
  readonly price: string;
13
14
  readonly oneOff: string;
@@ -25,8 +26,9 @@ const LABELS: Readonly<Record<ConsentLocale, ConfirmationLabels>> = {
25
26
  de: {
26
27
  subject: "Vertragsbestätigung",
27
28
  intro:
28
- "vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
29
+ "Vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
29
30
  plan: "Tarif",
31
+ item: "Leistung",
30
32
  oneOffPayment: "Einmalzahlung",
31
33
  price: "Preis",
32
34
  oneOff: "einmalig",
@@ -47,8 +49,9 @@ const LABELS: Readonly<Record<ConsentLocale, ConfirmationLabels>> = {
47
49
  en: {
48
50
  subject: "Contract confirmation",
49
51
  intro:
50
- "thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
52
+ "Thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
51
53
  plan: "Plan",
54
+ item: "Item",
52
55
  oneOffPayment: "One-off payment",
53
56
  price: "Price",
54
57
  oneOff: "one-off",
@@ -68,15 +71,25 @@ const LABELS: Readonly<Record<ConsentLocale, ConfirmationLabels>> = {
68
71
  },
69
72
  };
70
73
 
74
+ export type ContractConfirmationSection =
75
+ | { readonly text: string }
76
+ | { readonly heading: string }
77
+ | { readonly markdown: string };
78
+
71
79
  export type ContractConfirmationContent = {
72
80
  readonly subject: string;
73
81
  readonly header: string;
74
- readonly sections: readonly { readonly text: string }[];
82
+ readonly sections: readonly ContractConfirmationSection[];
75
83
  readonly footer: string;
76
84
  };
77
85
 
78
86
  export type RenderContractConfirmationArgs = {
79
87
  readonly consent: CheckoutConsentRecordedPayload;
88
+ readonly timeZone: string;
89
+ /** Translated plan name; falls back to the raw tier. */
90
+ readonly tierLabel?: string;
91
+ /** Translated purchased item for one-off payments; falls back to the generic label. */
92
+ readonly itemLabel?: string;
80
93
  readonly consentGivenAtIso: string;
81
94
  readonly contractStartIso: string;
82
95
  readonly currentPeriodEndIso?: string;
@@ -109,7 +122,7 @@ function formatPrice(consent: CheckoutConsentRecordedPayload, locale: ConsentLoc
109
122
  return count === 1 ? `${amount} / ${intervalText}` : `${amount}, ${intervalText}`;
110
123
  }
111
124
 
112
- function formatDate(iso: string, locale: ConsentLocale): string {
125
+ function formatDate(iso: string, locale: ConsentLocale, timeZone: string): string {
113
126
  let epochMilliseconds: number;
114
127
  try {
115
128
  epochMilliseconds = Temporal.Instant.from(iso).epochMilliseconds;
@@ -117,20 +130,16 @@ function formatDate(iso: string, locale: ConsentLocale): string {
117
130
  return iso;
118
131
  }
119
132
  return new Intl.DateTimeFormat(locale, {
120
- dateStyle: "long",
121
- timeStyle: "short",
122
- timeZone: "UTC",
133
+ year: "numeric",
134
+ month: "long",
135
+ day: "numeric",
136
+ hour: "2-digit",
137
+ minute: "2-digit",
138
+ timeZone,
139
+ timeZoneName: "short",
123
140
  }).format(epochMilliseconds);
124
141
  }
125
142
 
126
- function paragraphs(text: string): readonly { readonly text: string }[] {
127
- return text
128
- .split(/\n{2,}/)
129
- .map((part) => part.trim())
130
- .filter((part) => part.length > 0)
131
- .map((part) => ({ text: part }));
132
- }
133
-
134
143
  /** Structured mail content (header/sections/footer) for the email channel's
135
144
  * renderer; the renderer owns HTML escaping, this only builds plain text. */
136
145
  export function renderContractConfirmation(
@@ -141,12 +150,17 @@ export function renderContractConfirmation(
141
150
  const texts = CONSENT_TEXTS[locale];
142
151
  const { consent } = args;
143
152
 
153
+ const { timeZone } = args;
154
+ const subjectLine =
155
+ consent.mode === "payment"
156
+ ? `${labels.item}: ${args.itemLabel ?? labels.oneOffPayment}`
157
+ : `${labels.plan}: ${args.tierLabel ?? consent.tier ?? labels.oneOffPayment}`;
144
158
  const contractLines = [
145
- `${labels.plan}: ${consent.tier ?? labels.oneOffPayment}`,
159
+ subjectLine,
146
160
  `${labels.price}: ${formatPrice(consent, locale)}`,
147
- `${labels.contractStart}: ${formatDate(args.contractStartIso, locale)}`,
161
+ `${labels.contractStart}: ${formatDate(args.contractStartIso, locale, timeZone)}`,
148
162
  ...(args.currentPeriodEndIso !== undefined
149
- ? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale)}`]
163
+ ? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale, timeZone)}`]
150
164
  : []),
151
165
  ];
152
166
  const vatNote = args.vatNote[locale] ?? args.vatNote[resolveConsentLocale()];
@@ -158,12 +172,12 @@ export function renderContractConfirmation(
158
172
  { text: labels.intro },
159
173
  ...contractLines.map((text) => ({ text })),
160
174
  ...(vatNote !== undefined ? [{ text: vatNote }] : []),
161
- { text: labels.consentHeading },
175
+ { heading: labels.consentHeading },
162
176
  { text: texts.earlyPerformance },
163
177
  { text: texts.withdrawalLoss },
164
- { text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
165
- { text: labels.termsHeading },
166
- ...paragraphs(args.termsContent),
178
+ { text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale, timeZone)}` },
179
+ { heading: labels.termsHeading },
180
+ { markdown: args.termsContent },
167
181
  ],
168
182
  footer: `${labels.contact} ${args.operatorEmail}`,
169
183
  };
@@ -2,7 +2,7 @@
2
2
  // validate + prepare before the provider call, record only after it succeeded.
3
3
 
4
4
  import { createHash } from "node:crypto";
5
- import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { type HandlerContext, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
7
7
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
8
8
  import * as z from "zod";
@@ -45,6 +45,11 @@ export type PreparedConsent = {
45
45
  readonly termsTemplateVersion: number;
46
46
  };
47
47
 
48
+ export type OrderItem = {
49
+ readonly labelKey: string;
50
+ readonly params?: Readonly<Record<string, string | number>>;
51
+ };
52
+
48
53
  export type ConsentPriceDetails = {
49
54
  readonly tier: string | null;
50
55
  readonly priceId: string;
@@ -52,6 +57,7 @@ export type ConsentPriceDetails = {
52
57
  ProviderPrice,
53
58
  "unitAmount" | "currency" | "interval" | "intervalCount"
54
59
  > | null;
60
+ readonly orderItem?: OrderItem;
55
61
  };
56
62
 
57
63
  export async function prepareConsent(
@@ -78,7 +84,7 @@ export async function prepareConsent(
78
84
  let terms: Awaited<ReturnType<typeof resolver.resolveTemplate>>;
79
85
  try {
80
86
  terms = await resolver.resolveTemplate({
81
- tenantId: ctx.user.tenantId,
87
+ tenantId: SYSTEM_TENANT_ID,
82
88
  slug: options.termsTextBlock,
83
89
  kind: TEXT_BLOCK_KIND,
84
90
  locale,
@@ -136,6 +142,10 @@ export async function recordConsent(
136
142
  termsTemplateVersion: prepared.termsTemplateVersion,
137
143
  locale: prepared.locale,
138
144
  actorUserId: String(ctx.user.id),
145
+ ...(details.orderItem && {
146
+ itemLabelKey: details.orderItem.labelKey,
147
+ ...(details.orderItem.params && { itemLabelParams: details.orderItem.params }),
148
+ }),
139
149
  };
140
150
  const tenantId = ctx.user.tenantId;
141
151
  const isSubscription = prepared.mode === "subscription";
@@ -2,7 +2,7 @@
2
2
  // Crypto-free so the browser panel can share the locale rule with the server.
3
3
  export const CONSENT_LOCALES = ["de", "en"] as const;
4
4
  export type ConsentLocale = (typeof CONSENT_LOCALES)[number];
5
- export const FALLBACK_CONSENT_LOCALE: ConsentLocale = "de";
5
+ export const FALLBACK_CONSENT_LOCALE: ConsentLocale = "en";
6
6
 
7
7
  function isConsentLocale(value: string): value is ConsentLocale {
8
8
  return CONSENT_LOCALES.some((locale) => locale === value);
@@ -5,9 +5,12 @@
5
5
  import {
6
6
  access,
7
7
  createSystemUser,
8
+ SYSTEM_TENANT_ID,
8
9
  type WriteHandlerDef,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
10
11
  import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
13
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
11
14
  import * as z from "zod";
12
15
  import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.js";
13
16
  import { UserQueries } from "../../user/index.js";
@@ -46,8 +49,19 @@ export type IssueContractConfirmationResult =
46
49
  const recipientRowSchema = z.object({
47
50
  email: z.string().min(1),
48
51
  isDeleted: z.boolean().optional(),
52
+ timezone: z.string().nullish(),
49
53
  });
50
54
 
55
+ // createI18n returns the key itself for an unknown key.
56
+ function translatedOrUndefined(translation: string, key: string): string | undefined {
57
+ return translation === key ? undefined : translation;
58
+ }
59
+
60
+ function pickTimeZone(userZone: string | null | undefined, tenantZone: string): string {
61
+ if (userZone && isValidIanaTimeZone(userZone)) return userZone;
62
+ return isValidIanaTimeZone(tenantZone) ? tenantZone : "UTC";
63
+ }
64
+
51
65
  export function createIssueContractConfirmationHandler(
52
66
  options: ResolvedBillingFoundationOptions,
53
67
  consumerProtection: ConsumerProtectionOptions,
@@ -143,7 +157,7 @@ export function createIssueContractConfirmationHandler(
143
157
 
144
158
  const locale = resolveConsentLocale(consent.locale);
145
159
  const terms = await requireTemplateResolver(ctx, "billing-foundation").resolveTemplate({
146
- tenantId,
160
+ tenantId: SYSTEM_TENANT_ID,
147
161
  slug: consumerProtection.termsTextBlock,
148
162
  kind: TEXT_BLOCK_KIND,
149
163
  locale,
@@ -154,9 +168,28 @@ export function createIssueContractConfirmationHandler(
154
168
  );
155
169
  }
156
170
 
171
+ const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
172
+ const tierLabel =
173
+ consent.tier !== null && options.catalog
174
+ ? translatedOrUndefined(
175
+ i18n.t(options.catalog.tierLabelKey(consent.tier), locale),
176
+ options.catalog.tierLabelKey(consent.tier),
177
+ )
178
+ : undefined;
179
+ const itemLabel =
180
+ consent.itemLabelKey !== undefined
181
+ ? translatedOrUndefined(
182
+ i18n.t(consent.itemLabelKey, locale, consent.itemLabelParams),
183
+ consent.itemLabelKey,
184
+ )
185
+ : undefined;
186
+
157
187
  const issuedAtIso = options.now().toString();
158
188
  const content = renderContractConfirmation({
159
189
  consent,
190
+ timeZone: pickTimeZone(recipient.data.timezone, ctx.tz.tenant),
191
+ ...(tierLabel !== undefined && { tierLabel }),
192
+ ...(itemLabel !== undefined && { itemLabel }),
160
193
  consentGivenAtIso: consentEvent.createdAt.toString(),
161
194
  contractStartIso: issuedAtIso,
162
195
  ...(payload.currentPeriodEndIso !== undefined && {
@@ -138,6 +138,8 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
138
138
  termsTemplateVersion: z.number(),
139
139
  locale: z.string().min(2).max(35),
140
140
  actorUserId: z.string().min(1),
141
+ itemLabelKey: z.string().min(1).max(200).optional(),
142
+ itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
141
143
  });
142
144
  export type CheckoutConsentRecordedPayload = z.infer<typeof checkoutConsentRecordedPayloadSchema>;
143
145
 
@@ -11,10 +11,13 @@ import {
11
11
  consentTextVersion,
12
12
  } from "../consumer-protection/consent-text.js";
13
13
  import { buildBillingPlans } from "../plan-catalog.js";
14
- import type {
15
- BillingPlanCatalog,
16
- BillingPlansResult,
17
- ResolvedBillingFoundationOptions,
14
+ import {
15
+ type BillingPlanCatalog,
16
+ type BillingPlansResult,
17
+ type ConsumerProtectionOptions,
18
+ isLegalLinkSet,
19
+ type LegalLinkSet,
20
+ type ResolvedBillingFoundationOptions,
18
21
  } from "../types.js";
19
22
 
20
23
  const billingPlansSchema = z.object({}).strict();
@@ -41,13 +44,19 @@ export function createBillingPlansQuery(
41
44
  de: consentTextView("de"),
42
45
  en: consentTextView("en"),
43
46
  },
44
- legalLinks: consumerProtection.legalLinks,
47
+ legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
45
48
  },
46
49
  };
47
50
  },
48
51
  };
49
52
  }
50
53
 
54
+ function legalLinksPerLocale(
55
+ legalLinks: ConsumerProtectionOptions["legalLinks"],
56
+ ): Readonly<Record<(typeof CONSENT_LOCALES)[number], LegalLinkSet>> {
57
+ return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
58
+ }
59
+
51
60
  function consentTextView(locale: (typeof CONSENT_LOCALES)[number]) {
52
61
  const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
53
62
  return { earlyPerformance, withdrawalLoss, consentTextVersion: consentTextVersion(locale) };
@@ -23,8 +23,14 @@
23
23
  // hand-picked priceId.
24
24
 
25
25
  import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
26
27
  import * as z from "zod";
27
- import { findProviderPlugin, openCheckout } from "../checkout-core.js";
28
+ import {
29
+ assertCheckoutAllowed,
30
+ type OpenCheckoutInput,
31
+ openCheckout,
32
+ startProviderCheckout,
33
+ } from "../checkout-core.js";
28
34
  import {
29
35
  type ConsentPayload,
30
36
  consentCheckoutFields,
@@ -64,7 +70,7 @@ export function createCheckoutSessionHandler(
64
70
  ): WriteHandlerDef {
65
71
  const consumerProtection = options.consumerProtection;
66
72
  const schema = consumerProtection
67
- ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() })
73
+ ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() }).strict()
68
74
  : createCheckoutSessionSchema;
69
75
  return {
70
76
  name: "create-checkout-session",
@@ -78,34 +84,43 @@ export function createCheckoutSessionHandler(
78
84
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
79
85
  const payload = event.payload as CreateCheckoutSessionPayload;
80
86
 
81
- const mode = payload.mode ?? "subscription";
82
- const consent = consumerProtection
83
- ? await prepareConsent(ctx, consumerProtection, payload.consent, mode)
84
- : undefined;
85
-
86
- const result = await openCheckout(
87
- ctx,
88
- { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now },
89
- {
90
- providerName: payload.providerName,
91
- priceId: payload.priceId,
92
- successUrl: payload.successUrl,
93
- cancelUrl: payload.cancelUrl,
94
- ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
95
- ...(payload.mode && { mode: payload.mode }),
96
- ...(consent && consentCheckoutFields(consent)),
97
- },
98
- );
87
+ const openOptions = { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now };
88
+ const checkoutInput: OpenCheckoutInput = {
89
+ providerName: payload.providerName,
90
+ priceId: payload.priceId,
91
+ successUrl: payload.successUrl,
92
+ cancelUrl: payload.cancelUrl,
93
+ ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
94
+ ...(payload.mode && { mode: payload.mode }),
95
+ };
99
96
 
100
- if (consent) {
101
- const plugin = findProviderPlugin(ctx, payload.providerName)?.plugin;
102
- await recordConsent(ctx, consent, {
103
- tier: mode === "subscription" ? (plugin?.priceToTier?.[payload.priceId] ?? null) : null,
104
- priceId: payload.priceId,
105
- price: await lookupPrice(ctx, plugin, payload.priceId),
106
- });
97
+ if (!consumerProtection) {
98
+ const result = await openCheckout(ctx, openOptions, checkoutInput);
99
+ return {
100
+ isSuccess: true as const,
101
+ data: { url: result.url, providerName: result.providerName },
102
+ };
107
103
  }
108
104
 
105
+ const mode = payload.mode ?? "subscription";
106
+ const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
107
+ const price = await lookupPrice(ctx, plugin, payload.priceId);
108
+ const orderItem =
109
+ mode === "payment"
110
+ ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
111
+ : undefined;
112
+ const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
113
+ const result = await startProviderCheckout(ctx, plugin, {
114
+ ...checkoutInput,
115
+ ...consentCheckoutFields(consent),
116
+ });
117
+ await recordConsent(ctx, consent, {
118
+ tier: mode === "subscription" ? (plugin.priceToTier?.[payload.priceId] ?? null) : null,
119
+ priceId: payload.priceId,
120
+ price,
121
+ ...(orderItem && { orderItem }),
122
+ });
123
+
109
124
  return {
110
125
  isSuccess: true as const,
111
126
  data: { url: result.url, providerName: result.providerName },
@@ -114,18 +129,28 @@ export function createCheckoutSessionHandler(
114
129
  };
115
130
  }
116
131
 
117
- // A failed price lookup must not lose a consent the buyer already gave at the
118
- // provider, so it degrades to null price fields.
132
+ // The price is part of the consent record, so it must be known before the
133
+ // buyer is sent to the provider.
119
134
  async function lookupPrice(
120
135
  ctx: HandlerContext,
121
- plugin: SubscriptionProviderPlugin | undefined,
136
+ plugin: SubscriptionProviderPlugin,
122
137
  priceId: string,
123
- ): Promise<ProviderPrice | null> {
124
- if (!plugin?.retrievePrices) return null;
138
+ ): Promise<ProviderPrice> {
139
+ let price: ProviderPrice | undefined;
125
140
  try {
126
- const prices = await plugin.retrievePrices(ctx, [priceId]);
127
- return prices.find((p) => p.priceId === priceId) ?? null;
128
- } catch {
129
- return null;
141
+ const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
142
+ price = prices.find((p) => p.priceId === priceId);
143
+ } catch (cause) {
144
+ throw priceUnavailable(priceId, cause);
130
145
  }
146
+ if (!price) throw priceUnavailable(priceId);
147
+ return price;
148
+ }
149
+
150
+ function priceUnavailable(priceId: string, cause?: unknown): UnprocessableError {
151
+ return new UnprocessableError("price_unavailable", {
152
+ i18nKey: "billing-foundation.errors.priceUnavailable",
153
+ message: `billing-foundation: no live price resolved for priceId "${priceId}"`,
154
+ ...(cause instanceof Error && { cause }),
155
+ });
131
156
  }
@@ -33,6 +33,16 @@ export {
33
33
  type SubscriptionStatus,
34
34
  SubscriptionStatuses,
35
35
  } from "./constants.js";
36
+ export {
37
+ type ConsentPayload,
38
+ consentPayloadSchema,
39
+ type OrderItem,
40
+ } from "./consumer-protection/consent-gate.js";
41
+ export {
42
+ CONSENT_LOCALES,
43
+ type ConsentLocale,
44
+ resolveConsentLocale,
45
+ } from "./consumer-protection/consent-locale.js";
36
46
  export {
37
47
  type ContractTerminationRoutesOptions,
38
48
  createContractTerminationRoutes,
@@ -79,6 +89,7 @@ export {
79
89
  type BillingPlanView,
80
90
  type ConsumerProtectionOptions,
81
91
  KNOWN_RECURRING_INTERVALS,
92
+ type LegalLinkSet,
82
93
  type PaymentEvent,
83
94
  type ProviderPrice,
84
95
  type ProviderSubscriptionSnapshot,
@@ -37,6 +37,8 @@ import {
37
37
  type SubscriptionStatus,
38
38
  type TerminationScope,
39
39
  } from "./constants.js";
40
+ import type { OrderItem } from "./consumer-protection/consent-gate.js";
41
+ import type { ConsentLocale } from "./consumer-protection/consent-locale.js";
40
42
 
41
43
  // =============================================================================
42
44
  // Normalisierter Webhook-Event
@@ -380,6 +382,18 @@ export type BillingPlanCatalog<TTier extends string = string> = {
380
382
  readonly providerName?: string;
381
383
  };
382
384
 
385
+ export type LegalLinkSet = {
386
+ readonly terms: string;
387
+ readonly withdrawal: string;
388
+ readonly privacy: string;
389
+ };
390
+
391
+ export function isLegalLinkSet(
392
+ links: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>,
393
+ ): links is LegalLinkSet {
394
+ return "terms" in links;
395
+ }
396
+
383
397
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
384
398
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
385
399
  * payload and record it as an event before the buyer reaches the provider. */
@@ -395,12 +409,16 @@ export type ConsumerProtectionOptions = {
395
409
  * host that resolves none (the platform apex) — the declaration then runs
396
410
  * without a request tenant and finds the contract by the declarant's email. */
397
411
  readonly terminationScope?: TerminationScope;
398
- /** Root-relative paths or absolute https URLs. */
399
- readonly legalLinks: {
400
- readonly terms: string;
401
- readonly withdrawal: string;
402
- readonly privacy: string;
403
- };
412
+ /** Names the item of a one-off payment (mode "payment") for the consent record
413
+ * and the confirmation mail; the label key is resolved through the registry
414
+ * i18n, so it must be qualified with the registering feature. */
415
+ readonly oneOffItemLabel?: (
416
+ ctx: HandlerContext,
417
+ priceId: string,
418
+ ) => Promise<OrderItem | undefined>;
419
+ /** Root-relative paths or absolute https URLs. One set for every locale, or
420
+ * one set per consent locale (`de` and `en` required). */
421
+ readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
404
422
  };
405
423
 
406
424
  export type BillingFoundationOptions<TTier extends string = string> = {
@@ -472,7 +490,7 @@ export type BillingPlansResult = {
472
490
  }
473
491
  >
474
492
  >;
475
- readonly legalLinks: ConsumerProtectionOptions["legalLinks"];
493
+ readonly legalLinks: Readonly<Record<ConsentLocale, LegalLinkSet>>;
476
494
  };
477
495
  };
478
496
 
@@ -5,7 +5,13 @@
5
5
 
6
6
  import * as z from "zod";
7
7
  import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
8
- import type { BillingFoundationOptions, ConsumerProtectionOptions } from "./types.js";
8
+ import { CONSENT_LOCALES, type ConsentLocale } from "./consumer-protection/consent-locale.js";
9
+ import {
10
+ type BillingFoundationOptions,
11
+ type ConsumerProtectionOptions,
12
+ isLegalLinkSet,
13
+ type LegalLinkSet,
14
+ } from "./types.js";
9
15
 
10
16
  function isRootRelativePath(path: string): boolean {
11
17
  return path.startsWith("/") && !path.startsWith("//");
@@ -36,15 +42,42 @@ function validateConsumerProtection(cp: ConsumerProtectionOptions): void {
36
42
  `createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`,
37
43
  );
38
44
  }
45
+ if (cp.oneOffItemLabel !== undefined && typeof cp.oneOffItemLabel !== "function") {
46
+ throw new Error(
47
+ "createBillingFoundationFeature: consumerProtection.oneOffItemLabel must be a function.",
48
+ );
49
+ }
39
50
  if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
40
51
  throw new Error(
41
52
  `createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`,
42
53
  );
43
54
  }
44
- for (const [key, link] of Object.entries(cp.legalLinks)) {
55
+ if (isLegalLinkSet(cp.legalLinks)) {
56
+ validateLegalLinkSet(cp.legalLinks, "legalLinks");
57
+ } else {
58
+ validatePerLocaleLegalLinks(cp.legalLinks);
59
+ }
60
+ }
61
+
62
+ function validatePerLocaleLegalLinks(
63
+ legalLinks: Readonly<Record<ConsentLocale, LegalLinkSet>>,
64
+ ): void {
65
+ for (const locale of CONSENT_LOCALES) {
66
+ const set = legalLinks[locale];
67
+ if (!set) {
68
+ throw new Error(
69
+ `createBillingFoundationFeature: consumerProtection.legalLinks needs a "${locale}" entry when given per locale.`,
70
+ );
71
+ }
72
+ validateLegalLinkSet(set, `legalLinks.${locale}`);
73
+ }
74
+ }
75
+
76
+ function validateLegalLinkSet(set: LegalLinkSet, path: string): void {
77
+ for (const [key, link] of Object.entries(set)) {
45
78
  if (!isRootRelativeOrHttpsUrl(link)) {
46
79
  throw new Error(
47
- `createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`,
80
+ `createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`,
48
81
  );
49
82
  }
50
83
  }
@@ -293,7 +293,7 @@ export function BillingPlansPanel(_props: ExtensionSectionProps): ReactNode {
293
293
  {t("billing-foundation.plans.switchRequiresReactivation")}
294
294
  </Banner>
295
295
  )}
296
- {consumerProtection !== undefined && canManage && (
296
+ {consumerProtection !== undefined && canManage && result.subscription?.cancelAt == null && (
297
297
  <div>
298
298
  <Button
299
299
  variant="secondary"
@@ -40,7 +40,7 @@ export function CancelContractDialog({
40
40
  }: CancelContractDialogProps): ReactNode {
41
41
  const t = useTranslation();
42
42
  const locale = useLocale().locale();
43
- const { Modal, Heading, Text, Field, Input, Button, Banner } = usePrimitives();
43
+ const { Modal, Text, Field, Input, Button, Banner } = usePrimitives();
44
44
  const terminate = useMutation<TerminateContractReceipt>(
45
45
  SubscriptionFoundationHandlers.terminateContract,
46
46
  );
@@ -78,7 +78,6 @@ export function CancelContractDialog({
78
78
  testId="cancel-contract-dialog"
79
79
  >
80
80
  <div className="flex flex-col gap-4">
81
- <Heading variant="page">{t("billing-foundation.cancel.title")}</Heading>
82
81
  {step === "form" && (
83
82
  <>
84
83
  <Text variant="muted">{t("billing-foundation.cancel.declaration")}</Text>
@@ -21,7 +21,11 @@ export type CheckoutConsentDialogProps = {
21
21
  readonly open: boolean;
22
22
  readonly onOpenChange: (open: boolean) => void;
23
23
  readonly planName: string;
24
- readonly price: { readonly amount: string; readonly renewalKey?: string; readonly count: string };
24
+ readonly price: {
25
+ readonly amount: string;
26
+ readonly renewalKey?: string;
27
+ readonly count?: string;
28
+ };
25
29
  readonly consumerProtection: ConsumerProtectionView;
26
30
  readonly onOrder: (consent: CheckoutConsentPayload) => Promise<WriteResult<{ url: string }>>;
27
31
  readonly onConsentTextOutdated: () => void;
@@ -39,7 +43,7 @@ export function CheckoutConsentDialog({
39
43
  }: CheckoutConsentDialogProps): ReactNode {
40
44
  const t = useTranslation();
41
45
  const uiLocale = useLocale().locale();
42
- const { Modal, Heading, Text, Field, Input, Button, Link, Banner } = usePrimitives();
46
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
43
47
  const [earlyPerformance, setEarlyPerformance] = useState(false);
44
48
  const [withdrawalLoss, setWithdrawalLoss] = useState(false);
45
49
  const [submitting, setSubmitting] = useState(false);
@@ -47,7 +51,7 @@ export function CheckoutConsentDialog({
47
51
 
48
52
  const consentLocale = resolveConsentLocale(uiLocale);
49
53
  const texts = consumerProtection.consentTexts[consentLocale];
50
- const { legalLinks } = consumerProtection;
54
+ const legalLinks = consumerProtection.legalLinks[consentLocale];
51
55
 
52
56
  async function order(): Promise<void> {
53
57
  setSubmitting(true);
@@ -76,15 +80,21 @@ export function CheckoutConsentDialog({
76
80
  testId="checkout-consent-dialog"
77
81
  >
78
82
  <div className="flex flex-col gap-4">
79
- <Heading variant="page">{t("billing-foundation.consent.title")}</Heading>
80
83
  <div className="flex flex-col gap-1" data-testid="checkout-consent-summary">
81
84
  <Text>
82
85
  {planName} — {price.amount}
83
86
  </Text>
84
87
  {price.renewalKey !== undefined && (
85
- <Text variant="muted">{t(price.renewalKey, { count: price.count })}</Text>
88
+ <>
89
+ <Text variant="muted">
90
+ {t(
91
+ price.renewalKey,
92
+ price.count !== undefined ? { count: price.count } : undefined,
93
+ )}
94
+ </Text>
95
+ <Text variant="muted">{t("billing-foundation.consent.cancelAnytime")}</Text>
96
+ </>
86
97
  )}
87
- <Text variant="muted">{t("billing-foundation.consent.cancelAnytime")}</Text>
88
98
  </div>
89
99
  <div className="flex flex-wrap gap-4">
90
100
  <Link href={legalLinks.terms} target="_blank">