@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +35 -7
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +36 -22
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +12 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +34 -1
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +14 -5
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +61 -36
- package/packages/bundled-features/src/billing-foundation/index.ts +11 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +25 -7
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +36 -3
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +1 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +16 -6
- package/packages/bundled-features/src/billing-foundation/web/index.ts +12 -0
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/constants.ts +2 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +8 -3
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +10 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +10 -11
- 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.
|
|
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.
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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<
|
|
350
|
+
): Promise<CheckoutCapableProvider> {
|
|
341
351
|
const { plugin } = resolveProviderPlugin(ctx, input.providerName);
|
|
342
|
-
if (!plugin
|
|
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
|
+
}
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
175
|
+
{ heading: labels.consentHeading },
|
|
162
176
|
{ text: texts.earlyPerformance },
|
|
163
177
|
{ text: texts.withdrawalLoss },
|
|
164
|
-
{ text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
|
|
165
|
-
{
|
|
166
|
-
|
|
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
|
};
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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";
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-locale.ts
CHANGED
|
@@ -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 = "
|
|
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);
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts
CHANGED
|
@@ -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
|
|
15
|
-
BillingPlanCatalog,
|
|
16
|
-
BillingPlansResult,
|
|
17
|
-
|
|
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) };
|
package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 (
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
118
|
-
//
|
|
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
|
|
136
|
+
plugin: SubscriptionProviderPlugin,
|
|
122
137
|
priceId: string,
|
|
123
|
-
): Promise<ProviderPrice
|
|
124
|
-
|
|
138
|
+
): Promise<ProviderPrice> {
|
|
139
|
+
let price: ProviderPrice | undefined;
|
|
125
140
|
try {
|
|
126
|
-
const prices = await plugin.retrievePrices(ctx, [priceId]);
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
|
|
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
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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: {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
177
|
-
bodyHtml:
|
|
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
|
|
73
|
-
benefits: (tier) => [{ labelKey: `gallery
|
|
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
|
|
144
|
-
"gallery
|
|
145
|
-
"gallery
|
|
146
|
-
"gallery
|
|
147
|
-
"gallery
|
|
148
|
-
"gallery
|
|
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: "
|
|
46
|
-
en: "
|
|
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) => {
|