From 7ffe95f24ab9b768ff897d0102ab9ec7adde52ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:52:37 +0000 Subject: [PATCH] =?UTF-8?q?K=C3=BCndigung=20und=20Widerruf:=20Vertrag=20bz?= =?UTF-8?q?w.=20Bestellung=20ausw=C3=A4hlbar=20(=C2=A7=20312k,=20=C2=A7=20?= =?UTF-8?q?356a=20BGB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- public/kuendigen.html | 21 +- public/legal-form.js | 41 +- public/widerrufen.html | 13 +- .../K03 Eingang Kuendigung.html | 14 +- .../mail-templates/K04 Eingang Widerruf.html | 19 +- .../T08 Kuendigung eingegangen.html | 21 +- .../T09 Widerruf eingegangen.html | 20 +- server/routes/abo.js | 390 ++++++++++++-- test/mailTemplates.test.js | 46 ++ test/publicForms.test.js | 494 ++++++++++++++++++ 10 files changed, 992 insertions(+), 87 deletions(-) create mode 100644 test/publicForms.test.js diff --git a/public/kuendigen.html b/public/kuendigen.html index 33128ad..27633c8 100644 --- a/public/kuendigen.html +++ b/public/kuendigen.html @@ -14,6 +14,18 @@

Verträge hier kündigen

+ +
+ Vertrag + + +
+ +
Art der Kündigung @@ -34,10 +46,7 @@ - - - -
+
Zeitpunkt @@ -52,13 +61,13 @@

Ihre Kündigung ist eingegangen

Eingang:

-

Die Bestätigung mit dem Zeitpunkt, zu dem Ihr Vertrag endet, haben wir an Ihre E-Mail-Adresse geschickt.

+

Eine Eingangsbestätigung mit diesen Angaben haben wir an Ihre E-Mail-Adresse geschickt. Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail.

diff --git a/public/legal-form.js b/public/legal-form.js index a11dd88..e272a49 100644 --- a/public/legal-form.js +++ b/public/legal-form.js @@ -1,6 +1,8 @@ // Oeffentliche Erklaerungsseiten: Kuendigung (§ 312k BGB) und Widerruf (§ 356a BGB). // Ohne Login erreichbar, Formular sofort sichtbar (keine Zwischenschritte). -// Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar. +// Der Kunde waehlt den Vertrag (Kuendigung: Abo oder nur der Zusatzspeicher, ganz oder mit Paketzahl) bzw. die +// Bestellung (Widerruf: Abschluss, Wechsel, Zusatzspeicher); die Auswahl geht in den Request und steht in der +// Bestaetigung. Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar. function initLegalForm({ endpoint, fields }) { const form = document.getElementById('decl-form') const done = document.getElementById('done') @@ -21,23 +23,34 @@ function initLegalForm({ endpoint, fields }) { const reasonWrap = document.getElementById('reason-wrap') form.querySelectorAll('[name="kind"]').forEach((r) => - r.addEventListener('change', () => reasonWrap?.classList.toggle('hidden', form.kind.value !== 'ausserordentlich')) + r.addEventListener('change', () => reasonWrap?.classList.toggle('hidden', form.elements.kind.value !== 'ausserordentlich')) ) const dateInput = document.getElementById('effective-date') form.querySelectorAll('[name="when"]').forEach((r) => r.addEventListener('change', () => { - const onDate = form.when.value === 'datum' + const onDate = form.elements.when.value === 'datum' dateInput?.classList.toggle('hidden', !onDate) if (onDate) dateInput?.focus() }) ) + // Nur der Zusatzspeicher: Paketzahl abfragen; der Zeitpunkt entfaellt (Ende des Abrechnungsmonats) + const storageOnly = () => form.elements.contract?.value === 'zusatzspeicher' + const packagesWrap = document.getElementById('packages-wrap') + const whenWrap = document.getElementById('when-wrap') + form.querySelectorAll('[name="contract"]').forEach((r) => + r.addEventListener('change', () => { + packagesWrap?.classList.toggle('hidden', !storageOnly()) + whenWrap?.classList.toggle('hidden', storageOnly()) + }) + ) form.addEventListener('submit', async (ev) => { ev.preventDefault() errEl.classList.add('hidden') const payload = {} for (const f of fields) payload[f] = String(form.elements[f]?.value ?? '').trim() - if (form.when?.value === 'datum') payload.effective = dateInput?.value || '' + if (form.elements.when?.value === 'datum' && !storageOnly()) payload.effective = dateInput?.value || '' + if (form.elements.contract && !storageOnly()) delete payload.packages const btn = form.querySelector('button[type="submit"]') btn.disabled = true try { @@ -50,17 +63,25 @@ function initLegalForm({ endpoint, fields }) { if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`) const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'medium' }) document.getElementById('done-at').textContent = at - const rows = [['Vertrag', 'WEBklar Wartungs-Abo']] + // Zeile "Vertrag": die gewaehlte Beschriftung (data-text des Radioknopfs) statt eines festen Namens + const rows = [] for (const f of fields) { - if (!payload[f] || f === 'effective') continue + if (f === 'effective') continue + if (f === 'packages') { + if (storageOnly()) rows.push(['Pakete', payload.packages ? `${payload.packages} Paket(e) zu je 5 GB` : 'alle gebuchten Pakete']) + continue + } + if (!payload[f]) continue const label = form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f const value = form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f] rows.push([label, value]) } - if (form.when) { - rows.push(['Zeitpunkt', payload.effective - ? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)` - : 'zum nächstmöglichen Zeitpunkt']) + if (form.elements.when) { + rows.push(['Zeitpunkt', storageOnly() + ? 'zum Ende des laufenden Abrechnungsmonats' + : payload.effective + ? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)` + : 'zum nächstmöglichen Zeitpunkt']) } document.getElementById('done-list').innerHTML = rows.map(([k, v]) => `
${esc(k)}
${esc(v)}
`).join('') form.classList.add('hidden') diff --git a/public/widerrufen.html b/public/widerrufen.html index 3cd50c5..968a493 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -14,12 +14,17 @@

Vertrag widerrufen

+ +
+ Vertrag + + + +
+ - - - @@ -40,7 +45,7 @@ diff --git a/server/mail-templates/K03 Eingang Kuendigung.html b/server/mail-templates/K03 Eingang Kuendigung.html index 027552c..99e0da3 100644 --- a/server/mail-templates/K03 Eingang Kuendigung.html +++ b/server/mail-templates/K03 Eingang Kuendigung.html @@ -7,8 +7,8 @@ Betreff: Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo Absender: „WEBklar“ Empfänger: E-Mail aus dem Formular · BCC intern - Platzhalter: {{name}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{wunschtermin}}, {{grund}}, {{vertragsende}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden + Platzhalter: {{name}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{vertrag}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{pakete}}, {{pakete_neu}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, pruefen, abo --> @@ -39,7 +39,7 @@ a{color:#111111;} Kundennummer{{kundennummer}} -VertragWEBklar Wartungs-AboGewünschter Zeitpunkt{{wunschtermin}} +Vertrag{{vertrag}}Gewünschter Zeitpunkt{{wunschtermin}} Grund{{grund}} @@ -63,8 +63,16 @@ a{color:#111111;} + + + + + + + Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit. + Viele Grüße
Ihr WEBklar-Team diff --git a/server/mail-templates/K04 Eingang Widerruf.html b/server/mail-templates/K04 Eingang Widerruf.html index ef8f4d1..97ba6d8 100644 --- a/server/mail-templates/K04 Eingang Widerruf.html +++ b/server/mail-templates/K04 Eingang Widerruf.html @@ -7,8 +7,8 @@ Betreff: Eingangsbestätigung Ihres Widerrufs – WEBklar Wartungs-Abo Absender: „WEBklar“ Empfänger: E-Mail aus dem Formular · BCC intern - Platzhalter: {{name}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer + Platzhalter: {{name}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, pruefen --> @@ -34,13 +34,24 @@ a{color:#111111;} - - diff --git a/server/mail-templates/T08 Kuendigung eingegangen.html b/server/mail-templates/T08 Kuendigung eingegangen.html index ec6b015..50fdc6a 100644 --- a/server/mail-templates/T08 Kuendigung eingegangen.html +++ b/server/mail-templates/T08 Kuendigung eingegangen.html @@ -7,8 +7,8 @@ Betreff: [Abo] Kündigung eingegangen: {{email}} Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com - Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden + Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{vertrag}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}}, {{pakete}}, {{fehler}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, kein_speicher, wechsel_ausstehend, speicher_fehlgeschlagen, nachweis_fehlt --> @@ -38,7 +38,7 @@ a{color:#111111;} - + @@ -62,6 +62,21 @@ a{color:#111111;} + + + + + + + + + + + + + + +
Widerruf eingegangen
Wir haben Ihren Widerruf erhalten.
Guten Tag {{name}},
wir haben Ihren Widerruf des Vertrags „WEBklar Wartungs-Abo“ am {{eingang_zeitpunkt}} Uhr erhalten.
+ + + + + + + + + + + + +
Name{{name}}
E-Mail{{email}}
Guten Tag {{name}},
wir haben Ihren Widerruf der Bestellung „{{bestellung}}“ am {{eingang_zeitpunkt}} Uhr erhalten.
Name{{name}}
E-Mail{{email}}
Bestellung{{bestellung}}
Kundennummer{{kundennummer}}
Viele Grüße
Ihr WEBklar-Team
Kundennummer{{kundennummer}}
Art{{kuendigungsart}}
Gewünschter Zeitpunkt{{wunschtermin}}
Vertrag{{vertrag}}
Art{{kuendigungsart}}
Gewünschter Zeitpunkt{{wunschtermin}}
Grund{{grund}}
 
diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index 87634b8..37b4b41 100644 --- a/server/mail-templates/T09 Widerruf eingegangen.html +++ b/server/mail-templates/T09 Widerruf eingegangen.html @@ -7,8 +7,8 @@ Betreff: [Abo] WIDERRUF eingegangen: {{email}} Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com - Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen + Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, keine_frist, nachweis_fehlt --> @@ -34,7 +34,7 @@ a{color:#111111;} -
Widerruf
Widerruf eingegangen
+ diff --git a/server/routes/abo.js b/server/routes/abo.js index 58f4a17..27ca5a8 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -5,7 +5,7 @@ import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js' -import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js' +import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js' import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' @@ -20,7 +20,7 @@ import { reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' -import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js' +import { PRODUCT_KEYS, describeItems, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, cancelAtPeriodEnd, @@ -29,6 +29,7 @@ import { createAboCheckout, createBillingPortalUrl, getCurrentSubscription, + getStripe, grantCreditForInvoice, loadInvoice, minTermMonthsFor, @@ -632,19 +633,67 @@ router.post('/webhook', async (req, res) => { // Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse. // --------------------------------------------------------------------------- +// Ratenbegrenzung: hoechstens 10 Erklaerungen je Stunde und IP-Adresse (Datenschutzerklaerung Abschnitt 7: die +// IP-Adresse dient nur dazu, die Zahl der Anfragen zu begrenzen, sie wird nicht mit der Erklaerung gespeichert). +// Eintraege, die aelter als eine Stunde sind, fliegen bei jedem Aufruf raus - auch die anderer IP-Adressen (F30). +const RATE_WINDOW_MS = 60 * 60 * 1000 +const RATE_MAX = 10 const recentRequests = new Map() // ip -> [timestamps] -function rateLimited(ip) { - const now = Date.now() - const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000) - list.push(now) - recentRequests.set(ip, list) - return list.length > 10 + +function purgeRateLimit(now) { + for (const [ip, list] of recentRequests) { + const fresh = list.filter((t) => now - t < RATE_WINDOW_MS) + if (fresh.length) recentRequests.set(ip, fresh) + else recentRequests.delete(ip) + } } +function rateLimited(ip, now = Date.now()) { + purgeRateLimit(now) + const list = recentRequests.get(ip) || [] + list.push(now) + recentRequests.set(ip, list) + return list.length > RATE_MAX +} + +/** Nur fuer Tests: alle Zaehler der Ratenbegrenzung loeschen. */ +export function resetPublicRateLimit() { + recentRequests.clear() +} + +/** Nur fuer Tests: Zahl der IP-Adressen, die zum Zeitpunkt now noch gezaehlt wuerden (aendert nichts). */ +export function publicRateLimitState({ now = Date.now() } = {}) { + const ips = [...recentRequests.values()].filter((list) => list.some((t) => now - t < RATE_WINDOW_MS)).length + return { ips } +} + +// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert +const CONTRACTS = ['abo', 'zusatzspeicher'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher'] +// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) +const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' } + +/** + * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher'; packages (nur beim + * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). + */ function validDeclaration(body) { const name = String(body?.name || '').trim().slice(0, 200) const email = String(body?.email || '').trim().slice(0, 255) if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null + const contract = CONTRACTS.includes(body?.contract) ? body.contract : 'abo' + const order = ORDERS.includes(body?.order) ? body.order : 'abschluss' + let packages = null + if (contract === 'zusatzspeicher') { + const raw = String(body?.packages ?? '').trim() + if (raw) { + packages = /^\d+$/.test(raw) ? Number(raw) : NaN + if (!(packages >= 1 && packages <= config.mailStorage.maxUnits)) { + return { error: `Bitte geben Sie 1 bis ${config.mailStorage.maxUnits} Pakete an oder lassen Sie das Feld leer (alle Pakete).` } + } + } + } return { name, email, @@ -652,9 +701,30 @@ function validDeclaration(body) { kind: ['ausserordentlich', 'geschaeftsaufgabe'].includes(body?.kind) ? body.kind : 'ordentlich', reason: String(body?.reason || '').trim().slice(0, 2000), effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '', + contract, + order, + packages, } } +/** Ratenbegrenzung und Erklaerung pruefen; bei 429/400 ist die Antwort schon geschickt (null). */ +function readDeclaration(req, res) { + if (rateLimited(req.ip)) { + res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }) + return null + } + const d = validDeclaration(req.body) + if (!d) { + res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' }) + return null + } + if (d.error) { + res.status(400).json({ error: d.error }) + return null + } + return d +} + async function findActiveSubscriptionByEmail(email) { const customer = (await getCustomerByEmail(email).catch(() => null)) || @@ -664,51 +734,177 @@ async function findActiveSubscriptionByEmail(email) { return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null } -router.post('/public/cancel', async (req, res) => { - if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }) - const d = validDeclaration(req.body) - if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' }) - const receivedAt = new Date() +/** Name des Abos aus dem Katalog ("WEBklar Hosting-Abo"), null ohne Abo. */ +const productNameOf = (sub) => (sub ? config.products[productKeyOf(sub)]?.name || 'WEBklar Abo' : null) - let sub = null +/** Beschriftung des gekuendigten Vertrags fuer K03/T08: das tatsaechliche Abo (Spec 4.5) bzw. der Zusatzspeicher. */ +function contractLabel(contract, sub) { + const abo = productNameOf(sub) || 'WEBklar Abo (Hosting- oder Wartungs-Abo)' + return contract === 'zusatzspeicher' ? `E-Mail-Zusatzspeicher${sub ? ` zum ${abo}` : ''}` : abo +} + +/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */ +function orderLabel(order, sub, { customer = false } = {}) { + if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' + if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' + const abo = productNameOf(sub) + return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` +} + +/** + * Nachweis der Erklaerung als Vertragsereignis kuendigung bzw. widerruf (F31, Datenschutz Abschnitt 7: ohne + * IP-Adresse und Browser). Feste ID je Abo und Eingangszeitpunkt (eventIdFor, 36 Zeichen). Laesst es sich + * nicht speichern, geht die Erklaerung trotzdem weiter: die Mails sind der Nachweis, T08/T09 sagen es dem Team. + */ +async function recordDeclarationEvent(sub, type, receivedAt, details, status) { + try { + return await recordContractEvent({ + id: eventIdFor(type, `${sub.$id}:${receivedAt.toISOString()}`), + customerId: sub.customerId, + subscriptionId: sub.$id, + type, + productKey: productKeyOf(sub), + details, + consent: null, + status, + }) + } catch (err) { + console.error(`[abo] Ereignis ${type} für Abo ${sub.$id} nicht gespeichert:`, err.message) + return null + } +} + +/** Kuendigung des Abos wie bisher: Stripe (ausser ausserordentlich), Eingang am Abo, Ereignis kuendigung. */ +async function cancelAbo(sub, d, receivedAt) { let effectiveAt = null try { - sub = await findActiveSubscriptionByEmail(d.email) - if (sub && d.kind !== 'ausserordentlich') { + if (d.kind !== 'ausserordentlich') { const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton', d.kind, receivedAt) - sub = result.subscription + sub = result.subscription || sub effectiveAt = result.effectiveAt } - if (sub) { - await updateDocument(config.collections.subscriptions, sub.$id, { - cancelRequestedAt: receivedAt.toISOString(), - }) - } + await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString() }) } catch (err) { console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message) } + // Erledigt nur, wenn nichts mehr zu tun ist; ausserordentlich, Geschaeftsaufgabe (Nachweis) und Stripe-Fehler + // bleiben als Aufgabe offen + const status = effectiveAt && d.kind === 'ordentlich' ? 'erledigt' : 'offen' + const event = await recordDeclarationEvent(sub, 'kuendigung', receivedAt, { contract: 'abo', kind: d.kind, effectiveAt }, status) + return { sub, effectiveAt, event, outcome: effectiveAt ? 'gekuendigt' : 'offen' } +} + +/** + * Wechsel ins Wartungs-Abo, der noch auf die Kartenzahlung wartet (offenes Ereignis wechsel ohne effectiveAt + * oder pending_update am Stripe-Abo): eine Aenderung des Abos wuerde die Wechselrechnung stornieren. Ein + * abgelaufener Wechsel wird vorher wie in GET /api/abo mit Stripe abgeglichen. + */ +async function pendingCardSwitch(sub, stripeSub) { + const readOpen = async () => + (await listContractEvents(sub.customerId)).find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === sub.$id) || null + let open = await readOpen() + if (open && (await reconcilePendingSwitch(open))) open = await readOpen() + return Boolean((open && !open.details?.effectiveAt) || stripeSub?.pending_update) +} + +/** + * Kuendigung nur des Zusatzspeichers (Bedingungen § 8 Abs. 7/8, § 10 Abs. 5): gebuchte Pakete minus packages + * (leer = alle) ueber changeStorage mit viaCancellation (Task 8: nur Verringerung, in jedem laufenden Status, + * auch bei gekuendigtem Abo), wirksam zum Ende des Abrechnungsmonats. Ohne gebuchten Speicher, waehrend ein + * Kartenwechsel aussteht oder wenn changeStorage scheitert, aendert sich nichts: K03 sagt "wir pruefen", T08 + * traegt den Grund (F20). Liefert { outcome, error, event, details? } (details nur nach einer Verringerung). + */ +async function cancelStorage(sub, d, receivedAt) { + // Gebuchte Menge frisch aus Stripe (der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken); die + // Antwort zeigt auch, ob eine Aenderung bis zur Zahlung zurueckgehalten wird + let stripeSub = null + let fromUnits = Number(sub.storageUnits) || 0 + try { + stripeSub = await getStripe().subscriptions.retrieve(sub.$id) + fromUnits = describeItems(stripeSub.items?.data).storageUnits + } catch (err) { + console.error(`[abo] Stripe-Abo ${sub.$id} nicht lesbar, Speicherstand aus dem Spiegel:`, err.message) + } + const base = { contract: 'zusatzspeicher', effectiveAt: null, packages: d.packages, fromUnits, toUnits: null } + const pending = async (outcome, error = null) => ({ + outcome, + error, + event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, base, 'offen'), + }) + if (fromUnits <= 0) return pending('kein_speicher') + // Laesst sich nicht klaeren, ob ein Wechsel aussteht, bleibt Stripe unangetastet + try { + if (await pendingCardSwitch(sub, stripeSub)) return pending('wechsel_ausstehend') + } catch (err) { + console.error(`[abo] Wechsel-Prüfung für Abo ${sub.$id} nicht möglich:`, err.message) + return pending('fehlgeschlagen', `Vertragsereignisse nicht lesbar: ${err.message}`) + } + const restUnits = Math.max(0, fromUnits - (d.packages ?? fromUnits)) + try { + const { change } = await changeStorage(sub.customerId, restUnits, null, { viaCancellation: true }) + const details = { + contract: 'zusatzspeicher', + effectiveAt: change.effectiveAt, + packages: change.fromUnits - change.toUnits, + fromUnits: change.fromUnits, + toUnits: change.toUnits, + } + return { outcome: 'reduziert', error: null, details, event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, details, 'erledigt') } + } catch (err) { + console.error(`[abo] Zusatzspeicher über die Kündigung nicht verringert (Abo ${sub.$id}):`, err.message) + return pending('fehlgeschlagen', err.message || 'unbekannter Fehler') + } +} + +router.post('/public/cancel', async (req, res) => { + const d = readDeclaration(req, res) + if (!d) return + const receivedAt = new Date() + const storage = d.contract === 'zusatzspeicher' + + let sub = null + let result = { outcome: 'kein_abo', effectiveAt: null, details: null, event: null, error: null } + try { + sub = await findActiveSubscriptionByEmail(d.email) + if (sub) { + result = storage ? await cancelStorage(sub, d, receivedAt) : await cancelAbo(sub, d, receivedAt) + sub = result.sub || sub + } + } catch (err) { + console.error('[abo] Kuendigung nicht verarbeitbar:', err.message) + } + const reduced = result.outcome === 'reduziert' + const effectiveAt = storage ? result.details?.effectiveAt || null : result.effectiveAt const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind] const common = { name: d.name, email: d.email, kundennummer: d.customerNumber, - wunschtermin: d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt', + vertrag: contractLabel(d.contract, sub), + // Eine Verringerung des Speichers wirkt immer zum Ende des Abrechnungsmonats + wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt', grund: d.reason, vertragsende: fmtDate(effectiveAt), + // {{pakete}} = gekuendigte Pakete (F31); solange nichts verringert wurde, der Wunsch des Kunden + pakete: reduced ? result.details.packages : d.packages ?? 'alle', } await sendCustomerMail(d.email, { template: 'K03', - values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) }, + values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) }, blocks: { kundennummer: Boolean(d.customerNumber), grund: Boolean(d.reason), - automatisch_gekuendigt: Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe', - sonderkuendigung: Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe', - ausserordentlich: !effectiveAt && d.kind === 'ausserordentlich', - kein_abo_gefunden: !effectiveAt && d.kind !== 'ausserordentlich', + abo: !storage, + automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe', + sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe', + ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich', + // Abo: nicht automatisch gekuendigt (kein Abo oder Stripe-Fehler) -> "wir ordnen zu"; Speicher: kein Abo + kein_abo_gefunden: storage ? !sub : !effectiveAt && d.kind !== 'ausserordentlich', + zusatzspeicher: reduced, + pruefen: storage && Boolean(sub) && !reduced, }, }).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message)) @@ -718,74 +914,160 @@ router.post('/public/cancel', async (req, res) => { ...common, kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1), stripe_abo_id: sub?.stripeSubscriptionId || '', + fehler: result.error || '', }, blocks: { kundennummer: Boolean(d.customerNumber), grund: Boolean(d.reason), - automatisch_gekuendigt: Boolean(effectiveAt), - sonderkuendigung: d.kind === 'geschaeftsaufgabe', + automatisch_gekuendigt: !storage && Boolean(effectiveAt), + sonderkuendigung: !storage && d.kind === 'geschaeftsaufgabe', // Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler - ausserordentlich: !effectiveAt && Boolean(sub), + ausserordentlich: !storage && !effectiveAt && Boolean(sub), kein_abo_gefunden: !sub, + zusatzspeicher: reduced, + kein_speicher: result.outcome === 'kein_speicher', + wechsel_ausstehend: result.outcome === 'wechsel_ausstehend', + speicher_fehlgeschlagen: result.outcome === 'fehlgeschlagen', + nachweis_fehlt: Boolean(sub) && !result.event, }, }) return res.json({ ok: true, receivedAt: receivedAt.toISOString() }) }) +/** Spaetestes Fristende einer Liste von Widerrufsfristen (openWithdrawals) oder null. */ +const latestUntil = (list) => list.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null) + +/** + * Widerruf der gewaehlten Bestellung: liegt eine offene Frist des Typs vor (openWithdrawals; Altbestand ohne + * Ereignis abschluss: ab startedAt, F14), bekommen alle Bezugsereignisse den Status widerrufen (F03), beim + * Abschluss wird das Abo wie bisher auf Kuendigung gesetzt; Wechsel und Zusatzspeicher setzt das Team von Hand + * zurueck (T09). Ohne offene Frist aendert sich nichts. Immer entsteht das Ereignis widerruf mit + * details { order, eventId, eventIds, inTime }. + */ +async function withdrawOrder(sub, d, receivedAt) { + const type = ORDER_EVENT_TYPE[d.order] + const events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type) + let open = openWithdrawals(events, receivedAt) + let deadline = latestUntil(open) + if (type === 'abschluss' && !events.length && sub.startedAt) { + const { endsAt } = withdrawalDeadline(sub.startedAt) + deadline = endsAt + if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }] + } + if (!deadline) { + // Frist schon vorbei: das spaeteste Fristende der Bestellungen dieses Typs fuer den Hinweis ans Team + const dates = events.map((e) => e.details?.contractAt ?? e.consentAt).filter(Boolean) + deadline = latestUntil(dates.map((at) => ({ until: withdrawalDeadline(at).endsAt }))) + } + const inTime = open.length > 0 + const ids = open.map((w) => w.eventId).filter(Boolean) + const withdrawn = [] + let canceled = false + let cancelFailed = false + if (inTime) { + for (const id of ids) { + try { + await updateContractEvent(id, { status: 'widerrufen', doneAt: receivedAt.toISOString(), doneBy: 'widerrufsbutton' }) + withdrawn.push(events.find((e) => e.$id === id)) + } catch (err) { + console.error(`[abo] Ereignis ${id} nicht auf widerrufen gesetzt:`, err.message) + } + } + if (type === 'abschluss') { + try { + // Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab + sub = (await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')) || sub + canceled = true + await updateDocument(config.collections.subscriptions, sub.$id, { withdrawalRequestedAt: receivedAt.toISOString() }) + } catch (err) { + cancelFailed = true + console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message) + } + } + } + const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen') + return { sub, inTime, deadline, withdrawn, canceled, cancelFailed, event } +} + +/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */ +function describeWithdrawn(events) { + return events + .map((e) => { + const at = fmtDate(e.details?.contractAt ?? e.consentAt) + const what = e.type === 'speicher' ? `${e.details?.fromUnits ?? '?'} → ${e.details?.toUnits ?? '?'} Pakete` : `${e.type} ${e.details?.switchedAt ? `vom ${fmtDate(e.details.switchedAt)}` : ''}`.trim() + return `${e.$id}: ${what} (bestellt am ${at})` + }) + .join('\n') +} + router.post('/public/withdraw', async (req, res) => { - if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }) - const d = validDeclaration(req.body) - if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' }) + const d = readDeclaration(req, res) + if (!d) return const receivedAt = new Date() let sub = null - let canceled = false + let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null } try { sub = await findActiveSubscriptionByEmail(d.email) if (sub) { - // Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab - sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf') - canceled = true - await updateDocument(config.collections.subscriptions, sub.$id, { - withdrawalRequestedAt: receivedAt.toISOString(), - }) + result = await withdrawOrder(sub, d, receivedAt) + sub = result.sub || sub } } catch (err) { - console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message) + console.error('[abo] Widerruf nicht verarbeitbar:', err.message) } + const { inTime } = result await sendCustomerMail(d.email, { template: 'K04', - values: { name: d.name, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber }, - blocks: { kundennummer: Boolean(d.customerNumber) }, + values: { + name: d.name, + bestellung: orderLabel(d.order, sub, { customer: true }), + eingang_zeitpunkt: fmtDateTime(receivedAt), + email: d.email, + kundennummer: d.customerNumber, + }, + blocks: { + kundennummer: Boolean(d.customerNumber), + abschluss: inTime && d.order === 'abschluss', + wechsel: inTime && d.order === 'wechsel', + zusatzspeicher: inTime && d.order === 'zusatzspeicher', + pruefen: !inTime, + }, }).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) - // Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss - const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null + // Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zaehlen muss await sendAdminNotice({ template: 'T09', values: { email: d.email, name: d.name, kundennummer: d.customerNumber, + bestellung: orderLabel(d.order, sub), stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), - fristende: fmtDate(deadline?.endsAt), + fristende: fmtDate(result.deadline), rechtzeitig: !sub ? 'ungeprüft (kein Abo gefunden)' - : !deadline - ? 'ungeprüft (Abo-Beginn fehlt)' - : receivedAt <= deadline.endsAt - ? 'RECHTZEITIG' - : 'NACH Fristende, vor einer Ablehnung bitte prüfen', + : inTime + ? 'RECHTZEITIG' + : result.deadline + ? 'NACH Fristende, vor einer Ablehnung bitte prüfen' + : 'ungeprüft (keine Bestellung dieser Art gefunden)', + ereignisse: describeWithdrawn(result.withdrawn), }, blocks: { kundennummer: Boolean(d.customerNumber), abo_gefunden: Boolean(sub), - auf_kuendigung_gesetzt: canceled, + auf_kuendigung_gesetzt: result.canceled, kein_abo_gefunden: !sub, - kuendigung_fehlgeschlagen: Boolean(sub) && !canceled, + kuendigung_fehlgeschlagen: result.cancelFailed, + abschluss: inTime && d.order === 'abschluss', + wechsel: inTime && d.order === 'wechsel', + zusatzspeicher: inTime && d.order === 'zusatzspeicher', + keine_frist: Boolean(sub) && !inTime, + nachweis_fehlt: Boolean(sub) && !result.event, }, }) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 82f027b..ec3d102 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -245,3 +245,49 @@ test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/) assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/) }) + +// Task 10: Kuendigung und Widerruf mit Auswahl - K03/T08 nennen den Vertrag und die gekuendigten Pakete, K04/T09 die +// Bestellung; die Handgriffe je Bestellung und "wir pruefen" ohne offene Frist. Endfassung der Texte in Aufgabe 12 +test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Auswahl', () => { + for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`) + for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`) + assert.ok(loadTemplate('K04').placeholders.includes('bestellung')) + assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen']) + for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`) + for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`) + for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`) + for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`) + + const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' } + const speicher = renderMail('K03', k03, { zusatzspeicher: true }) + assert.deepEqual(speicher.problems, []) + assert.match(speicher.text, /Vertrag: E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo/) + assert.match(speicher.text, /Ihr Zusatzspeicher verringert sich zum\n31\.10\.2026/) + assert.match(speicher.text, /um 2 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/) + assert.doesNotMatch(speicher.text, /Wartungs-Abo\n|Stundenguthaben|Wir prüfen Ihre Kündigung/) + assert.match(renderMail('K03', k03, { pruefen: true }).text, /bestätigen Ihnen den Zeitpunkt, zu dem sie wirkt, gesondert per E-Mail/) + assert.match(renderMail('K03', k03, { abo: true }).text, /Stundenguthaben verfällt/) + + const k04 = { ...sampleValues(loadTemplate('K04')), bestellung: 'Wechsel ins Wartungs-Abo' } + assert.match(renderMail('K04', k04, { wechsel: true }).text, /Widerruf der Bestellung „Wechsel ins Wartungs-Abo“/) + assert.match(renderMail('K04', k04, { wechsel: true }).text, /bisherigen Preis/) + assert.match(renderMail('K04', k04, { zusatzspeicher: true }).text, /Menge vor der Bestellung/) + assert.match(renderMail('K04', k04, { abschluss: true }).text, /keine weiteren Abbuchungen/) + assert.match(renderMail('K04', k04, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/) + assert.doesNotMatch(renderMail('K04', k04, {}).text, /Wie es weitergeht|WEBklar Wartungs-Abo“/) + + const t08 = sampleValues(loadTemplate('T08')) + assert.match(renderMail('T08', t08, { wechsel_ausstehend: true }).text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/) + assert.match(renderMail('T08', t08, { speicher_fehlgeschlagen: true }).text, /NICHT automatisch verringert/) + assert.match(renderMail('T08', t08, { kein_speicher: true }).text, /kein Zusatzspeicher gebucht/) + assert.match(renderMail('T08', t08, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/) + + const t09 = { ...sampleValues(loadTemplate('T09')), ereignisse: 'speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete' } + const speicherT09 = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true }) + assert.match(speicherT09.text, /Menge zurück/) + assert.match(speicherT09.text, /speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete/) + assert.doesNotMatch(speicherT09.text, /Abo in Stripe sofort beenden/) + assert.match(renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text, /Abo in Stripe sofort beenden/) + assert.match(renderMail('T09', t09, { abo_gefunden: true, keine_frist: true }).text, /keine offene Frist gefunden/) + assert.match(renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text, /Preis zurück auf das Hosting-Abo/) +}) diff --git a/test/publicForms.test.js b/test/publicForms.test.js new file mode 100644 index 0000000..e7333de --- /dev/null +++ b/test/publicForms.test.js @@ -0,0 +1,494 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { mailSink } from './support/mailSink.js' + +// Oeffentliche Kuendigungs- und Widerrufsseite (Task 10): Auswahl des Vertrags (Abo oder nur der +// E-Mail-Zusatzspeicher, ganz oder mit Paketzahl) bzw. der Bestellung (Abschluss, Wechsel, Zusatzspeicher). +// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + hourCredits: schemaKeys('hourCredits'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post } = await import('./support/testApp.js') +const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') +const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState } = await import('../server/routes/abo.js') +const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js') +const { withdrawalDeadline } = await import('../server/services/deadlines.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') + +const app = await startTestApp({ '/api/abo': aboRoutes }) +// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) + +// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs (ensurePrice findet sie, stripeSubscription() haengt +// dieselben IDs an die Abo-Posten) +const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } }) +st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents)) +st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents)) +st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents)) + +const DAY_MS = 86400000 +const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString() +const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) +const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd) + +async function clearEvents(customerId) { + for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) +} + +/** Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen (units Pakete); leert Ereignisse und Senke. */ +async function resetSubH({ units = 0, patch = {}, stripe = {} } = {}) { + await clearEvents('c1') + mails.sent.length = 0 + aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, ...patch }) + st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units, ...stripe })) +} + +// Jeder Test faengt ohne Ratenbegrenzung an (10 Anfragen je Stunde und IP, F30) +beforeEach(() => resetPublicRateLimit()) + +const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h') +const storageQuantity = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)?.quantity ?? 0 +const bySubject = (start) => mails.sent.find((m) => m.subject.startsWith(start)) || null +const k03 = () => bySubject('Eingangsbestätigung Ihrer Kündigung') +const k04 = () => bySubject('Eingangsbestätigung Ihres Widerrufs') +const k13 = () => bySubject('Ihr E-Mail-Zusatzspeicher') +const t08 = () => bySubject('[Abo] Kündigung eingegangen') +const t09 = () => bySubject('[Abo] WIDERRUF eingegangen') +const t13 = () => bySubject('[Speicher]') +const events = async (type) => (await listContractEvents('c1')).filter((e) => e.type === type) + +const cancel = (body) => post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', ...body }) +const withdraw = (body) => post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', ...body }) + +// --------------------------------------------------------------------------- +// Kuendigung: nur der Zusatzspeicher (Rechtsrunde 28.09.2026, Entscheidung 5) +// --------------------------------------------------------------------------- + +test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async () => { + await resetSubH({ units: 3 }) + const before = st.calls.length + const res = await cancel({ contract: 'zusatzspeicher' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(st.calls.findLast((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h').body['items[0][quantity]'], '0') + assert.equal(subscriptionUpdates(before)[0].body.proration_behavior, 'none') + assert.equal(storageQuantity(), 0) + const all = await listContractEvents('c1') + assert.equal(all[0].type, 'kuendigung') + // F31: das Ereignis traegt Vertrag, Wirksamkeit und die Pakete; ohne IP oder Browser (Datenschutz Abschnitt 7) + assert.deepEqual(all[0].details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 3, fromUnits: 3, toUnits: 0 }) + assert.equal(all[0].status, 'erledigt') + assert.equal(all[0].subscriptionId, 'sub_h') + assert.equal(all[0].consentIp ?? null, null) + assert.equal(all[0].$id.length, 36) + // Die Verringerung selbst ist das Ereignis speicher ueber den Kuendigungsweg (Task 8) + const [speicher] = await events('speicher') + assert.equal(speicher.details.viaCancellation, true) + assert.equal(speicher.details.toUnits, 0) + // K03 nennt Vertrag, Pakete und den Zeitpunkt; K13 entfaellt (F31), T08 und T13 gehen ans Team + assert.ok(k03(), 'K03') + assert.match(k03().text, /E-Mail-Zusatzspeicher/) + assert.ok(k03().text.includes('um 3 Paket(e) gekündigt; danach bleiben 0 Paket(e)'), k03().text) + assert.ok(k03().text.includes(PERIOD_END_LABEL), PERIOD_END_LABEL) + assert.doesNotMatch(k03().text, /Wartungs-Abo\n/, 'kein fester Vertragsname') + assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular') + assert.match(t08().text, /um 3 Paket\(e\) verringert/) + assert.match(t08().text, /E-Mail-Zusatzspeicher/) + assert.ok(t13(), 'T13 (Speicher-Aufgabe)') + // Die Abo-Kuendigung selbst bleibt aus: kein cancel_at, kein cancelRequestedAt + assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false) + assert.equal(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, undefined) +}) + +test('2 von 3 Paketen ergibt Menge 1', async () => { + await resetSubH({ units: 3 }) + const res = await cancel({ contract: 'zusatzspeicher', packages: '2' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(storageQuantity(), 1) + const [ev] = await events('kuendigung') + assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 2, fromUnits: 3, toUnits: 1 }) + assert.ok(k03().text.includes('um 2 Paket(e) gekündigt; danach bleiben 1 Paket(e)'), k03().text) + assert.doesNotMatch(k03().text, /um 3 Paket/) +}) + +test('Ohne packages ergibt Menge 0; mehr Pakete als gebucht heißt alle', async () => { + await resetSubH({ units: 3 }) + assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '' })).status, 200) + assert.equal(storageQuantity(), 0) + await resetSubH({ units: 2 }) + assert.equal((await cancel({ contract: 'zusatzspeicher', packages: 10 })).status, 200) + assert.equal(storageQuantity(), 0) + const [ev] = await events('kuendigung') + assert.equal(ev.details.packages, 2) + assert.match(k03().text, /um 2 Paket\(e\) gekündigt/) +}) + +test('Gekündigtes Abo: die Menge wird trotzdem reduziert', async () => { + await resetSubH({ + units: 2, + patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd }, + stripe: { cancel_at_period_end: true }, + }) + const res = await cancel({ contract: 'zusatzspeicher', packages: '1' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(storageQuantity(), 1) + const [ev] = await events('kuendigung') + assert.equal(ev.details.toUnits, 1) + assert.match(k03().text, /um 1 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/) +}) + +test('packages: 0 oder 11 ergibt 400, ohne Stripe-Aufruf und ohne Mail', async () => { + await resetSubH({ units: 3 }) + const before = st.calls.length + for (const packages of [0, 11, '0', '11', '1,5', 'x']) { + const res = await cancel({ contract: 'zusatzspeicher', packages }) + assert.equal(res.status, 400, String(packages)) + assert.match(res.body.error, /1 bis 10/) + } + assert.equal(st.calls.length, before) + assert.deepEqual(mails.sent, []) + assert.deepEqual(await listContractEvents('c1'), []) + // Beim Abo zaehlt das Feld nicht (im Formular ausgeblendet) + assert.equal((await cancel({ contract: 'abo', packages: '0' })).status, 200) +}) + +test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Änderung', async () => { + await resetSubH({ units: 0 }) + const before = st.calls.length + const res = await cancel({ contract: 'zusatzspeicher' }) + assert.equal(res.status, 200) + assert.equal(st.calls.filter((c) => c.method === 'POST').length, st.calls.slice(0, before).filter((c) => c.method === 'POST').length) + // K03 sagt "wir pruefen", T08 sagt dem Team, dass kein Speicher gebucht ist; Ereignis als Aufgabe (offen) + assert.match(k03().text, /prüfen/) + assert.doesNotMatch(k03().text, /endet zum|verringert/) + assert.match(t08().text, /kein Zusatzspeicher gebucht/i) + const [ev] = await events('kuendigung') + assert.equal(ev.status, 'offen') + assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 0, toUnits: null }) + assert.equal(t13(), null) +}) + +test('Unbekannte E-Mail: 200, keine Änderung', async () => { + await resetSubH({ units: 1 }) + const before = st.calls.length + const res = await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'abo' }) + assert.equal(res.status, 200) + assert.equal(st.calls.length, before) + assert.match(k03().text, /ordnen Ihre Kündigung/) + assert.match(t08().text, /KEIN aktives Abo/) + assert.deepEqual(await listContractEvents('c1'), []) + // Auch beim Zusatzspeicher + mails.sent.length = 0 + assert.equal((await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'zusatzspeicher' })).status, 200) + assert.equal(st.calls.length, before) + assert.match(k03().text, /ordnen Ihre Kündigung/) + assert.match(k03().text, /E-Mail-Zusatzspeicher/) +}) + +test('Kündigung des Abos: wie bisher bei Stripe, dazu das Ereignis kuendigung mit dem Abo-Namen', async () => { + await resetSubH({ units: 2 }) + const before = st.calls.length + const res = await cancel({ contract: 'abo', customerNumber: 'KD-1' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const upd = subscriptionUpdates(before) + assert.equal(upd.length, 1) + assert.equal(upd[0].body['metadata[cancelSource]'], 'kuendigungsbutton') + assert.ok(upd[0].body.cancel_at_period_end === 'true' || upd[0].body.cancel_at, 'Kuendigung bei Stripe') + assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt) + // Der Speicher bleibt (er endet mit dem Abo) + assert.equal(storageQuantity(), 2) + const [ev] = await events('kuendigung') + assert.equal(ev.status, 'erledigt') + assert.equal(ev.details.contract, 'abo') + assert.equal(ev.details.kind, 'ordentlich') + assert.ok(ev.details.effectiveAt) + assert.equal(ev.productKey, 'hosting') + assert.deepEqual(await events('speicher'), []) + // Die Bestaetigung nennt das tatsaechliche Abo (Spec 4.5) + assert.match(k03().text, /Vertrag: WEBklar Hosting-Abo/) + assert.match(k03().text, /Ihr Abo endet zum/) + assert.match(t08().text, /Vertrag: WEBklar Hosting-Abo/) + assert.match(t08().text, /Automatisch gekündigt/) + // Standard ohne contract: das Abo + await resetSubH({ units: 0 }) + assert.equal((await cancel({})).status, 200) + assert.equal((await events('kuendigung'))[0].details.contract, 'abo') +}) + +test('Außerordentliche Kündigung des Abos: keine Stripe-Änderung, Ereignis offen fürs Team', async () => { + await resetSubH({ units: 0 }) + const before = st.calls.length + assert.equal((await cancel({ kind: 'ausserordentlich', reason: 'Grund' })).status, 200) + assert.deepEqual(subscriptionUpdates(before), []) + const [ev] = await events('kuendigung') + assert.equal(ev.status, 'offen') + assert.deepEqual(ev.details, { contract: 'abo', kind: 'ausserordentlich', effectiveAt: null }) + assert.match(k03().text, /Wir prüfen Ihre Kündigung/) +}) + +// Controller: waehrend ein Kartenwechsel aussteht (offenes Ereignis wechsel, Stripe pending_update) darf die +// Verringerung das Stripe-Abo nicht anfassen - Stripe wuerde die Wechselrechnung stornieren +test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wir prüfen“, T08 mit Hinweis', async () => { + await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } }) + const switchedAt = new Date().toISOString() + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting', switchedAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' }, + consent, status: 'offen', + }) + const before = st.calls.length + const res = await cancel({ contract: 'zusatzspeicher', packages: '1' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update') + assert.equal(storageQuantity(), 2) + assert.deepEqual(await events('speicher'), []) + const [ev] = await events('kuendigung') + assert.equal(ev.status, 'offen') + assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: 1, fromUnits: 2, toUnits: null }) + assert.match(k03().text, /prüfen/) + assert.match(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/) + assert.equal((await events('wechsel'))[0].status, 'offen') +}) + +// F20: wirft changeStorage trotzdem (z. B. Stripe lehnt ab), bleibt die Seite bei 200 - K03 "wir pruefen", T08 Hinweis +test('changeStorage scheitert: 200, K03 „wir prüfen“, T08 nennt den Fehler', async () => { + await resetSubH({ units: 2 }) + st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau lehnt ab' }) + const res = await cancel({ contract: 'zusatzspeicher' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(storageQuantity(), 2) + const [ev] = await events('kuendigung') + assert.equal(ev.status, 'offen') + assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 2, toUnits: null }) + assert.match(k03().text, /prüfen/) + assert.match(t08().text, /nicht automatisch verringert/i) + assert.match(t08().text, /Stripe/) + assert.equal((await events('speicher'))[0].status, 'gescheitert') +}) + +// Sind die Ereignisse nicht lesbar (Wechsel-Pruefung unmoeglich), bleibt Stripe unangetastet: "wir pruefen" + T08 +test('Ereignisse nicht lesbar: Speicher nicht angefasst, K03 „wir prüfen“, T08 nennt den Fehler', async () => { + await resetSubH({ units: 2 }) + aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 }) + const before = st.calls.length + const res = await cancel({ contract: 'zusatzspeicher' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(subscriptionUpdates(before), []) + assert.equal(storageQuantity(), 2) + assert.match(k03().text, /prüfen/) + assert.match(t08().text, /NICHT automatisch verringert/) + const [ev] = await events('kuendigung') + assert.equal(ev.status, 'offen') +}) + +// --------------------------------------------------------------------------- +// Widerruf: Bestellung waehlen (Abschluss, Wechsel, Zusatzspeicher) +// --------------------------------------------------------------------------- + +test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Preiswechsel', async () => { + await resetSubH({ units: 0 }) + const consentAt = daysAgo(3) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt }, + consent: { ...consent, consentAt }, status: 'erledigt', + }) + const before = st.calls.length + const res = await withdraw({ order: 'wechsel' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const evs = await listContractEvents('c1') + const wechsel = evs.find((e) => e.type === 'wechsel') + assert.equal(wechsel.status, 'widerrufen') + assert.equal(st.calls.slice(before).filter((c) => c.method === 'POST' && /subscriptions/.test(c.path)).length, 0) + const widerruf = evs.find((e) => e.type === 'widerruf') + assert.equal(widerruf.details.order, 'wechsel') + assert.equal(widerruf.details.eventId, wechsel.$id) + assert.deepEqual(widerruf.details.eventIds, [wechsel.$id]) + assert.equal(widerruf.status, 'offen') + assert.equal(widerruf.$id.length, 36) + assert.equal(widerruf.consentIp ?? null, null) + // Das Abo wird nicht gekuendigt; withdrawalRequestedAt bleibt der Abo-Kuendigung vorbehalten + assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false) + assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined) + // K04 nennt die Bestellung, T09 die Handgriffe und die Frist + assert.match(k04().text, /Wechsel ins Wartungs-Abo/) + assert.match(k04().text, /bisherigen Preis/) + assert.doesNotMatch(k04().text, /keine weiteren Abbuchungen/) + assert.match(t09().text, /RECHTZEITIG/) + assert.match(t09().text, /Preis .*zurück/) + assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(consentAt).endsAt))) + assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/) +}) + +test('Widerruf nach Fristende: 200, nichts geändert', async () => { + await resetSubH({ units: 0 }) + const consentAt = daysAgo(30) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt }, + consent: { ...consent, consentAt }, status: 'erledigt', + }) + const before = st.calls.length + const res = await withdraw({ order: 'wechsel' }) + assert.equal(res.status, 200) + const evs = await listContractEvents('c1') + assert.notEqual(evs.find((e) => e.type === 'wechsel').status, 'widerrufen') + assert.equal(evs.find((e) => e.type === 'widerruf').details.eventId, null) + assert.deepEqual(evs.find((e) => e.type === 'widerruf').details.eventIds, []) + assert.equal(subscriptionUpdates(before).length, 0) + assert.match(k04().text, /prüfen/) + assert.match(t09().text, /keine offene Frist gefunden/) + assert.match(t09().text, /NACH Fristende/) +}) + +// F03: mehrere Speicher-Bestellungen (Plus) in der Frist -> alle widerrufen; ein Minus hat keine Frist +test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerrufen, T09 listet sie', async () => { + await resetSubH({ units: 3 }) + const plus1 = await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting', + details: { kind: 'einrichten', fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt: daysAgo(5) }, status: 'erledigt', + }) + const minus = await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting', + details: { kind: 'periodenende', fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd, + }) + const plus2 = await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting', + details: { kind: 'einrichten', fromUnits: 1, toUnits: 3 }, consent: { ...consent, consentAt: daysAgo(1) }, status: 'offen', dueAt: daysAgo(1), + }) + const before = st.calls.length + const res = await withdraw({ order: 'zusatzspeicher' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const evs = await listContractEvents('c1') + const statusOf = (id) => evs.find((e) => e.$id === id).status + assert.equal(statusOf(plus1.$id), 'widerrufen') + assert.equal(statusOf(plus2.$id), 'widerrufen') + assert.equal(statusOf(minus.$id), 'offen') + const widerruf = evs.find((e) => e.type === 'widerruf') + assert.equal(widerruf.details.order, 'zusatzspeicher') + assert.deepEqual([...widerruf.details.eventIds].sort(), [plus1.$id, plus2.$id].sort()) + assert.ok([plus1.$id, plus2.$id].includes(widerruf.details.eventId)) + assert.equal(subscriptionUpdates(before).length, 0) + assert.match(k04().text, /E-Mail-Zusatzspeicher/) + assert.match(k04().text, /Menge/) + assert.match(t09().text, /0 → 2/) + assert.match(t09().text, /1 → 3/) + assert.match(t09().text, /Menge .*zurück/) +}) + +test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => { + const startedAt = daysAgo(2) + await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } }) + const before = st.calls.length + const res = await withdraw({ order: 'abschluss', customerNumber: 'KD-1' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const upd = subscriptionUpdates(before) + assert.equal(upd.length, 1) + assert.equal(upd[0].body.cancel_at_period_end, 'true') + assert.equal(upd[0].body['metadata[cancelSource]'], 'widerruf') + assert.ok(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt) + const [widerruf] = await events('widerruf') + assert.deepEqual(widerruf.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: true }) + assert.match(k04().text, /Abschluss Ihres Abos/) + assert.match(k04().text, /keine weiteren Abbuchungen/) + assert.match(t09().text, /RECHTZEITIG/) + assert.match(t09().text, /auf Kündigung gesetzt/) + assert.match(t09().text, /Abo in Stripe sofort beenden/) + assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(startedAt).endsAt))) + + // Standard ohne order: der Abschluss; mit Ereignis abschluss zaehlt allein das Ereignis (hier 40 Tage alt: Frist vorbei) + await resetSubH({ units: 0 }) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting', + details: { contractAt: hostingDoc.startedAt }, consent: { ...consent, consentAt: hostingDoc.startedAt }, + }) + const late = st.calls.length + assert.equal((await withdraw({})).status, 200) + assert.deepEqual(subscriptionUpdates(late), []) + assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false) + const [w2] = await events('widerruf') + assert.deepEqual(w2.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: false }) + assert.match(k04().text, /prüfen/) + assert.match(t09().text, /keine offene Frist gefunden/) +}) + +test('Widerruf mit unbekannter E-Mail oder ungültiger Auswahl', async () => { + await resetSubH({ units: 0 }) + const before = st.calls.length + const res = await post('/api/abo/public/withdraw', { name: 'X', email: 'niemand@example.invalid', order: 'zusatzspeicher' }) + assert.equal(res.status, 200) + assert.equal(st.calls.length, before) + assert.match(t09().text, /KEIN aktives Abo/) + assert.deepEqual(await listContractEvents('c1'), []) + // Unbekannte Auswahl faellt auf den Standard zurueck; ohne Name oder E-Mail 400 + assert.equal((await withdraw({ order: 'irgendwas' })).status, 200) + assert.equal((await events('widerruf'))[0].details.order, 'abschluss') + assert.equal((await post('/api/abo/public/withdraw', { name: '', email: 'k@example.invalid' })).status, 400) + assert.equal((await post('/api/abo/public/cancel', { name: 'K', email: 'kein-mail' })).status, 400) +}) + +// F30: Ratenbegrenzung je IP (10 je Stunde), fuer Tests zuruecksetzbar; alte Eintraege verschwinden nach einer +// Stunde, damit IP-Adressen nicht laenger als noetig im Speicher bleiben (Datenschutz Abschnitt 7) +test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde werden entfernt', async () => { + await resetSubH({ units: 0 }) + for (let i = 0; i < 10; i += 1) assert.equal((await cancel({})).status, 200, `Anfrage ${i + 1}`) + const limited = await cancel({}) + assert.equal(limited.status, 429) + assert.equal((await withdraw({})).status, 429, 'gemeinsames Limit beider Seiten') + assert.equal(publicRateLimitState().ips, 1) + resetPublicRateLimit() + assert.equal(publicRateLimitState().ips, 0) + assert.equal((await cancel({})).status, 200) + // Nach einer Stunde ist der Eintrag weg (Zeit im Test vorgegeben) + assert.equal(publicRateLimitState({ now: Date.now() + 3600 * 1000 + 1 }).ips, 0) + assert.equal(publicRateLimitState().ips, 1) +}) + +// --------------------------------------------------------------------------- +// Formulare: Auswahl vorhanden und wortgleich (Spec 4.5); die Bedienung prueft Task 10 Schritt 4 mit Playwright +// --------------------------------------------------------------------------- + +const html = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8') + +test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext', () => { + const src = html('kuendigen.html') + assert.match(src, /name="contract" value="abo"[^>]*> Mein Abo \(Hosting- oder Wartungs-Abo, mit Zusatzspeicher\)/) + assert.match(src, /name="contract" value="zusatzspeicher"[^>]*> Nur den E-Mail-Zusatzspeicher/) + assert.match(src, /]*min="1"[^>]*max="10"/) + assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) + assert.match(src, /Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail\./) + assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/) +}) + +test('widerrufen.html: Radioknöpfe order', () => { + const src = html('widerrufen.html') + assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/) + assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/) + assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/) + assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) + assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) +}) + +test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeicher', () => { + const src = html('legal-form.js') + assert.doesNotMatch(src, /\['Vertrag', 'WEBklar Wartungs-Abo'\]/) + assert.match(src, /packages/) +})
Name{{name}}
E-Mail{{email}}
@@ -51,7 +51,21 @@ a{color:#111111;} + + + + + + + + + + + + + +
Name{{name}}
E-Mail{{email}}
Bestellung{{bestellung}}
Kundennummer{{kundennummer}}