@cosmicdrift/kumiko-samples 0.342.0 → 0.344.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/constants.ts +5 -1
- package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +99 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +7 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
- 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/channel-discord/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -0
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
- package/packages/bundled-features/src/channel-push/feature.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/changes.json +8 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/changes.json +32 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
- package/packages/bundled-features/src/delivery/constants.ts +4 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
- package/packages/bundled-features/src/delivery/events.ts +15 -1
- package/packages/bundled-features/src/delivery/feature.ts +32 -4
- package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
- package/packages/bundled-features/src/delivery/i18n.ts +55 -0
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +43 -20
- package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
- package/packages/bundled-features/src/delivery/public-names.ts +4 -0
- package/packages/bundled-features/src/delivery/tables.ts +3 -0
- package/packages/bundled-features/src/delivery/testing.ts +5 -9
- package/packages/bundled-features/src/delivery/types.ts +6 -2
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
- package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
- package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
- package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
- package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
- package/packages/bundled-features/src/jobs/changes.json +6 -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/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/constants.ts +10 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -0
- package/packages/bundled-features/src/secrets/i18n.ts +5 -3
- package/packages/bundled-features/src/secrets/index.ts +8 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/redact.ts +27 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
- package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
- package/packages/bundled-features/src/user/index.ts +1 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +35 -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/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
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">
|