From 0d2254ca20d23e3d99e765aa5f18c6d7c27e6b5b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:15:51 +0000 Subject: [PATCH] =?UTF-8?q?K=C3=BCndigung=20und=20Widerruf:=20Paketzahl=20?= =?UTF-8?q?in=20jeder=20Best=C3=A4tigung,=20klare=20Hinweise=20f=C3=BCrs?= =?UTF-8?q?=20Team,=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../K03 Eingang Kuendigung.html | 10 +- .../T08 Kuendigung eingegangen.html | 14 +- .../T09 Widerruf eingegangen.html | 18 +- server/routes/abo.js | 90 ++++++++-- test/mailTemplates.test.js | 13 ++ test/publicForms.test.js | 169 ++++++++++++++++-- 6 files changed, 275 insertions(+), 39 deletions(-) diff --git a/server/mail-templates/K03 Eingang Kuendigung.html b/server/mail-templates/K03 Eingang Kuendigung.html index 99e0da3..8b5a8b5 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}}, {{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 + Platzhalter: {{name}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{vertrag}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{pakete}}, {{pakete_neu}}, {{pakete_angabe}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, pruefen, abo, speicher_angaben, speicher_kind_pruefung --> @@ -39,6 +39,9 @@ a{color:#111111;} Kundennummer{{kundennummer}} + +Pakete{{pakete_angabe}} + Vertrag{{vertrag}}Gewünschter Zeitpunkt{{wunschtermin}} Grund{{grund}} @@ -69,6 +72,9 @@ 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. diff --git a/server/mail-templates/T08 Kuendigung eingegangen.html b/server/mail-templates/T08 Kuendigung eingegangen.html index 50fdc6a..c04dfd6 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}}, {{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 + Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{vertrag}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}}, {{pakete}}, {{pakete_angabe}}, {{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, speicher_angaben, speicher_kind_pruefung --> @@ -38,6 +38,9 @@ a{color:#111111;} Kundennummer{{kundennummer}} + +Pakete{{pakete_angabe}} + Vertrag{{vertrag}}Art{{kuendigungsart}}Gewünschter Zeitpunkt{{wunschtermin}} Grund{{grund}} @@ -69,14 +72,17 @@ a{color:#111111;} - + - + + + +   diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index 37b4b41..172022c 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}}, {{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 + Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} + 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, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen --> @@ -39,7 +39,7 @@ a{color:#111111;} Kundennummer{{kundennummer}} -Stripe-Abo{{stripe_abo_id}} (auf Kündigung gesetzt)Abo-Beginn{{abo_beginn}}Widerrufsfrist bis{{fristende}}, 24 UhrEingang{{rechtzeitig}} +Stripe-Abo{{stripe_abo_id}} (auf Kündigung gesetzt)Abo-Beginn{{abo_beginn}}Widerrufsfrist bis{{fristende}}Eingang{{rechtzeitig}} @@ -63,6 +63,18 @@ a{color:#111111;} + + + + + + + + + + + + diff --git a/server/routes/abo.js b/server/routes/abo.js index 571485e..e068e17 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -645,7 +645,8 @@ const RATE_WINDOW_MS = 60 * 60 * 1000 const RATE_MAX = 10 const recentRequests = new Map() // ip -> [timestamps] -function purgeRateLimit(now) { +/** Eintraege aelter als eine Stunde (zum Zeitpunkt now) entfernen; laeuft bei jeder Anfrage, exportiert fuer Tests. */ +export function purgeRateLimit(now = Date.now()) { for (const [ip, list] of recentRequests) { const fresh = list.filter((t) => now - t < RATE_WINDOW_MS) if (fresh.length) recentRequests.set(ip, fresh) @@ -666,10 +667,9 @@ 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 } +/** Nur fuer Tests: Zahl der IP-Adressen, die gerade in der Map stehen. */ +export function publicRateLimitState() { + return { ips: recentRequests.size } } // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert @@ -892,9 +892,14 @@ router.post('/public/cancel', async (req, res) => { 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', + // {{pakete}} = tatsaechlich gekuendigte Pakete (F31, nur nach einer Verringerung); {{pakete_angabe}} = die + // Erklaerung des Kunden (Inhalt der Erklaerung, § 312k Abs. 4 BGB) in jedem Speicherpfad + pakete: reduced ? result.details.packages : '', + pakete_angabe: d.packages ? `${d.packages} Paket(e)` : 'alle gebuchten Pakete', } + // Eine ausserordentliche Kuendigung oder Geschaeftsaufgabe des Speichers wird trotzdem zum Periodenende + // verringert; die Pruefung der Art kommt dazu (Fix 1, 6) + const storageKindCheck = storage && d.kind !== 'ordentlich' await sendCustomerMail(d.email, { template: 'K03', @@ -910,6 +915,8 @@ router.post('/public/cancel', async (req, res) => { kein_abo_gefunden: storage ? !sub : !effectiveAt && d.kind !== 'ausserordentlich', zusatzspeicher: reduced, pruefen: storage && Boolean(sub) && !reduced, + speicher_angaben: storage, + speicher_kind_pruefung: storageKindCheck && reduced, }, }).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message)) @@ -934,6 +941,8 @@ router.post('/public/cancel', async (req, res) => { wechsel_ausstehend: result.outcome === 'wechsel_ausstehend', speicher_fehlgeschlagen: result.outcome === 'fehlgeschlagen', nachweis_fehlt: Boolean(sub) && !result.event, + speicher_angaben: storage, + speicher_kind_pruefung: storageKindCheck, }, }) @@ -952,13 +961,27 @@ const latestUntil = (list) => list.reduce((latest, w) => (!latest || w.until > l */ 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) + // Ereignisse nicht lesbar: nichts automatisch aendern (ausser dem Rueckfall auf startedAt beim Abschluss), + // T09 sagt "von Hand pruefen" (Fix 1, 4) + let events = [] + let unreadable = false + try { + events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type) + } catch (err) { + unreadable = true + console.error(`[abo] Vertragsereignisse für Widerruf (Abo ${sub.$id}) nicht lesbar:`, err.message) + } 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 }] + let startMissing = false + if (type === 'abschluss' && !events.length) { + if (sub.startedAt) { + const { endsAt } = withdrawalDeadline(sub.startedAt) + deadline = endsAt + if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }] + } else { + startMissing = true + } } if (!deadline) { // Frist schon vorbei: das spaeteste Fristende der Bestellungen dieses Typs fuer den Hinweis ans Team @@ -992,7 +1015,23 @@ async function withdrawOrder(sub, d, receivedAt) { } } 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 } + return { + sub, + inTime, + deadline, + withdrawn, + canceled, + cancelFailed, + event, + unreadable, + startMissing, + // Dieselbe Bestellung wurde schon widerrufen (Fix 1, 5) + alreadyWithdrawn: !inTime && events.some((e) => e.status === 'widerrufen'), + // "NACH Fristende" nur, wenn die Frist wirklich vorbei ist + late: !inTime && Boolean(deadline) && receivedAt > deadline, + // Kartenwechsel, der noch auf die Zahlung wartet: die offene Wechselrechnung muss storniert werden (Fix 1, 7) + openSwitchWithdrawn: withdrawn.some((e) => e.type === 'wechsel' && !e.details?.effectiveAt), + } } /** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */ @@ -1012,7 +1051,7 @@ router.post('/public/withdraw', async (req, res) => { const receivedAt = new Date() let sub = null - let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null } + let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false } try { sub = await findActiveSubscriptionByEmail(d.email) if (sub) { @@ -1023,6 +1062,8 @@ router.post('/public/withdraw', async (req, res) => { console.error('[abo] Widerruf nicht verarbeitbar:', err.message) } const { inTime } = result + // Fuer T09: warum sich die Frist nicht pruefen liess + const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : '' await sendCustomerMail(d.email, { template: 'K04', @@ -1052,15 +1093,22 @@ router.post('/public/withdraw', async (req, res) => { bestellung: orderLabel(d.order, sub), stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), - fristende: fmtDate(result.deadline), + fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', rechtzeitig: !sub ? 'ungeprüft (kein Abo gefunden)' : inTime ? 'RECHTZEITIG' - : result.deadline - ? 'NACH Fristende, vor einer Ablehnung bitte prüfen' - : 'ungeprüft (keine Bestellung dieser Art gefunden)', + : result.alreadyWithdrawn + ? 'bereits widerrufen' + : pruefhinweis + ? `ungeprüft (${pruefhinweis})` + : result.late + ? 'NACH Fristende, vor einer Ablehnung bitte prüfen' + : result.deadline + ? 'ungeprüft (keine offene Frist: Bestellung gescheitert)' + : 'ungeprüft (keine Bestellung dieser Art gefunden)', ereignisse: describeWithdrawn(result.withdrawn), + pruefhinweis, }, blocks: { kundennummer: Boolean(d.customerNumber), @@ -1071,8 +1119,12 @@ router.post('/public/withdraw', async (req, res) => { abschluss: inTime && d.order === 'abschluss', wechsel: inTime && d.order === 'wechsel', zusatzspeicher: inTime && d.order === 'zusatzspeicher', - keine_frist: Boolean(sub) && !inTime, + keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn, nachweis_fehlt: Boolean(sub) && !result.event, + wechsel_offen: result.openSwitchWithdrawn, + abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn, + nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis), + bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn, }, }) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index ec3d102..4a32a66 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -290,4 +290,17 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au 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/) + // Fix 1: Paketzahl in jedem Speicherpfad (K03/T08), Pruefung der Kuendigungsart, T09-Hinweise + assert.match(renderMail('K03', { ...k03, pakete_angabe: 'alle gebuchten Pakete' }, { speicher_angaben: true, pruefen: true }).text, /Pakete: alle gebuchten Pakete/) + assert.match(renderMail('K03', { ...k03, kuendigungsart: 'außerordentliche Kündigung' }, { speicher_kind_pruefung: true }).text, /Ihre außerordentliche Kündigung wird geprüft/) + const t08a = { ...t08, pakete_angabe: '2 Paket(e)', kuendigungsart: 'Außerordentliche Kündigung' } + assert.match(renderMail('T08', t08a, { speicher_angaben: true, wechsel_ausstehend: true }).text, /Pakete: 2 Paket\(e\)/) + assert.match(renderMail('T08', t08a, { wechsel_ausstehend: true }).text, /gekündigt: 2 Paket\(e\)/) + assert.match(renderMail('T08', t08a, { speicher_kind_pruefung: true }).text, /Außerordentliche Kündigung des Zusatzspeichers/) + const t09b = { ...t09, fristende: 'keine Frist gefunden', pruefhinweis: 'Ereignisse nicht lesbar – bitte von Hand prüfen' } + assert.match(renderMail('T09', t09b, { abo_gefunden: true }).text, /Widerrufsfrist bis: keine Frist gefunden/) + assert.match(renderMail('T09', t09b, { abo_gefunden: true, nicht_pruefbar: true }).text, /Ereignisse nicht lesbar – bitte von Hand prüfen/) + assert.match(renderMail('T09', t09b, { abo_gefunden: true, wechsel: true, wechsel_offen: true }).text, /Wechselrechnung noch offen – stornieren/) + assert.match(renderMail('T09', t09b, { abo_gefunden: true, abschluss_verspaetet: true }).text, /als ordentliche Kündigung behandeln/) + assert.match(renderMail('T09', t09b, { abo_gefunden: true, bereits_widerrufen: true }).text, /schon widerrufen/) }) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index e7333de..d1b2f65 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -22,7 +22,7 @@ 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 { default: aboRoutes, resetPublicRateLimit, publicRateLimitState, purgeRateLimit } = 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') @@ -180,10 +180,17 @@ test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Ände assert.match(k03().text, /prüfen/) assert.doesNotMatch(k03().text, /endet zum|verringert/) assert.match(t08().text, /kein Zusatzspeicher gebucht/i) + // § 312k Abs. 4 BGB: die Bestaetigung nennt den Inhalt der Erklaerung, also auch die Paketzahl + assert.match(k03().text, /Pakete: alle gebuchten Pakete/) + assert.match(t08().text, /Pakete: alle gebuchten Pakete/) 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) + mails.sent.length = 0 + assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200) + assert.match(k03().text, /Pakete: 2 Paket\(e\)/) + assert.match(t08().text, /Pakete: 2 Paket\(e\)/) }) test('Unbekannte E-Mail: 200, keine Änderung', async () => { @@ -201,6 +208,8 @@ test('Unbekannte E-Mail: 200, keine Änderung', async () => { assert.equal(st.calls.length, before) assert.match(k03().text, /ordnen Ihre Kündigung/) assert.match(k03().text, /E-Mail-Zusatzspeicher/) + assert.match(k03().text, /Pakete: alle gebuchten Pakete/) + assert.match(t08().text, /Pakete: alle gebuchten Pakete/) }) test('Kündigung des Abos: wie bisher bei Stripe, dazu das Ereignis kuendigung mit dem Abo-Namen', async () => { @@ -247,15 +256,18 @@ test('Außerordentliche Kündigung des Abos: keine Stripe-Änderung, Ereignis of // 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' }) + const openSwitch = () => + 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', + }) + // Ausloeser 1: nur das offene Ereignis wechsel (Stripe traegt noch kein pending_update, z. B. Webhook-Rueckstand) + await resetSubH({ units: 2 }) + await openSwitch() + let before = st.calls.length + let 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) @@ -264,8 +276,26 @@ test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wi 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(k03().text, /Pakete: 1 Paket\(e\)/) assert.match(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/) + assert.match(t08().text, /Pakete: 1 Paket\(e\)/) assert.equal((await events('wechsel'))[0].status, 'offen') + + // Ausloeser 2: nur pending_update am Stripe-Abo (Ereignis nicht gespeichert, nachweis_fehlt beim Wechsel) + await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } }) + before = st.calls.length + res = await cancel({ contract: 'zusatzspeicher' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update') + assert.equal(storageQuantity(), 2) + assert.match(t08().text, /Wechsel ausstehend/) + assert.match(t08().text, /gekündigt: alle gebuchten Pakete/) + assert.match(k03().text, /Pakete: alle gebuchten Pakete/) + + // Beide Ausloeser weg: Verringerung laeuft + await resetSubH({ units: 2 }) + assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '1' })).status, 200) + assert.equal(storageQuantity(), 1) }) // F20: wirft changeStorage trotzdem (z. B. Stripe lehnt ab), bleibt die Seite bei 200 - K03 "wir pruefen", T08 Hinweis @@ -280,7 +310,9 @@ test('changeStorage scheitert: 200, K03 „wir prüfen“, T08 nennt den Fehler' 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, /gekündigt: alle gebuchten Pakete/) assert.match(t08().text, /Stripe/) + assert.match(k03().text, /Pakete: alle gebuchten Pakete/) assert.equal((await events('speicher'))[0].status, 'gescheitert') }) @@ -457,9 +489,124 @@ test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde 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) + // Eintraege aelter als eine Stunde werden aus der Map entfernt (Zeit im Test vorgegeben), juengere bleiben + purgeRateLimit(Date.now() + 3600 * 1000 - 5000) + assert.equal(publicRateLimitState().ips, 1) + purgeRateLimit(Date.now() + 3600 * 1000 + 1) + assert.equal(publicRateLimitState().ips, 0) +}) + +// Fix 1 (6): eine ausserordentliche Kuendigung des Speichers wird trotzdem zum Periodenende verringert; die Pruefung +// der Art steht in K03 und T08 +test('Zusatzspeicher außerordentlich gekündigt: trotzdem zum Periodenende verringert, Prüfung angekündigt', async () => { + await resetSubH({ units: 3 }) + const res = await cancel({ contract: 'zusatzspeicher', packages: '2', kind: 'ausserordentlich', reason: 'Postfach dauernd voll' }) + 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 2 Paket\(e\) gekündigt/) + assert.match(k03().text, /außerordentliche Kündigung wird geprüft/) + assert.match(t08().text, /Außerordentliche Kündigung des Zusatzspeichers/) + assert.match(t08().text, /Grund: Postfach dauernd voll/) + // ordentlich: kein Pruefungshinweis + await resetSubH({ units: 3 }) + assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200) + assert.doesNotMatch(k03().text, /wird geprüft/) + assert.doesNotMatch(t08().text, /des Zusatzspeichers: Grund prüfen/) +}) + +// Fix 1 (4): Ereignisse nicht lesbar - Abschluss faellt auf startedAt zurueck (F14), sonst Hinweis "von Hand pruefen" +test('Widerruf bei nicht lesbaren Ereignissen: Hinweis fürs Team, Abschluss zählt weiter ab startedAt', async () => { + await resetSubH({ units: 0 }) + aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 }) + let before = st.calls.length + let res = await withdraw({ order: 'wechsel' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(subscriptionUpdates(before), []) + assert.match(k04().text, /prüfen/) + assert.match(t09().text, /Ereignisse nicht lesbar – bitte von Hand prüfen/) + assert.doesNotMatch(t09().text, /keine Bestellung dieser Art/) + assert.equal((await events('widerruf')).length, 1, 'Ereignis widerruf trotzdem gespeichert') + + // Abschluss in der Frist: der Rueckfall auf startedAt gilt auch, wenn die Ereignisse nicht lesbar sind + const startedAt = daysAgo(2) + await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } }) + aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 }) + before = st.calls.length + res = await withdraw({ order: 'abschluss' }) + assert.equal(res.status, 200) + assert.equal(subscriptionUpdates(before).length, 1) + assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, true) + assert.match(t09().text, /RECHTZEITIG/) + assert.match(t09().text, /Ereignisse nicht lesbar/) + + // Abo-Beginn fehlt (kein Ereignis abschluss, kein startedAt): eigener Hinweis, nichts geaendert + await resetSubH({ units: 0, patch: { startedAt: null, termStartAt: null } }) + before = st.calls.length + res = await withdraw({ order: 'abschluss' }) + assert.equal(res.status, 200) + assert.deepEqual(subscriptionUpdates(before), []) + assert.match(t09().text, /Abo-Beginn fehlt/) + assert.doesNotMatch(t09().text, /keine Bestellung dieser Art|NACH Fristende/) +}) + +// Fix 1 (5): "NACH Fristende" nur nach dem Fristende; ein zweiter Widerruf derselben Bestellung heisst "bereits widerrufen" +test('Wiederholter Widerruf: „bereits widerrufen“ statt „NACH Fristende“', 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', + }) + assert.equal((await withdraw({ order: 'wechsel' })).status, 200) + assert.match(t09().text, /RECHTZEITIG/) + mails.sent.length = 0 + assert.equal((await withdraw({ order: 'wechsel' })).status, 200) + assert.match(t09().text, /bereits widerrufen/) + assert.doesNotMatch(t09().text, /NACH Fristende|RECHTZEITIG/) + assert.match(k04().text, /prüfen/) + const widerrufe = await events('widerruf') + assert.equal(widerrufe.length, 2) + assert.equal(widerrufe[0].details.eventId, null) +}) + +// Fix 1 (7): Widerruf eines noch nicht wirksamen Kartenwechsels bzw. eines verspaeteten Abschluss-Widerrufs +test('T09: offene Wechselrechnung stornieren; verspäteter Abschluss-Widerruf ggf. als Kündigung', async () => { + await resetSubH({ units: 0 }) + const consentAt = daysAgo(1) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting', switchedAt: consentAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' }, + consent: { ...consent, consentAt }, status: 'offen', + }) + assert.equal((await withdraw({ order: 'wechsel' })).status, 200) + assert.equal((await events('wechsel'))[0].status, 'widerrufen') + assert.match(t09().text, /Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird/) + + // Wirksamer (bezahlter) Wechsel: kein Storno-Hinweis + await resetSubH({ units: 0 }) + 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', + }) + assert.equal((await withdraw({ order: 'wechsel' })).status, 200) + assert.doesNotMatch(t09().text, /Wechselrechnung noch offen/) + + // Abschluss nach der Frist (Fixture: Abo seit 40 Tagen): Hinweis auf die ordentliche Kuendigung + await resetSubH({ units: 0 }) + assert.equal((await withdraw({ order: 'abschluss' })).status, 200) + assert.match(t09().text, /NACH Fristende/) + assert.match(t09().text, /ggf\. als ordentliche Kündigung behandeln/) + // Zusatzspeicher ohne jede Bestellung: keine Frist gefunden statt "–, 24 Uhr" + mails.sent.length = 0 + assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200) + assert.match(t09().text, /Widerrufsfrist bis: keine Frist gefunden/) + assert.doesNotMatch(t09().text, /–, 24 Uhr|als ordentliche Kündigung/) + assert.match(t09().text, /keine Bestellung dieser Art/) }) // ---------------------------------------------------------------------------