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:
1 parent
2d9b75b3bf
commit
c4ec24448e
10 files changed
+319
-77
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user