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
@@ -8,8 +8,9 @@
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
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).
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -80,6 +81,19 @@ a{color:#111111;}
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
|
||||
</table></td></tr>
|
||||
<!-- ENDE nach_frist -->
|
||||
<!-- WENN begonnen -->
|
||||
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">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.</td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Name</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_name}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Leistung</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{leistung}}</strong></td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Person oder Platz</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{person}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Datum</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{datum}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Uhrzeit</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}} Uhr</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Eingegangen am</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{eingang}}</strong></td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_email}}</td></tr>
|
||||
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:11px 12px 11px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Telefon</td><td class="val ink rowline" valign="top" style="padding:11px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gast_telefon}}</td></tr>
|
||||
</table></td></tr>
|
||||
<!-- ENDE begonnen -->
|
||||
<!-- WENN nicht_zugeordnet -->
|
||||
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Über das Widerrufsformular ist eine Erklärung eingegangen, die wir keiner Buchung zuordnen konnten. Bitte prüfe die Angaben und melde Dich beim Absender.</td></tr>
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user