From e15486263ff97fbbcd8ed118c4508e3db73e4c13 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:12:47 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Mails=20an=20G=C3=A4ste,=20?= =?UTF-8?q?Vermieter=20und=20Team?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../G16 Buchungsanfrage eingegangen.html | 52 ++++++ .../G17 Buchungsanfrage freigegeben.html | 61 +++++++ .../G18 Buchungsanfrage abgelehnt.html | 56 ++++++ .../G19 Buchungsbestätigung.html | 61 +++++++ .../G20 Zahlung eingegangen (Unterkunft).html | 54 ++++++ .../G21 Reservierung abgelaufen.html | 52 ++++++ .../mail-templates/G22 Buchung storniert.html | 58 ++++++ .../K31 Neue Buchungsanfrage.html | 55 ++++++ server/mail-templates/K32 Neue Buchung.html | 54 ++++++ .../K33 Kalenderabgleich fehlgeschlagen.html | 48 +++++ .../K34 Anfrage Buchungskalender.html | 55 ++++++ .../T22 Neue Anfrage Buchungskalender.html | 55 ++++++ server/services/mailer.js | 5 +- server/services/termine/outbox.js | 37 ++++ server/services/unterkunft/mails.js | 171 +++++++++++++++++- test/mailTemplates.test.js | 76 +++++++- test/termineOutbox.test.js | 38 +++- test/unterkunftMails.test.js | 113 ++++++++++++ 18 files changed, 1095 insertions(+), 6 deletions(-) create mode 100644 server/mail-templates/G16 Buchungsanfrage eingegangen.html create mode 100644 server/mail-templates/G17 Buchungsanfrage freigegeben.html create mode 100644 server/mail-templates/G18 Buchungsanfrage abgelehnt.html create mode 100644 server/mail-templates/G19 Buchungsbestätigung.html create mode 100644 server/mail-templates/G20 Zahlung eingegangen (Unterkunft).html create mode 100644 server/mail-templates/G21 Reservierung abgelaufen.html create mode 100644 server/mail-templates/G22 Buchung storniert.html create mode 100644 server/mail-templates/K31 Neue Buchungsanfrage.html create mode 100644 server/mail-templates/K32 Neue Buchung.html create mode 100644 server/mail-templates/K33 Kalenderabgleich fehlgeschlagen.html create mode 100644 server/mail-templates/K34 Anfrage Buchungskalender.html create mode 100644 server/mail-templates/T22 Neue Anfrage Buchungskalender.html create mode 100644 test/unterkunftMails.test.js diff --git a/server/mail-templates/G16 Buchungsanfrage eingegangen.html b/server/mail-templates/G16 Buchungsanfrage eingegangen.html new file mode 100644 index 0000000..9210e44 --- /dev/null +++ b/server/mail-templates/G16 Buchungsanfrage eingegangen.html @@ -0,0 +1,52 @@ + + + + + + + + +Ihre Buchungsanfrage bei {{firma}} ist eingegangen + + + + + + + + + diff --git a/server/mail-templates/G17 Buchungsanfrage freigegeben.html b/server/mail-templates/G17 Buchungsanfrage freigegeben.html new file mode 100644 index 0000000..5d87eaf --- /dev/null +++ b/server/mail-templates/G17 Buchungsanfrage freigegeben.html @@ -0,0 +1,61 @@ + + + + + + + + +Ihre Buchungsanfrage {{nummer}} bei {{firma}}: jetzt buchen + + + + + + + + + diff --git a/server/mail-templates/G18 Buchungsanfrage abgelehnt.html b/server/mail-templates/G18 Buchungsanfrage abgelehnt.html new file mode 100644 index 0000000..ecd2b09 --- /dev/null +++ b/server/mail-templates/G18 Buchungsanfrage abgelehnt.html @@ -0,0 +1,56 @@ + + + + + + + + +Zu Ihrer Buchungsanfrage {{nummer}} bei {{firma}} + + + + + + + + + diff --git a/server/mail-templates/G19 Buchungsbestätigung.html b/server/mail-templates/G19 Buchungsbestätigung.html new file mode 100644 index 0000000..17c03d2 --- /dev/null +++ b/server/mail-templates/G19 Buchungsbestätigung.html @@ -0,0 +1,61 @@ + + + + + + + + +Ihre Buchung {{nummer}} bei {{firma}} ist bestätigt + + + + + + + + + diff --git a/server/mail-templates/G20 Zahlung eingegangen (Unterkunft).html b/server/mail-templates/G20 Zahlung eingegangen (Unterkunft).html new file mode 100644 index 0000000..1724a88 --- /dev/null +++ b/server/mail-templates/G20 Zahlung eingegangen (Unterkunft).html @@ -0,0 +1,54 @@ + + + + + + + + +Zahlung zu Ihrer Buchung {{nummer}} eingegangen + + + + + + + + + diff --git a/server/mail-templates/G21 Reservierung abgelaufen.html b/server/mail-templates/G21 Reservierung abgelaufen.html new file mode 100644 index 0000000..815f65b --- /dev/null +++ b/server/mail-templates/G21 Reservierung abgelaufen.html @@ -0,0 +1,52 @@ + + + + + + + + +Ihre Reservierung {{nummer}} bei {{firma}} ist abgelaufen + + + + + + + + + diff --git a/server/mail-templates/G22 Buchung storniert.html b/server/mail-templates/G22 Buchung storniert.html new file mode 100644 index 0000000..192fdbf --- /dev/null +++ b/server/mail-templates/G22 Buchung storniert.html @@ -0,0 +1,58 @@ + + + + + + + + +Ihre Buchung {{nummer}} bei {{firma}} wurde storniert + + + + + + + + + diff --git a/server/mail-templates/K31 Neue Buchungsanfrage.html b/server/mail-templates/K31 Neue Buchungsanfrage.html new file mode 100644 index 0000000..5a7460a --- /dev/null +++ b/server/mail-templates/K31 Neue Buchungsanfrage.html @@ -0,0 +1,55 @@ + + + + + + + + +Neue Buchungsanfrage {{nummer}} für {{unterkunft}} + + + + + + + + + diff --git a/server/mail-templates/K32 Neue Buchung.html b/server/mail-templates/K32 Neue Buchung.html new file mode 100644 index 0000000..e7225c6 --- /dev/null +++ b/server/mail-templates/K32 Neue Buchung.html @@ -0,0 +1,54 @@ + + + + + + + + +Neue Buchung {{nummer}} für {{unterkunft}} + + + + + + + + + diff --git a/server/mail-templates/K33 Kalenderabgleich fehlgeschlagen.html b/server/mail-templates/K33 Kalenderabgleich fehlgeschlagen.html new file mode 100644 index 0000000..afae82c --- /dev/null +++ b/server/mail-templates/K33 Kalenderabgleich fehlgeschlagen.html @@ -0,0 +1,48 @@ + + + + + + + + +Der Kalenderabgleich für {{unterkunft}} funktioniert nicht + + + + + + + + + diff --git a/server/mail-templates/K34 Anfrage Buchungskalender.html b/server/mail-templates/K34 Anfrage Buchungskalender.html new file mode 100644 index 0000000..9eaf2de --- /dev/null +++ b/server/mail-templates/K34 Anfrage Buchungskalender.html @@ -0,0 +1,55 @@ + + + + + + + + +Deine Anfrage zum Buchungskalender für {{projekt}} + + + + + + + + + diff --git a/server/mail-templates/T22 Neue Anfrage Buchungskalender.html b/server/mail-templates/T22 Neue Anfrage Buchungskalender.html new file mode 100644 index 0000000..aae109e --- /dev/null +++ b/server/mail-templates/T22 Neue Anfrage Buchungskalender.html @@ -0,0 +1,55 @@ + + + + + + + + +[Buchungskalender] Neue Anfrage: {{kunde_name}} + + + + + + + + + diff --git a/server/services/mailer.js b/server/services/mailer.js index 5a5781d..aedd6b3 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -191,9 +191,9 @@ const oneLine = (value) => String(value ?? '').replace(/\s*[\r\n]+\s*/g, ' ').tr * (Betriebs-E-Mail), nie BCC. Mit `ics` haengt die Kalenderdatei als icalEvent (nodemailer-Kalenderteil, kein Anhang) an. * Wirft bei fehlendem SMTP, ungueltiger Adresse oder Methode - der Aufrufer (Outbox) entscheidet ueber Wiederholung. * @param {{ to: string, fromName: string, replyTo?: string|null, subject: string, html: string, text: string, - * ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH' }} message + * ics?: string|null, icsMethod?: 'REQUEST'|'CANCEL'|'PUBLISH', attachments?: object[]|null (nodemailer, unveraendert, z. B. Rechnungs-PDF) }} message */ -export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) { +export async function sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST', attachments = null }) { const mailer = getTransporter() if (!mailer) throw new Error('SMTP nicht konfiguriert') checkMailbox(to, 'to') @@ -211,6 +211,7 @@ export async function sendGuestMail({ to, fromName, replyTo = null, subject, htm text, html, icalEvent: ics ? { method: icsMethod, filename: 'termin.ics', content: ics } : undefined, + attachments: Array.isArray(attachments) && attachments.length ? attachments : undefined, }) } diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index e41522a..198e7af 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -3,6 +3,8 @@ import { Query, countDocuments, createDocument, deleteDocument, getDocument, kei import { withLocks } from '../keyedLock.js' import { sendGuestMail } from '../mailer.js' import { renderMail } from '../mailTemplates.js' +import { ladeRechnung } from '../unterkunft/speicher.js' +import { rechnungPdf } from '../unterkunft/rechnungPdf.js' import { pruefeNow, pruefeText, zeitMs } from './util.js' /** @@ -286,6 +288,7 @@ async function sende(id, now) { if (!platz) return 'wartet' try { + const attachments = await baueAnhaenge(doc) await sendGuestMail({ to: doc.an, fromName: doc.fromName, @@ -295,6 +298,7 @@ async function sende(id, now) { text: doc.text, ics: doc.ics || null, icsMethod: doc.icsMethod || 'REQUEST', + ...(attachments.length ? { attachments } : {}), }) } catch (err) { gibPlatzFrei(platz) @@ -305,6 +309,39 @@ async function sende(id, now) { }) } +/** Nach so vielen Fehlschlaegen beim Erzeugen des Anhangs geht die Mail ohne ihn raus (Buchungskalender). */ +const ANHANG_VERSUCHE = 3 + +/** + * Anhaenge des Eintrags. Das Feld `anhang` ist JSON {art:'ukRechnung', rechnungId}: die Rechnung (ukRechnungen) wird als PDF + * gerendert (Rechnung-.pdf bzw. Stornorechnung-.pdf). Ohne `anhang` oder mit anderem Inhalt: keine Anhaenge. Scheitert das + * Laden oder Rendern, wirft die Funktion einen gewoehnlichen Fehler (der Eintrag bleibt `wartet` und wird wie jeder vorlaeufige + * Fehler wiederholt); ab dem vierten Versuch geht die Mail ohne Anhang raus. Der Fehlertext enthaelt keine Personendaten. + */ +async function baueAnhaenge(doc) { + if (!doc.anhang) return [] + let verweis + try { + verweis = JSON.parse(doc.anhang) + } catch { + return [] + } + if (verweis?.art !== 'ukRechnung' || typeof verweis.rechnungId !== 'string' || !verweis.rechnungId) return [] + try { + const rechnung = await ladeRechnung(verweis.rechnungId) + if (!rechnung?.inhalt) throw new Error('Rechnung nicht lesbar') + const storno = rechnung.art === 'storno' + const pdf = rechnungPdf(rechnung.inhalt, { art: rechnung.art, nummer: rechnung.nummer, datum: rechnung.datum }) + return [{ filename: `${storno ? 'Stornorechnung' : 'Rechnung'}-${rechnung.nummer}.pdf`, content: Buffer.from(pdf), contentType: 'application/pdf' }] + } catch (err) { + if ((Number(doc.versuche) || 0) >= ANHANG_VERSUCHE) { + console.error(`[termine-outbox] Eintrag ${doc.$id}: Rechnungs-PDF nicht erzeugt, Mail geht ohne Anhang raus (${String(err?.message).slice(0, 80)})`) + return [] + } + throw new Error(`Anhang nicht erzeugt: ${String(err?.message).slice(0, 80)}`) + } +} + /** * Ist der Bestaetigungslink dieser G01 schon verfallen? Das Halte-Fenster (config.termine.holdMinutes, bei jedem Aufruf * gelesen) plus eine Minute Toleranz ab Einreihen; genau so alt ist noch nicht veraltet. G01 ohne Buchung (zu_viele) hat diff --git a/server/services/unterkunft/mails.js b/server/services/unterkunft/mails.js index a4c1147..a679d11 100644 --- a/server/services/unterkunft/mails.js +++ b/server/services/unterkunft/mails.js @@ -1,10 +1,179 @@ -// Stub: Task 20 ersetzt den Inhalt, die Signaturen bleiben. Protokolliert die Aufrufe fuer Tests. +import { config } from '../../config.js' +import { loadTemplate } from '../mailTemplates.js' +import { sendAdminNotice, sendCustomerMail } from '../mailer.js' +import { einreihen } from '../termine/outbox.js' +import { formatTag } from './datum.js' +import { formatEuro } from './preis.js' +import { ladeEinstellungen, ladePreise, ladeUnterkunft } from './speicher.js' + +/** + * Mails des Buchungskalenders: G16-G22 an Gaeste (ueber die Mail-Warteschlange termineOutbox, Absendername = Firma des Vermieters, + * Antworten an dessen E-Mail), K31-K34 an den Vermieter (Kunde des Portals, ohne BCC bei Gastdaten) und T22 ans Team. + * `_aufrufe` protokolliert jeden Aufruf fuer Tests (die Mail geht trotzdem raus). Fehler werden geworfen; der Aufrufer + * (ablauf.js) faengt sie ab, weil der gespeicherte Schritt nicht rueckgaengig gemacht wird. + * + * extra je Gastmail: G17 token (Roh-Token, nur hier und nur einmal bekannt; ergibt den Buchungslink), text (G18 Begruendung), + * rechnung (G19: ukRechnungen-Dokument), stornoRechnung + erstattetCent + grund (G22), G21 braucht nichts. + */ export const _aufrufe = [] +const LEER = '–' +const text = (v) => (typeof v === 'string' ? v.trim() : '') +const GAST_ARTEN = ['G16', 'G17', 'G18', 'G19', 'G20', 'G21', 'G22'] + +const mehrzahl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}` +const FRIST_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + +function fristText(wert) { + const t = Date.parse(wert) + if (!Number.isFinite(t)) return LEER + const teile = Object.fromEntries(FRIST_FORMAT.formatToParts(new Date(t)).map((p) => [p.type, p.value])) + return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr` +} + +function anbieterKontakt(e) { + return [text(e.firma), text(e.strasse), [text(e.plz), text(e.ort)].filter(Boolean).join(' '), text(e.email), text(e.telefon)].filter(Boolean).join('\n') +} + +function postenText(posten) { + if (!Array.isArray(posten) || posten.length === 0) return LEER + return posten.map((p) => `${p.text}: ${formatEuro(p.menge * p.einzelCent)}`).join('\n') +} + +const ZAHLART = { online: 'Online-Zahlung', vorort: 'Zahlung vor Ort' } + +/** Einstellungen des Vermieters: aus dem Kontext, sonst aus der Datenbank. */ +async function einstellungenVon(ctx, featureId) { + return ctx?.einstellungen ?? (featureId ? await ladeEinstellungen(featureId) : null) ?? {} +} + +/** Gemeinsame Werte aller Mails zu einer Anfrage. */ +async function werteZuAnfrage(ctx, anfrage) { + const e = await einstellungenVon(ctx, anfrage.featureId ?? ctx?.featureId) + const u = anfrage.unterkunftId ? await ladeUnterkunft(anfrage.unterkunftId) : null + const n = Number(anfrage.naechte) || naechteVon(anfrage) + return { + einstellungen: e, + unterkunft: u, + werte: { + gast_name: text(anfrage.gast?.name) || 'Gast', + firma: text(e.firma) || text(u?.name) || 'Ihr Gastgeber', + farbe: /^#[0-9a-fA-F]{6}$/.test(text(e.akzentfarbe)) ? e.akzentfarbe : '#111111', + anbieter_kontakt: anbieterKontakt(e) || LEER, + unterkunft: text(u?.name) || LEER, + zeitraum: `${formatTag(anfrage.von)} – ${formatTag(anfrage.bis)}`, + naechte: mehrzahl(n, 'Nacht', 'Nächte'), + personen: mehrzahl(Number(anfrage.personen) || 0, 'Person', 'Personen'), + nummer: text(anfrage.nummer) || LEER, + }, + } +} + +function naechteVon(a) { + return Math.max(0, Math.round((Date.parse(a.bis) - Date.parse(a.von)) / 86400000)) +} + +async function stornoText(anfrage) { + // Schnappschuss bei der Freigabe gilt; nur ohne ihn die aktuellen Bedingungen + if (text(anfrage.stornobedingungenText)) return text(anfrage.stornobedingungenText) + const preise = anfrage.unterkunftId ? await ladePreise(anfrage.unterkunftId) : null + return text(preise?.stornobedingungen) || 'Es wurden keine besonderen Stornobedingungen vereinbart.' +} + export async function anGast(art, ctx, anfrage, extra = {}) { _aufrufe.push({ an: 'gast', art, anfrageId: anfrage?.$id, extra }) + if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Gastmail`) + extra = extra ?? {} + const gast = anfrage?.gast ?? {} + if (!text(gast.email)) throw new TypeError('Empfänger fehlt') + const { einstellungen: e, werte } = await werteZuAnfrage(ctx, anfrage) + const values = { ...werte } + const blocks = {} + let anhang = null + + if (art !== 'G16' && art !== 'G18' && art !== 'G21' && art !== 'G20') values.stornobedingungen = await stornoText(anfrage) + if (art === 'G17' || art === 'G19' || art === 'G20') values.gesamt = formatEuro(anfrage.summen?.bruttoCent ?? 0) + if (art === 'G17' || art === 'G19') values.posten_text = postenText(anfrage.posten) + if (art === 'G17') { + values.frist = fristText(anfrage.fristAt) + const token = text(extra.token) + values.buchungs_link = `${config.publicBaseUrl}/unterkunft/${ctx?.siteKey ?? ''}/buchung/${anfrage.$id}?t=${token}` + values.nachricht_vermieter = text(anfrage.nachrichtVermieter) + blocks.nachricht = values.nachricht_vermieter !== '' + if (!token) throw new TypeError('Token für den Buchungslink fehlt') + } + if (art === 'G18') { + values.nachricht_vermieter = text(extra.text) + blocks.nachricht = values.nachricht_vermieter !== '' + } + if (art === 'G19') { + const zahlart = anfrage.zahlart + values.zahlart_text = ZAHLART[zahlart] ?? LEER + values.rechnung_nummer = text(extra.rechnung?.nummer) || LEER + blocks.online_unbezahlt = zahlart === 'online' && anfrage.status !== 'bezahlt' + if (extra.rechnung?.$id) anhang = { art: 'ukRechnung', rechnungId: extra.rechnung.$id } + } + if (art === 'G20') { + values.rechnung_nummer = text(extra.rechnung?.nummer) || text(anfrage.rechnungNummer) || LEER + } + if (art === 'G21') values.frist = fristText(anfrage.fristAt) + if (art === 'G22') { + values.rechnung_nummer = text(extra.stornoRechnung?.nummer) || LEER + values.nachricht_vermieter = text(extra.grund) + blocks.nachricht = values.nachricht_vermieter !== '' + const erstattung = Number(extra.erstattetCent) || 0 + values.erstattung = erstattung > 0 ? formatEuro(erstattung) : '' + blocks.erstattung = erstattung > 0 + if (extra.stornoRechnung?.$id) anhang = { art: 'ukRechnung', rechnungId: extra.stornoRechnung.$id } + } + + return einreihen({ + featureId: anfrage.featureId ?? ctx?.featureId, + bookingId: anfrage.$id, + art: `uk-${art}`, + an: text(gast.email), + fromName: values.firma.slice(0, 120), + replyTo: text(e.email) || null, + template: art, + values, + blocks, + ...(anhang ? { anhang: JSON.stringify(anhang) } : {}), + }) } +const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(ctx?.projectId ?? '')}` + +/** + * Mail an den Vermieter (K31-K34, Kunde des Portals) bzw. ans Team (T22). K31/K32: values.anfrage; die Werte lassen sich ueberschreiben, + * values.an ersetzt die Adresse (Standard ctx.besitzerEmail). T22 geht an die Team-Adressen und wirft nie. + */ export async function anVermieter(template, ctx, values = {}) { _aufrufe.push({ an: 'vermieter', template, values }) + const { anfrage, an, ...eigene } = values ?? {} + const vorgabe = {} + if (anfrage) { + const { werte } = await werteZuAnfrage(ctx, anfrage) + Object.assign(vorgabe, werte, { + kunde_name: text(ctx?.kundeName) || werte.firma, + gast_email: text(anfrage.gast?.email) || LEER, + nachricht_gast: text(anfrage.gast?.nachricht) || LEER, + gesamt: formatEuro(anfrage.summen?.bruttoCent ?? 0), + zahlart_text: ZAHLART[anfrage.zahlart] ?? LEER, + }) + } + const alle = { portal_url: portalUrl(ctx), ...vorgabe, ...eigene } + if (template === 'T22') { + const r = await sendAdminNotice({ template, values: nurPlatzhalter(template, alle) }) + if (!r?.sent) console.warn(`[unterkunft] Team-Mail ${template} nicht gesendet: ${r?.reason || 'unbekannt'}`) + return r + } + const empfaenger = text(an) || text(ctx?.besitzerEmail) + if (!empfaenger) throw new TypeError('Empfänger fehlt') + return sendCustomerMail(empfaenger, { template, values: nurPlatzhalter(template, alle), blocks: eigene.blocks ?? {}, bccTeam: ['K33', 'K34'].includes(template) }) +} + +/** Nur die Werte, die die Vorlage kennt (keine Warnung wegen Zusatzwerten wie farbe oder anbieter_kontakt). */ +function nurPlatzhalter(template, werte) { + const kennt = loadTemplate(template).placeholders + return Object.fromEntries(Object.entries(werte).filter(([k]) => kennt.includes(k) && k !== 'blocks')) } diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 9fe42ff..9cd12c5 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -8,7 +8,7 @@ const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)] +const EXPECTED = [...ids('G', 22), ...ids('K', 34), ...ids('T', 22)] // GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten) const GUEST = ids('G', 8) const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'] @@ -27,7 +27,7 @@ for (const id of EXPECTED) { const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage) - assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Buchungskalender)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop] oder [Buchungskalender] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') @@ -1280,3 +1280,75 @@ test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tag assert.match(mit.text, /innerhalb von 14 Tagen/) assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/) }) + +// --------------------------------------------------------------------------- +// Buchungskalender (Task 20): G16–G22 an Gäste (Sie-Form, neutral), K31–K34 und T22 (Du-Form, WEBklar) +// --------------------------------------------------------------------------- + +const UK_GAST = ['G16', 'G17', 'G18', 'G19', 'G20', 'G21', 'G22'] +const UK_BASIS = ['gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'firma', 'farbe', 'anbieter_kontakt'] +const UK_ERWARTET = { + G16: { p: [], b: [] }, + G17: { p: ['posten_text', 'gesamt', 'frist', 'buchungs_link', 'stornobedingungen', 'nachricht_vermieter'], b: ['nachricht'] }, + G18: { p: ['nachricht_vermieter'], b: ['nachricht'] }, + G19: { p: ['posten_text', 'gesamt', 'zahlart_text', 'rechnung_nummer', 'stornobedingungen'], b: ['online_unbezahlt'] }, + G20: { p: ['gesamt', 'rechnung_nummer'], b: [] }, + G21: { p: ['frist'], b: [] }, + G22: { p: ['rechnung_nummer', 'erstattung', 'nachricht_vermieter'], b: ['erstattung', 'nachricht'] }, +} + +test('G16–G22: Platzhalter und Blöcke, Sie-Form, kein WEBklar-Logo, keine festen Adressen', () => { + for (const id of UK_GAST) { + const tpl = loadTemplate(id) + assert.deepEqual([...tpl.placeholders].sort(), [...UK_BASIS, ...UK_ERWARTET[id].p].sort(), `${id}: Platzhalter`) + assert.deepEqual([...tpl.blocks].sort(), [...UK_ERWARTET[id].b].sort(), `${id}: Blöcke`) + assert.doesNotMatch(sourceOf(id), /<img|webklar-logo|project\.webklar\.com/i, `${id}: neutral`) + for (const on of [true, false]) { + const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on)) + assert.deepEqual(mail.problems, [], id) + assert.doesNotMatch(mail.text, DU_FORM, `${id}: Sie-Form\n${mail.text}`) + assert.match(mail.html, /background:Wert-farbe/, id) + } + } +}) + +test('G17: Preis, Frist, Stornobedingungen, Knopf und der Satz zur verbindlichen Buchung', () => { + const mail = renderMail('G17', sampleValues(loadTemplate('G17')), { nachricht: true }) + for (const w of ['Wert-posten_text', 'Wert-gesamt', 'Wert-frist', 'Wert-stornobedingungen']) assert.ok(mail.text.includes(w), w) + assert.match(mail.html, /href="Wert-buchungs_link"[^>]*>Jetzt verbindlich buchen/) + assert.ok(mail.text.includes('Erst mit dem Klick auf „Zahlungspflichtig buchen“ auf der Buchungsseite ist Ihre Buchung verbindlich.')) +}) + +test('G19: dauerhafter Datenträger mit allen Angaben, Hinweis kein Widerrufsrecht, Bezahl-Hinweis nur im Block', () => { + const tpl = loadTemplate('G19') + const aus = renderMail('G19', sampleValues(tpl), { online_unbezahlt: false }) + const an = renderMail('G19', sampleValues(tpl), { online_unbezahlt: true }) + for (const w of ['Wert-posten_text', 'Wert-gesamt', 'Wert-zahlart_text', 'Wert-stornobedingungen', 'Wert-anbieter_kontakt', 'Wert-rechnung_nummer']) assert.ok(aus.text.includes(w), w) + assert.match(aus.text, /kein gesetzliches Widerrufsrecht/) + assert.ok(!aus.text.includes('Bezahl-Link')) + assert.ok(an.text.includes('Den Bezahl-Link finden Sie auf der Buchungsseite aus unserer vorigen E-Mail.')) +}) + +test('G22: Erstattung „wird veranlasst“ nur im Block', () => { + const tpl = loadTemplate('G22') + assert.ok(renderMail('G22', { ...sampleValues(tpl), erstattung: '50,00 €' }, { erstattung: true }).text.includes('Eine Erstattung von 50,00 € wird veranlasst.')) + assert.ok(!renderMail('G22', sampleValues(tpl), { erstattung: false }).text.includes('Erstattung von')) +}) + +test('K31–K34, T22: Dateien, Platzhalter, Du-Form', () => { + const erwartet = { + K31: ['kunde_name', 'gast_name', 'gast_email', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'nachricht_gast', 'portal_url'], + K32: ['kunde_name', 'gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'gesamt', 'zahlart_text', 'portal_url'], + K33: ['kunde_name', 'unterkunft', 'portal_url'], + K34: ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'], + T22: ['kunde_name', 'email', 'projekt', 'nachricht', 'woms_url'], + } + for (const [id, p] of Object.entries(erwartet)) { + const tpl = loadTemplate(id) + assert.deepEqual([...tpl.placeholders].sort(), [...p].sort(), id) + const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true)) + assert.deepEqual(mail.problems, [], id) + } + assert.equal(loadTemplate('K32').file, 'K32 Neue Buchung.html') + assert.equal(loadTemplate('T22').subject, '[Buchungskalender] Neue Anfrage: {{kunde_name}}') +}) diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index acd495c..8de5be1 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -10,7 +10,7 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // Produktion. Der Mailtransport ist ein Fake, der Sendeversuche zaehlt und gezielt Fehler wirft (nodemailer-Fehler // tragen `code` und SMTP-`responseCode`). Alle Zeiten sind feste Parameter. const aw = await startAppwriteMock({ - schema: { termineOutbox: schemaKeys('termineOutbox') }, + schema: { termineOutbox: schemaKeys('termineOutbox'), ukRechnungen: schemaKeys('ukRechnungen') }, datetimes: TERMINE_DATETIMES, defaultLimit: 25, }) @@ -1290,3 +1290,39 @@ test('einreihen: optionaler Anhang-Verweis wird gespeichert (Senden unverändert const attribut = (await import('../server/services/termine/schema.js')).TERMINE_COLLECTIONS.termineOutbox.attributes.find((a) => a.key === 'anhang') assert.deepEqual(attribut, { key: 'anhang', type: 'string', size: 500, required: false }) }) + +// Buchungskalender (Task 20): anhang {art:'ukRechnung', rechnungId} -> Rechnungs-PDF als Anhang +const RE_INHALT = { aussteller: { firma: 'Haus' }, empfaenger: { name: 'Eva' }, unterkunft: { name: 'Wohnung' }, von: '2026-11-12', bis: '2026-11-14', naechte: 2, personen: 2, posten: [{ text: 'Übernachtung', menge: 2, einzelCent: 10000, ustProzent: 0 }], summen: { bruttoCent: 20000, nettoCent: 20000, ust: [] }, steuerart: 'kleinunternehmer' } +const seedRechnung = (extra = {}) => aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f_feature01', anfrageId: 'a1', art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01', bruttoCent: 20000, inhalt: JSON.stringify(RE_INHALT), ...extra }) + +test('Anhang ukRechnung: gesendete Mail hat Rechnung-<nr>.pdf (beginnt mit %PDF); Stornorechnung heißt Stornorechnung-<nr>.pdf', async () => { + seedRechnung() + seedRechnung({ $id: 'r2', art: 'storno', nummer: 'RE-2026-0002' }) + await einreihen(eintrag({ bookingId: 'b_re', anhang: JSON.stringify({ art: 'ukRechnung', rechnungId: 'r1' }) }), JETZT) + await einreihen(eintrag({ bookingId: 'b_st', anhang: JSON.stringify({ art: 'ukRechnung', rechnungId: 'r2' }) }), JETZT) + await einreihen(eintrag({ bookingId: 'b_ohne' }), JETZT) + await warteAufVersand() + const namen = tr.senke.sent.map((m) => (m.attachments ?? []).map((a) => a.filename)) + assert.deepEqual(namen.map((n) => n.join()).sort(), ['', 'Rechnung-RE-2026-0001.pdf', 'Stornorechnung-RE-2026-0002.pdf']) + const mit = tr.senke.sent.find((m) => m.attachments?.length) + assert.equal(Buffer.from(mit.attachments[0].content, 'base64').subarray(0, 4).toString(), '%PDF') + assert.equal(mit.attachments[0].contentType, 'application/pdf') +}) + +test('Anhang nicht erzeugbar: Eintrag bleibt wartet (Wiederholung), nach 3 Fehlversuchen geht die Mail ohne Anhang raus', async () => { + const doc = await einreihen(eintrag({ bookingId: 'b_fehlt', art: 'uk-G19', anhang: JSON.stringify({ art: 'ukRechnung', rechnungId: 'gibtsnicht' }) }), JETZT) + await warteAufVersand() + let d = await lies(doc.$id) + assert.equal(d.status, 'wartet') + assert.equal(d.versuche, 1) + assert.equal(tr.aufrufe, 0) + let jetzt = JETZT + for (const warte of [2 * MIN, 6 * MIN, 31 * MIN]) { + jetzt = plus(warte, jetzt) + await sendeEintrag(doc.$id, jetzt) + } + d = await lies(doc.$id) + assert.equal(d.status, 'gesendet') + assert.equal(tr.senke.sent.length, 1) + assert.equal(tr.senke.sent[0].attachments?.length ?? 0, 0) +}) diff --git a/test/unterkunftMails.test.js b/test/unterkunftMails.test.js new file mode 100644 index 0000000..fc2a611 --- /dev/null +++ b/test/unterkunftMails.test.js @@ -0,0 +1,113 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Buchungskalender Task 20: Mails an Gaeste (ueber die Warteschlange), Vermieter und Team +const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen', 'termineOutbox'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), + datetimes: { ...TERMINE_DATETIMES, ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { warteAufVersand, resetOutboxForTests } = await import('../server/services/termine/outbox.js') +const mails = await import('../server/services/unterkunft/mails.js') +const sp = await import('../server/services/unterkunft/speicher.js') +after(() => setMailTransportForTests(null)) + +const EIN = { firma: 'Haus am See', strasse: 'Weg 1', plz: '10115', ort: 'Berlin', email: 'vermieter@example.com', telefon: '0123', akzentfarbe: '#336699' } +const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop', besitzerEmail: 'besitzer@example.com', einstellungen: EIN } +const POSTEN = [{ text: 'Übernachtung, 2 Nächte', menge: 2, einzelCent: 10000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }] +const anfrage = (extra = {}) => ({ + $id: 'a1', featureId: 'f1', unterkunftId: 'u1', nummer: 'A-K7QM', von: '2026-11-12', bis: '2026-11-14', personen: 2, status: 'freigegeben', + gast: { name: 'Eva Gast', email: 'eva@example.com', nachricht: 'Hallo' }, posten: POSTEN, summen: { bruttoCent: 25000 }, + fristAt: '2026-11-05T16:00:00.000Z', stornobedingungenText: 'Kostenlos bis 7 Tage vorher.', nachrichtVermieter: '', ...extra, +}) +let sink +beforeEach(async () => { + for (const c of ['termineOutbox', 'ukRechnungen', 'ukUnterkuenfte', 'ukEinstellungen']) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) + config.smtp.from = 'noreply@webklar.com' + resetOutboxForTests() + mails._aufrufe.length = 0 + sink = mailSink() + setMailTransportForTests(sink) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung See', maxPersonen: 4, mindestNaechte: 1, aktiv: true }) +}) +const gesendet = async () => { + await warteAufVersand() + return sink.sent +} + +test('G17: Absender = Firma, Antworten an den Vermieter, Buchungslink mit Token, Preis und Frist', async () => { + await mails.anGast('G17', ctx, anfrage(), { token: 'tok123' }) + const [m] = await gesendet() + assert.equal(m.from.name, 'Haus am See') + assert.equal(m.replyTo[0].address, 'vermieter@example.com') + assert.equal(m.to[0].address, 'eva@example.com') + assert.ok(m.text.includes(`${config.publicBaseUrl}/unterkunft/abcdefghijklmnop/buchung/a1?t=tok123`)) + assert.ok(m.text.includes('250,00 €')) + assert.ok(m.text.includes('12.11.2026 – 14.11.2026')) + assert.ok(m.text.includes('Kostenlos bis 7 Tage vorher.')) + assert.ok(m.text.includes('05.11.2026, 17:00 Uhr')) + assert.ok(m.text.includes('Erst mit dem Klick auf „Zahlungspflichtig buchen“')) + assert.equal(mails._aufrufe[0].art, 'G17') +}) + +test('Minimaler ctx: Einstellungen und Unterkunft werden selbst geladen', async () => { + aw.seed('ukEinstellungen', { $id: 'f1', featureId: 'f1', ...EIN, firma: 'Geladene Firma' }) + await mails.anGast('G21', { featureId: 'f1' }, anfrage()) + const [m] = await gesendet() + assert.equal(m.from.name, 'Geladene Firma') + assert.ok(m.text.includes('Wohnung See')) + assert.doesNotMatch(m.text, /buchung\/a1/) +}) + +test('G17 ohne Token wirft, G16 hat keinen Link', async () => { + await assert.rejects(() => mails.anGast('G17', ctx, anfrage(), {}), TypeError) + await mails.anGast('G16', ctx, anfrage({ status: 'neu', posten: null, summen: null })) + const [m] = await gesendet() + assert.doesNotMatch(m.text, /\?t=/) +}) + +test('G19 hängt die Rechnung als PDF an (online, unbezahlt: Bezahl-Hinweis), G22 die Stornorechnung mit Erstattung', async () => { + const inhalt = { aussteller: { firma: 'Haus am See' }, empfaenger: { name: 'Eva Gast' }, unterkunft: { name: 'Wohnung See' }, von: '2026-11-12', bis: '2026-11-14', naechte: 2, personen: 2, posten: POSTEN, summen: { bruttoCent: 25000, nettoCent: 23000, ust: [] }, steuerart: 'kleinunternehmer', zahlart: 'online' } + const re = await sp.legeRechnungAn({ featureId: 'f1', anfrageId: 'a1', art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01', bruttoCent: 25000, inhalt }) + const st = await sp.legeRechnungAn({ featureId: 'f1', anfrageId: 'a1', art: 'storno', nummer: 'RE-2026-0002', datum: '2026-11-02', bruttoCent: -25000, inhalt: { ...inhalt, bezugNummer: 'RE-2026-0001' } }) + await mails.anGast('G19', ctx, anfrage({ status: 'gebucht', zahlart: 'online' }), { rechnung: re }) + await mails.anGast('G22', ctx, anfrage({ status: 'storniert' }), { stornoRechnung: st, erstattetCent: 25000, grund: 'Wasserschaden' }) + const sent = await gesendet() + const g19 = sent.find((m) => m.subject.includes('bestätigt')) + const g22 = sent.find((m) => m.subject.includes('storniert')) + assert.equal(g19.attachments.length, 1) + assert.equal(g19.attachments[0].filename, 'Rechnung-RE-2026-0001.pdf') + assert.ok(Buffer.from(g19.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF') + assert.ok(g19.text.includes('Den Bezahl-Link finden Sie auf der Buchungsseite')) + assert.equal(g22.attachments[0].filename, 'Stornorechnung-RE-2026-0002.pdf') + assert.ok(g22.text.includes('Eine Erstattung von 250,00 € wird veranlasst.')) + assert.ok(g22.text.includes('Wasserschaden')) +}) + +test('G22 ohne Erstattung: kein Erstattungssatz; G19 vor Ort bezahlt: kein Bezahl-Hinweis', async () => { + await mails.anGast('G22', ctx, anfrage({ status: 'storniert' }), { erstattetCent: 0 }) + await mails.anGast('G19', ctx, anfrage({ status: 'gebucht', zahlart: 'vorort' }), {}) + const sent = await gesendet() + assert.ok(sent.every((m) => !m.text.includes('Erstattung von') && !m.text.includes('Bezahl-Link'))) +}) + +test('Vermieter: K31 geht an den Besitzer ohne BCC, mit Portal-Link; T22 ans Team; unbekannte Gastart wirft', async () => { + await mails.anVermieter('K31', ctx, { anfrage: anfrage({ status: 'neu' }) }) + const [k] = await gesendet() + assert.equal(k.to[0].address, 'besitzer@example.com') + assert.ok(!k.bcc || k.bcc.length === 0) + assert.ok(k.text.includes(`${config.publicBaseUrl}/dashboard.html#buchungen?projekt=p1`)) + assert.ok(k.text.includes('Eva Gast')) + await assert.rejects(() => mails.anGast('K31', ctx, anfrage()), TypeError) +})