From c4ec24448ee65d6069913710b8230a61dec109a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:16:19 +0000 Subject: [PATCH] Terminplaner: Fix Widerruf nach Terminbeginn, Zustimmung beim Verschieben, Berliner Widerrufsfrist, Grenzen nur ohne Treffer Co-Authored-By: Claude Sonnet 5.5 --- .../mail-templates/K21 Termin geaendert.html | 16 +- server/services/termine/ablauf.js | 118 +++++++--- server/services/termine/einstellungen.js | 2 +- server/services/termine/limits.js | 7 +- server/services/termine/mails.js | 21 +- test/mailTemplates.test.js | 6 +- test/termineAblaufGast.test.js | 8 +- test/termineAblaufVerwalten.test.js | 201 ++++++++++++++++-- test/termineKontext.test.js | 13 +- test/termineLimits.test.js | 4 +- 10 files changed, 319 insertions(+), 77 deletions(-) diff --git a/server/mail-templates/K21 Termin geaendert.html b/server/mail-templates/K21 Termin geaendert.html index 4340dfe..b6f3191 100644 --- a/server/mail-templates/K21 Termin geaendert.html +++ b/server/mail-templates/K21 Termin geaendert.html @@ -8,8 +8,9 @@ Absender: „WEBklar“ Empfänger: Betrieb (E-Mail-Adresse des Betriebs) · OHNE BCC (enthält Angaben von Gästen) Platzhalter: {{betreff}}, {{kunde_name}}, {{leistung}}, {{person}}, {{datum}}, {{zeit}}, {{gast_name}}, {{gast_email}}, {{gast_telefon}}, {{gast_nachricht}}, {{portal_link}}, {{alt_datum}}, {{alt_zeit}}, {{eingang}} - Optionale Blöcke (genau einer pro Mail): abgesagt, verschoben, widerrufen, nicht_zugeordnet, nach_frist + Optionale Blöcke (genau einer pro Mail, sie schließen sich gegenseitig aus): abgesagt, verschoben, widerrufen, nicht_zugeordnet, nach_frist, begonnen Den Betreff und die Überschrift setzt der Server je Fall. Block nicht_zugeordnet: Erklärung ohne passende Buchung (Angaben des Absenders). + Block nach_frist: Widerrufsfrist bei Eingang abgelaufen. Block begonnen: Erklärung nach Terminbeginn, die Frist läuft noch (Betrieb prüft Erlöschen bzw. Wertersatz). --> @@ -80,6 +81,19 @@ a{color:#111111;} Telefon{{gast_telefon}} + +Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte. Bitte prüfe, ob das Widerrufsrecht schon erloschen ist (vollständige Leistung nach Zustimmung zum vorzeitigen Beginn) oder ob Wertersatz in Frage kommt. + + + + + + + + + +
Name{{gast_name}}
Leistung{{leistung}}
Person oder Platz{{person}}
Datum{{datum}}
Uhrzeit{{zeit}} Uhr
Eingegangen am{{eingang}}
E-Mail{{gast_email}}
Telefon{{gast_telefon}}
+ Über das Widerrufsformular ist eine Erklärung eingegangen, die wir keiner Buchung zuordnen konnten. Bitte prüfe die Angaben und melde Dich beim Absender. diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index 46637d5..e7eadac 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -1,10 +1,12 @@ import { createHash, randomUUID } from 'node:crypto' import { config } from '../../config.js' +import { withdrawalDeadline } from '../deadlines.js' import { withLocks } from '../keyedLock.js' import { aendere, hole, imZeitraum, legeAn, offeneFuerEmail, perEmailUndNummer, perHash } from './buchungen.js' import { brauchtZustimmungVorzeitig, emailAdresse, + hatPreis, istVerbraucher, knopfText, preisHinweis, @@ -49,8 +51,8 @@ import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js' * * Verwaltung durch den Gast (Verwaltungstoken, gueltig bis Terminende + 1 Tag): sageAbGast, verschiebeGast (mit * zeitenFuerToken fuer die freien Zeiten), widerrufeGast; ohne Link widerrufeOhneLink. Absagen und Verschieben gehen bis - * absageBisStd vor Beginn (danach 409 "Absage nur telefonisch"), der Widerruf haengt nur an Vertragsschluss + 14 Tagen - * und am Beginn des Termins. Beim Verschieben werden die neuen Zellen VOR der Aenderung der Buchung belegt (scheitert + * absageBisStd vor Beginn (danach 409 "Absage nur telefonisch"), der Widerruf haengt nur am Ende der Widerrufsfrist + * (14 Tage ab Vertragsschluss, §§ 187, 188, 193 BGB nach Berliner Kalender, deadlines.js) und am Beginn des Termins. Beim Verschieben werden die neuen Zellen VOR der Aenderung der Buchung belegt (scheitert * das, bleibt alles wie es war), die alten erst danach freigegeben; die Zellen einer abgesagten Buchung gelten ohnehin * als frei, ein Fehler beim Freigeben macht eine Absage also nicht rueckgaengig (nur ein Log). Fuer die Zellen gilt: * nie selbst eine termine-res-Sperre halten (belegeZellen/gibZellenFrei sind nicht wiedereintrittsfaehig). @@ -59,7 +61,6 @@ import { belegeZellen, gibZellenFrei, zellenFuer } from './zellen.js' const MIN_MS = 60 * 1000 const STUNDE_MS = 60 * MIN_MS const TAG_MS = 24 * STUNDE_MS -const WIDERRUF_TAGE = 14 const ADRESSE_WARTE_MS = 5000 const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ const ZEIT_RE = /^([01]\d|2[0-3]):[0-5]\d$/ @@ -429,10 +430,20 @@ function vertragAm(buchung) { return Number.NaN } +/** + * Ende der Widerrufsfrist in ms (NaN ohne Vertragsschluss): 14 Tage ab dem Tag nach dem Vertragsschluss, auf den + * naechsten Werktag verschoben, wenn der letzte Tag ein Wochenende oder Feiertag ist (§§ 187, 188, 193 BGB), bis + * 23:59:59,999 Uhr Berliner Zeit (withdrawalDeadline). + */ +function fristEnde(buchung) { + const vertrag = vertragAm(buchung) + return Number.isFinite(vertrag) ? withdrawalDeadline(new Date(vertrag)).endsAt.getTime() : Number.NaN +} + /** * Besteht noch ein Widerrufsrecht? Verbindlich (sofort oder vom Betrieb angenommene Anfrage), Verbraucher, bestaetigt - * (kein Handeintrag des Betriebs), weniger als 14 Tage seit dem Vertragsschluss (vertragAm) und der Termin hat noch - * nicht begonnen. Unabhaengig von absageBisStd (§ 361 BGB: die Stornofrist schraenkt das Widerrufsrecht nicht ein). + * (kein Handeintrag des Betriebs), die Widerrufsfrist (fristEnde) ist nicht abgelaufen und der Termin hat noch nicht + * begonnen. Unabhaengig von absageBisStd (§ 361 BGB: die Stornofrist schraenkt das Widerrufsrecht nicht ein). */ export function kannWiderrufen(buchung, now) { const nowMs = pruefeNow(now, 'kannWiderrufen') @@ -440,7 +451,7 @@ export function kannWiderrufen(buchung, now) { buchung.verbraucher === true && buchung.status === 'bestaetigt' && buchung.quelle !== 'manuell' && - nowMs < vertragAm(buchung) + WIDERRUF_TAGE * TAG_MS && + nowMs <= fristEnde(buchung) && nowMs < zeitMs(buchung.startsAt) ) } @@ -697,6 +708,31 @@ async function verschiebeZeiten(ctx, buchung, tag, now) { return { zeiten, amTag } } +/** Steht im Nachweis der Buchung die Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB)? */ +function hatVorzeitigeZustimmung(buchung) { + try { + return istObjekt(JSON.parse(buchung.nachweis)?.vorzeitig) + } catch { + return false + } +} + +/** + * Beim Verschieben darf die Pflicht-Zustimmung zum vorzeitigen Beginn nicht umgangen werden: eine verbindliche, + * kostenpflichtige Verbraucherbuchung ohne diese Zustimmung (sie war beim Buchen nicht noetig, weil der Termin + * damals mehr als 14 Tage entfernt lag) darf nicht auf einen Beginn innerhalb der Widerrufsfrist verlegt werden. + * Ohne ermittelbares Fristende gilt die Zustimmung als noetig. + */ +function verschiebenBrauchtZustimmung(buchung, start) { + if (buchung.verbraucher !== true || buchung.buchungsart !== 'sofort' || buchung.quelle === 'manuell' || !hatPreis(buchung)) return false + if (hatVorzeitigeZustimmung(buchung)) return false + return !(start.getTime() > fristEnde(buchung)) +} + +/** Anredeneutral (R22): die Gaesteseite zeigt den Text fuer Sie- und Du-Betriebe. */ +const zustimmungHinweis = (betrieb) => + `Dieser Termin liegt innerhalb der Widerrufsfrist. Bitte neu buchen oder ${betrieb.telefon ? `anrufen: ${betrieb.telefon}.` : 'direkt beim Betrieb melden.'}` + /** Tag ('YYYY-MM-DD') und Uhrzeit ('HH:MM') des neuen Termins; sonst 400 mit den Feldern tag und zeit. */ function pruefeVerschiebung(roh) { if (!istObjekt(roh)) throw fehlerMit(400, { _: MELDUNG.formular }) @@ -720,7 +756,10 @@ function pruefeVerschiebung(roh) { * @param {{ tag: string, zeit: string }} roh Berliner Tag 'YYYY-MM-DD' und Uhrzeit 'HH:MM' * @param {Date} now * @returns {Promise<{ status: string, termin: { datum: string, zeit: string } }>} - * @throws Error 400 (Felder tag, zeit; dieselbe Zeit), 404, 409 ('frist_vorbei', 'nicht_moeglich', 'SLOT_VERGEBEN' Feld zeit) + * Eine verbindliche, kostenpflichtige Verbraucherbuchung ohne Zustimmung zum vorzeitigen Beginn laesst sich nicht auf einen + * Beginn innerhalb der Widerrufsfrist verschieben (409 'zustimmung_noetig'). + * @throws Error 400 (Felder tag, zeit; dieselbe Zeit), 404, 409 ('frist_vorbei', 'nicht_moeglich', 'zustimmung_noetig', + * 'SLOT_VERGEBEN' Feld zeit) */ export async function verschiebeGast(token, roh, now) { const nowMs = pruefeNow(now, 'verschiebeGast') @@ -741,6 +780,7 @@ export async function verschiebeGast(token, roh, now) { ? alt.ressourceId : waehleRessource(treffer.ressourceIds, beginnenAn(amTag, tag), ctx.einstellung, now) if (treffer.start.getTime() === zeitMs(alt.startsAt) && ressourceId === alt.ressourceId) throw fehlerMit(400, { zeit: MELDUNG.gleicheZeit }) + if (verschiebenBrauchtZustimmung(alt, treffer.start)) throw fehlerMit(409, { _: zustimmungHinweis(ctx.einstellung.betrieb) }, { code: 'zustimmung_noetig' }) const start = treffer.start const ende = new Date(start.getTime() + (zeitMs(alt.endsAt) - zeitMs(alt.startsAt))) @@ -819,14 +859,16 @@ export async function zeitenFuerToken(token, roh, now) { const VERTRAG_STATUS = ['bestaetigt', 'erledigt', 'nichterschienen'] /** - * Wie steht die Buchung zu einer Widerrufserklaerung? 'wirksam' (kannWiderrufen), 'nach_frist' (ein Verbrauchervertrag - * besteht oder bestand, aber die 14 Tage sind um oder der Termin hat begonnen: der Betrieb prueft) oder 'kein_recht' - * (Unternehmer, Freizeit, Handeintrag, noch nicht angenommene Anfrage, abgesagt, ...). + * Wie steht die Buchung zu einer Widerrufserklaerung? 'wirksam' (kannWiderrufen); 'nach_frist' (ein Verbrauchervertrag + * besteht oder bestand und die Widerrufsfrist ist wirklich abgelaufen); 'begonnen' (die Frist laeuft noch, aber der + * Termin hat schon begonnen: ob das Recht erloschen ist oder Wertersatz geschuldet wird, prueft der Betrieb, R27); + * 'kein_recht' (Unternehmer, Freizeit, Handeintrag, noch nicht angenommene Anfrage, abgesagt, ...). */ function widerrufLage(buchung, now) { if (kannWiderrufen(buchung, now)) return 'wirksam' const vertrag = buchung.verbraucher === true && buchung.quelle !== 'manuell' && Number.isFinite(vertragAm(buchung)) && VERTRAG_STATUS.includes(buchung.status) - return vertrag ? 'nach_frist' : 'kein_recht' + if (!vertrag) return 'kein_recht' + return now.getTime() <= fristEnde(buchung) ? 'begonnen' : 'nach_frist' } /** Eingang einer Erklaerung als Berliner Strings ('TT.MM.JJJJ', 'HH:MM'). */ @@ -852,8 +894,8 @@ function pruefeWiderrufEingabe(roh, { mitAdresse }) { /** * Erklaerung zu einer gefundenen Buchung festhalten, unter `termine-buchung:` nach frischem Lesen. - * wirksam: `abgesagt` von `gast`, widerrufenAt, Zellen frei. nach_frist: nur ein Vermerk in absageGrund, sonst bleibt - * alles. kein_recht: nichts. schon: die Buchung war schon widerrufen (nichts). weg: gibt es nicht mehr. + * wirksam: `abgesagt` von `gast`, widerrufenAt, Zellen frei. nach_frist und begonnen: nur ein Vermerk in absageGrund, + * sonst bleibt alles. kein_recht: nichts. schon: die Buchung war schon widerrufen (nichts). weg: gibt es nicht mehr. * => { lage, buchung } */ async function haltWiderrufFest(id, name, now) { @@ -865,8 +907,9 @@ async function haltWiderrufFest(id, name, now) { if (lage === 'kein_recht') return { lage, buchung: frisch } const eingang = eingangVon(now) const wann = `${eingang.datum}, ${eingang.uhrzeit} Uhr` - if (lage === 'nach_frist') { - const vermerk = `Widerruf nach Ablauf der Frist erklärt von: ${name} (eingegangen ${wann})` + if (lage !== 'wirksam') { + const wannText = lage === 'begonnen' ? 'nach Terminbeginn' : 'nach Ablauf der Frist' + const vermerk = `Widerruf ${wannText} erklärt von: ${name} (eingegangen ${wann})` return { lage, buchung: await aendere(id, { absageGrund: vermerk, updatedAt: iso(now) }) } } const buchung = await aendere(id, { @@ -887,26 +930,33 @@ function ueberspringe(was, bookingId) { console.warn(`[termine-ablauf] ${was} nicht gesendet (${bookingId ? buchungsNummer(bookingId) : '-'}): Grenze erreicht`) } -/** Mails zu einer festgehaltenen Erklaerung: G08 an die Adresse des Gastes (mit Grenzen) und K21 an den Betrieb. */ +const WIDERRUF_MAIL = { wirksam: ['zugeordnet', 'widerrufen'], nach_frist: ['nach_frist', 'nach_frist'], begonnen: ['begonnen', 'begonnen'] } + +/** + * Mails zu einer Erklaerung, die zu einer Buchung gehoert: G08 an die Adresse des Gastes und K21 an den Betrieb (Fall je + * Lage: G08 zugeordnet/nach_frist/begonnen, K21 widerrufen/nach_frist/begonnen). Ein Treffer unterliegt keiner Grenze + * (R30): die Eingangsbestaetigung muss immer rausgehen, die Grenzen schuetzen nur vor Erklaerungen ins Leere. + */ async function widerrufsMails(ctx, lage, buchung, adresse, now) { const id = buchung.$id - if (darfG08(ctx.feature.$id, adresse, now)) { - await mailSicher('G08', id, () => anGast('G08', ctx, buchung, { fall: lage === 'wirksam' ? 'zugeordnet' : 'nach_frist', eingang: now, an: adresse }, now)) - } else { - ueberspringe('G08', id) - } - await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: lage === 'wirksam' ? 'widerrufen' : 'nach_frist', extra: { eingang: now } }, now)) + const [g08, k21] = WIDERRUF_MAIL[lage] + await mailSicher('G08', id, () => anGast('G08', ctx, buchung, { fall: g08, eingang: now, an: adresse }, now)) + await mailSicher('K21', id, () => anBetrieb('K21', ctx, buchung, { block: k21, extra: { eingang: now } }, now)) } -/** Erklaerung ohne passende Buchung: G08 (ohne Bezug) an die angegebene Adresse, K21 `nicht_zugeordnet` an den Betrieb, beide mit Grenzen. */ -async function nichtZugeordnetMails(ctx, { name, email, nummer }, now) { +/** + * Erklaerung ohne passende Buchung: G08 (ohne Bezug) an die angegebene Adresse, K21 `nicht_zugeordnet` an den Betrieb. + * begrenzt (kein Treffer): G08 und K21 unterliegen den Grenzen; ohne begrenzt (Buchung ohne Widerrufsrecht, aber + * mit passender Adresse und Nummer) gehen beide immer raus (R30). + */ +async function nichtZugeordnetMails(ctx, { name, email, nummer }, now, { begrenzt }) { const featureId = ctx.feature.$id - if (darfG08(featureId, email, now)) { + if (!begrenzt || darfG08(featureId, email, now)) { await mailSicher('G08', null, () => anGast('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: now, an: email }, now)) } else { ueberspringe('G08', null) } - if (!darfK21NichtZugeordnet(featureId, now)) return ueberspringe('K21', null) + if (begrenzt && !darfK21NichtZugeordnet(featureId, now)) return ueberspringe('K21', null) const erklaerung = { gastName: name, gastEmail: email, gastTelefon: '', gastNachricht: `Angegebene Buchungsnummer: ${nummer || 'keine Angabe'}` } await mailSicher('K21', null, () => anBetrieb('K21', ctx, erklaerung, { block: 'nicht_zugeordnet', extra: { eingang: now } }, now)) } @@ -914,8 +964,9 @@ async function nichtZugeordnetMails(ctx, { name, email, nummer }, now) { /** * Gast widerruft per Verwaltungslink (Formular nach § 356a BGB, Knopf "Widerruf bestätigen"). Wirksam, solange ein * Widerrufsrecht besteht (kannWiderrufen), unabhaengig von der Stornofrist: `abgesagt` von `gast`, widerrufenAt, Zellen - * frei, G08 `zugeordnet` an die Adresse der Buchung, K21 `widerrufen`. Nach Ablauf der Frist (oder bei begonnenem Termin) - * wird die Erklaerung nur vermerkt und mit G08/K21 `nach_frist` gemeldet. Hat die Buchung kein Widerrufsrecht (nicht + * frei, G08 `zugeordnet` an die Adresse der Buchung, K21 `widerrufen`. Nach Ablauf der Frist wird die Erklaerung nur + * vermerkt und mit G08/K21 `nach_frist` gemeldet, nach Terminbeginn bei noch laufender Frist mit G08 (ohne Fallblock) + * und K21 `begonnen`. G08 geht hier immer raus (keine Grenze, R30). Hat die Buchung kein Widerrufsrecht (nicht * angenommene Anfrage, Unternehmer, ...), 409 code 'kein_widerruf'. Ein weiterer Klick meldet den Eingang der ersten Erklaerung. * @param {string} token Verwaltungstoken * @param {{ name: string }} roh @@ -939,11 +990,14 @@ export async function widerrufeGast(token, roh, now) { * Widerruf ohne Link (Widerrufsfunktion im Widget, § 356a BGB). Die Antwort ist in jedem Fall dieselbe * (`{ eingang }` mit dem Eingang jetzt): sie verraet nicht, ob eine Buchung zu E-Mail-Adresse und Nummer passt. * - Treffer mit Widerrufsrecht: wie widerrufeGast (abgesagt, widerrufenAt, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`). - * - Treffer nach Ablauf der Frist: nur vermerkt, G08 `nach_frist` und K21 `nach_frist`; der Termin bleibt. + * - Treffer nach Ablauf der Widerrufsfrist: nur vermerkt, G08 `nach_frist` und K21 `nach_frist`; der Termin bleibt. + * - Treffer nach Terminbeginn, Frist noch nicht um: nur vermerkt, G08 ohne Fallblock und K21 `begonnen`; der Betrieb + * prueft, ob das Recht erloschen ist oder Wertersatz geschuldet wird. * - sonst (kein Treffer oder Buchung ohne Widerrufsrecht): G08 ohne Bezug an die angegebene Adresse und K21 * `nicht_zugeordnet` an den Betrieb, damit der Betrieb die Erklaerung pruefen kann. * G08 geht nie mit Text des Gastes raus. Grenzen (limits.js): 3 G08 je Adresse und Tag, 20 je Funktion und Stunde, - * 10 K21 `nicht_zugeordnet` je Funktion und Stunde; eine erreichte Grenze ueberspringt nur die Mail. + * 30 K21 `nicht_zugeordnet` je Funktion und Stunde, nur fuer Erklaerungen ohne Treffer (ein Treffer bekommt seine + * Mails immer, R30); eine erreichte Grenze ueberspringt nur die Mail. * @param {{ feature: object, einstellung: object }} ctx Kontext (kontext.js) * @param {{ name: string, email: string, nummer?: string }} roh * @param {Date} now @@ -959,7 +1013,7 @@ export async function widerrufeOhneLink(ctx, roh, now) { const treffer = await perEmailUndNummer(ctx.feature.$id, erklaerung.email, erklaerung.nummer) const erg = treffer ? await haltWiderrufFest(treffer.$id, erklaerung.name, now) : { lage: 'weg' } if (erg.lage === 'schon') return antwort // schon bestaetigt und gemeldet - if (erg.lage === 'wirksam' || erg.lage === 'nach_frist') await widerrufsMails(ctx, erg.lage, erg.buchung, erklaerung.email, now) - else await nichtZugeordnetMails(ctx, erklaerung, now) + if (erg.lage === 'wirksam' || erg.lage === 'nach_frist' || erg.lage === 'begonnen') await widerrufsMails(ctx, erg.lage, erg.buchung, erklaerung.email, now) + else await nichtZugeordnetMails(ctx, erklaerung, now, { begrenzt: erg.lage === 'weg' }) return antwort } diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 93e8872..f73f961 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -550,7 +550,7 @@ function farbe(fehl, wert) { // ---------------------------------------------------------------- Texte und Regeln /** Hat die Leistung einen Preis (fest/ab und mehr als 0)? */ -function hatPreis(leistung) { +export function hatPreis(leistung) { return !!leistung && leistung.preisArt !== 'keiner' && Number.isInteger(leistung.preisCent) && leistung.preisCent > 0 } diff --git a/server/services/termine/limits.js b/server/services/termine/limits.js index c728280..d370fcb 100644 --- a/server/services/termine/limits.js +++ b/server/services/termine/limits.js @@ -8,7 +8,8 @@ import { pruefeNow, pruefeText } from './util.js' * Ein Neustart des Prozesses leert alles (die Grenzen sind Missbrauchsschutz, kein Buchungsrecht). * * Allgemein: zaehle / zaehleAlle (true = Ereignis erlaubt und gezaehlt, false = Grenze erreicht, nichts gezaehlt). - * Mails des Widerrufs (Aufgabe 13): darfG08 und darfK21NichtZugeordnet. Die Grenzen je IP, siteKey und G01 ergaenzt + * Mails des Widerrufs (Aufgabe 13): darfG08 und darfK21NichtZugeordnet; sie gelten nur fuer Erklaerungen ohne passende + * Buchung, ein Treffer bekommt seine Mails immer. Die Grenzen je IP, siteKey und G01 ergaenzt * Aufgabe 16 in diesem Modul. */ @@ -20,7 +21,7 @@ const TAG_MS = 24 * STUNDE_MS const G08_JE_ADRESSE = 3 const G08_JE_FUNKTION = 20 /** K21 "nicht zugeordnet": je Funktion und Stunde. */ -const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 10 +const K21_NICHT_ZUGEORDNET_JE_FUNKTION = 30 /** schluessel -> { start (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne. */ const zaehler = new Map() @@ -74,7 +75,7 @@ export function darfG08(featureId, adresse, now) { ) } -/** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 10 je Funktion und Stunde. */ +/** Darf eine K21 "nicht zugeordnet" an den Betrieb gehen? Hoechstens 30 je Funktion und Stunde. */ export function darfK21NichtZugeordnet(featureId, now) { return zaehle(`k21-nicht-zugeordnet:${featureId}`, K21_NICHT_ZUGEORDNET_JE_FUNKTION, STUNDE_MS, now) } diff --git a/server/services/termine/mails.js b/server/services/termine/mails.js index 2f60337..835f4dc 100644 --- a/server/services/termine/mails.js +++ b/server/services/termine/mails.js @@ -35,10 +35,12 @@ import { datumKurz, datumLang, uhrzeit } from './zeit.js' * G07 altStart (bisheriger Beginn, Pflicht) * G06/G07 ohneIcs: true laesst die Kalenderdatei weg (eine unbestaetigte Anfrage hat nie eine Einladung bekommen, * es gibt im Kalender des Gastes nichts abzusagen oder zu verschieben) - * G08 fall 'zugeordnet' | 'nach_frist' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (bei 'nicht_zugeordnet') + * G08 fall 'zugeordnet' | 'nach_frist' | 'begonnen' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (Adresse der + * Erklaerung, Pflicht bei 'nicht_zugeordnet'). 'begonnen' (Widerruf nach Terminbeginn, Frist noch nicht um) und + * 'nicht_zugeordnet' tragen keinen Fallblock (neutraler Satz) und keine Buchungsnummer * K20 fall (= block) 'anfrage' oder nichts - * K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' (Pflicht); altStart (verschoben); - * eingang (widerrufen, nach_frist, nicht_zugeordnet); bei 'nicht_zugeordnet' ist buchung { gastName, gastEmail, + * K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' | 'begonnen' (Pflicht); + * altStart (verschoben); eingang (widerrufen, nach_frist, begonnen, nicht_zugeordnet); bei 'nicht_zugeordnet' ist buchung { gastName, gastEmail, * gastTelefon, gastNachricht } aus der Erklaerung * K20/K21 kundeName (Anrede des Kunden; sonst Name aus dem Kundendokument, zuletzt der Betriebsname) * Fuer Mails ohne Buchung gibt extra.bookingId den Bezug im Outbox-Eintrag an (Vorgabe '-'). @@ -90,6 +92,7 @@ export const BETREFFE = einfrieren({ verschoben: 'Termin verschoben: {leistung}, jetzt am {datum} um {zeit}', widerrufen: 'Termin widerrufen: {leistung} am {datum} um {zeit}', nach_frist: 'Widerruf nach Ablauf der Frist: {leistung} am {datum} um {zeit}', + begonnen: 'Widerruf nach Terminbeginn: {leistung} am {datum} um {zeit}', nicht_zugeordnet: 'Widerruf ohne passende Buchung', }, }, @@ -328,16 +331,16 @@ function gastG07({ ctx, anrede, buchung, extra }) { } function gastG08({ ctx, anrede, buchung, extra }) { - const fall = wahlFall(extra.fall, ['zugeordnet', 'nach_frist', 'nicht_zugeordnet'], 'G08') - const zugeordnet = fall !== 'nicht_zugeordnet' + const fall = wahlFall(extra.fall, ['zugeordnet', 'nach_frist', 'begonnen', 'nicht_zugeordnet'], 'G08') + const mitBlock = fall === 'zugeordnet' || fall === 'nach_frist' const values = { ...betriebsWerte(ctx), eingang: eingangText(extra.eingang), // die gespeicherte Nummer der gefundenen Buchung, nie eine Eingabe des Gastes - nummer: zugeordnet ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER, + nummer: mitBlock ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER, betreff: betreff('G08', anrede, 'standard', {}), } - return { values, blocks: { [anrede]: true, ...(zugeordnet ? { [fall]: true } : {}) } } + return { values, blocks: { [anrede]: true, ...(mitBlock ? { [fall]: true } : {}) } } } const GAST_BAUER = { @@ -379,7 +382,7 @@ function betriebK20({ ctx, buchung, extra }) { } function betriebK21({ ctx, buchung, extra }) { - const fall = wahlFall(extra.fall, ['abgesagt', 'verschoben', 'widerrufen', 'nicht_zugeordnet', 'nach_frist'], 'K21') + const fall = wahlFall(extra.fall, ['abgesagt', 'verschoben', 'widerrufen', 'nicht_zugeordnet', 'nach_frist', 'begonnen'], 'K21') const values = { ...betriebsMailWerte(ctx, buchung, extra), leistung: LEER, @@ -400,7 +403,7 @@ function betriebK21({ ctx, buchung, extra }) { const alt = pflichtDatum(extra.altStart, 'extra.altStart') Object.assign(values, { alt_datum: datumLang(alt), alt_zeit: uhrzeit(alt) }) } - if (['widerrufen', 'nach_frist', 'nicht_zugeordnet'].includes(fall)) values.eingang = eingangText(extra.eingang) + if (['widerrufen', 'nach_frist', 'begonnen', 'nicht_zugeordnet'].includes(fall)) values.eingang = eingangText(extra.eingang) values.betreff = betreff('K21', 'du', fall, { leistung: values.leistung, datum: kurz.datum, zeit: kurz.zeit }) return { values, blocks: { [fall]: true } } } diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 0ab64b6..34ae3c0 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -762,7 +762,7 @@ test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fal assert.doesNotMatch(sourceOf(id), /google|tracking|pixel/i, id) } assert.deepEqual([...loadTemplate('K20').blocks], ['anfrage']) - assert.deepEqual([...loadTemplate('K21').blocks].sort(), ['abgesagt', 'nach_frist', 'nicht_zugeordnet', 'verschoben', 'widerrufen']) + assert.deepEqual([...loadTemplate('K21').blocks].sort(), ['abgesagt', 'begonnen', 'nach_frist', 'nicht_zugeordnet', 'verschoben', 'widerrufen']) for (const p of ['alt_datum', 'alt_zeit', 'eingang']) assert.ok(loadTemplate('K21').placeholders.includes(p), `K21: ${p}`) const t20 = loadTemplate('K20') @@ -785,9 +785,11 @@ test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fal assert.match(fall('widerrufen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/) assert.match(fall('widerrufen').text, /widerrufen/) assert.match(fall('nach_frist').text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen/) + assert.match(fall('begonnen').text, /Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte\. Bitte prüfe, ob das Widerrufsrecht schon erloschen ist \(vollständige Leistung nach Zustimmung zum vorzeitigen Beginn\) oder ob Wertersatz in Frage kommt\./) + assert.match(fall('begonnen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/) assert.match(fall('nicht_zugeordnet').text, /keiner Buchung zuordnen/) // je Fall erscheint nur der eigene Abschnitt - const SATZ = { abgesagt: 'hat den Termin abgesagt', verschoben: 'hat den Termin verschoben', widerrufen: 'hat den Vertrag widerrufen', nach_frist: 'Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen', nicht_zugeordnet: 'keiner Buchung zuordnen' } + const SATZ = { abgesagt: 'hat den Termin abgesagt', verschoben: 'hat den Termin verschoben', widerrufen: 'hat den Vertrag widerrufen', nach_frist: 'Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen', begonnen: 'nachdem der Termin bereits begonnen hatte', nicht_zugeordnet: 'keiner Buchung zuordnen' } for (const block of Object.keys(SATZ)) { const mail = fall(block) assert.deepEqual(mail.problems, [], block) diff --git a/test/termineAblaufGast.test.js b/test/termineAblaufGast.test.js index d15a2c0..a6a56de 100644 --- a/test/termineAblaufGast.test.js +++ b/test/termineAblaufGast.test.js @@ -902,10 +902,12 @@ test('vorschau mit Verwaltungstoken liefert gast und die Aktionen nach Lage (abs assert.equal(v.knopf, '') assert.ok(!JSON.stringify(v).includes(buchung.$id)) - // nach 14 Tagen ab Bestaetigung (Termin liegt 21 Tage nach der Buchung): kein Widerruf mehr, Absagen geht - const nach14 = await vorschau(verwaltung, plus(JETZT, 14 * 24 * 60 * MIN)) + // Vertrag am Montag 05.10.: die Frist endet mit Ablauf des Montags 19.10. (Berliner Zeit, 23:59:59,999 MESZ = 21:59:59,999 UTC); + // danach (Termin liegt 21 Tage nach der Buchung) kein Widerruf mehr, Absagen geht + const fristEnde = new Date('2026-10-19T21:59:59.999Z') + const nach14 = await vorschau(verwaltung, plus(fristEnde, 1)) assert.deepEqual([nach14.aktionen.widerrufen, nach14.aktionen.absagen], [false, true]) - assert.equal((await vorschau(verwaltung, plus(JETZT, 14 * 24 * 60 * MIN - 1))).aktionen.widerrufen, true) + assert.equal((await vorschau(verwaltung, fristEnde)).aktionen.widerrufen, true) // 24 Stunden vor Beginn geht das Absagen noch, eine Minute spaeter nicht mehr (Absagefrist 24 h); Beginn: keine Aktion const start = new Date(buchung.startsAt) assert.equal((await vorschau(verwaltung, plus(start, -24 * 60 * MIN))).aktionen.absagen, true) diff --git a/test/termineAblaufVerwalten.test.js b/test/termineAblaufVerwalten.test.js index d5e19af..c381aee 100644 --- a/test/termineAblaufVerwalten.test.js +++ b/test/termineAblaufVerwalten.test.js @@ -639,6 +639,58 @@ test('verschiebeGast: eine fuer die alte Zeit gesendete Erinnerung wird zurueckg assert.equal(buchung(keine.id).erinnerungGesendetAt ?? null, null) }) +test('verschiebeGast: ohne Zustimmung zum vorzeitigen Beginn nicht in die Widerrufsfrist einer kostenpflichtigen Verbraucherbuchung (R28)', async () => { + const plan = { 1: [['09:00', '18:00']], 2: [['09:00', '18:00']] } + const ctx = await speichere(einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }] })) + const a = await gebucht(ctx) // Mo 26.10. 10:00, Vertrag Mo 05.10. => Fristende Mo 19.10.; 21 Tage Vorlauf: keine Zustimmung noetig gewesen + assert.equal(JSON.parse(a.buchung.nachweis).vorzeitig, undefined) + const standVorher = claimsStand() + const docVorher = JSON.stringify(buchung(a.id)) + const mails = sink.sent.length + + // innerhalb der Frist (auch am letzten Tag, 17:00 Uhr): 409, nichts aendert sich + for (const [tag, zeit] of [['2026-10-12', '11:00'], ['2026-10-19', '17:00']]) { + const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag, zeit }, JETZT)) + assert.equal(err.status, 409, `${tag} ${zeit}`) + assert.equal(err.code, 'zustimmung_noetig') + assert.deepEqual(err.fehler, { _: 'Dieser Termin liegt innerhalb der Widerrufsfrist. Bitte neu buchen oder anrufen: 06301 1234.' }) + } + assert.equal(claimsStand(), standVorher) + assert.equal(JSON.stringify(buchung(a.id)), docVorher) + assert.equal(sink.sent.length, mails) + assert.equal(eintraege().filter((d) => d.art === 'G07').length, 0) + + // am Tag nach dem Fristende geht es + const erg = await verschiebeGast(a.verwaltung, { tag: '2026-10-20', zeit: '09:00' }, JETZT) + assert.deepEqual(erg.termin, { datum: 'Dienstag, 20.10.2026', zeit: '09:00' }) + assert.equal(iso(buchung(a.id).startsAt), '2026-10-20T07:00:00.000Z') + + // mit der Zustimmung zum vorzeitigen Beginn (beim Buchen gegeben) ist es erlaubt + const b = await gebucht(ctx, { ...NAH, zeit: '12:00', email: 'zwei@example.com' }) + assert.ok(JSON.parse(b.buchung.nachweis).vorzeitig) + await verschiebeGast(b.verwaltung, { tag: '2026-10-19', zeit: '17:00' }, JETZT) + assert.equal(iso(buchung(b.id).startsAt), '2026-10-19T15:00:00.000Z') + + // ohne Preis, als Unternehmer oder bei einer Anfrage gilt die Regel nicht + const frei = await speichere( + einstellung({ + ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }], + leistungen: [...eingabeEinstellung().leistungen, { id: 's_bbbbbbb3', name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', ressourcen: [R1, R2] }], + }), + ) + const gratis = await gebucht(frei, { leistung: 's_bbbbbbb3', zeit: '14:00', email: 'drei@example.com' }) + await verschiebeGast(gratis.verwaltung, { tag: '2026-10-12', zeit: '11:00' }, JETZT) + assert.equal(iso(buchung(gratis.id).startsAt), '2026-10-12T09:00:00.000Z') + const anfrage = await gebucht(frei, { leistung: S_ANFRAGE, zeit: '16:00', email: 'vier@example.com' }) + await verschiebeGast(anfrage.verwaltung, { tag: '2026-10-12', zeit: '15:00' }, JETZT) + assert.equal(iso(buchung(anfrage.id).startsAt), '2026-10-12T13:00:00.000Z') + const unternehmer = await speichere(einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }], recht: { gaesteSind: 'unternehmer', freizeit: false } })) + const u = await gebucht(unternehmer, { zeit: '16:00', email: 'fuenf@example.com' }) + assert.equal(u.buchung.verbraucher, false) + await verschiebeGast(u.verwaltung, { tag: '2026-10-12', zeit: '09:00' }, JETZT) + assert.equal(iso(buchung(u.id).startsAt), '2026-10-12T07:00:00.000Z') +}) + test('verschiebeGast: Token, Modus und Uhr werden geprueft; abgesagte Termine lassen sich nicht verschieben', async () => { const ctx = await speichere() const { id, verwaltung, token } = await gebucht(ctx) @@ -708,10 +760,11 @@ test('zeitenFuerToken: Eingaben, Token, Frist und Uhr', async () => { // ---------------------------------------------------------------- Widerruf per Link -test('kannWiderrufen: sofort ab bestaetigtAt, angenommene Anfrage ab angenommenAt; 14 Tage, Beginn, Verbraucher, Handeintrag', () => { +test('kannWiderrufen: sofort ab bestaetigtAt, angenommene Anfrage ab angenommenAt; Fristende, Beginn, Verbraucher, Handeintrag', () => { const basis = { buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: iso(JETZT), startsAt: iso(plus(JETZT, 30 * TAG_MS)) } - assert.equal(kannWiderrufen(basis, plus(JETZT, 14 * TAG_MS - 1)), true) - assert.equal(kannWiderrufen(basis, plus(JETZT, 14 * TAG_MS)), false) + // Vertrag Montag 05.10.: Fristende Montag 19.10., 23:59:59,999 Uhr Berliner Zeit (MESZ) = 21:59:59,999 UTC + assert.equal(kannWiderrufen(basis, new Date('2026-10-19T21:59:59.999Z')), true) + assert.equal(kannWiderrufen(basis, new Date('2026-10-19T22:00:00.000Z')), false) assert.equal(kannWiderrufen(basis, plus(new Date(basis.startsAt), 0)), false, 'Termin hat begonnen') assert.equal(kannWiderrufen({ ...basis, startsAt: iso(plus(JETZT, 3 * TAG_MS)) }, plus(JETZT, 3 * TAG_MS - 1)), true, 'bis zum Beginn') assert.equal(kannWiderrufen({ ...basis, verbraucher: false }, JETZT), false) @@ -722,12 +775,36 @@ test('kannWiderrufen: sofort ab bestaetigtAt, angenommene Anfrage ab angenommenA const anfrage = { ...basis, buchungsart: 'anfrage', bestaetigtAt: undefined, angenommenAt: iso(plus(JETZT, 2 * TAG_MS)) } assert.equal(kannWiderrufen(anfrage, plus(JETZT, 3 * TAG_MS)), true, 'angenommene Anfrage') - assert.equal(kannWiderrufen(anfrage, plus(JETZT, 16 * TAG_MS - 1)), true, 'die Frist zaehlt ab der Annahme') - assert.equal(kannWiderrufen(anfrage, plus(JETZT, 16 * TAG_MS)), false) + // Annahme Mittwoch 07.10.: Fristende Mittwoch 21.10. (21:59:59,999 UTC) + assert.equal(kannWiderrufen(anfrage, new Date('2026-10-21T21:59:59.999Z')), true, 'die Frist zaehlt ab der Annahme') + assert.equal(kannWiderrufen(anfrage, new Date('2026-10-21T22:00:00.000Z')), false) assert.equal(kannWiderrufen({ ...anfrage, angenommenAt: undefined }, JETZT), false, 'ohne Annahme kein Vertrag') assert.equal(kannWiderrufen({ ...anfrage, bestaetigtAt: iso(JETZT), angenommenAt: undefined }, JETZT), false, 'bestaetigtAt allein macht eine Anfrage nicht verbindlich') }) +test('kannWiderrufen: Fristende nach §§ 187, 188, 193 BGB im Berliner Kalender - Zeitumstellung, Wochenende, Feiertag, Tageswechsel', () => { + const buchungAm = (vertrag) => ({ buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: vertrag, startsAt: '2027-02-01T09:00:00.000Z' }) + const grenze = (vertrag, letzterMoment) => { + const b = buchungAm(vertrag) + const ende = new Date(letzterMoment) + assert.equal(kannWiderrufen(b, ende), true, `${vertrag}: noch ${letzterMoment}`) + assert.equal(kannWiderrufen(b, new Date(ende.getTime() + 1)), false, `${vertrag}: nach ${letzterMoment} nicht mehr`) + } + // Sommerzeit endet am 25.10.: Vertrag Montag 12.10. 10:00 MESZ => letzter Tag Montag 26.10. (MEZ), 23:59:59,999 = 22:59:59,999 UTC. + // (14 x 24 Stunden wuerden schon am 26.10. um 08:00 UTC enden) + grenze('2026-10-12T08:00:00.000Z', '2026-10-26T22:59:59.999Z') + // Sommerzeit beginnt am 29.03.: Vertrag Montag 16.03. 10:00 MEZ => letzter Tag Montag 30.03. (MESZ), 21:59:59,999 UTC + grenze('2026-03-16T09:00:00.000Z', '2026-03-30T21:59:59.999Z') + // der 14. Tag ist ein Samstag (24.10.) => Montag 26.10.; der Sonntag dazwischen zaehlt noch mit + grenze('2026-10-10T10:00:00.000Z', '2026-10-26T22:59:59.999Z') + assert.equal(kannWiderrufen(buchungAm('2026-10-10T10:00:00.000Z'), new Date('2026-10-25T10:00:00.000Z')), true) + // der 14. Tag ist Freitag, 25.12. (Feiertag), danach Samstag 26.12. (Feiertag) und Sonntag => Montag 28.12. + grenze('2026-12-11T10:00:00.000Z', '2026-12-28T22:59:59.999Z') + // Vertrag um 23:30 MESZ am 05.10. (21:30 UTC) zaehlt noch fuer den 05.10., um 00:30 MESZ am 06.10. (22:30 UTC am 05.10.) fuer den 06.10. + grenze('2026-10-05T21:30:00.000Z', '2026-10-19T21:59:59.999Z') + grenze('2026-10-05T22:30:00.000Z', '2026-10-20T21:59:59.999Z') +}) + test('widerrufeGast: 2 Stunden vor dem Termin trotz abgelaufener Stornofrist; abgesagt von gast, Zellen frei, G08 zugeordnet, K21 widerrufen', async () => { const ctx = await speichere() const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx, NAH) @@ -770,19 +847,20 @@ test('widerrufeGast: Eingang in Berliner Zeit (22:30 UTC = 00:30 am Folgetag)', assert.deepEqual(erg, { eingang: { datum: '11.10.2026', uhrzeit: '00:30' } }) }) -test('widerrufeGast: die 14 Tage gelten ab der Bestaetigung; am letzten Moment wirksam, danach nur gespeichert und gemeldet (nach_frist)', async () => { +test('widerrufeGast: die Frist gilt ab der Bestaetigung bis Fristende; am letzten Moment wirksam, danach nur gespeichert und gemeldet (nach_frist)', async () => { const ctx = await speichere() const a = await gebucht(ctx) // Termin 21 Tage nach der Buchung const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) const vorher = sink.sent.length - await widerrufeGast(a.verwaltung, { name: 'Max' }, plus(JETZT, 14 * TAG_MS - 1)) + // Vertrag Montag 05.10. => Fristende Montag 19.10., 23:59:59,999 MESZ + const fristEnde = new Date('2026-10-19T21:59:59.999Z') + assert.deepEqual(await widerrufeGast(a.verwaltung, { name: 'Max' }, fristEnde), { eingang: { datum: '19.10.2026', uhrzeit: '23:59' } }) assert.equal(buchung(a.id).status, 'abgesagt') - const spaet = plus(JETZT, 14 * TAG_MS) - const erg = await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, spaet) + const erg = await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, plus(fristEnde, 1)) await warteAufVersand() - assert.deepEqual(erg, { eingang: { datum: '19.10.2026', uhrzeit: '10:00' } }) + assert.deepEqual(erg, { eingang: { datum: '20.10.2026', uhrzeit: '00:00' } }) const nachher = buchung(b.id) assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt') assert.equal(nachher.widerrufenAt, undefined) @@ -799,6 +877,54 @@ test('widerrufeGast: die 14 Tage gelten ab der Bestaetigung; am letzten Moment w assert.equal(an(neu, 'info@salon.de').find((m) => /nach Ablauf/i.test(m.subject)).subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 12:00') }) +test('Widerruf nach Terminbeginn bei noch laufender Frist (R27): vermerkt, G08 ohne "abgelaufen", K21 begonnen, Termin bleibt; nach Fristende nach_frist', async () => { + const ctx = await speichere() + const a = await gebucht(ctx, NAH) // 12.10.2026 10:00 MESZ, Vertrag 05.10.: die Frist laeuft bis 19.10. + const b = await gebucht(ctx, { ...NAH, zeit: '12:00', email: 'zwei@example.com' }) + const antwortA = { eingang: { datum: '12.10.2026', uhrzeit: '10:20' } } + const vorher = sink.sent.length + + // ohne Link, 20 Minuten nach Beginn + const jetztA = plus(new Date(a.buchung.startsAt), 20 * MIN) + assert.deepEqual(await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(a.id) }, jetztA), antwortA) + // mit Link, 10 Minuten nach Beginn + const jetztB = plus(new Date(b.buchung.startsAt), 10 * MIN) + assert.deepEqual(await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, jetztB), { eingang: { datum: '12.10.2026', uhrzeit: '12:10' } }) + await warteAufVersand() + + for (const [x, grund] of [[a, 'Max Mustermann'], [b, 'Erika Beispiel']]) { + const nachher = buchung(x.id) + assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt') + assert.equal(nachher.widerrufenAt, undefined) + assert.equal(claims(x.id).length, 10) + assert.match(nachher.absageGrund, /nach Terminbeginn/) + assert.ok(nachher.absageGrund.includes(grund)) + } + const neu = sink.sent.slice(vorher) + assert.equal(neu.length, 4) + const g08 = an(neu, 'max@example.com')[0] + assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs') + assert.match(g08.text, /12\.10\.2026, 10:20 Uhr/) + assert.match(g08.text, /prüft Ihre Erklärung und meldet sich bei Ihnen/) + assert.doesNotMatch(g08.text, /abgelaufen|Der Termin ist abgesagt/) + assert.ok(!g08.text.includes(buchungsNummer(a.id)), 'neutraler Satz, keine Zusage zur Buchung') + const k21 = an(neu, 'info@salon.de').find((m) => m.text.includes('max@example.com')) + assert.equal(k21.subject, 'Widerruf nach Terminbeginn: Damenschnitt am 12.10.2026 um 10:00') + assert.match(k21.text, /Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte\./) + assert.match(k21.text, /wertersatz|Wertersatz/) + assert.doesNotMatch(k21.text, /Widerrufsfrist bei Eingang bereits abgelaufen/) + assert.equal(eintraege().filter((d) => d.art === 'G08').length, 2) + + // nach Fristende (Montag 19.10., 23:59:59,999) dieselbe Erklaerung: jetzt wirklich nach der Frist + const vorher2 = sink.sent.length + await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(a.id) }, new Date('2026-10-19T22:00:00.000Z')) + await warteAufVersand() + const spaet = sink.sent.slice(vorher2) + assert.match(an(spaet, 'max@example.com')[0].text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/) + assert.equal(an(spaet, 'info@salon.de')[0].subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 12.10.2026 um 10:00') + assert.match(buchung(a.id).absageGrund, /nach Ablauf der Frist/) +}) + test('widerrufeGast: eine nicht angenommene Anfrage kann nicht widerrufen werden (409), nur absagen; ebenso Unternehmer', async () => { const ctx = await speichere() const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE }) @@ -828,8 +954,9 @@ test('widerrufeGast: eine vom Betrieb angenommene Anfrage ist widerrufbar, die F // Annahme durch den Betrieb (Aufgabe 14) von Hand nachgestellt const angenommen = plus(JETZT, 2 * TAG_MS) aw.seed('termineBookings', { ...b, status: 'bestaetigt', angenommenAt: iso(angenommen) }) - assert.equal((await vorschau(verwaltung, plus(angenommen, 13 * TAG_MS))).aktionen.widerrufen, true) - assert.equal((await vorschau(verwaltung, plus(angenommen, 14 * TAG_MS))).aktionen.widerrufen, false) + const fristEnde = new Date('2026-10-21T21:59:59.999Z') // Annahme Mittwoch 07.10. => Fristende Mittwoch 21.10. + assert.equal((await vorschau(verwaltung, fristEnde)).aktionen.widerrufen, true) + assert.equal((await vorschau(verwaltung, plus(fristEnde, 1))).aktionen.widerrufen, false) const jetzt = plus(angenommen, 13 * TAG_MS) // 20.10.2026, der Termin am 26.10. liegt noch in der Zukunft const erg = await widerrufeGast(verwaltung, { name: 'Max' }, jetzt) assert.ok(erg.eingang) @@ -996,34 +1123,62 @@ test('widerrufeOhneLink: vierter G08 an dieselbe Adresse am Tag wird nicht gesen assert.equal(eintraege().filter((d) => d.art === 'G08').length, 4) }) -test('widerrufeOhneLink: 20 G08 je Funktion und Stunde, 10 K21 nicht_zugeordnet je Funktion und Stunde; weitere werden still uebersprungen', async () => { +test('widerrufeOhneLink: 20 G08 je Funktion und Stunde, 30 K21 nicht_zugeordnet je Funktion und Stunde; weitere werden still uebersprungen', async () => { const ctx = await speichere() const antworten = [] - for (let i = 0; i < 22; i += 1) antworten.push(await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT)) + for (let i = 0; i < 32; i += 1) antworten.push(await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT)) await warteAufVersand() for (const a of antworten) assert.deepEqual(a, antworten[0]) assert.equal(eintraege().filter((d) => d.art === 'G08').length, 20) - assert.equal(eintraege().filter((d) => d.art === 'K21').length, 10) + assert.equal(eintraege().filter((d) => d.art === 'K21').length, 30) // eine Stunde spaeter beginnen beide Fenster neu await widerrufeOhneLink(ctx, { name: 'Spaet', email: 'spaet@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 60 * MIN)) await warteAufVersand() assert.equal(eintraege().filter((d) => d.art === 'G08').length, 21) - assert.equal(eintraege().filter((d) => d.art === 'K21').length, 11) + assert.equal(eintraege().filter((d) => d.art === 'K21').length, 31) }) -test('widerrufeOhneLink: Grenzen gelten auch fuer Treffer; bei erreichter G08-Grenze geht der Widerruf trotzdem durch', async () => { +test('widerrufeOhneLink: ein Treffer bekommt seine Mails immer und zaehlt nicht mit; die Grenzen gelten nur ohne Treffer (R30)', async () => { const ctx = await speichere() - const { id } = await gebucht(ctx) + const a = await gebucht(ctx) // max@example.com + const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'vier@example.com' }) // nicht angenommen: kein Widerrufsrecht + const g08 = () => eintraege().filter((d) => d.art === 'G08') + const k21 = () => eintraege().filter((d) => d.art === 'K21') + + // Adresse max@ (3 Erklaerungen ins Leere), dann Funktion und K21-Grenze ausschoepfen for (let i = 0; i < 3; i += 1) await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, JETZT) + for (let i = 0; i < 28; i += 1) await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT) await warteAufVersand() - assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3) - const erg = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(id) }, plus(JETZT, MIN)) + assert.equal(g08().length, 20, 'G08: Funktionsgrenze erreicht') + const kNichtZugeordnet = k21().length + assert.equal(kNichtZugeordnet, 30, 'K21 nicht_zugeordnet: Grenze erreicht') + const nochEine = await widerrufeOhneLink(ctx, { name: 'Noch', email: 'neu@example.com', nummer: 'ZZZZZZZZ' }, JETZT) + await warteAufVersand() + assert.equal(g08().length, 20, 'ohne Treffer bleibt die Grenze') + assert.equal(k21().length, 30) + + // Treffer mit Widerrufsrecht: G08 (an die Adresse mit erschoepftem Tageskontingent) und K21 gehen trotzdem raus + const erg = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(a.id) }, plus(JETZT, MIN)) await warteAufVersand() assert.deepEqual(erg, { eingang: { datum: '05.10.2026', uhrzeit: '10:01' } }) - assert.equal(buchung(id).status, 'abgesagt', 'der Widerruf ist wirksam') - assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3, 'aber keine vierte Bestaetigung') - assert.equal(eintraege().filter((d) => d.art === 'K21' && d.bookingId === id).length, 1, 'der Betrieb erfaehrt davon') + assert.deepEqual(nochEine, { eingang: { datum: '05.10.2026', uhrzeit: '10:00' } }) + assert.equal(buchung(a.id).status, 'abgesagt', 'der Widerruf ist wirksam') + assert.equal(g08().length, 21) + assert.equal(g08().filter((d) => d.bookingId === a.id).length, 1) + assert.equal(k21().filter((d) => d.bookingId === a.id).length, 1, 'der Betrieb erfaehrt davon') + + // Treffer ohne Widerrufsrecht (nicht angenommene Anfrage): G08 und K21 nicht_zugeordnet ebenfalls ohne Grenze + await widerrufeOhneLink(ctx, { name: 'Vier', email: 'vier@example.com', nummer: buchungsNummer(anfrage.id) }, plus(JETZT, 2 * MIN)) + await warteAufVersand() + assert.equal(g08().length, 22) + assert.equal(k21().length, 32) + assert.equal(buchung(anfrage.id).status, 'angefragt', 'die Anfrage bleibt unveraendert') + + // der Treffer hat nichts verbraucht: ohne Treffer bleibt es bei der Grenze, nach einer Stunde geht es wieder + await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 3 * MIN)) + await warteAufVersand() + assert.equal(g08().length, 22) }) test('widerrufeOhneLink: Eingaben - Name und E-Mail Pflicht (400), sonst nichts gespeichert und keine Mail', async () => { diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js index e3a5291..146dfdb 100644 --- a/test/termineKontext.test.js +++ b/test/termineKontext.test.js @@ -439,6 +439,7 @@ const FAELLE = [ ['G07', buchung({ icsSequenz: 1 }), { altStart: ALT_START }], ['G08', buchung(), { fall: 'zugeordnet', eingang: EINGANG }], ['G08', buchung(), { fall: 'nach_frist', eingang: EINGANG }], + ['G08', buchung(), { fall: 'begonnen', eingang: EINGANG }], ['G08', null, { fall: 'nicht_zugeordnet', eingang: EINGANG, an: 'erika@example.com' }], ['K20', buchung(), {}], ['K20', buchung({ status: 'angefragt' }), { fall: 'anfrage' }], @@ -446,6 +447,7 @@ const FAELLE = [ ['K21', buchung(), { fall: 'verschoben', altStart: ALT_START }], ['K21', buchung(), { fall: 'widerrufen', eingang: EINGANG }], ['K21', buchung(), { fall: 'nach_frist', eingang: EINGANG }], + ['K21', buchung(), { fall: 'begonnen', eingang: EINGANG }], ['K21', ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }], ] @@ -506,6 +508,7 @@ test('Betreffzeilen der Betriebsmails: Buchung/Anfrage und je Fall von K21; nie assert.match(betreff('K21', buchung(), { fall: 'verschoben', altStart: ALT_START }), /^Termin verschoben: Damenschnitt, jetzt am 12\.10\.2026 um 10:00$/) assert.match(betreff('K21', buchung(), { fall: 'widerrufen', eingang: EINGANG }), /^Termin widerrufen: /) assert.match(betreff('K21', buchung(), { fall: 'nach_frist', eingang: EINGANG }), /^Widerruf nach Ablauf der Frist: /) + assert.equal(betreff('K21', buchung(), { fall: 'begonnen', eingang: EINGANG }), 'Widerruf nach Terminbeginn: Damenschnitt am 12.10.2026 um 10:00') assert.equal(betreff('K21', ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }), 'Widerruf ohne passende Buchung') for (const [art, b, extra] of FAELLE) { const text = betreff(art, b, extra) @@ -751,6 +754,14 @@ test('G08: Eingang als "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit), Nummer nur der g assert.deepEqual(keine.blocks, { sie: true }, 'weder zugeordnet noch nach_frist') assert.equal(keine.values.nummer, '–') + // Widerruf nach Terminbeginn (Frist laeuft noch): neutraler Satz ohne Fallblock und ohne Nummer + const begonnen = werteFuer('G08', ctx, buchung(), { fall: 'begonnen', eingang: EINGANG }) + assert.deepEqual(begonnen.blocks, { sie: true }) + assert.equal(begonnen.values.nummer, '–') + const text = renderMail('G08', begonnen.values, begonnen.blocks).text + assert.doesNotMatch(text, /abgelaufen|Der Termin ist abgesagt/) + assert.match(text, /prüft Ihre Erklärung und meldet sich bei Ihnen/) + // Nummer aus der Buchung, nie aus einer Eingabe des Gastes assert.throws(() => werteFuer('G08', ctx, null, { fall: 'zugeordnet', eingang: EINGANG }), TypeError) assert.throws(() => werteFuer('G08', ctx, buchung(), { eingang: EINGANG }), TypeError, 'fall ist Pflicht') @@ -769,7 +780,7 @@ test('K20 und K21: Gastangaben, "–" fuer Leeres, Kundenname, ein Fallblock je assert.deepEqual(werteFuer('K20', ctx, buchung(), { fall: 'anfrage' }).blocks, { anfrage: true }) assert.equal(werteFuer('K20', ctx, buchung(), {}).values.kunde_name, 'Salon Müller', 'ohne Kundenname der Betriebsname') - for (const fall of ['abgesagt', 'verschoben', 'widerrufen', 'nach_frist']) { + for (const fall of ['abgesagt', 'verschoben', 'widerrufen', 'nach_frist', 'begonnen']) { const { blocks } = werteFuer('K21', ctx, buchung(), { fall, altStart: ALT_START, eingang: EINGANG }) assert.deepEqual(blocks, { [fall]: true }, fall) } diff --git a/test/termineLimits.test.js b/test/termineLimits.test.js index bf6a971..069c498 100644 --- a/test/termineLimits.test.js +++ b/test/termineLimits.test.js @@ -56,8 +56,8 @@ test('darfG08: 3 je Adresse und Tag (auch ueber Funktionen), 20 je Funktion und assert.equal(darfG08('f1', 'spaet@example.com', plus(STUNDE)), true, 'nach einer Stunde wieder') }) -test('darfK21NichtZugeordnet: 10 je Funktion und Stunde', () => { - assert.equal(Array.from({ length: 12 }, () => darfK21NichtZugeordnet('f1', T0)).filter(Boolean).length, 10) +test('darfK21NichtZugeordnet: 30 je Funktion und Stunde', () => { + assert.equal(Array.from({ length: 32 }, () => darfK21NichtZugeordnet('f1', T0)).filter(Boolean).length, 30) assert.equal(darfK21NichtZugeordnet('f2', T0), true) assert.equal(darfK21NichtZugeordnet('f1', plus(STUNDE)), true) })