Terminplaner: Fix Widerruf nach Terminbeginn, Zustimmung beim Verschieben, Berliner Widerrufsfrist, Grenzen nur ohne Treffer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 2d9b75b3bf
commit c4ec24448e
10 files changed
+319 -77

No files matched your search

+86 -32
View File
@@ -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:<id>` 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
}