feat(legal): electronic withdrawal function and remaining compliance fixes

Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 11:14:02 +02:00
1 parent 4ab1a7edae
commit dcfad4e011
27 files changed
+1049 -34

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines, withdrawalReceiptEmailContent } from "./withdrawal";
const input = {
name: "Erika Mustermann",
orderNumber: "WH-2026-0042",
email: "[email protected]",
message: null,
};
// 26.09.2026 12:03:07 UTC = 14:03:07 in Berlin (Sommerzeit)
const receivedAt = new Date(Date.UTC(2026, 8, 26, 12, 3, 7));
describe("formatReceivedAt", () => {
it("formats in German local time with seconds", () => {
expect(formatReceivedAt(receivedAt, "de")).toBe("26.09.2026, 14:03:07 Uhr (MESZ)");
});
it("uses winter time in winter", () => {
expect(formatReceivedAt(new Date(Date.UTC(2026, 0, 5, 9, 0, 0)), "de")).toBe("05.01.2026, 10:00:00 Uhr (MEZ)");
});
it("formats in English with the German time zone", () => {
expect(formatReceivedAt(receivedAt, "en")).toBe("26 September 2026, 14:03:07 (CEST, German time)");
});
});
describe("withdrawalDeclarationLines", () => {
it("states the withdrawal and the identifying data", () => {
expect(withdrawalDeclarationLines(input, "de")).toEqual([
"Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.",
"Name: Erika Mustermann",
"E-Mail: [email protected]",
]);
});
it("adds an optional message", () => {
expect(withdrawalDeclarationLines({ ...input, message: "Falsche Sorte" }, "en")).toEqual([
"I hereby withdraw from the contract for order WH-2026-0042.",
"Name: Erika Mustermann",
"Email: [email protected]",
"Message: Falsche Sorte",
]);
});
});
describe("escapeHtml", () => {
it("neutralises markup", () => {
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe("&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;");
});
});
describe("withdrawalReceiptEmailContent", () => {
it("contains the declaration and the time of receipt in text and HTML", () => {
const mail = withdrawalReceiptEmailContent(input, receivedAt, "de");
expect(mail.subject).toBe("Eingangsbestätigung deines Widerrufs zu WH-2026-0042");
expect(mail.text).toContain("Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.");
expect(mail.text).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
expect(mail.bodyHtml).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
});
it("escapes user input in the HTML body and strips line breaks from the subject", () => {
const mail = withdrawalReceiptEmailContent(
{ ...input, name: `<img src=x onerror=alert(1)>`, orderNumber: "A1\r\nBcc: [email protected]" },
receivedAt,
"de"
);
expect(mail.bodyHtml).not.toContain("<img");
expect(mail.bodyHtml).toContain("&lt;img src=x onerror=alert(1)&gt;");
expect(mail.subject).not.toMatch(/[\r\n]/);
});
});