@cosmicdrift/kumiko-bundled-features 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 (32) hide show
  1. package/dist/billing-foundation/checkout-core.d.ts +15 -4
  2. package/dist/billing-foundation/checkout-core.js +18 -6
  3. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +13 -3
  4. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +23 -20
  5. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +6 -1
  6. package/dist/billing-foundation/consumer-protection/consent-gate.js +6 -1
  7. package/dist/billing-foundation/consumer-protection/consent-locale.js +1 -1
  8. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +24 -2
  9. package/dist/billing-foundation/events.d.ts +2 -0
  10. package/dist/billing-foundation/events.js +2 -0
  11. package/dist/billing-foundation/handlers/billing-plans.query.d.ts +1 -1
  12. package/dist/billing-foundation/handlers/billing-plans.query.js +5 -1
  13. package/dist/billing-foundation/handlers/create-checkout-session.write.js +46 -24
  14. package/dist/billing-foundation/index.d.ts +3 -1
  15. package/dist/billing-foundation/index.js +2 -0
  16. package/dist/billing-foundation/types.d.ts +16 -7
  17. package/dist/billing-foundation/types.js +3 -0
  18. package/dist/billing-foundation/validate-options.d.ts +1 -1
  19. package/dist/billing-foundation/validate-options.js +24 -2
  20. package/dist/billing-foundation/web/billing-plans-panel.js +1 -1
  21. package/dist/billing-foundation/web/cancel-contract-dialog.js +2 -2
  22. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -1
  23. package/dist/billing-foundation/web/checkout-consent-dialog.js +4 -4
  24. package/dist/billing-foundation/web/index.d.ts +3 -1
  25. package/dist/billing-foundation/web/index.js +2 -0
  26. package/dist/legal-pages/constants.d.ts +2 -0
  27. package/dist/legal-pages/feature.js +7 -2
  28. package/dist/renderer-simple/simple-renderer.js +8 -0
  29. package/package.json +9 -9
  30. package/src/billing-foundation/changes.json +7 -0
  31. package/src/legal-pages/changes.json +6 -0
  32. package/src/renderer-simple/changes.json +6 -0
@@ -92,11 +92,22 @@ export type OpenCheckoutInput = {
92
92
  readonly locale?: string;
93
93
  readonly submitMessage?: string;
94
94
  };
95
- /** The one checkout entry-point create-checkout-session and
96
- * start-plan-checkout both funnel through. `mode: "payment"` (one-off
97
- * top-ups etc.) skips the price/subscription gates entirely — those apply
98
- * to recurring subscriptions only. */
95
+ type CheckoutCapableProvider = SubscriptionProviderPlugin & {
96
+ readonly createCheckoutSession: NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>;
97
+ };
98
+ /** All gates that run before a provider checkout call: provider, billing
99
+ * enabled, redirect origins, mode gates, foreign provider customer.
100
+ * `mode: "payment"` (one-off top-ups etc.) skips the price/subscription
101
+ * gates — those apply to recurring subscriptions only. */
102
+ export declare function assertCheckoutAllowed(ctx: HandlerContext, options: OpenCheckoutOptions, input: OpenCheckoutInput): Promise<CheckoutCapableProvider>;
103
+ export declare function startProviderCheckout(ctx: HandlerContext, plugin: CheckoutCapableProvider, input: OpenCheckoutInput): Promise<{
104
+ readonly url: string;
105
+ readonly providerName: string;
106
+ }>;
107
+ /** The one checkout entry-point start-plan-checkout, switch-plan and the
108
+ * non-consumer-protection create-checkout-session funnel through. */
99
109
  export declare function openCheckout(ctx: HandlerContext, options: OpenCheckoutOptions, input: OpenCheckoutInput): Promise<{
100
110
  readonly url: string;
101
111
  readonly providerName: string;
102
112
  }>;
113
+ export {};
@@ -234,13 +234,16 @@ function assertOneOffPriceAllowed(plugin, input) {
234
234
  });
235
235
  }
236
236
  }
237
- /** The one checkout entry-point create-checkout-session and
238
- * start-plan-checkout both funnel through. `mode: "payment"` (one-off
239
- * top-ups etc.) skips the price/subscription gates entirely — those apply
240
- * to recurring subscriptions only. */
241
- export async function openCheckout(ctx, options, input) {
237
+ function canCreateCheckoutSession(plugin) {
238
+ return plugin.createCheckoutSession !== undefined;
239
+ }
240
+ /** All gates that run before a provider checkout call: provider, billing
241
+ * enabled, redirect origins, mode gates, foreign provider customer.
242
+ * `mode: "payment"` (one-off top-ups etc.) skips the price/subscription
243
+ * gates — those apply to recurring subscriptions only. */
244
+ export async function assertCheckoutAllowed(ctx, options, input) {
242
245
  const { plugin } = resolveProviderPlugin(ctx, input.providerName);
243
- if (!plugin.createCheckoutSession) {
246
+ if (!canCreateCheckoutSession(plugin)) {
244
247
  throw new Error(`subscription-foundation: provider "${input.providerName}" has no createCheckoutSession-method (e.g. Apple-IAP-only providers). Use the provider's native checkout flow.`);
245
248
  }
246
249
  // Checked before the redirect-origin hardening for every mode — a
@@ -272,6 +275,9 @@ export async function openCheckout(ctx, options, input) {
272
275
  });
273
276
  }
274
277
  }
278
+ return plugin;
279
+ }
280
+ export async function startProviderCheckout(ctx, plugin, input) {
275
281
  const result = await plugin.createCheckoutSession(ctx, {
276
282
  priceId: input.priceId,
277
283
  tenantId: ctx.user.tenantId,
@@ -288,3 +294,9 @@ export async function openCheckout(ctx, options, input) {
288
294
  });
289
295
  return { url: result.url, providerName: input.providerName };
290
296
  }
297
+ /** The one checkout entry-point start-plan-checkout, switch-plan and the
298
+ * non-consumer-protection create-checkout-session funnel through. */
299
+ export async function openCheckout(ctx, options, input) {
300
+ const plugin = await assertCheckoutAllowed(ctx, options, input);
301
+ return startProviderCheckout(ctx, plugin, input);
302
+ }
@@ -1,14 +1,24 @@
1
1
  import type { CheckoutConsentRecordedPayload } from "../events.js";
2
+ export type ContractConfirmationSection = {
3
+ readonly text: string;
4
+ } | {
5
+ readonly heading: string;
6
+ } | {
7
+ readonly markdown: string;
8
+ };
2
9
  export type ContractConfirmationContent = {
3
10
  readonly subject: string;
4
11
  readonly header: string;
5
- readonly sections: readonly {
6
- readonly text: string;
7
- }[];
12
+ readonly sections: readonly ContractConfirmationSection[];
8
13
  readonly footer: string;
9
14
  };
10
15
  export type RenderContractConfirmationArgs = {
11
16
  readonly consent: CheckoutConsentRecordedPayload;
17
+ readonly timeZone: string;
18
+ /** Translated plan name; falls back to the raw tier. */
19
+ readonly tierLabel?: string;
20
+ /** Translated purchased item for one-off payments; falls back to the generic label. */
21
+ readonly itemLabel?: string;
12
22
  readonly consentGivenAtIso: string;
13
23
  readonly contractStartIso: string;
14
24
  readonly currentPeriodEndIso?: string;
@@ -3,8 +3,9 @@ import { CONSENT_TEXTS, resolveConsentLocale } from "./consent-text.js";
3
3
  const LABELS = {
4
4
  de: {
5
5
  subject: "Vertragsbestätigung",
6
- intro: "vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
6
+ intro: "Vielen Dank für deine Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
7
7
  plan: "Tarif",
8
+ item: "Leistung",
8
9
  oneOffPayment: "Einmalzahlung",
9
10
  price: "Preis",
10
11
  oneOff: "einmalig",
@@ -24,8 +25,9 @@ const LABELS = {
24
25
  },
25
26
  en: {
26
27
  subject: "Contract confirmation",
27
- intro: "thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
28
+ intro: "Thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
28
29
  plan: "Plan",
30
+ item: "Item",
29
31
  oneOffPayment: "One-off payment",
30
32
  price: "Price",
31
33
  oneOff: "one-off",
@@ -69,7 +71,7 @@ function formatPrice(consent, locale) {
69
71
  : `${labels.every} ${count} ${unit.many}`;
70
72
  return count === 1 ? `${amount} / ${intervalText}` : `${amount}, ${intervalText}`;
71
73
  }
72
- function formatDate(iso, locale) {
74
+ function formatDate(iso, locale, timeZone) {
73
75
  let epochMilliseconds;
74
76
  try {
75
77
  epochMilliseconds = Temporal.Instant.from(iso).epochMilliseconds;
@@ -78,18 +80,15 @@ function formatDate(iso, locale) {
78
80
  return iso;
79
81
  }
80
82
  return new Intl.DateTimeFormat(locale, {
81
- dateStyle: "long",
82
- timeStyle: "short",
83
- timeZone: "UTC",
83
+ year: "numeric",
84
+ month: "long",
85
+ day: "numeric",
86
+ hour: "2-digit",
87
+ minute: "2-digit",
88
+ timeZone,
89
+ timeZoneName: "short",
84
90
  }).format(epochMilliseconds);
85
91
  }
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
92
  /** Structured mail content (header/sections/footer) for the email channel's
94
93
  * renderer; the renderer owns HTML escaping, this only builds plain text. */
95
94
  export function renderContractConfirmation(args) {
@@ -97,12 +96,16 @@ export function renderContractConfirmation(args) {
97
96
  const labels = LABELS[locale];
98
97
  const texts = CONSENT_TEXTS[locale];
99
98
  const { consent } = args;
99
+ const { timeZone } = args;
100
+ const subjectLine = consent.mode === "payment"
101
+ ? `${labels.item}: ${args.itemLabel ?? labels.oneOffPayment}`
102
+ : `${labels.plan}: ${args.tierLabel ?? consent.tier ?? labels.oneOffPayment}`;
100
103
  const contractLines = [
101
- `${labels.plan}: ${consent.tier ?? labels.oneOffPayment}`,
104
+ subjectLine,
102
105
  `${labels.price}: ${formatPrice(consent, locale)}`,
103
- `${labels.contractStart}: ${formatDate(args.contractStartIso, locale)}`,
106
+ `${labels.contractStart}: ${formatDate(args.contractStartIso, locale, timeZone)}`,
104
107
  ...(args.currentPeriodEndIso !== undefined
105
- ? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale)}`]
108
+ ? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale, timeZone)}`]
106
109
  : []),
107
110
  ];
108
111
  const vatNote = args.vatNote[locale] ?? args.vatNote[resolveConsentLocale()];
@@ -113,12 +116,12 @@ export function renderContractConfirmation(args) {
113
116
  { text: labels.intro },
114
117
  ...contractLines.map((text) => ({ text })),
115
118
  ...(vatNote !== undefined ? [{ text: vatNote }] : []),
116
- { text: labels.consentHeading },
119
+ { heading: labels.consentHeading },
117
120
  { text: texts.earlyPerformance },
118
121
  { text: texts.withdrawalLoss },
119
- { text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
120
- { text: labels.termsHeading },
121
- ...paragraphs(args.termsContent),
122
+ { text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale, timeZone)}` },
123
+ { heading: labels.termsHeading },
124
+ { markdown: args.termsContent },
122
125
  ],
123
126
  footer: `${labels.contact} ${args.operatorEmail}`,
124
127
  };
@@ -1,4 +1,4 @@
1
- import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import type { ConsumerProtectionOptions, ProviderPrice } from "../types.js";
4
4
  export type CheckoutMode = "subscription" | "payment";
@@ -18,10 +18,15 @@ export type PreparedConsent = {
18
18
  readonly termsHash: string;
19
19
  readonly termsTemplateVersion: number;
20
20
  };
21
+ export type OrderItem = {
22
+ readonly labelKey: string;
23
+ readonly params?: Readonly<Record<string, string | number>>;
24
+ };
21
25
  export type ConsentPriceDetails = {
22
26
  readonly tier: string | null;
23
27
  readonly priceId: string;
24
28
  readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
29
+ readonly orderItem?: OrderItem;
25
30
  };
26
31
  export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload | undefined, mode: CheckoutMode): Promise<PreparedConsent>;
27
32
  export declare function consentCheckoutFields(prepared: PreparedConsent): {
@@ -1,6 +1,7 @@
1
1
  // Consent gate shared by start-plan-checkout and create-checkout-session:
2
2
  // validate + prepare before the provider call, record only after it succeeded.
3
3
  import { createHash } from "node:crypto";
4
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
5
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
6
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
6
7
  import * as z from "zod";
@@ -36,7 +37,7 @@ export async function prepareConsent(ctx, options, consent, mode) {
36
37
  let terms;
37
38
  try {
38
39
  terms = await resolver.resolveTemplate({
39
- tenantId: ctx.user.tenantId,
40
+ tenantId: SYSTEM_TENANT_ID,
40
41
  slug: options.termsTextBlock,
41
42
  kind: TEXT_BLOCK_KIND,
42
43
  locale,
@@ -84,6 +85,10 @@ export async function recordConsent(ctx, prepared, details) {
84
85
  termsTemplateVersion: prepared.termsTemplateVersion,
85
86
  locale: prepared.locale,
86
87
  actorUserId: String(ctx.user.id),
88
+ ...(details.orderItem && {
89
+ itemLabelKey: details.orderItem.labelKey,
90
+ ...(details.orderItem.params && { itemLabelParams: details.orderItem.params }),
91
+ }),
87
92
  };
88
93
  const tenantId = ctx.user.tenantId;
89
94
  const isSubscription = prepared.mode === "subscription";
@@ -1,7 +1,7 @@
1
1
  // @runtime client
2
2
  // Crypto-free so the browser panel can share the locale rule with the server.
3
3
  export const CONSENT_LOCALES = ["de", "en"];
4
- export const FALLBACK_CONSENT_LOCALE = "de";
4
+ export const FALLBACK_CONSENT_LOCALE = "en";
5
5
  function isConsentLocale(value) {
6
6
  return CONSENT_LOCALES.some((locale) => locale === value);
7
7
  }
@@ -1,8 +1,10 @@
1
1
  // § 312f contract confirmation: one mail per recorded consent, sent by the
2
2
  // system-only issue-contract-confirmation handler that the event-triggered
3
3
  // jobs (register-consent.ts) call.
4
- import { access, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { access, createSystemUser, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
7
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
6
8
  import * as z from "zod";
7
9
  import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.js";
8
10
  import { UserQueries } from "../../user/index.js";
@@ -22,7 +24,17 @@ export const issueContractConfirmationSchema = z
22
24
  const recipientRowSchema = z.object({
23
25
  email: z.string().min(1),
24
26
  isDeleted: z.boolean().optional(),
27
+ timezone: z.string().nullish(),
25
28
  });
29
+ // createI18n returns the key itself for an unknown key.
30
+ function translatedOrUndefined(translation, key) {
31
+ return translation === key ? undefined : translation;
32
+ }
33
+ function pickTimeZone(userZone, tenantZone) {
34
+ if (userZone && isValidIanaTimeZone(userZone))
35
+ return userZone;
36
+ return isValidIanaTimeZone(tenantZone) ? tenantZone : "UTC";
37
+ }
26
38
  export function createIssueContractConfirmationHandler(options, consumerProtection) {
27
39
  return {
28
40
  name: "issue-contract-confirmation",
@@ -100,7 +112,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
100
112
  }
101
113
  const locale = resolveConsentLocale(consent.locale);
102
114
  const terms = await requireTemplateResolver(ctx, "billing-foundation").resolveTemplate({
103
- tenantId,
115
+ tenantId: SYSTEM_TENANT_ID,
104
116
  slug: consumerProtection.termsTextBlock,
105
117
  kind: TEXT_BLOCK_KIND,
106
118
  locale,
@@ -108,9 +120,19 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
108
120
  if (terms.version !== consent.termsTemplateVersion) {
109
121
  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
122
  }
123
+ const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
124
+ const tierLabel = consent.tier !== null && options.catalog
125
+ ? translatedOrUndefined(i18n.t(options.catalog.tierLabelKey(consent.tier), locale), options.catalog.tierLabelKey(consent.tier))
126
+ : undefined;
127
+ const itemLabel = consent.itemLabelKey !== undefined
128
+ ? translatedOrUndefined(i18n.t(consent.itemLabelKey, locale, consent.itemLabelParams), consent.itemLabelKey)
129
+ : undefined;
111
130
  const issuedAtIso = options.now().toString();
112
131
  const content = renderContractConfirmation({
113
132
  consent,
133
+ timeZone: pickTimeZone(recipient.data.timezone, ctx.tz.tenant),
134
+ ...(tierLabel !== undefined && { tierLabel }),
135
+ ...(itemLabel !== undefined && { itemLabel }),
114
136
  consentGivenAtIso: consentEvent.createdAt.toString(),
115
137
  contractStartIso: issuedAtIso,
116
138
  ...(payload.currentPeriodEndIso !== undefined && {
@@ -65,6 +65,8 @@ export declare const checkoutConsentRecordedPayloadSchema: z.ZodObject<{
65
65
  termsTemplateVersion: z.ZodNumber;
66
66
  locale: z.ZodString;
67
67
  actorUserId: z.ZodString;
68
+ itemLabelKey: z.ZodOptional<z.ZodString>;
69
+ itemLabelParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
68
70
  }, z.core.$strip>;
69
71
  export type CheckoutConsentRecordedPayload = z.infer<typeof checkoutConsentRecordedPayloadSchema>;
70
72
  export declare const CONTRACT_CONFIRMATION_ISSUED_EVENT_SHORT: "contract-confirmation-issued";
@@ -101,6 +101,8 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
101
101
  termsTemplateVersion: z.number(),
102
102
  locale: z.string().min(2).max(35),
103
103
  actorUserId: z.string().min(1),
104
+ itemLabelKey: z.string().min(1).max(200).optional(),
105
+ itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
104
106
  });
105
107
  // =============================================================================
106
108
  // contract-confirmation-issued — § 312f confirmation mail sent
@@ -1,3 +1,3 @@
1
1
  import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { BillingPlanCatalog, ResolvedBillingFoundationOptions } from "../types.js";
2
+ import { type BillingPlanCatalog, type ResolvedBillingFoundationOptions } from "../types.js";
3
3
  export declare function createBillingPlansQuery(options: ResolvedBillingFoundationOptions, catalog: BillingPlanCatalog): QueryHandlerDef;
@@ -5,6 +5,7 @@ import * as z from "zod";
5
5
  import { findCatalogProvider } from "../checkout-core.js";
6
6
  import { CONSENT_TEXTS, consentTextVersion, } from "../consumer-protection/consent-text.js";
7
7
  import { buildBillingPlans } from "../plan-catalog.js";
8
+ import { isLegalLinkSet, } from "../types.js";
8
9
  const billingPlansSchema = z.object({}).strict();
9
10
  export function createBillingPlansQuery(options, catalog) {
10
11
  return {
@@ -25,12 +26,15 @@ export function createBillingPlansQuery(options, catalog) {
25
26
  de: consentTextView("de"),
26
27
  en: consentTextView("en"),
27
28
  },
28
- legalLinks: consumerProtection.legalLinks,
29
+ legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
29
30
  },
30
31
  };
31
32
  },
32
33
  };
33
34
  }
35
+ function legalLinksPerLocale(legalLinks) {
36
+ return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
37
+ }
34
38
  function consentTextView(locale) {
35
39
  const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
36
40
  return { earlyPerformance, withdrawalLoss, consentTextVersion: consentTextVersion(locale) };
@@ -21,8 +21,9 @@
21
21
  // billing-foundation:write:switch-plan instead. Plan-driven callers should
22
22
  // prefer start-plan-checkout/switch-plan over calling this handler with a
23
23
  // hand-picked priceId.
24
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
24
25
  import * as z from "zod";
25
- import { findProviderPlugin, openCheckout } from "../checkout-core.js";
26
+ import { assertCheckoutAllowed, openCheckout, startProviderCheckout, } from "../checkout-core.js";
26
27
  import { consentCheckoutFields, consentPayloadSchema, prepareConsent, recordConsent, } from "../consumer-protection/consent-gate.js";
27
28
  const createCheckoutSessionSchema = z.object({
28
29
  /** Welcher Provider — entityName eines registrierten subscription-
@@ -44,7 +45,7 @@ const createCheckoutSessionSchema = z.object({
44
45
  export function createCheckoutSessionHandler(options) {
45
46
  const consumerProtection = options.consumerProtection;
46
47
  const schema = consumerProtection
47
- ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() })
48
+ ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() }).strict()
48
49
  : createCheckoutSessionSchema;
49
50
  return {
50
51
  name: "create-checkout-session",
@@ -56,27 +57,39 @@ export function createCheckoutSessionHandler(options) {
56
57
  handler: async (event, ctx) => {
57
58
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
58
59
  const payload = event.payload;
59
- const mode = payload.mode ?? "subscription";
60
- const consent = consumerProtection
61
- ? await prepareConsent(ctx, consumerProtection, payload.consent, mode)
62
- : undefined;
63
- const result = await openCheckout(ctx, { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now }, {
60
+ const openOptions = { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now };
61
+ const checkoutInput = {
64
62
  providerName: payload.providerName,
65
63
  priceId: payload.priceId,
66
64
  successUrl: payload.successUrl,
67
65
  cancelUrl: payload.cancelUrl,
68
66
  ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
69
67
  ...(payload.mode && { mode: payload.mode }),
70
- ...(consent && consentCheckoutFields(consent)),
71
- });
72
- if (consent) {
73
- const plugin = findProviderPlugin(ctx, payload.providerName)?.plugin;
74
- await recordConsent(ctx, consent, {
75
- tier: mode === "subscription" ? (plugin?.priceToTier?.[payload.priceId] ?? null) : null,
76
- priceId: payload.priceId,
77
- price: await lookupPrice(ctx, plugin, payload.priceId),
78
- });
68
+ };
69
+ if (!consumerProtection) {
70
+ const result = await openCheckout(ctx, openOptions, checkoutInput);
71
+ return {
72
+ isSuccess: true,
73
+ data: { url: result.url, providerName: result.providerName },
74
+ };
79
75
  }
76
+ const mode = payload.mode ?? "subscription";
77
+ const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
78
+ const price = await lookupPrice(ctx, plugin, payload.priceId);
79
+ const orderItem = mode === "payment"
80
+ ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
81
+ : undefined;
82
+ const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
83
+ const result = await startProviderCheckout(ctx, plugin, {
84
+ ...checkoutInput,
85
+ ...consentCheckoutFields(consent),
86
+ });
87
+ await recordConsent(ctx, consent, {
88
+ tier: mode === "subscription" ? (plugin.priceToTier?.[payload.priceId] ?? null) : null,
89
+ priceId: payload.priceId,
90
+ price,
91
+ ...(orderItem && { orderItem }),
92
+ });
80
93
  return {
81
94
  isSuccess: true,
82
95
  data: { url: result.url, providerName: result.providerName },
@@ -84,16 +97,25 @@ export function createCheckoutSessionHandler(options) {
84
97
  },
85
98
  };
86
99
  }
87
- // A failed price lookup must not lose a consent the buyer already gave at the
88
- // provider, so it degrades to null price fields.
100
+ // The price is part of the consent record, so it must be known before the
101
+ // buyer is sent to the provider.
89
102
  async function lookupPrice(ctx, plugin, priceId) {
90
- if (!plugin?.retrievePrices)
91
- return null;
103
+ let price;
92
104
  try {
93
- const prices = await plugin.retrievePrices(ctx, [priceId]);
94
- return prices.find((p) => p.priceId === priceId) ?? null;
105
+ const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
106
+ price = prices.find((p) => p.priceId === priceId);
95
107
  }
96
- catch {
97
- return null;
108
+ catch (cause) {
109
+ throw priceUnavailable(priceId, cause);
98
110
  }
111
+ if (!price)
112
+ throw priceUnavailable(priceId);
113
+ return price;
114
+ }
115
+ function priceUnavailable(priceId, cause) {
116
+ return new UnprocessableError("price_unavailable", {
117
+ i18nKey: "billing-foundation.errors.priceUnavailable",
118
+ message: `billing-foundation: no live price resolved for priceId "${priceId}"`,
119
+ ...(cause instanceof Error && { cause }),
120
+ });
99
121
  }
@@ -2,6 +2,8 @@ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.js";
2
2
  export { type BillingInfo, type BillingInfoQueryDeps, createBillingInfoQueryConfig, } from "./billing-info-query.js";
3
3
  export { isBillingEnabled, type ResolvedProvider, resolveProviderPlugin, } from "./checkout-core.js";
4
4
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, type BillingEventKind, BillingEventKinds, type BillingPlanAction, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, SubscriptionCancelTimings, type SubscriptionEventType, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "./constants.js";
5
+ export { type ConsentPayload, consentPayloadSchema, type OrderItem, } from "./consumer-protection/consent-gate.js";
6
+ export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "./consumer-protection/consent-locale.js";
5
7
  export { type ContractTerminationRoutesOptions, createContractTerminationRoutes, } from "./consumer-protection/termination-pages.js";
6
8
  export { paymentEntity, subscriptionEntity } from "./entities.js";
7
9
  export { INVOICE_PAID_EVENT_QN, INVOICE_PAID_EVENT_SHORT, INVOICE_PAYMENT_FAILED_EVENT_QN, INVOICE_PAYMENT_FAILED_EVENT_SHORT, PAYMENT_AGGREGATE_TYPE, PAYMENT_RECEIVED_EVENT_QN, PAYMENT_RECEIVED_EVENT_SHORT, type PaymentEventHeaders, type PaymentEventPayload, paymentEventPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, SUBSCRIPTION_CANCELED_EVENT_QN, SUBSCRIPTION_CANCELED_EVENT_SHORT, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_SHORT, SUBSCRIPTION_UPDATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_SHORT, type SubscriptionEventHeaders, type SubscriptionEventPayload, subscriptionEventPayloadSchema, } from "./events.js";
@@ -10,5 +12,5 @@ export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscript
10
12
  export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.js";
11
13
  export { billingPlansPanel, createBillingPlansScreen } from "./screens.js";
12
14
  export { createSubscriptionTierSync, effectiveTierFromSubscription, SUBSCRIPTION_WEBHOOK_PATH, type SubscriptionTierSyncDeps, } from "./subscription-tier-sync.js";
13
- export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.js";
15
+ export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type LegalLinkSet, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.js";
14
16
  export { createSubscriptionWebhookRoute, type SubscriptionWebhookRouteOptions, } from "./webhook-handler.js";
@@ -3,6 +3,8 @@ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.js";
3
3
  export { createBillingInfoQueryConfig, } from "./billing-info-query.js";
4
4
  export { isBillingEnabled, resolveProviderPlugin, } from "./checkout-core.js";
5
5
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, BillingEventKinds, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, SubscriptionCancelTimings, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "./constants.js";
6
+ export { consentPayloadSchema, } from "./consumer-protection/consent-gate.js";
7
+ export { CONSENT_LOCALES, resolveConsentLocale, } from "./consumer-protection/consent-locale.js";
6
8
  export { createContractTerminationRoutes, } from "./consumer-protection/termination-pages.js";
7
9
  export { paymentEntity, subscriptionEntity } from "./entities.js";
8
10
  export { INVOICE_PAID_EVENT_QN, INVOICE_PAID_EVENT_SHORT, INVOICE_PAYMENT_FAILED_EVENT_QN, INVOICE_PAYMENT_FAILED_EVENT_SHORT, PAYMENT_AGGREGATE_TYPE, PAYMENT_RECEIVED_EVENT_QN, PAYMENT_RECEIVED_EVENT_SHORT, paymentEventPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, SUBSCRIPTION_CANCELED_EVENT_QN, SUBSCRIPTION_CANCELED_EVENT_SHORT, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_SHORT, SUBSCRIPTION_UPDATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema, } from "./events.js";
@@ -3,6 +3,8 @@ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
5
  import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus, type TerminationScope } from "./constants.js";
6
+ import type { OrderItem } from "./consumer-protection/consent-gate.js";
7
+ import type { ConsentLocale } from "./consumer-protection/consent-locale.js";
6
8
  export type SubscriptionEvent = {
7
9
  /** Discriminator against PaymentEvent. Optional (instead of required) so
8
10
  * existing plugins like subscription-mollie, which don't set this field,
@@ -279,6 +281,12 @@ export type BillingPlanCatalog<TTier extends string = string> = {
279
281
  /** Defaults to the only registered provider exposing `priceToTier`. */
280
282
  readonly providerName?: string;
281
283
  };
284
+ export type LegalLinkSet = {
285
+ readonly terms: string;
286
+ readonly withdrawal: string;
287
+ readonly privacy: string;
288
+ };
289
+ export declare function isLegalLinkSet(links: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>): links is LegalLinkSet;
282
290
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
283
291
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
284
292
  * payload and record it as an event before the buyer reaches the provider. */
@@ -294,12 +302,13 @@ export type ConsumerProtectionOptions = {
294
302
  * host that resolves none (the platform apex) — the declaration then runs
295
303
  * without a request tenant and finds the contract by the declarant's email. */
296
304
  readonly terminationScope?: TerminationScope;
297
- /** Root-relative paths or absolute https URLs. */
298
- readonly legalLinks: {
299
- readonly terms: string;
300
- readonly withdrawal: string;
301
- readonly privacy: string;
302
- };
305
+ /** Names the item of a one-off payment (mode "payment") for the consent record
306
+ * and the confirmation mail; the label key is resolved through the registry
307
+ * i18n, so it must be qualified with the registering feature. */
308
+ readonly oneOffItemLabel?: (ctx: HandlerContext, priceId: string) => Promise<OrderItem | undefined>;
309
+ /** Root-relative paths or absolute https URLs. One set for every locale, or
310
+ * one set per consent locale (`de` and `en` required). */
311
+ readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
303
312
  };
304
313
  export type BillingFoundationOptions<TTier extends string = string> = {
305
314
  /** Absolute http(s) URL. Its origin is the only redirect origin
@@ -361,7 +370,7 @@ export type BillingPlansResult = {
361
370
  readonly withdrawalLoss: string;
362
371
  readonly consentTextVersion: string;
363
372
  }>>;
364
- readonly legalLinks: ConsumerProtectionOptions["legalLinks"];
373
+ readonly legalLinks: Readonly<Record<ConsentLocale, LegalLinkSet>>;
365
374
  };
366
375
  };
367
376
  declare module "@cosmicdrift/kumiko-framework/engine" {
@@ -30,3 +30,6 @@ import { SUBSCRIPTION_PROVIDER_EXTENSION, } from "./constants.js";
30
30
  // =============================================================================
31
31
  // Closed set a provider's recurring-price interval narrows to; an unrecognized provider value maps to null instead of widening this type.
32
32
  export const KNOWN_RECURRING_INTERVALS = ["day", "week", "month", "year"];
33
+ export function isLegalLinkSet(links) {
34
+ return "terms" in links;
35
+ }
@@ -1,4 +1,4 @@
1
- import type { BillingFoundationOptions } from "./types.js";
1
+ import { type BillingFoundationOptions } from "./types.js";
2
2
  /** Validates `options` and throws a plain `Error` with a clear message at
3
3
  * feature-definition time — same pattern as `createCapOverviewFeature`.
4
4
  * `createBillingFoundationFeature()` (no options) always succeeds; the
@@ -4,6 +4,8 @@
4
4
  // re-exported from index.ts.
5
5
  import * as z from "zod";
6
6
  import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
7
+ import { CONSENT_LOCALES } from "./consumer-protection/consent-locale.js";
8
+ import { isLegalLinkSet, } from "./types.js";
7
9
  function isRootRelativePath(path) {
8
10
  return path.startsWith("/") && !path.startsWith("//");
9
11
  }
@@ -29,12 +31,32 @@ function validateConsumerProtection(cp) {
29
31
  if (!z.email().safeParse(cp.operatorEmail).success) {
30
32
  throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
31
33
  }
34
+ if (cp.oneOffItemLabel !== undefined && typeof cp.oneOffItemLabel !== "function") {
35
+ throw new Error("createBillingFoundationFeature: consumerProtection.oneOffItemLabel must be a function.");
36
+ }
32
37
  if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
33
38
  throw new Error(`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`);
34
39
  }
35
- for (const [key, link] of Object.entries(cp.legalLinks)) {
40
+ if (isLegalLinkSet(cp.legalLinks)) {
41
+ validateLegalLinkSet(cp.legalLinks, "legalLinks");
42
+ }
43
+ else {
44
+ validatePerLocaleLegalLinks(cp.legalLinks);
45
+ }
46
+ }
47
+ function validatePerLocaleLegalLinks(legalLinks) {
48
+ for (const locale of CONSENT_LOCALES) {
49
+ const set = legalLinks[locale];
50
+ if (!set) {
51
+ throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks needs a "${locale}" entry when given per locale.`);
52
+ }
53
+ validateLegalLinkSet(set, `legalLinks.${locale}`);
54
+ }
55
+ }
56
+ function validateLegalLinkSet(set, path) {
57
+ for (const [key, link] of Object.entries(set)) {
36
58
  if (!isRootRelativeOrHttpsUrl(link)) {
37
- throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
59
+ throw new Error(`createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
38
60
  }
39
61
  }
40
62
  }
@@ -189,7 +189,7 @@ export function BillingPlansPanel(_props) {
189
189
  date: toInstant(result.subscription.cancelAt).toLocaleString(locale, {
190
190
  dateStyle: "medium",
191
191
  }),
192
- }), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] }), consumerProtection !== undefined &&
192
+ }), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && result.subscription?.cancelAt == null && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] }), consumerProtection !== undefined &&
193
193
  consentPlan !== undefined &&
194
194
  consentPlan.price !== null && (_jsx(CheckoutConsentDialog, { open: true, onOpenChange: (open) => {
195
195
  if (!open)
@@ -14,7 +14,7 @@ const TERMINATION_KINDS = ["ordinary", "extraordinary"];
14
14
  export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
15
15
  const t = useTranslation();
16
16
  const locale = useLocale().locale();
17
- const { Modal, Heading, Text, Field, Input, Button, Banner } = usePrimitives();
17
+ const { Modal, Text, Field, Input, Button, Banner } = usePrimitives();
18
18
  const terminate = useMutation(SubscriptionFoundationHandlers.terminateContract);
19
19
  const [step, setStep] = useState("form");
20
20
  const [declarationType, setDeclarationType] = useState("termination");
@@ -38,7 +38,7 @@ export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
38
38
  function formatInstant(iso) {
39
39
  return toInstant(iso).toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
40
40
  }
41
- return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.cancel.title"), testId: "cancel-contract-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Heading, { variant: "page", children: t("billing-foundation.cancel.title") }), step === "form" && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.declaration") }), _jsx(ModeSwitch, { value: declarationType, options: DECLARATION_TYPES.map((value) => ({
41
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.cancel.title"), testId: "cancel-contract-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [step === "form" && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.declaration") }), _jsx(ModeSwitch, { value: declarationType, options: DECLARATION_TYPES.map((value) => ({
42
42
  value,
43
43
  label: t(`billing-foundation.cancel.declaration.${value}`),
44
44
  })), onChange: setDeclarationType, ariaLabel: t("billing-foundation.cancel.declaration"), testId: "cancel-declaration-type" }), declarationType === "withdrawal" && (_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.withdrawalHint") })), _jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.kind") }), _jsx(ModeSwitch, { value: terminationKind, options: TERMINATION_KINDS.map((value) => ({
@@ -16,7 +16,7 @@ export type CheckoutConsentDialogProps = {
16
16
  readonly price: {
17
17
  readonly amount: string;
18
18
  readonly renewalKey?: string;
19
- readonly count: string;
19
+ readonly count?: string;
20
20
  };
21
21
  readonly consumerProtection: ConsumerProtectionView;
22
22
  readonly onOrder: (consent: CheckoutConsentPayload) => Promise<WriteResult<{
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
3
  import { useState } from "react";
4
4
  import { resolveConsentLocale } from "../consumer-protection/consent-locale.js";
@@ -7,14 +7,14 @@ export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
7
7
  export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }) {
8
8
  const t = useTranslation();
9
9
  const uiLocale = useLocale().locale();
10
- const { Modal, Heading, Text, Field, Input, Button, Link, Banner } = usePrimitives();
10
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
11
11
  const [earlyPerformance, setEarlyPerformance] = useState(false);
12
12
  const [withdrawalLoss, setWithdrawalLoss] = useState(false);
13
13
  const [submitting, setSubmitting] = useState(false);
14
14
  const [errorText, setErrorText] = useState(null);
15
15
  const consentLocale = resolveConsentLocale(uiLocale);
16
16
  const texts = consumerProtection.consentTexts[consentLocale];
17
- const { legalLinks } = consumerProtection;
17
+ const legalLinks = consumerProtection.legalLinks[consentLocale];
18
18
  async function order() {
19
19
  setSubmitting(true);
20
20
  setErrorText(null);
@@ -34,5 +34,5 @@ export function CheckoutConsentDialog({ open, onOpenChange, planName, price, con
34
34
  onConsentTextOutdated();
35
35
  }
36
36
  }
37
- return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Heading, { variant: "page", children: t("billing-foundation.consent.title") }), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsx(Text, { variant: "muted", children: t(price.renewalKey, { count: price.count }) })), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }), _jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !earlyPerformance || !withdrawalLoss || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
37
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }))] }), _jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !earlyPerformance || !withdrawalLoss || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
38
38
  }
@@ -1,4 +1,6 @@
1
1
  export { BILLING_PLANS_SCREEN_ID, type BillingPlanAction, BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "../constants.js";
2
- export type { BillingPlanBenefit, BillingPlanCatalog, BillingPlansResult, BillingPlanView, } from "../types.js";
2
+ export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "../consumer-protection/consent-locale.js";
3
+ export type { BillingPlanBenefit, BillingPlanCatalog, BillingPlansResult, BillingPlanView, LegalLinkSet, } from "../types.js";
3
4
  export { BillingPlansPanel } from "./billing-plans-panel.js";
5
+ export { CheckoutConsentDialog, type CheckoutConsentDialogProps, type CheckoutConsentPayload, CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
4
6
  export { type BillingFoundationClientOptions, billingFoundationClient } from "./client-plugin.js";
@@ -4,5 +4,7 @@
4
4
  // the server side (createBillingFoundationFeature) lives under
5
5
  // `@cosmicdrift/kumiko-bundled-features/billing-foundation` and has no React deps.
6
6
  export { BILLING_PLANS_SCREEN_ID, BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "../constants.js";
7
+ export { CONSENT_LOCALES, resolveConsentLocale, } from "../consumer-protection/consent-locale.js";
7
8
  export { BillingPlansPanel } from "./billing-plans-panel.js";
9
+ export { CheckoutConsentDialog, CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
8
10
  export { billingFoundationClient } from "./client-plugin.js";
@@ -4,6 +4,8 @@ export type LegalPageRoute = {
4
4
  readonly slug: string;
5
5
  readonly lang: string;
6
6
  readonly titleFallback: string;
7
+ /** Prepend the page title as `<h1>` to the rendered body. Default false. */
8
+ readonly titleHeading?: boolean;
7
9
  };
8
10
  export type LegalRequiredBlock = {
9
11
  readonly slug: string;
@@ -1,5 +1,6 @@
1
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { defineFeature, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { escapeHtml } from "@cosmicdrift/kumiko-headless";
3
4
  import { cachedSecurePageResponse, redirectToCanonicalPath, slashVariantOf, } from "../page-render/index.js";
4
5
  import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, } from "./constants.js";
5
6
  import { runLegalPagesBootCheck } from "./lib/boot-check.js";
@@ -85,9 +86,13 @@ export function createLegalPagesFeature(opts = {}) {
85
86
  extra,
86
87
  });
87
88
  }
89
+ const title = data.title || route.titleFallback;
90
+ const renderedBody = renderMarkdownToHtml(data.content);
88
91
  const html = wrapLayout({
89
- title: data.title || route.titleFallback,
90
- bodyHtml: renderMarkdownToHtml(data.content),
92
+ title,
93
+ bodyHtml: route.titleHeading
94
+ ? `<h1>${escapeHtml(title)}</h1>${renderedBody}`
95
+ : renderedBody,
91
96
  lang: route.lang,
92
97
  slug: route.slug,
93
98
  });
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ import { renderSafeMarkdown } from "../page-render/index.js";
2
3
  const DEFAULT_PRIMARY_COLOR = "#2563eb";
3
4
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
4
5
  function isHttpUrl(value) {
@@ -95,6 +96,13 @@ function renderSection(section, primaryColor) {
95
96
  if ("text" in section) {
96
97
  return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
97
98
  }
99
+ if ("heading" in section) {
100
+ return `<h2 style="margin:24px 0 12px;color:#111;font-size:16px;font-weight:600">${escapeHtml(section.heading)}</h2>`;
101
+ }
102
+ if ("markdown" in section) {
103
+ const markdownHtml = renderSafeMarkdown(section.markdown);
104
+ return `<div style="color:#333;font-size:14px;line-height:1.5">${markdownHtml}</div>`;
105
+ }
98
106
  if ("button" in section) {
99
107
  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>`;
100
108
  }
package/package.json CHANGED
@@ -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>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.343.0",
507
- "@cosmicdrift/kumiko-framework": "0.343.0",
508
- "@cosmicdrift/kumiko-headless": "0.343.0",
509
- "@cosmicdrift/kumiko-renderer": "0.343.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.343.0",
511
- "@cosmicdrift/kumiko-types": "0.343.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.344.0",
507
+ "@cosmicdrift/kumiko-framework": "0.344.0",
508
+ "@cosmicdrift/kumiko-headless": "0.344.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.344.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.344.0",
511
+ "@cosmicdrift/kumiko-types": "0.344.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.343.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.343.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.344.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.344.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }
@@ -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",
@@ -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",
@@ -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",