@cosmicdrift/kumiko-bundled-features 0.337.1 → 0.338.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/dist/agent-tools/tool-catalog.js +37 -17
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-accept.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-info.query.js +1 -0
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
- package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -0
- package/dist/billing-foundation/aggregate-id.d.ts +2 -0
- package/dist/billing-foundation/aggregate-id.js +8 -0
- package/dist/billing-foundation/checkout-core.d.ts +3 -0
- package/dist/billing-foundation/checkout-core.js +3 -0
- package/dist/billing-foundation/constants.d.ts +31 -0
- package/dist/billing-foundation/constants.js +35 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +88 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
- package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
- package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
- package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
- package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-termination.js +12 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +43 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.js +239 -0
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +51 -0
- package/dist/billing-foundation/consumer-protection/termination-record.js +162 -0
- package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
- package/dist/billing-foundation/consumer-protection/termination-request.js +263 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
- package/dist/billing-foundation/events.d.ts +86 -2
- package/dist/billing-foundation/events.js +76 -2
- package/dist/billing-foundation/feature.js +6 -2
- package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
- package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/process-event.write.js +43 -15
- package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
- package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +15 -1
- package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
- package/dist/billing-foundation/i18n.js +58 -0
- package/dist/billing-foundation/index.d.ts +3 -2
- package/dist/billing-foundation/index.js +2 -1
- package/dist/billing-foundation/types.d.ts +49 -9
- package/dist/billing-foundation/validate-options.js +47 -10
- package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
- package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
- package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
- package/dist/billing-foundation/webhook-handler.js +5 -2
- package/dist/cap-counter/with-cap-enforcement.js +7 -3
- package/dist/cap-overview/access.d.ts +2 -1
- package/dist/cap-overview/access.js +26 -5
- package/dist/cap-overview/feature.d.ts +4 -0
- package/dist/cap-overview/feature.js +8 -6
- package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
- package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +12 -7
- package/dist/cap-overview/index.d.ts +2 -2
- package/dist/cap-overview/index.js +2 -2
- package/dist/cap-overview/screens.d.ts +1 -1
- package/dist/cap-overview/screens.js +17 -16
- package/dist/config/handlers/set.write.js +18 -2
- package/dist/crypto-shredding/feature.js +3 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
- package/dist/crypto-shredding/handlers/forget-subject.write.js +24 -13
- package/dist/custom-fields/feature.js +9 -6
- package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -0
- package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -0
- package/dist/custom-fields/handlers/set-custom-field.write.js +1 -0
- package/dist/delivery/delivery-service.js +9 -7
- package/dist/document-ingest-foundation/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +1 -0
- package/dist/form-draft/handlers/save.write.js +1 -0
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -0
- package/dist/managed-pages/handlers/set.write.js +1 -0
- package/dist/notes-history/executor.d.ts +2 -0
- package/dist/notes-history/executor.js +9 -1
- package/dist/notes-history/feature.d.ts +2 -3
- package/dist/notes-history/feature.js +2 -1
- package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
- package/dist/notes-history/handlers/add-note.write.js +20 -3
- package/dist/notes-history-user-data/hooks.js +26 -8
- package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -0
- package/dist/subscription-mollie/plugin-methods.js +1 -0
- package/dist/subscription-mollie/verify-webhook.js +5 -3
- package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
- package/dist/subscription-stripe/plugin-methods.js +78 -13
- package/dist/subscription-stripe/verify-webhook.js +7 -3
- package/dist/tags/executor.d.ts +2 -0
- package/dist/tags/executor.js +9 -1
- package/dist/tags/feature.d.ts +4 -3
- package/dist/tags/feature.js +4 -2
- package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
- package/dist/tags/handlers/assign-tag.write.js +4 -4
- package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
- package/dist/tags/handlers/remove-tag.write.js +3 -3
- package/dist/tenant-handover/handlers/claim.write.js +1 -0
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -0
- package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -0
- package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/user/handlers/detail.query.js +1 -0
- package/dist/user/handlers/list.query.js +1 -0
- package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -0
- package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
- package/dist/user-data-rights/handlers/download-by-job.query.js +1 -0
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -0
- package/dist/user-data-rights/handlers/export-status.query.js +1 -0
- package/dist/user-data-rights/handlers/lift-restriction.write.js +1 -0
- package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -0
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -0
- package/dist/user-data-rights/handlers/request-export.write.js +1 -0
- package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -0
- package/dist/user-data-rights/run-forget-cleanup.js +30 -15
- package/package.json +9 -9
- package/src/agent-tools/changes.json +14 -0
- package/src/billing-foundation/changes.json +49 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +14 -0
- package/src/config/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/custom-fields/changes.json +7 -0
- package/src/delivery/changes.json +7 -0
- package/src/notes-history/changes.json +7 -0
- package/src/subscription-mollie/changes.json +13 -0
- package/src/subscription-stripe/changes.json +13 -0
- package/src/tags/changes.json +7 -0
- package/src/user-data-rights/changes.json +6 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Public § 312k pages (/legal/kuendigen, /legal/cancel): form → review → confirm,
|
|
2
|
+
// no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
|
|
3
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
import { securePageHeaders, wrapInLayout } from "../../page-render/index.js";
|
|
5
|
+
import { SubscriptionFoundationHandlers } from "../constants.js";
|
|
6
|
+
import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS } from "../events.js";
|
|
7
|
+
import { formatReceivedAt } from "./termination-mail.js";
|
|
8
|
+
import { requestContractTerminationSchema } from "./termination-request.js";
|
|
9
|
+
import { TERMINATION_TEXTS } from "./termination-texts.js";
|
|
10
|
+
const DEFAULT_PATHS = {
|
|
11
|
+
de: "/legal/kuendigen",
|
|
12
|
+
en: "/legal/cancel",
|
|
13
|
+
};
|
|
14
|
+
const FORM_FIELDS = [
|
|
15
|
+
"declarationType",
|
|
16
|
+
"terminationKind",
|
|
17
|
+
"name",
|
|
18
|
+
"email",
|
|
19
|
+
"customerReference",
|
|
20
|
+
"reason",
|
|
21
|
+
];
|
|
22
|
+
const EMPTY_TEXT = "";
|
|
23
|
+
const EMPTY_FORM = {
|
|
24
|
+
declarationType: "termination",
|
|
25
|
+
terminationKind: "ordinary",
|
|
26
|
+
name: "",
|
|
27
|
+
email: "",
|
|
28
|
+
customerReference: "",
|
|
29
|
+
reason: EMPTY_TEXT,
|
|
30
|
+
};
|
|
31
|
+
// The shared security headers leave framing to SAMEORIGIN; this form must
|
|
32
|
+
// never be framed (clickjacking a "cancel now" button).
|
|
33
|
+
function pageHeaders() {
|
|
34
|
+
const headers = securePageHeaders({
|
|
35
|
+
"content-type": "text/html; charset=utf-8",
|
|
36
|
+
"cache-control": "no-store",
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
...headers,
|
|
40
|
+
"content-security-policy": `${headers["content-security-policy"] ?? ""}; frame-ancestors 'none'; form-action 'self'`,
|
|
41
|
+
"x-frame-options": "DENY",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function htmlResponse(locale, title, bodyHtml, status) {
|
|
45
|
+
return new Response(wrapInLayout({ title, bodyHtml, lang: locale }), {
|
|
46
|
+
status,
|
|
47
|
+
headers: pageHeaders(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function isOneOf(options, value) {
|
|
51
|
+
return options.includes(value);
|
|
52
|
+
}
|
|
53
|
+
async function readForm(c) {
|
|
54
|
+
const body = await c.req.parseBody();
|
|
55
|
+
const text = (key) => {
|
|
56
|
+
const value = body[key];
|
|
57
|
+
return typeof value === "string" ? value : "";
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
step: text("step"),
|
|
61
|
+
values: {
|
|
62
|
+
declarationType: text("declarationType"),
|
|
63
|
+
terminationKind: text("terminationKind"),
|
|
64
|
+
name: text("name").trim(),
|
|
65
|
+
email: text("email").trim(),
|
|
66
|
+
customerReference: text("customerReference").trim(),
|
|
67
|
+
reason: text("reason").trim(),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function payloadOf(values, locale) {
|
|
72
|
+
return {
|
|
73
|
+
declarationType: values.declarationType,
|
|
74
|
+
terminationKind: values.terminationKind,
|
|
75
|
+
name: values.name,
|
|
76
|
+
email: values.email,
|
|
77
|
+
locale,
|
|
78
|
+
...(values.customerReference !== "" && { customerReference: values.customerReference }),
|
|
79
|
+
...(values.reason !== "" && { reason: values.reason }),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function validationMessages(values, locale) {
|
|
83
|
+
const parsed = requestContractTerminationSchema.safeParse(payloadOf(values, locale));
|
|
84
|
+
if (parsed.success)
|
|
85
|
+
return [];
|
|
86
|
+
const page = TERMINATION_TEXTS[locale].page;
|
|
87
|
+
const byField = {
|
|
88
|
+
name: page.errorName,
|
|
89
|
+
email: page.errorEmail,
|
|
90
|
+
reason: page.errorReason,
|
|
91
|
+
declarationType: page.errorChoice,
|
|
92
|
+
terminationKind: page.errorChoice,
|
|
93
|
+
};
|
|
94
|
+
return [
|
|
95
|
+
...new Set(parsed.error.issues.map((issue) => byField[String(issue.path[0])] ?? page.errorGeneric)),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
function errorsHtml(messages, page) {
|
|
99
|
+
if (messages.length === 0)
|
|
100
|
+
return "";
|
|
101
|
+
return `<div role="alert"><h2>${escapeHtml(page.errorsHeading)}</h2><ul>${messages
|
|
102
|
+
.map((message) => `<li>${escapeHtml(message)}</li>`)
|
|
103
|
+
.join("")}</ul></div>`;
|
|
104
|
+
}
|
|
105
|
+
function radioGroup(name, options, labels, selected) {
|
|
106
|
+
return options
|
|
107
|
+
.map((option) => `<label><input type="radio" name="${name}" value="${escapeHtmlAttr(option)}"${option === selected ? " checked" : ""}> ${escapeHtml(labels[option])}</label><br>`)
|
|
108
|
+
.join("");
|
|
109
|
+
}
|
|
110
|
+
function textField(name, label, value, attributes) {
|
|
111
|
+
return `<p><label>${escapeHtml(label)}<br><input type="${escapeHtmlAttr(attributes.type ?? "text")}" name="${name}" value="${escapeHtmlAttr(value)}" maxlength="${attributes.maxlength}"${attributes.required ? " required" : ""}></label></p>`;
|
|
112
|
+
}
|
|
113
|
+
function formPage(locale, path, values, messages, status) {
|
|
114
|
+
const texts = TERMINATION_TEXTS[locale];
|
|
115
|
+
const { page } = texts;
|
|
116
|
+
const body = `<h1>${escapeHtml(page.title)}</h1>
|
|
117
|
+
<p>${escapeHtml(page.lead)}</p>
|
|
118
|
+
${errorsHtml(messages, page)}
|
|
119
|
+
<form method="post" action="${escapeHtmlAttr(path)}">
|
|
120
|
+
<input type="hidden" name="step" value="review">
|
|
121
|
+
<fieldset><legend>${escapeHtml(page.declarationTypeHeading)}</legend>${radioGroup("declarationType", CONTRACT_TERMINATION_DECLARATION_TYPES, texts.declarationTypeLabel, values.declarationType)}</fieldset>
|
|
122
|
+
<fieldset><legend>${escapeHtml(page.kindHeading)}</legend>${radioGroup("terminationKind", CONTRACT_TERMINATION_KINDS, texts.terminationKindLabel, values.terminationKind)}<p>${escapeHtml(page.earliestDate)}</p></fieldset>
|
|
123
|
+
${textField("name", page.nameLabel, values.name, { required: true, maxlength: 200 })}
|
|
124
|
+
${textField("email", page.emailLabel, values.email, { type: "email", required: true, maxlength: 254 })}
|
|
125
|
+
${textField("customerReference", page.customerReferenceLabel, values.customerReference, { maxlength: 200 })}
|
|
126
|
+
<p><label>${escapeHtml(page.reasonLabel)} (${escapeHtml(page.reasonHint)})<br><textarea name="reason" rows="5" maxlength="2000">${escapeHtml(values.reason)}</textarea></label></p>
|
|
127
|
+
<p><button type="submit">${escapeHtml(page.reviewButton)}</button></p>
|
|
128
|
+
</form>`;
|
|
129
|
+
return htmlResponse(locale, page.title, body, status);
|
|
130
|
+
}
|
|
131
|
+
function hiddenInputs(values) {
|
|
132
|
+
return FORM_FIELDS.map((field) => `<input type="hidden" name="${field}" value="${escapeHtmlAttr(values[field])}">`).join("\n");
|
|
133
|
+
}
|
|
134
|
+
function reviewPage(locale, path, values) {
|
|
135
|
+
const texts = TERMINATION_TEXTS[locale];
|
|
136
|
+
const { page } = texts;
|
|
137
|
+
// validationMessages already proved the enum values.
|
|
138
|
+
const declarationType = isOneOf(CONTRACT_TERMINATION_DECLARATION_TYPES, values.declarationType)
|
|
139
|
+
? values.declarationType
|
|
140
|
+
: "termination";
|
|
141
|
+
const terminationKind = isOneOf(CONTRACT_TERMINATION_KINDS, values.terminationKind)
|
|
142
|
+
? values.terminationKind
|
|
143
|
+
: "ordinary";
|
|
144
|
+
const row = (label, value) => value === ""
|
|
145
|
+
? ""
|
|
146
|
+
: `<tr><th scope="row">${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
|
|
147
|
+
const body = `<h1>${escapeHtml(page.reviewTitle)}</h1>
|
|
148
|
+
<p>${escapeHtml(page.reviewLead)}</p>
|
|
149
|
+
<table>
|
|
150
|
+
${row(texts.declarationType, texts.declarationTypeLabel[declarationType])}
|
|
151
|
+
${row(texts.terminationKind, texts.terminationKindLabel[terminationKind])}
|
|
152
|
+
${row(texts.name, values.name)}
|
|
153
|
+
${row(texts.email, values.email)}
|
|
154
|
+
${row(texts.customerReference, values.customerReference)}
|
|
155
|
+
${row(texts.reasonFieldLabel, values.reason)}
|
|
156
|
+
</table>
|
|
157
|
+
<form method="post" action="${escapeHtmlAttr(path)}">
|
|
158
|
+
${hiddenInputs(values)}
|
|
159
|
+
<button type="submit" name="step" value="confirm">${escapeHtml(page.confirmButton[declarationType])}</button>
|
|
160
|
+
<button type="submit" name="step" value="edit">${escapeHtml(page.backButton)}</button>
|
|
161
|
+
</form>`;
|
|
162
|
+
return htmlResponse(locale, page.reviewTitle, body, 200);
|
|
163
|
+
}
|
|
164
|
+
function resultPage(locale, receivedAtIso, requestId) {
|
|
165
|
+
const { page } = TERMINATION_TEXTS[locale];
|
|
166
|
+
const body = `<h1>${escapeHtml(page.resultTitle)}</h1>
|
|
167
|
+
<p>${escapeHtml(page.resultLead)}</p>
|
|
168
|
+
<p>${escapeHtml(page.resultReceivedAt)}: <strong>${escapeHtml(formatReceivedAt(receivedAtIso, locale))}</strong></p>
|
|
169
|
+
<p>${escapeHtml(page.resultRequestId)}: <code>${escapeHtml(requestId)}</code></p>
|
|
170
|
+
<p>${escapeHtml(page.resultMailNote)}</p>`;
|
|
171
|
+
return htmlResponse(locale, page.resultTitle, body, 200);
|
|
172
|
+
}
|
|
173
|
+
const RATE_LIMITED_CODE = "rate_limited";
|
|
174
|
+
async function submitDeclaration(c, app, clientIp, values, locale) {
|
|
175
|
+
// deps.write only resolves a session user under /api/*; this page lives
|
|
176
|
+
// outside it, so it re-enters the dispatcher through the app like the
|
|
177
|
+
// user-export by-token route. The resolved clientIp as env keeps the
|
|
178
|
+
// handler's per-IP rate limit per visitor instead of one shared bucket.
|
|
179
|
+
const response = await app.fetch(new Request(`${new URL(c.req.url).origin}/api/write`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/json" },
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
type: SubscriptionFoundationHandlers.requestContractTermination,
|
|
184
|
+
payload: payloadOf(values, locale),
|
|
185
|
+
}),
|
|
186
|
+
}), clientIp);
|
|
187
|
+
const parsed = await response.json().catch(() => null);
|
|
188
|
+
if (response.status === 429 || parsed?.error?.code === RATE_LIMITED_CODE) {
|
|
189
|
+
return { kind: "rate-limited" };
|
|
190
|
+
}
|
|
191
|
+
const requestId = parsed?.data?.requestId;
|
|
192
|
+
const receivedAtIso = parsed?.data?.receivedAtIso;
|
|
193
|
+
if (response.ok &&
|
|
194
|
+
parsed?.isSuccess === true &&
|
|
195
|
+
typeof requestId === "string" &&
|
|
196
|
+
typeof receivedAtIso === "string") {
|
|
197
|
+
return { kind: "received", requestId, receivedAtIso };
|
|
198
|
+
}
|
|
199
|
+
return { kind: "failed" };
|
|
200
|
+
}
|
|
201
|
+
function routesFor(locale, path) {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
method: "GET",
|
|
205
|
+
path,
|
|
206
|
+
entry: "anonymous",
|
|
207
|
+
handler: () => formPage(locale, path, EMPTY_FORM, [], 200),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
method: "POST",
|
|
211
|
+
path,
|
|
212
|
+
entry: "anonymous",
|
|
213
|
+
handler: async (c, deps) => {
|
|
214
|
+
const { step, values } = await readForm(c);
|
|
215
|
+
if (step === "edit")
|
|
216
|
+
return formPage(locale, path, values, [], 200);
|
|
217
|
+
const messages = validationMessages(values, locale);
|
|
218
|
+
if (messages.length > 0)
|
|
219
|
+
return formPage(locale, path, values, messages, 400);
|
|
220
|
+
if (step !== "confirm")
|
|
221
|
+
return reviewPage(locale, path, values);
|
|
222
|
+
const outcome = await submitDeclaration(c, deps.app, deps.clientIp, values, locale);
|
|
223
|
+
const { page } = TERMINATION_TEXTS[locale];
|
|
224
|
+
if (outcome.kind === "received") {
|
|
225
|
+
return resultPage(locale, outcome.receivedAtIso, outcome.requestId);
|
|
226
|
+
}
|
|
227
|
+
if (outcome.kind === "rate-limited") {
|
|
228
|
+
return htmlResponse(locale, page.rateLimitedTitle, `<h1>${escapeHtml(page.rateLimitedTitle)}</h1><p>${escapeHtml(page.rateLimitedBody)}</p>`, 429);
|
|
229
|
+
}
|
|
230
|
+
return formPage(locale, path, values, [page.errorGeneric], 400);
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
/** GET + POST pages for de (`/legal/kuendigen`) and en (`/legal/cancel`);
|
|
236
|
+
* mount via the app's `extraRoutes`. */
|
|
237
|
+
export function createContractTerminationRoutes(options = {}) {
|
|
238
|
+
return ["de", "en"].flatMap((locale) => routesFor(locale, options.paths?.[locale] ?? DEFAULT_PATHS[locale]));
|
|
239
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type QueryHandlerDef, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { type ProviderCancelOutcome } from "../events.js";
|
|
4
|
+
import type { ResolvedBillingFoundationOptions } from "../types.js";
|
|
5
|
+
import type { OperatorNoticeReason } from "./termination-texts.js";
|
|
6
|
+
export declare const recordContractTerminationSchema: z.ZodObject<{
|
|
7
|
+
requestId: z.ZodString;
|
|
8
|
+
declarationType: z.ZodEnum<{
|
|
9
|
+
termination: "termination";
|
|
10
|
+
withdrawal: "withdrawal";
|
|
11
|
+
}>;
|
|
12
|
+
terminationKind: z.ZodEnum<{
|
|
13
|
+
extraordinary: "extraordinary";
|
|
14
|
+
ordinary: "ordinary";
|
|
15
|
+
}>;
|
|
16
|
+
channel: z.ZodEnum<{
|
|
17
|
+
account: "account";
|
|
18
|
+
public: "public";
|
|
19
|
+
}>;
|
|
20
|
+
when: z.ZodEnum<{
|
|
21
|
+
immediately: "immediately";
|
|
22
|
+
none: "none";
|
|
23
|
+
"period-end": "period-end";
|
|
24
|
+
}>;
|
|
25
|
+
}, z.core.$strict>;
|
|
26
|
+
export type RecordContractTerminationResult = {
|
|
27
|
+
readonly receivedAtIso: string;
|
|
28
|
+
readonly effectiveAtIso: string | null;
|
|
29
|
+
readonly providerCancel: ProviderCancelOutcome;
|
|
30
|
+
readonly noticeReason: Extract<OperatorNoticeReason, "provider_cannot_cancel" | "provider_error" | "no_active_subscription"> | null;
|
|
31
|
+
};
|
|
32
|
+
export declare const recordUnmatchedContractTerminationSchema: z.ZodObject<{
|
|
33
|
+
requestId: z.ZodString;
|
|
34
|
+
declarationType: z.ZodEnum<{
|
|
35
|
+
termination: "termination";
|
|
36
|
+
withdrawal: "withdrawal";
|
|
37
|
+
}>;
|
|
38
|
+
terminationKind: z.ZodEnum<{
|
|
39
|
+
extraordinary: "extraordinary";
|
|
40
|
+
ordinary: "ordinary";
|
|
41
|
+
}>;
|
|
42
|
+
receivedAtIso: z.ZodString;
|
|
43
|
+
matchResult: z.ZodEnum<{
|
|
44
|
+
ambiguous: "ambiguous";
|
|
45
|
+
none: "none";
|
|
46
|
+
}>;
|
|
47
|
+
}, z.core.$strict>;
|
|
48
|
+
export declare const terminableSubscriptionSchema: z.ZodObject<{}, z.core.$strict>;
|
|
49
|
+
export declare function createTerminableSubscriptionQuery(): QueryHandlerDef;
|
|
50
|
+
export declare function createRecordContractTerminationHandler(options: ResolvedBillingFoundationOptions): WriteHandlerDef;
|
|
51
|
+
export declare function createRecordUnmatchedContractTerminationHandler(): WriteHandlerDef;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// System-only building blocks of the § 312k flow: the per-tenant record
|
|
2
|
+
// handler (provider cancel + event), the system-stream handler for
|
|
3
|
+
// declarations no tenant matched, and the non-terminal probe the anonymous
|
|
4
|
+
// request handler uses to find candidate tenants.
|
|
5
|
+
import { access, SYSTEM_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { subscriptionAggregateId, terminationUnmatchedAggregateId } from "../aggregate-id.js";
|
|
9
|
+
import { resolveProviderPlugin } from "../checkout-core.js";
|
|
10
|
+
import { isTerminalSubscriptionStatus, SubscriptionCancelTimings } from "../constants.js";
|
|
11
|
+
import { CONTRACT_TERMINATION_CHANNELS, CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS, CONTRACT_TERMINATION_REQUESTED_EVENT_QN, CONTRACT_TERMINATION_UNMATCHED_AGGREGATE_TYPE, CONTRACT_TERMINATION_UNMATCHED_EVENT_QN, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
|
|
12
|
+
import { getSubscriptionForTenant } from "../get-subscription-for-tenant.js";
|
|
13
|
+
const REQUESTED_CANCEL_TIMINGS = [
|
|
14
|
+
SubscriptionCancelTimings.periodEnd,
|
|
15
|
+
SubscriptionCancelTimings.immediately,
|
|
16
|
+
"none",
|
|
17
|
+
];
|
|
18
|
+
export const recordContractTerminationSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
requestId: z.string().min(1).max(100),
|
|
21
|
+
declarationType: z.enum(CONTRACT_TERMINATION_DECLARATION_TYPES),
|
|
22
|
+
terminationKind: z.enum(CONTRACT_TERMINATION_KINDS),
|
|
23
|
+
channel: z.enum(CONTRACT_TERMINATION_CHANNELS),
|
|
24
|
+
when: z.enum(REQUESTED_CANCEL_TIMINGS),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
export const recordUnmatchedContractTerminationSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
requestId: z.string().min(1).max(100),
|
|
30
|
+
declarationType: z.enum(CONTRACT_TERMINATION_DECLARATION_TYPES),
|
|
31
|
+
terminationKind: z.enum(CONTRACT_TERMINATION_KINDS),
|
|
32
|
+
receivedAtIso: z.string().min(1),
|
|
33
|
+
matchResult: z.enum(["none", "ambiguous"]),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export const terminableSubscriptionSchema = z.object({}).strict();
|
|
37
|
+
export function createTerminableSubscriptionQuery() {
|
|
38
|
+
return {
|
|
39
|
+
name: "terminable-subscription",
|
|
40
|
+
description: "Tells whether the caller tenant holds a subscription that is not terminal; the anonymous termination request uses it to pick the tenant a declaration belongs to.",
|
|
41
|
+
agent: { expose: false },
|
|
42
|
+
schema: terminableSubscriptionSchema,
|
|
43
|
+
access: { roles: [SYSTEM_ROLE] },
|
|
44
|
+
handler: async (_query, ctx) => {
|
|
45
|
+
const subscription = await getSubscriptionForTenant(ctx, ctx.user.tenantId);
|
|
46
|
+
return {
|
|
47
|
+
terminable: subscription !== null && !isTerminalSubscriptionStatus(subscription.status),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function createRecordContractTerminationHandler(options) {
|
|
53
|
+
return {
|
|
54
|
+
name: "record-contract-termination",
|
|
55
|
+
agent: { expose: false },
|
|
56
|
+
schema: recordContractTerminationSchema,
|
|
57
|
+
access: { roles: access.system },
|
|
58
|
+
handler: async (event, ctx) => {
|
|
59
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
60
|
+
const payload = event.payload;
|
|
61
|
+
const tenantId = event.user.tenantId;
|
|
62
|
+
if (payload.channel === "public" && payload.when === SubscriptionCancelTimings.immediately) {
|
|
63
|
+
throw new UnprocessableError("immediate_cancel_not_allowed", {
|
|
64
|
+
message: "billing-foundation: the public channel never cancels at the provider immediately",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const receivedAtIso = options.now().toString();
|
|
68
|
+
const stream = await ctx.fetchForWriting({
|
|
69
|
+
aggregateId: subscriptionAggregateId(tenantId),
|
|
70
|
+
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
71
|
+
});
|
|
72
|
+
let providerCancel = "none";
|
|
73
|
+
let effectiveAtIso = null;
|
|
74
|
+
let noticeReason = null;
|
|
75
|
+
if (payload.when !== "none") {
|
|
76
|
+
const subscription = await getSubscriptionForTenant(ctx, tenantId);
|
|
77
|
+
if (subscription === null || isTerminalSubscriptionStatus(subscription.status)) {
|
|
78
|
+
noticeReason = "no_active_subscription";
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const plugin = findPlugin(ctx, subscription.providerName);
|
|
82
|
+
if (!plugin?.cancelSubscription) {
|
|
83
|
+
noticeReason = "provider_cannot_cancel";
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
try {
|
|
87
|
+
await plugin.cancelSubscription(ctx, {
|
|
88
|
+
providerSubscriptionId: subscription.providerSubscriptionId,
|
|
89
|
+
when: payload.when,
|
|
90
|
+
});
|
|
91
|
+
providerCancel = payload.when;
|
|
92
|
+
effectiveAtIso =
|
|
93
|
+
payload.when === SubscriptionCancelTimings.immediately
|
|
94
|
+
? receivedAtIso
|
|
95
|
+
: subscription.currentPeriodEnd.toString();
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
ctx.log?.warn(`[billing-foundation:record-contract-termination] provider cancel failed for tenant ${tenantId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
noticeReason = "provider_error";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const recorded = {
|
|
105
|
+
requestId: payload.requestId,
|
|
106
|
+
declarationType: payload.declarationType,
|
|
107
|
+
terminationKind: payload.terminationKind,
|
|
108
|
+
channel: payload.channel,
|
|
109
|
+
receivedAtIso,
|
|
110
|
+
effectiveAtIso,
|
|
111
|
+
providerCancel,
|
|
112
|
+
};
|
|
113
|
+
await stream.appendOne({
|
|
114
|
+
type: CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
|
|
115
|
+
payload: recorded,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
isSuccess: true,
|
|
119
|
+
data: {
|
|
120
|
+
receivedAtIso,
|
|
121
|
+
effectiveAtIso,
|
|
122
|
+
providerCancel,
|
|
123
|
+
noticeReason,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function findPlugin(ctx, providerName) {
|
|
130
|
+
try {
|
|
131
|
+
return resolveProviderPlugin(ctx, providerName).plugin;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function createRecordUnmatchedContractTerminationHandler() {
|
|
138
|
+
return {
|
|
139
|
+
name: "record-unmatched-contract-termination",
|
|
140
|
+
agent: { expose: false },
|
|
141
|
+
schema: recordUnmatchedContractTerminationSchema,
|
|
142
|
+
access: { roles: access.system },
|
|
143
|
+
handler: async (event, ctx) => {
|
|
144
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
145
|
+
const payload = event.payload;
|
|
146
|
+
const stream = await ctx.fetchForWriting({
|
|
147
|
+
aggregateId: terminationUnmatchedAggregateId(payload.requestId),
|
|
148
|
+
aggregateType: CONTRACT_TERMINATION_UNMATCHED_AGGREGATE_TYPE,
|
|
149
|
+
});
|
|
150
|
+
const recorded = {
|
|
151
|
+
requestId: payload.requestId,
|
|
152
|
+
declarationType: payload.declarationType,
|
|
153
|
+
terminationKind: payload.terminationKind,
|
|
154
|
+
channel: "public",
|
|
155
|
+
receivedAtIso: payload.receivedAtIso,
|
|
156
|
+
matchResult: payload.matchResult,
|
|
157
|
+
};
|
|
158
|
+
await stream.appendOne({ type: CONTRACT_TERMINATION_UNMATCHED_EVENT_QN, payload: recorded });
|
|
159
|
+
return { isSuccess: true, data: { recorded: true } };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { type ProviderCancelOutcome } from "../events.js";
|
|
4
|
+
import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.js";
|
|
5
|
+
export declare const requestContractTerminationSchema: z.ZodObject<{
|
|
6
|
+
declarationType: z.ZodEnum<{
|
|
7
|
+
termination: "termination";
|
|
8
|
+
withdrawal: "withdrawal";
|
|
9
|
+
}>;
|
|
10
|
+
terminationKind: z.ZodEnum<{
|
|
11
|
+
extraordinary: "extraordinary";
|
|
12
|
+
ordinary: "ordinary";
|
|
13
|
+
}>;
|
|
14
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
15
|
+
name: z.ZodString;
|
|
16
|
+
email: z.ZodEmail;
|
|
17
|
+
customerReference: z.ZodOptional<z.ZodString>;
|
|
18
|
+
locale: z.ZodOptional<z.ZodEnum<{
|
|
19
|
+
de: "de";
|
|
20
|
+
en: "en";
|
|
21
|
+
}>>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
export declare const terminateContractSchema: z.ZodObject<{
|
|
24
|
+
declarationType: z.ZodEnum<{
|
|
25
|
+
termination: "termination";
|
|
26
|
+
withdrawal: "withdrawal";
|
|
27
|
+
}>;
|
|
28
|
+
terminationKind: z.ZodEnum<{
|
|
29
|
+
extraordinary: "extraordinary";
|
|
30
|
+
ordinary: "ordinary";
|
|
31
|
+
}>;
|
|
32
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
33
|
+
}, z.core.$strict>;
|
|
34
|
+
export type RequestContractTerminationResult = {
|
|
35
|
+
readonly requestId: string;
|
|
36
|
+
readonly receivedAtIso: string;
|
|
37
|
+
};
|
|
38
|
+
export type TerminateContractResult = {
|
|
39
|
+
readonly requestId: string;
|
|
40
|
+
readonly receivedAtIso: string;
|
|
41
|
+
readonly effectiveAtIso: string | null;
|
|
42
|
+
readonly providerCancel: ProviderCancelOutcome;
|
|
43
|
+
};
|
|
44
|
+
export declare function createRequestContractTerminationHandler(options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): WriteHandlerDef;
|
|
45
|
+
export declare function createTerminateContractHandler(options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): WriteHandlerDef;
|