@cosmicdrift/kumiko-samples 0.343.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 (25) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
  4. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +35 -7
  5. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +36 -22
  6. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +12 -2
  7. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts +1 -1
  8. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +34 -1
  9. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  10. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +14 -5
  11. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +61 -36
  12. package/packages/bundled-features/src/billing-foundation/index.ts +11 -0
  13. package/packages/bundled-features/src/billing-foundation/types.ts +25 -7
  14. package/packages/bundled-features/src/billing-foundation/validate-options.ts +36 -3
  15. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -1
  16. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +1 -2
  17. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +16 -6
  18. package/packages/bundled-features/src/billing-foundation/web/index.ts +12 -0
  19. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  20. package/packages/bundled-features/src/legal-pages/constants.ts +2 -0
  21. package/packages/bundled-features/src/legal-pages/feature.ts +8 -3
  22. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  23. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +10 -0
  24. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +10 -11
  25. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.343.0",
3
+ "version": "0.344.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.343.0",
3
+ "version": "0.344.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.344.0",
4
+ "type": "breaking",
5
+ "title": "Consumer-protection gaps - per-locale legal links, gate order, confirmation mail, exports",
6
+ "detail": "All changes below apply only when `createBillingFoundationFeature` gets `consumerProtection`. Apps without it behave as before.\n`ConsumerProtectionOptions.legalLinks` takes either one `LegalLinkSet` or one set per consent locale (`{ de, en }`, both required). `BillingPlansResult.consumerProtection.legalLinks` is now always `Record<\"de\" | \"en\", LegalLinkSet>`; a single set is returned for both locales. Code that reads `legalLinks.terms` from the billing-plans result must read `legalLinks[locale].terms`. `CheckoutConsentDialog` picks the set for the consent locale.\n`FALLBACK_CONSENT_LOCALE` is now `\"en\"` (was `\"de\"`). A locale other than de/en, or none, resolves to English in `resolveConsentLocale`, the consent gate, the confirmation mail's VAT note and the termination record.\n`create-checkout-session` with consumer protection now checks in this order: provider, billing enabled, redirect origins, price and subscription gates, then the live price (new `UnprocessableError` `price_unavailable` when the provider cannot return it), then the consent, then the provider call. A missing consent no longer hides a disabled provider, an existing subscription or an unknown price. Its payload schema is strict, so unknown fields are rejected. `assertCheckoutAllowed` and `startProviderCheckout` split `openCheckout` in checkout-core.\nNew option `ConsumerProtectionOptions.oneOffItemLabel(ctx, priceId)`: the server resolves the label of a one-off item (for example a credit pack). It is stored on the consent event as `itemLabelKey` / `itemLabelParams` and shown in the confirmation mail as \"Leistung\" / \"Item\". The client cannot set it.\nThe terms text block is read from the system tenant, not from the buyer's tenant, in the consent gate and the confirmation mail. A tenant-owned block with the same slug no longer changes `termsHash` or the mailed text.\nThe confirmation mail shows dates in the recipient's time zone (user zone, else tenant zone, else UTC) with the zone name, renders the terms as Markdown with real headings and shows the translated plan name (`catalog.tierLabelKey`, looked up through the registry i18n, so the key must be qualified with the registering feature; untranslated keys fall back to the raw tier). Its opening sentence starts with a capital letter.\nNew exports: `consentPayloadSchema`, `ConsentPayload`, `OrderItem`, `CONSENT_LOCALES`, `ConsentLocale`, `resolveConsentLocale`, `LegalLinkSet`. The `/web` entry adds `CheckoutConsentDialog`, `CheckoutConsentDialogProps`, `CheckoutConsentPayload`, `CONSENT_TEXT_OUTDATED_CODE`, `resolveConsentLocale`, `ConsentLocale` and `LegalLinkSet`. `CheckoutConsentDialog` `price.count` is optional, and the \"cancel at any time\" note only shows for a recurring price (`price.renewalKey` set).\nThe checkout and cancel dialogs no longer repeat the modal title as a heading, and the cancel-contract button is hidden once a cancellation is scheduled.",
7
+ "migration": "Only apps that pass consumerProtection are affected. BillingPlansResult.consumerProtection.legalLinks is now keyed by locale (legalLinks.de / legalLinks.en). FALLBACK_CONSENT_LOCALE changed from de to en. create-checkout-session rejects unknown payload fields when consumer protection is on."
8
+ },
2
9
  {
3
10
  "version": "0.341.0",
4
11
  "type": "improvement",
@@ -329,17 +329,27 @@ function assertOneOffPriceAllowed(
329
329
  }
330
330
  }
331
331
 
332
- /** The one checkout entry-point create-checkout-session and
333
- * start-plan-checkout both funnel through. `mode: "payment"` (one-off
334
- * top-ups etc.) skips the price/subscription gates entirely — those apply
335
- * to recurring subscriptions only. */
336
- export async function openCheckout(
332
+ type CheckoutCapableProvider = SubscriptionProviderPlugin & {
333
+ readonly createCheckoutSession: NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>;
334
+ };
335
+
336
+ function canCreateCheckoutSession(
337
+ plugin: SubscriptionProviderPlugin,
338
+ ): plugin is CheckoutCapableProvider {
339
+ return plugin.createCheckoutSession !== undefined;
340
+ }
341
+
342
+ /** All gates that run before a provider checkout call: provider, billing
343
+ * enabled, redirect origins, mode gates, foreign provider customer.
344
+ * `mode: "payment"` (one-off top-ups etc.) skips the price/subscription
345
+ * gates — those apply to recurring subscriptions only. */
346
+ export async function assertCheckoutAllowed(
337
347
  ctx: HandlerContext,
338
348
  options: OpenCheckoutOptions,
339
349
  input: OpenCheckoutInput,
340
- ): Promise<{ readonly url: string; readonly providerName: string }> {
350
+ ): Promise<CheckoutCapableProvider> {
341
351
  const { plugin } = resolveProviderPlugin(ctx, input.providerName);
342
- if (!plugin.createCheckoutSession) {
352
+ if (!canCreateCheckoutSession(plugin)) {
343
353
  throw new Error(
344
354
  `subscription-foundation: provider "${input.providerName}" has no createCheckoutSession-method (e.g. Apple-IAP-only providers). Use the provider's native checkout flow.`,
345
355
  );
@@ -376,7 +386,14 @@ export async function openCheckout(
376
386
  });
377
387
  }
378
388
  }
389
+ return plugin;
390
+ }
379
391
 
392
+ export async function startProviderCheckout(
393
+ ctx: HandlerContext,
394
+ plugin: CheckoutCapableProvider,
395
+ input: OpenCheckoutInput,
396
+ ): Promise<{ readonly url: string; readonly providerName: string }> {
380
397
  const result = await plugin.createCheckoutSession(ctx, {
381
398
  priceId: input.priceId,
382
399
  tenantId: ctx.user.tenantId,
@@ -393,3 +410,14 @@ export async function openCheckout(
393
410
  });
394
411
  return { url: result.url, providerName: input.providerName };
395
412
  }
413
+
414
+ /** The one checkout entry-point start-plan-checkout, switch-plan and the
415
+ * non-consumer-protection create-checkout-session funnel through. */
416
+ export async function openCheckout(
417
+ ctx: HandlerContext,
418
+ options: OpenCheckoutOptions,
419
+ input: OpenCheckoutInput,
420
+ ): Promise<{ readonly url: string; readonly providerName: string }> {
421
+ const plugin = await assertCheckoutAllowed(ctx, options, input);
422
+ return startProviderCheckout(ctx, plugin, input);
423
+ }
@@ -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">
@@ -13,11 +13,23 @@ export {
13
13
  type SubscriptionStatus,
14
14
  SubscriptionStatuses,
15
15
  } from "../constants.js";
16
+ export {
17
+ CONSENT_LOCALES,
18
+ type ConsentLocale,
19
+ resolveConsentLocale,
20
+ } from "../consumer-protection/consent-locale.js";
16
21
  export type {
17
22
  BillingPlanBenefit,
18
23
  BillingPlanCatalog,
19
24
  BillingPlansResult,
20
25
  BillingPlanView,
26
+ LegalLinkSet,
21
27
  } from "../types.js";
22
28
  export { BillingPlansPanel } from "./billing-plans-panel.js";
29
+ export {
30
+ CheckoutConsentDialog,
31
+ type CheckoutConsentDialogProps,
32
+ type CheckoutConsentPayload,
33
+ CONSENT_TEXT_OUTDATED_CODE,
34
+ } from "./checkout-consent-dialog.js";
23
35
  export { type BillingFoundationClientOptions, billingFoundationClient } from "./client-plugin.js";
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.344.0",
4
+ "type": "improvement",
5
+ "title": "Optional title heading on legal pages",
6
+ "detail": "`LegalPageRoute` has an optional `titleHeading` flag. When `true`, the page body starts with an `<h1>` holding the escaped page title. The default (`false`) leaves the body unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -10,6 +10,8 @@ export type LegalPageRoute = {
10
10
  readonly slug: string;
11
11
  readonly lang: string;
12
12
  readonly titleFallback: string;
13
+ /** Prepend the page title as `<h1>` to the rendered body. Default false. */
14
+ readonly titleHeading?: boolean;
13
15
  };
14
16
 
15
17
  // Shape of one boot-check-required block — the type behind
@@ -4,6 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { escapeHtml } from "@cosmicdrift/kumiko-headless";
7
8
  import {
8
9
  cachedSecurePageResponse,
9
10
  type PublicPageWrapLayout,
@@ -87,7 +88,7 @@ export type LegalPagesOptions = {
87
88
 
88
89
  export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
89
90
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
90
- const routes = opts.routes ?? LEGAL_ROUTES;
91
+ const routes: readonly LegalPageRoute[] = opts.routes ?? LEGAL_ROUTES;
91
92
  const requiredBlocks = opts.requiredBlocks ?? LEGAL_REQUIRED_BLOCKS;
92
93
  validateRoutes(routes);
93
94
  if (opts.routes !== undefined && opts.routes.length > 0 && opts.requiredBlocks === undefined) {
@@ -172,9 +173,13 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
172
173
  });
173
174
  }
174
175
 
176
+ const title = data.title || route.titleFallback;
177
+ const renderedBody = renderMarkdownToHtml(data.content);
175
178
  const html = wrapLayout({
176
- title: data.title || route.titleFallback,
177
- bodyHtml: renderMarkdownToHtml(data.content),
179
+ title,
180
+ bodyHtml: route.titleHeading
181
+ ? `<h1>${escapeHtml(title)}</h1>${renderedBody}`
182
+ : renderedBody,
178
183
  lang: route.lang,
179
184
  slug: route.slug,
180
185
  });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.344.0",
4
+ "type": "improvement",
5
+ "title": "Heading and Markdown sections in the simple mail renderer",
6
+ "detail": "`createSimpleRenderer` accepts two more section kinds: `{ heading: string }` (rendered as an escaped `<h2>`) and `{ markdown: string }` (rendered with the hardened Markdown renderer; raw HTML is escaped and unsafe link targets are replaced by `#`). Existing section kinds render as before."
7
+ },
2
8
  {
3
9
  "version": "0.341.0",
4
10
  "type": "improvement",
@@ -1,5 +1,6 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
2
  import type { NotificationRenderer } from "../delivery/index.js";
3
+ import { renderSafeMarkdown } from "../page-render/index.js";
3
4
 
4
5
  export type MailBranding = {
5
6
  /** Shown as text when there is no logo, and as the logo alt text. */
@@ -141,6 +142,8 @@ function renderBrandingFooter(
141
142
 
142
143
  type Section =
143
144
  | { readonly text: string }
145
+ | { readonly heading: string }
146
+ | { readonly markdown: string }
144
147
  | { readonly button: { readonly label: string; readonly url: string } };
145
148
 
146
149
  type EmailTemplateData = {
@@ -157,6 +160,13 @@ function renderSection(section: Section, primaryColor: string): string {
157
160
  if ("text" in section) {
158
161
  return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
159
162
  }
163
+ if ("heading" in section) {
164
+ return `<h2 style="margin:24px 0 12px;color:#111;font-size:16px;font-weight:600">${escapeHtml(section.heading)}</h2>`;
165
+ }
166
+ if ("markdown" in section) {
167
+ const markdownHtml = renderSafeMarkdown(section.markdown);
168
+ return `<div style="color:#333;font-size:14px;line-height:1.5">${markdownHtml}</div>`;
169
+ }
160
170
  if ("button" in section) {
161
171
  return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
162
172
  }
@@ -69,8 +69,8 @@ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition
69
69
  baseUrl,
70
70
  catalog: {
71
71
  plans: ["starter", "pro"],
72
- tierLabelKey: (tier) => `gallery.plan.${tier}.label`,
73
- benefits: (tier) => [{ labelKey: `gallery.plan.${tier}.benefit` }],
72
+ tierLabelKey: (tier) => `gallery-screens:plan.${tier}.label`,
73
+ benefits: (tier) => [{ labelKey: `gallery-screens:plan.${tier}.benefit` }],
74
74
  resolveCurrentTier,
75
75
  viewRoles: ["TenantAdmin", "SystemAdmin"],
76
76
  successPath: GALLERY_PLAN_PATH,
@@ -83,9 +83,8 @@ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition
83
83
  operatorEmail: GALLERY_OPERATOR_EMAIL,
84
84
  terminationScope: "platform",
85
85
  legalLinks: {
86
- terms: "/legal/terms",
87
- withdrawal: "/legal/withdrawal",
88
- privacy: "/legal/privacy",
86
+ de: { terms: "/legal/terms", withdrawal: "/legal/withdrawal", privacy: "/legal/privacy" },
87
+ en: { terms: "/legal/terms", withdrawal: "/legal/withdrawal", privacy: "/legal/privacy" },
89
88
  },
90
89
  },
91
90
  });
@@ -140,12 +139,12 @@ export const galleryScreensFeature: FeatureDefinition = defineFeature("gallery-s
140
139
  r.translations({
141
140
  keys: {
142
141
  "gallery:nav.billing": { de: "Abrechnung", en: "Billing" },
143
- "gallery.plan.free.label": { de: "Free", en: "Free" },
144
- "gallery.plan.starter.label": { de: "Starter", en: "Starter" },
145
- "gallery.plan.pro.label": { de: "Pro", en: "Pro" },
146
- "gallery.plan.starter.benefit": { de: "Für kleine Teams", en: "For small teams" },
147
- "gallery.plan.pro.benefit": { de: "Für wachsende Teams", en: "For growing teams" },
148
- "gallery.plan.free.benefit": { de: "Zum Ausprobieren", en: "To try things out" },
142
+ "gallery-screens:plan.free.label": { de: "Free", en: "Free" },
143
+ "gallery-screens:plan.starter.label": { de: "Starter", en: "Starter" },
144
+ "gallery-screens:plan.pro.label": { de: "Pro", en: "Pro" },
145
+ "gallery-screens:plan.starter.benefit": { de: "Für kleine Teams", en: "For small teams" },
146
+ "gallery-screens:plan.pro.benefit": { de: "Für wachsende Teams", en: "For growing teams" },
147
+ "gallery-screens:plan.free.benefit": { de: "Zum Ausprobieren", en: "To try things out" },
149
148
  },
150
149
  });
151
150
  return {};
@@ -42,8 +42,8 @@ const features = [
42
42
  ];
43
43
 
44
44
  const TERMS_TEXT: Readonly<Record<string, string>> = {
45
- de: "## Allgemeine Geschäftsbedingungen\n\nDiese Bedingungen gelten für alle Verträge über die Plattform.",
46
- en: "## Terms and conditions\n\nThese terms apply to every contract concluded through the platform.",
45
+ de: "Diese Bedingungen gelten für alle Verträge über die Plattform.",
46
+ en: "These terms apply to every contract concluded through the platform.",
47
47
  };
48
48
 
49
49
  const seedTerms: SeedFn = async (stack) => {