diff --git a/server/mail-templates/G09 Bestellbestaetigung.html b/server/mail-templates/G09 Bestellbestaetigung.html new file mode 100644 index 0000000..24265e9 --- /dev/null +++ b/server/mail-templates/G09 Bestellbestaetigung.html @@ -0,0 +1,84 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}} + + + + + + + + + diff --git a/server/mail-templates/G10 Versand.html b/server/mail-templates/G10 Versand.html new file mode 100644 index 0000000..0eaaa59 --- /dev/null +++ b/server/mail-templates/G10 Versand.html @@ -0,0 +1,53 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} wurde versendet + + + + + + + + + diff --git a/server/mail-templates/G11 Abholbereit.html b/server/mail-templates/G11 Abholbereit.html new file mode 100644 index 0000000..a2980c7 --- /dev/null +++ b/server/mail-templates/G11 Abholbereit.html @@ -0,0 +1,49 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} ist abholbereit + + + + + + + + + diff --git a/server/mail-templates/G12 Eingang Widerruf.html b/server/mail-templates/G12 Eingang Widerruf.html new file mode 100644 index 0000000..4587ca4 --- /dev/null +++ b/server/mail-templates/G12 Eingang Widerruf.html @@ -0,0 +1,55 @@ + + + + + + + + +Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}} + + + + + + + + + diff --git a/server/mail-templates/G13 Erstattung.html b/server/mail-templates/G13 Erstattung.html new file mode 100644 index 0000000..b2f725d --- /dev/null +++ b/server/mail-templates/G13 Erstattung.html @@ -0,0 +1,53 @@ + + + + + + + + +Erstattung zu Ihrer Bestellung {{bestellnummer}} + + + + + + + + + diff --git a/server/mail-templates/G14 Zahlung fehlgeschlagen.html b/server/mail-templates/G14 Zahlung fehlgeschlagen.html new file mode 100644 index 0000000..6f65b7f --- /dev/null +++ b/server/mail-templates/G14 Zahlung fehlgeschlagen.html @@ -0,0 +1,47 @@ + + + + + + + + +Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen + + + + + + + + + diff --git a/server/mail-templates/K25 Neue Bestellung.html b/server/mail-templates/K25 Neue Bestellung.html new file mode 100644 index 0000000..43c48d6 --- /dev/null +++ b/server/mail-templates/K25 Neue Bestellung.html @@ -0,0 +1,64 @@ + + + + + + + + +Neue Bestellung {{bestellnummer}} in Deinem Webshop + + + + +In Deinem Webshop ist eine neue Bestellung eingegangen. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K26 Widerruf im Webshop.html b/server/mail-templates/K26 Widerruf im Webshop.html new file mode 100644 index 0000000..f6b7097 --- /dev/null +++ b/server/mail-templates/K26 Widerruf im Webshop.html @@ -0,0 +1,61 @@ + + + + + + + + +Widerruf in Deinem Webshop eingegangen + + + + +Ein Käufer hat in Deinem Webshop einen Vertrag widerrufen. ͏ ͏ ͏ + + + + + diff --git a/server/services/shop/mails.js b/server/services/shop/mails.js new file mode 100644 index 0000000..0bb0b54 --- /dev/null +++ b/server/services/shop/mails.js @@ -0,0 +1,390 @@ +import { config } from '../../config.js' +import { renderMail, loadTemplate } from '../mailTemplates.js' +import { sendCustomerMail } from '../mailer.js' +import { customerOf } from '../siteFeatures.js' +import { einreihen } from '../termine/outbox.js' +import { pruefeNow, zeitMs } from '../termine/util.js' +import { datumKurz, uhrzeit } from '../termine/zeit.js' +import { euro } from './geld.js' +import { oeffentlicheRechtstexte } from './kontext.js' +import { gewaehrleistungsHinweis } from './rechtstexte.js' + +/** + * Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4). + * + * Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung + * fehlgeschlagen) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname, + * Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des + * Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die + * Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht. + * Haendler (K25 neue Bestellung, K26 Widerruf) gehen direkt per sendCustomerMail an die Kunden-E-Mail, ohne BCC ans Team + * (die Mails enthalten Kaeuferdaten). + * + * Beide Wege werfen nie wegen eines Mailfehlers: Rueckgabe { ok: true, ... } | { ok: false, fehler }; der Fehlertext steht im Log (ohne + * Adresse), die Bestellung bleibt unberuehrt. + * + * Erwartete Form der Bestellung (`order` = shopOrders-Dokument; der JSON-Schnappschuss steht in `bestellung`, ersatzweise `daten`, + * als Text oder Objekt; alle Felder sind optional, Fehlendes erscheint als "–"): + * order.bestellnummer, order.kaeuferEmail, order.bezahltAt | order.erstelltAt + * snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') } + * snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } } + * snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT') + * snap.kaeufer { name, email, telefon } (oder snap.name) + * snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land } + * snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text + * snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops) + */ + +const LEER = '–' +const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14']) +/** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */ +const MAX_HTML = 95000 +const MAX_TEXT = 47000 +const LANDNAMEN = { DE: 'Deutschland', AT: 'Österreich' } +const ZAHLUNGSARTEN = { card: 'Kreditkarte, Apple Pay, Google Pay', klarna: 'Klarna', sepa_debit: 'SEPA-Lastschrift' } +// Wie WIDERRUF_TEXTE in produkte.js (dort nicht exportiert) +const WIDERRUF_AUSSCHLUSS = { + verderblich: 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware', + individuell: 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', + hygiene: 'Vom Widerruf ausgeschlossen: versiegelte Ware aus Hygienegründen, wenn die Versiegelung entfernt wurde', +} +const ABHOLUNG_HINWEIS = 'Bitte bringen Sie Ihre Bestellnummer mit.' + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const hatText = (wert) => typeof wert === 'string' && wert.trim() !== '' +const text = (wert) => (typeof wert === 'string' ? wert.trim() : '') +const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER) +const einzeilig = (wert) => text(wert).replace(/\s+/g, ' ') + +// ---------------------------------------------------------------- Bausteine + +/** Schnappschuss der Bestellung als Objekt ({} wenn es keinen oder keinen lesbaren gibt). */ +function schnappschuss(order) { + for (const feld of ['bestellung', 'daten']) { + const roh = order?.[feld] + if (istObjekt(roh)) return roh + if (typeof roh === 'string' && roh !== '') { + try { + const geparst = JSON.parse(roh) + if (istObjekt(geparst)) return geparst + } catch { + // unlesbar: naechstes Feld + } + } + } + return {} +} + +const einstellungenVon = (ctx) => ctx?.shop?.einstellungen ?? {} +const anbieterVon = (ctx, snap) => ({ ...(einstellungenVon(ctx).anbieter ?? {}), ...(istObjekt(snap?.einstellungenAuszug?.anbieter) ? snap.einstellungenAuszug.anbieter : {}) }) + +/** Name des Shops in Betreff, Kopf und Absender: Shopname, sonst Name des Anbieters. */ +function shopName(ctx, snap = {}) { + return einzeilig(snap?.einstellungenAuszug?.shopname) || einzeilig(einstellungenVon(ctx).shopname) || einzeilig(anbieterVon(ctx, snap).name) || 'Webshop' +} + +function anbieterZeile(a) { + const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ') + const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : '' + const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ') + return [text(a.name), anschrift, text(a.email)].filter(Boolean).join(', ') || LEER +} + +const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || '' +const shopSeite = (ctx) => `${config.publicBaseUrl}/shop/${siteKeyVon(ctx)}` + +/** Akzentfarbe als #rrggbb (nur geprueft, damit nichts in das style-Attribut gelangt). */ +const farbeVon = (e) => (/^#[0-9a-fA-F]{6}$/.test(e?.akzent ?? '') ? e.akzent : '#111111') + +/** Menge × Name (Variante) – Summe, eine Zeile je Position. */ +function positionenText(positionen) { + const zeilen = (Array.isArray(positionen) ? positionen : []) + .filter(istObjekt) + .map((p) => { + const variante = text(p.varianteText) ? ` (${einzeilig(p.varianteText)})` : '' + const summe = hatText(p.summeText) ? p.summeText.trim() : Number.isInteger(p.summeCent) ? euro(p.summeCent) : LEER + return `${Number.isInteger(p.menge) ? p.menge : LEER} × ${einzeilig(p.name) || LEER}${variante} – ${summe}` + }) + return zeilen.length ? zeilen.join('\n') : LEER +} + +function adresseText(a, fallbackName) { + if (!istObjekt(a)) return LEER + const feld = (...namen) => text(namen.map((n) => a[n]).find(hatText)) + const land = feld('country', 'land') + const zeilen = [ + feld('name') || text(fallbackName), + feld('line1', 'strasse'), + feld('line2'), + [feld('postal_code', 'plz'), feld('city', 'ort')].filter(Boolean).join(' '), + land && land !== 'DE' ? (LANDNAMEN[land] ?? land) : '', + ].filter(Boolean) + return zeilen.length ? zeilen.join('\n') : LEER +} + +function zahlungsartText(snap, e) { + const roh = text(snap.zahlungsart) + if (roh) return ZAHLUNGSARTEN[roh] ?? roh + return Array.isArray(e.zahlungsarten) && e.zahlungsarten.length === 1 ? (ZAHLUNGSARTEN[e.zahlungsarten[0]] ?? LEER) : 'Online-Zahlung' +} + +function ausschlussText(positionen) { + return (Array.isArray(positionen) ? positionen : []) + .filter((p) => istObjekt(p) && Object.hasOwn(WIDERRUF_AUSSCHLUSS, p.widerruf)) + .map((p) => `${einzeilig(p.name) || LEER}: ${WIDERRUF_AUSSCHLUSS[p.widerruf]}`) + .join('\n') +} + +/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit); ungueltige Zeitpunkte werfen. */ +function eingangText(eingang) { + const ms = zeitMs(eingang) + if (!Number.isFinite(ms)) throw new TypeError('Eingang ist kein Zeitpunkt') + const d = new Date(ms) + return `${datumKurz(d)}, ${uhrzeit(d)} Uhr` +} + +const datumText = (order, now) => { + const ms = zeitMs(order?.bezahltAt || order?.erstelltAt) + return datumKurz(Number.isFinite(ms) ? new Date(ms) : now) +} + +// ---------------------------------------------------------------- Werte + +/** + * Gemeinsame Werte der Kaeufermails aus dem Schnappschuss der Bestellung und dem Shop-Kontext: Kopf und Fuss (farbe, betrieb_name, + * anbieter_zeile, widerruf_url, impressum_url, datenschutz_url) und alle Werte von G09 (bestellnummer, datum, positionen, Summen, + * Lieferart, Adresse, Zahlungsart, Rechtstexte). `order` darf null sein (G12 ohne passende Bestellung): dann sind die + * Bestellwerte "–". Es sind mehr Werte als eine einzelne Vorlage braucht; anKaeufer reicht nur die deklarierten weiter. + * @param {{ doc?: object, shop: object }} ctx + * @param {object|null} order + * @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat + * @returns {Record} + */ +export function kaeuferWerte(ctx, order, now = new Date()) { + if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt') + const snap = schnappschuss(order) + const e = einstellungenVon(ctx) + const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {} + const anbieter = anbieterVon(ctx, snap) + const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) } + const rechtstexte = oeffentlicheRechtstexte(ctx) ?? {} + const summen = istObjekt(snap.summen) ? snap.summen : snap + const texte = istObjekt(summen.texte) ? summen.texte : {} + const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER) + const lieferart = snap.lieferart === 'abholung' ? 'abholung' : snap.lieferart === 'versand' ? 'versand' : '' + const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {} + const land = text(snap.land) + const shop = shopSeite(ctx) + + return { + farbe: farbeVon(e), + betrieb_name: shopName(ctx, snap), + anbieter_zeile: anbieterZeile(anbieter), + widerruf_url: `${shop}/widerruf`, + impressum_url: text(rechtstexte.impressumUrl) || shop, + datenschutz_url: text(rechtstexte.datenschutzUrl) || shop, + bestellnummer: oderLeer(order?.bestellnummer), + datum: datumText(order, now), + positionen: positionenText(snap.positionen), + zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent), + versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)), + gesamt: text(texte.gesamt) || cent(summen.gesamtCent ?? order?.summeCent), + steuer_hinweis: text(texte.steuerHinweis) || (e.kleinunternehmer === true ? 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' : LEER), + lieferart_text: lieferart === 'abholung' ? 'Abholung' : lieferart === 'versand' ? `Versand${land ? ` nach ${LANDNAMEN[land] ?? land}` : ''}` : LEER, + lieferadresse: adresseText(snap.lieferadresse ?? kaeufer.lieferadresse ?? kaeufer.adresse, kaeufer.name ?? snap.name), + abholort: oderLeer(abholung.adresse), + lieferzeit: oderLeer(auszug.lieferzeit ?? e.lieferzeit), + zahlungsart: zahlungsartText(snap, e), + ausschluss_text: ausschlussText(snap.positionen), + agb: oderLeer(rechtstexte.agb), + widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung), + widerrufsformular: oderLeer(rechtstexte.widerrufsformular), + gewaehrleistung: gewaehrleistungsHinweis(), + // fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat) + hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS, + } +} + +/** + * Werte und Bloecke einer Kaeufermail (alle Werte von kaeuferWerte plus die Werte der Aktion aus `extra`, gefiltert auf die + * Platzhalter der Vorlage). + * extra je Art: + * G10 versanddienst, sendungsnummer (beide optional; Block sendung, wenn eines da ist) + * G11 hinweis (ersetzt den Hinweis des Haendlers) + * G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht + * G13 betragCent (Pflicht, ganze Cent), grund + */ +function kaeuferMail(art, ctx, order, extra, now) { + const alle = kaeuferWerte(ctx, order, now) + const blocks = {} + switch (art) { + case 'G09': + blocks.versand = schnappschuss(order).lieferart === 'versand' + blocks.abholung = schnappschuss(order).lieferart === 'abholung' + blocks.ausschluss = alle.ausschluss_text !== '' + break + case 'G10': + alle.versanddienst = oderLeer(extra.versanddienst) + alle.sendungsnummer = oderLeer(extra.sendungsnummer) + blocks.sendung = hatText(extra.versanddienst) || hatText(extra.sendungsnummer) + break + case 'G11': + if (hatText(extra.hinweis)) alle.hinweis = extra.hinweis.trim() + break + case 'G12': + alle.name = text(extra.name) + if (hatText(extra.bestellnummer)) alle.bestellnummer = extra.bestellnummer.trim() + alle.eingang = eingangText(extra.eingang) + alle.nachricht = text(extra.nachricht) + blocks.nachricht = alle.nachricht !== '' + break + case 'G13': + if (!Number.isInteger(extra.betragCent) || extra.betragCent <= 0) throw new TypeError('extra.betragCent muss eine positive ganze Zahl sein') + alle.betrag = euro(extra.betragCent) + alle.grund = text(extra.grund) + blocks.grund = alle.grund !== '' + break + default: + break + } + const vorlage = loadTemplate(art) + const values = Object.fromEntries(Object.entries(alle).filter(([schluessel]) => vorlage.placeholders.includes(schluessel))) + return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) } +} + +const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT + +// ---------------------------------------------------------------- Einreihen + +/** + * Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist + * order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". Ist G09 zu lang fuer die Warteschlange + * (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit + * Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig. + * @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art + * @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js) + * @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null) + * @param {object} [extra] siehe kaeuferMail + * @param {Date} [now] + * @returns {Promise<{ ok: true, eintrag: object, gekuerzt: boolean } | { ok: false, fehler: string }>} + */ +export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) { + try { + if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`) + pruefeNow(now, 'anKaeufer') + extra = extra ?? {} + const { values, blocks } = kaeuferMail(art, ctx, order, extra, now) + const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail) + if (an === '') throw new TypeError('Empfänger fehlt') + + let gekuerzt = false + let mail = renderMail(art, values, blocks) + if (art === 'G09' && zuLang(mail)) { + values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb` + mail = renderMail(art, values, blocks) + gekuerzt = true + } + if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange') + + const e = einstellungenVon(ctx) + const eintrag = await einreihen( + { + featureId: ctx.doc.$id, + bookingId: order?.$id || (hatText(extra.bookingId) ? extra.bookingId : '-'), + art, + an, + fromName: shopName(ctx, schnappschuss(order)).slice(0, 120), + replyTo: text(anbieterVon(ctx, schnappschuss(order)).email) || text(e.anbieter?.email) || null, + template: art, + values, + blocks, + }, + now, + ) + return { ok: true, eintrag, gekuerzt } + } catch (err) { + console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' } + } +} + +// ---------------------------------------------------------------- Haendler + +/** Portal-Adresse der Webshop-Seite des Projekts. */ +const portalUrl = (ctx) => `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(ctx?.shop?.projectId || ctx?.doc?.projectId || '')}` + +/** + * Werte und Bloecke von K25 "Neue Bestellung" (an den Haendler) aus der bezahlten Bestellung. + * @param {{ ueberverkauft?: boolean }} [optionen] ueberverkauft: der Bestand reichte nicht (Block ueberverkauft) + * @returns {{ values: Record, blocks: Record }} + */ +export function haendlerK25(ctx, order, { ueberverkauft = false } = {}) { + const snap = schnappschuss(order) + const alle = kaeuferWerte(ctx, order) + const kaeufer = istObjekt(snap.kaeufer) ? snap.kaeufer : {} + return { + values: { + shopname: alle.betrieb_name, + bestellnummer: alle.bestellnummer, + gesamt: alle.gesamt, + positionen: alle.positionen, + lieferart_text: alle.lieferart_text, + kaeufer_name: oderLeer(kaeufer.name ?? snap.name), + portal_url: portalUrl(ctx), + }, + blocks: { ueberverkauft: ueberverkauft === true, sepa: text(snap.zahlungsart) === 'sepa_debit' }, + } +} + +/** + * Werte und Bloecke von K26 "Widerruf eingegangen" (an den Haendler) aus dem Widerruf (shopWiderrufe-Dokument oder + * { name, email, bestellnummer, nachricht, eingang }); eingang = Date | ISO. + */ +export function haendlerK26(ctx, widerruf, now = new Date()) { + const nachricht = text(widerruf?.nachricht) + return { + values: { + shopname: shopName(ctx), + bestellnummer: oderLeer(widerruf?.bestellnummer), + kaeufer_name: oderLeer(widerruf?.name), + kaeufer_email: oderLeer(widerruf?.email), + eingang: eingangText(widerruf?.eingang ?? widerruf?.eingangAt ?? now), + nachricht, + portal_url: portalUrl(ctx), + }, + blocks: { nachricht: nachricht !== '' }, + } +} + +/** + * Schickt eine Haendlermail (K25, K26) an die E-Mail des Kundenkontos, ohne BCC ans Team. kunde_name, shopname und portal_url + * ergaenzt die Funktion, wenn die Vorlage sie kennt und values sie nicht setzt. + * @param {string} template 'K25' | 'K26' + * @param {{ doc: object, shop: object }} ctx + * @param {Record} values + * @param {Record} [blocks] + * @returns {Promise<{ ok: true } | { ok: false, fehler: string }>} + */ +export async function anHaendler(template, ctx, values = {}, blocks = {}) { + try { + const kunde = await customerOf(ctx?.shop?.customerId) + if (!hatText(kunde?.email)) { + console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: Kunde ohne E-Mail-Adresse`) + return { ok: false, fehler: 'Der Kunde hat keine E-Mail-Adresse.' } + } + const erlaubt = loadTemplate(template).placeholders + const standard = { kunde_name: text(kunde.name) || 'Kunde', shopname: shopName(ctx), portal_url: portalUrl(ctx) } + const alle = { ...standard, ...values } + await sendCustomerMail(kunde.email, { + template, + values: Object.fromEntries(Object.entries(alle).filter(([schluessel]) => erlaubt.includes(schluessel))), + blocks, + bccTeam: false, + }) + return { ok: true } + } catch (err) { + // Nur Art und Code des Fehlers: Meldungen des Mailservers koennen die Adresse enthalten + console.warn(`[shop-mails] ${String(template).slice(0, 20)} nicht gesendet: ${err?.name} ${String(err?.code ?? err?.responseCode ?? err?.status ?? '').slice(0, 40)}`.trim()) + return { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' } + } +} diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index fc43e76..998ceb5 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -23,6 +23,7 @@ import { LOESCHFRIST_TAGE } from './frist.js' import { kontextPerFeature, ladeEinstellung } from './kontext.js' import { anGast } from './mails.js' import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js' +import { shopModus } from '../shop/schalter.js' import { termineAus } from './schalter.js' import { buchungsNummer } from './token.js' import { pruefeNow, zeitMs } from './util.js' @@ -57,6 +58,12 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei * TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen * bleiben aus. + * - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb + * laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und + * Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles + * andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit + * `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung + * abgeschalteter Funktionen nicht. * - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im * Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der * naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind @@ -127,18 +134,21 @@ async function schritt(lauf, was, fn, ersatz) { * Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true } * `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der * Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection). - * `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true - * und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen) + * `trotzAus` (async, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht + * `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) und der Job beschraenkt sich auf + * das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen) * und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre * gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen. */ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) { const nowMs = pruefeNow(now, job) let eingeschraenkt = false + let freigabe = null if (termineAus()) { if (trotzAus === null) return { aus: true } try { - if (!(await trotzAus())) return { aus: true } + freigabe = await trotzAus() + if (!freigabe) return { aus: true } } catch (err) { console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`) return { aus: true } @@ -155,13 +165,16 @@ async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {} } laufend.add(sperre) try { - return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt }) + return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt, freigabe }) } finally { laufend.delete(sperre) ueberlappGemeldet.delete(sperre) } } +/** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */ +const shopMailsLaufen = async () => shopModus() !== 'off' + /** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */ const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 }) @@ -285,10 +298,15 @@ export async function minutenJob(now) { await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), []) return { faellig, gesendet, fehler: lauf.fehler } }, - { teil: 'Mailversand' }, + { teil: 'Mailversand', trotzAus: shopMailsLaufen }, ), ]) - if (verfall.aus || mail.aus) return { aus: true } + if (verfall.aus && mail.aus) return { aus: true } + // Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief + if (verfall.aus) { + if (mail.uebersprungen) return { uebersprungen: true } + return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 } + } if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true } return { verfallen: verfall.verfallen ?? 0, @@ -792,6 +810,23 @@ async function gibtBezahlteTerminplaner() { return treffer.length > 0 } +/** + * Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes + * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen). + * Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher. + */ +async function tagesjobFreigabe() { + const mails = shopModus() !== 'off' + let termine = false + try { + termine = await gibtBezahlteTerminplaner() + } catch (err) { + if (!mails) throw err + console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`) + } + return termine || mails ? { termine, mails } : false +} + /** * Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des * Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs @@ -864,7 +899,7 @@ export async function taeglicherJob(now) { await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) return ergebnis }, - { trotzAus: gibtBezahlteTerminplaner }, + { trotzAus: tagesjobFreigabe }, ) } catch (err) { // Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter @@ -880,19 +915,28 @@ export async function taeglicherJob(now) { * und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team). */ async function eingeschraenkterTagesjob(lauf, now) { - const aboAbgleich = await aboAbgleichSchritt(lauf, now) - const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { - funktionen: 0, - buchungen: 0, - gedeckelt: 0, - }) + // Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile + const termine = lauf.freigabe?.termine !== false + const mails = lauf.freigabe?.mails === true + const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz() + const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine + ? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 }) + : { funktionen: 0, buchungen: 0, gedeckelt: 0 } + // Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen + const mailErgebnis = mails + ? { + festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0), + aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0), + } + : {} const ergebnis = { eingeschraenkt: true, aboAbgleich, abgeschaltet, - gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 }, + gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 }, geloescht: abgeschaltet.buchungen, aktiveBetriebe: null, + ...mailErgebnis, fehler: lauf.fehler, } await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) @@ -926,9 +970,12 @@ export function tagesbericht(ergebnis) { ? '' : `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, ` if (ergebnis.eingeschraenkt) { + // Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen) + const mitMails = Number.isInteger(ergebnis.aufgeraeumt) + const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : '' return ( - `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` + - `${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler` + `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` + + `${a.funktionen} Funktionen bereinigt, ${mailSegment}${deckel}${aboSegment}${ergebnis.fehler} Fehler` ) } const l = ergebnis.loeschfristen diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index df994b8..e12d26d 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -39,7 +39,7 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js' * pause Anmeldepause laeuft * budget Plaetze im Fenster >= mailsProStunde * reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt - * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten + * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12) und den normalen Mails vorbehalten * kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen * * Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage. @@ -71,8 +71,12 @@ const ID_BLOCK = 50 const iso = (wertMs) => new Date(wertMs).toISOString() -/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */ -export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08']) +/** + * Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Dazu die beiden + * Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger) und G12 Eingangsbestaetigung des Widerrufs. Die + * uebrigen Shop-Mails G10, G11, G13, G14 sind 'normal'. + */ +export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12']) /** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */ export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21']) @@ -115,7 +119,7 @@ function gesamtBremse(nowMs) { * 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz. * @param {number} nowMs * @param {string} featureId - * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21') + * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G14') * @returns {null|'pause'|'budget'|'reserve'|'kontingent'} */ export function pruefePlatz(nowMs, featureId, art) { @@ -438,7 +442,7 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) { } /** - * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21), + * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12) vor normalen vor nachrangigen (G05, K20, K21), * in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege * (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen * kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht. diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index c909cbf..b6ce0cd 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,10 +6,12 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. 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); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// 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); 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', 8), ...ids('K', 24), ...ids('T', 19), 'T20', 'T21'] +const EXPECTED = [...ids('G', 14), ...ids('K', 26), ...ids('T', 19), 'T20', 'T21'] +// 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'] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on])) @@ -1073,3 +1075,191 @@ test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Pre assert.match(mail.html, /Wir verkaufen keine <Waffen>\.
Bitte melde Dich\./) assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/) }) + +// --------------------------------------------------------------------------- +// Webshop E4: Käufermails G09–G14 (Sie-Form, Betreff in der Vorlage, Fuß mit Anbieter und Widerrufsfunktion) +// und K25, K26 an den Händler (Du-Form) +// --------------------------------------------------------------------------- + +const SHOP_GEMEIN = ['farbe', 'betrieb_name', 'anbieter_zeile', 'impressum_url', 'datenschutz_url', 'widerruf_url'] + +test('G09–G14: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => { + const erwartet = { + G09: { + s: 'Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}', + p: ['bestellnummer', 'datum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'], + b: ['versand', 'abholung', 'ausschluss'], + }, + G10: { s: 'Ihre Bestellung {{bestellnummer}} wurde versendet', p: ['bestellnummer', 'sendungsnummer', 'versanddienst'], b: ['sendung'] }, + G11: { s: 'Ihre Bestellung {{bestellnummer}} ist abholbereit', p: ['bestellnummer', 'abholort', 'hinweis'], b: [] }, + G12: { s: 'Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}', p: ['name', 'bestellnummer', 'eingang', 'nachricht'], b: ['nachricht'] }, + G13: { s: 'Erstattung zu Ihrer Bestellung {{bestellnummer}}', p: ['bestellnummer', 'betrag', 'grund'], b: ['grund'] }, + G14: { s: 'Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen', p: ['bestellnummer'], b: [] }, + } + assert.deepEqual(Object.keys(erwartet), SHOP_GUEST) + for (const [id, { s, p, b }] of Object.entries(erwartet)) { + const tpl = loadTemplate(id) + assert.equal(tpl.subject, s, id) + assert.deepEqual([...tpl.placeholders].sort(), [...p, ...SHOP_GEMEIN].sort(), `${id}: Platzhalter`) + assert.deepEqual([...tpl.blocks].sort(), [...b].sort(), `${id}: Blöcke`) + } +}) + +test('G09–G14: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => { + for (const id of SHOP_GUEST) { + const tpl = loadTemplate(id) + const source = sourceOf(id) + assert.match(source, /]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`) + assert.doesNotMatch(source, /]*>Impressum]*>Datenschutz]*>Vertrag widerrufen', farbe: '#112233', anbieter_zeile: 'Töpferei Muster, Hauptstr. 1, 12345 Musterstadt, info@example.com', impressum_url: 'https://shop.example/impressum', datenschutz_url: 'https://shop.example/datenschutz', widerruf_url: 'https://project.webklar.com/shop/sk/widerruf' } +const g = (id, werte, blocks = {}) => renderMail(id, { ...G_WERTE, ...werte }, blocks) + +const G09_WERTE = { + datum: '05.10.2026', + positionen: '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €', + zwischensumme: '36,50 €', + versand: '4,90 €', + gesamt: '41,40 €', + steuer_hinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + lieferart_text: 'Versand nach Deutschland', + lieferadresse: 'Erika Beispiel\nMusterweg 3\n12345 Musterstadt', + abholort: 'Werkstatt, Hauptstr. 1', + lieferzeit: '2–4 Werktage', + zahlungsart: 'Kreditkarte', + ausschluss_text: 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', + agb: 'AGB-Text Zeile 1\nAGB-Text Zeile 2', + widerrufsbelehrung: 'Widerrufsbelehrung Zeile 1\nZeile 2', + widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich', + gewaehrleistung: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre', +} + +test('G09 Versand: Betreff mit Bestellnummer und Shop, Positionen, Summen, Lieferadresse, alle Rechtstexte, Links', () => { + const mail = g('G09', G09_WERTE, { versand: true, abholung: false, ausschluss: true }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei ') + assert.match(mail.html, /Töpferei <Muster>/, 'Shopname maskiert') + assert.match(mail.html, /2 × Tasse \(blau\) – 24,00 €
1 × Teller – 12,50 €/) + assert.match(mail.html, /Erika Beispiel
Musterweg 3
12345 Musterstadt/) + for (const teil of ['26-K7QM2X', '05.10.2026', '36,50 €', '4,90 €', '41,40 €', '§ 19 UStG', 'Versand nach Deutschland', '2–4 Werktage', 'Kreditkarte', 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', 'AGB-Text Zeile 2', 'Widerrufsbelehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular', 'Allgemeine Geschäftsbedingungen']) { + assert.ok(mail.text.includes(teil), teil) + } + assert.match(mail.text, /Lieferadresse: Erika Beispiel/) + assert.doesNotMatch(mail.text, /Abholort|Werkstatt, Hauptstr/) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/shop\/sk\/widerruf"/) +}) + +test('G09 Abholung ohne Ausschluss: Abholort statt Lieferadresse, kein Widerrufs-Ausschluss', () => { + const mail = g('G09', { ...G09_WERTE, lieferart_text: 'Abholung', versand: 'Abholung – kostenlos' }, { versand: false, abholung: true, ausschluss: false }) + assert.deepEqual(mail.problems, []) + assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/) + assert.doesNotMatch(mail.text, /Lieferadresse|Musterweg|Hinweis zum Widerrufsrecht|ausgeschlossen/) + assert.match(mail.text, /Abholung – kostenlos/) +}) + +test('G10: Sendungsnummer und Versanddienst nur im Block sendung', () => { + const mit = g('G10', { sendungsnummer: '0034<1>', versanddienst: 'DHL' }, { sendung: true }) + assert.deepEqual(mit.problems, []) + assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet') + assert.match(mit.text, /Versanddienst: DHL/) + assert.match(mit.text, /Sendungsnummer: 0034<1>/) + assert.match(mit.html, /0034<1>/) + const ohne = g('G10', { sendungsnummer: '', versanddienst: '' }, {}) + assert.deepEqual(ohne.problems, []) + assert.doesNotMatch(ohne.text, /Sendungsnummer|Versanddienst/) + assert.match(ohne.text, /versendet/) +}) + +test('G11: Abholort und Hinweis', () => { + const mail = g('G11', { abholort: 'Werkstatt, Hauptstr. 1', hinweis: 'Di–Fr 10–16 Uhr' }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit') + assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/) + assert.match(mail.text, /Hinweis: Di–Fr 10–16 Uhr/) +}) + +test('G12: Eingang mit Datum und Uhrzeit, Erklärung nur im Block nachricht, Händler meldet sich', () => { + const werte = { name: 'Erika ', bestellnummer: '26-ZZZZZZ', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Bitte stornieren.\nDanke' } + const mit = g('G12', werte, { nachricht: true }) + assert.deepEqual(mit.problems, []) + assert.equal(mit.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei ') + assert.match(mit.text, /Guten Tag Erika ,/) + assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mit.text, /Bestellnummer: 26-ZZZZZZ/) + assert.match(mit.html, /Bitte stornieren\.
Danke/) + assert.match(mit.text, /meldet sich bei Ihnen/) + const ohne = g('G12', werte, {}) + assert.doesNotMatch(ohne.text, /Bitte stornieren|Ihre Erklärung:/) +}) + +test('G13: Betrag, Grund nur im Block, Gutschrift über die ursprüngliche Zahlungsart', () => { + const mit = g('G13', { betrag: '12,50 €', grund: 'Artikel beschädigt' }, { grund: true }) + assert.deepEqual(mit.problems, []) + assert.equal(mit.subject, 'Erstattung zu Ihrer Bestellung 26-K7QM2X') + assert.match(mit.text, /Erstatteter Betrag: 12,50 €/) + assert.match(mit.text, /Grund: Artikel beschädigt/) + assert.match(mit.text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./) + assert.doesNotMatch(g('G13', { betrag: '12,50 €', grund: 'x' }, {}).text, /Grund:/) +}) + +test('G14: nichts berechnet, kein Betrag', () => { + const mail = g('G14', {}) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen') + assert.match(mail.text, /wurde nicht abgeschlossen; es wurde nichts berechnet\./) +}) + +const K25_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', gesamt: '41,40 €', positionen: '2 × Tasse – 24,00 €\n1 × Teller – 12,50 €', lieferart_text: 'Versand nach Deutschland', kaeufer_name: 'Max ', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' } + +test('K25: Betreff, Platzhalter, Blöcke; Bestand-Warnung und Lastschrift-Hinweis nur im Block', () => { + const tpl = loadTemplate('K25') + assert.equal(tpl.file, 'K25 Neue Bestellung.html') + assert.equal(tpl.subject, 'Neue Bestellung {{bestellnummer}} in Deinem Webshop') + assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K25_WERTE).sort()) + assert.deepEqual(tpl.blocks, ['ueberverkauft', 'sepa']) + const normal = renderMail('K25', K25_WERTE, {}) + assert.deepEqual(normal.problems, []) + assert.equal(normal.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop') + assert.match(normal.text, /Hallo Erika/) + assert.match(normal.text, /Käufer: Max /) + assert.match(normal.html, /2 × Tasse – 24,00 €
1 × Teller – 12,50 €/) + assert.match(normal.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/) + assert.doesNotMatch(normal.text, /Achtung|Lastschrift/) + const voll = renderMail('K25', K25_WERTE, { ueberverkauft: true, sepa: true }) + assert.match(voll.text, /Achtung: Bestand reichte nicht/) + assert.match(voll.text, /Zahlung per Lastschrift/) + assert.doesNotMatch(renderMail('K25', K25_WERTE, { sepa: true }).text, /Achtung/) + assert.doesNotMatch(renderMail('K25', K25_WERTE, { ueberverkauft: true }).text, /Lastschrift/) +}) + +const K26_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', kaeufer_name: 'Max ', kaeufer_email: 'max@example.com', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Ich widerrufe.\nBitte melden', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' } + +test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tage', () => { + const tpl = loadTemplate('K26') + assert.equal(tpl.file, 'K26 Widerruf im Webshop.html') + assert.equal(tpl.subject, 'Widerruf in Deinem Webshop eingegangen') + assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K26_WERTE).sort()) + assert.deepEqual(tpl.blocks, ['nachricht']) + const mit = renderMail('K26', K26_WERTE, { nachricht: true }) + assert.deepEqual(mit.problems, []) + assert.match(mit.text, /Hallo Erika/) + assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mit.text, /E-Mail: max@example\.com/) + assert.match(mit.html, /Ich widerrufe\.
Bitte melden/) + assert.match(mit.text, /innerhalb von 14 Tagen/) + assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/) +}) diff --git a/test/shopMails.test.js b/test/shopMails.test.js new file mode 100644 index 0000000..c93de2b --- /dev/null +++ b/test/shopMails.test.js @@ -0,0 +1,360 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Webshop E4: Mails des Shops (services/shop/mails.js). Käufermails laufen durch die Warteschlange des Terminplaners +// (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess. +const aw = await startAppwriteMock({ + schema: { termineOutbox: schemaKeys('termineOutbox') }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js') +const M = await import('../server/services/shop/mails.js') +const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin +const SITE_KEY = 'abcdefghijklmnop' +const ABSENDER_VORHER = config.smtp.from +const BASIS_VORHER = config.publicBaseUrl + +const einstellungen = (over = {}) => ({ + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Töpferei Muster', + anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' }, + lieferzeit: '2–4 Werktage', + akzent: '#aa3300', + abholung: { aktiv: true, adresse: 'Werkstatt, Hauptstr. 1, 67722 Winnweiler', hinweis: 'Di–Fr 10–16 Uhr' }, + ...over, +}) +const texte = (over = {}) => ({ + agb: 'AGB Zeile 1\nAGB Zeile 2', + widerrufsbelehrung: 'Belehrung Zeile 1\nZeile 2', + widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich', + impressumUrl: 'https://toepferei.example/impressum', + datenschutzUrl: 'https://toepferei.example/datenschutz', + ...over, +}) +const ctxMit = (e = einstellungen(), t = texte()) => ({ + doc: { $id: 'f_shop01', customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY }, + shop: { + id: 'f_shop01', + customerId: 'c1', + projectId: 'p1', + siteKey: SITE_KEY, + einstellungen: e, + rechtstexte: { entwurf: texte(), bestaetigt: { texte: t, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } }, + }, +}) + +const bestellung = (over = {}, snap = {}) => ({ + $id: 'o_bestellung01', + bestellnummer: '26-K7QM2X', + kaeuferEmail: 'max@example.com', + bezahltAt: JETZT.toISOString(), + erstelltAt: new Date(JETZT.getTime() - 600000).toISOString(), + summeCent: 4140, + bestellung: JSON.stringify({ + positionen: [ + { name: 'Tasse', varianteText: 'blau', menge: 2, summeCent: 2400, widerruf: 'gesetzlich' }, + { name: 'Teller', varianteText: '', menge: 1, summeCent: 1250, summeText: '12,50 €', widerruf: 'individuell' }, + ], + summen: { zwischensummeCent: 3650, versandCent: 490, gesamtCent: 4140, texte: { zwischensumme: '36,50 €', versand: '4,90 €', gesamt: '41,40 €', steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' } }, + lieferart: 'versand', + land: 'DE', + kaeufer: { name: 'Max Mustermann', email: 'max@example.com' }, + lieferadresse: { name: 'Max Mustermann', line1: 'Musterweg 3', postal_code: '12345', city: 'Musterstadt', country: 'DE' }, + zahlungsart: 'card', + ...snap, + }), + ...over, +}) + +let sink +let log +beforeEach(async () => { + for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id) + for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id) + resetOutboxForTests() + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('customers', { $id: 'c1', name: 'Erika Muster', email: 'erika@toepferei.example' }) +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER +}) + +const eintraege = () => [...aw.docs('termineOutbox').values()] +const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) + +// ---------------------------------------------------------------- Werte + +test('kaeuferWerte: Kopf, Fuß, Positionen, Summen, Lieferadresse und Rechtstexte aus Schnappschuss und Shop', () => { + const w = kaeuferWerte(ctxMit(), bestellung(), JETZT) + // euro() setzt ein geschütztes Leerzeichen vor das €-Zeichen + for (const [schluessel, wert] of Object.entries(w)) w[schluessel] = wert.replaceAll('\u00a0', ' ') + assert.equal(w.farbe, '#aa3300') + assert.equal(w.betrieb_name, 'Töpferei Muster') + assert.equal(w.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example') + assert.equal(w.widerruf_url, `https://project.webklar.com/shop/${SITE_KEY}/widerruf`) + assert.equal(w.impressum_url, 'https://toepferei.example/impressum') + assert.equal(w.datenschutz_url, 'https://toepferei.example/datenschutz') + assert.equal(w.bestellnummer, '26-K7QM2X') + assert.equal(w.datum, '05.10.2026') + assert.equal(w.positionen, '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €') + assert.deepEqual([w.zwischensumme, w.versand, w.gesamt], ['36,50 €', '4,90 €', '41,40 €']) + assert.equal(w.steuer_hinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + assert.equal(w.lieferart_text, 'Versand nach Deutschland') + assert.equal(w.lieferadresse, 'Max Mustermann\nMusterweg 3\n12345 Musterstadt') + assert.equal(w.abholort, 'Werkstatt, Hauptstr. 1, 67722 Winnweiler') + assert.equal(w.lieferzeit, '2–4 Werktage') + assert.equal(w.zahlungsart, 'Kreditkarte, Apple Pay, Google Pay') + assert.equal(w.ausschluss_text, 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt') + assert.equal(w.agb, 'AGB Zeile 1\nAGB Zeile 2') + assert.equal(w.widerrufsbelehrung, 'Belehrung Zeile 1\nZeile 2') + assert.equal(w.widerrufsformular, 'An Töpferei Muster\nHiermit widerrufe ich') + assert.equal(w.gewaehrleistung, gewaehrleistungsHinweis()) +}) + +test('kaeuferWerte: Links fallen ohne Impressum-/Datenschutz-Adresse auf den Shop zurück, ungültige Farbe auf Schwarz, ohne Bestellung "–"', () => { + const w = kaeuferWerte(ctxMit(einstellungen({ akzent: 'red;x' }), texte({ impressumUrl: '', datenschutzUrl: '' })), null, JETZT) + assert.equal(w.farbe, '#111111') + assert.equal(w.impressum_url, `https://project.webklar.com/shop/${SITE_KEY}`) + assert.equal(w.datenschutz_url, `https://project.webklar.com/shop/${SITE_KEY}`) + assert.equal(w.bestellnummer, '–') + assert.equal(w.positionen, '–') + assert.equal(w.lieferadresse, '–') + assert.equal(w.datum, '05.10.2026', 'ohne Bestellung gilt das übergebene Datum') +}) + +// ---------------------------------------------------------------- Käufermails + +test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => { + const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) + assert.equal(erg.ok, true) + assert.equal(erg.gekuerzt, false) + await warteAufVersand() + + const [doc] = eintraege() + assert.equal(doc.art, 'G09') + assert.equal(doc.featureId, 'f_shop01') + assert.equal(doc.bookingId, 'o_bestellung01') + assert.equal(doc.fromName, 'Töpferei Muster') + assert.equal(doc.replyTo, 'info@toepferei.example') + assert.equal(doc.status, 'gesendet') + assert.equal(doc.an, '', 'nach dem Versand ist die Adresse gelöscht') + + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['max@example.com']) + assert.ok(!mail.bcc, 'kein BCC') + assert.equal(mail.from.name, 'Töpferei Muster') + assert.equal(mail.from.address, 'noreply@webklar.com') + assert.equal(mail.replyTo[0].address, 'info@toepferei.example') + assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei Muster') + for (const teil of ['2 × Tasse (blau) – 24,00 €', '41,40 €', 'Max Mustermann', 'Musterweg 3', 'Vom Widerruf ausgeschlossen', 'AGB Zeile 2', 'Belehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Vertrag widerrufen']) { + assert.ok(mail.text.includes(teil), teil) + } + assert.match(mail.html, new RegExp(`href="https://project\\.webklar\\.com/shop/${SITE_KEY}/widerruf"`)) + assert.doesNotMatch(mail.text, /Abholort/) + assert.deepEqual(warnLog(), [], 'keine Platzhalter-Warnung der Vorlage') +}) + +test('G09 Abholung: Abholort statt Lieferadresse', async () => { + const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { lieferart: 'abholung', lieferadresse: null, land: null, positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }] }), {}, JETZT) + assert.equal(erg.ok, true) + await warteAufVersand() + const text = sink.sent[0].text + assert.match(text, /Lieferart: Abholung/) + assert.match(text, /Abholort: Werkstatt, Hauptstr\. 1, 67722 Winnweiler/) + assert.doesNotMatch(text, /Lieferadresse|Hinweis zum Widerrufsrecht/) + assert.deepEqual(warnLog(), []) +}) + +test('G09: zu lang für die Warteschlange ⇒ der AGB-Text wird durch einen Link auf die AGB ersetzt, Belehrung und Formular bleiben', async () => { + const lang = 'Dies ist ein langer Satz der Geschäftsbedingungen.\n'.repeat(560) // etwa 28 000 Zeichen + const t = texte({ agb: lang, widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(1000), widerrufsformular: 'Formular Zeile\n'.repeat(300) }) + const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) + assert.equal(erg.ok, true) + assert.equal(erg.gekuerzt, true) + const doc = eintraege()[0] + assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`) + assert.match(doc.text, new RegExp(`Die vollständigen AGB finden Sie unter https://project\\.webklar\\.com/shop/${SITE_KEY}\\?ansicht=agb`)) + assert.doesNotMatch(doc.text, /langer Satz der Geschäftsbedingungen/) + assert.match(doc.text, /Belehrung Satz\./) + assert.match(doc.text, /Formular Zeile/) + assert.match(doc.text, /GESETZLICHE GEWÄHRLEISTUNG/) +}) + +test('G09: auch gekürzt zu lang ⇒ { ok: false } ohne Wurf und ohne Eintrag', async () => { + const t = texte({ widerrufsbelehrung: 'x'.repeat(60000) }) + const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) + assert.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }) + assert.equal(eintraege().length, 0) + assert.equal(sink.sent.length, 0) + assert.doesNotMatch(warnLog().join('\n'), /max@example\.com/) +}) + +test('G10: Versanddienst und Sendungsnummer (Block sendung); ohne beide kein Block', async () => { + assert.equal((await anKaeufer('G10', ctxMit(), bestellung(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true) + assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, JETZT)).ok, true) + await warteAufVersand() + const [mit, ohne] = sink.sent + assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet') + assert.match(mit.text, /Versanddienst: DHL/) + assert.match(mit.text, /Sendungsnummer: 00340434/) + assert.doesNotMatch(ohne.text, /Sendungsnummer/) +}) + +test('G11: Abholort und Hinweis des Händlers, ersatzweise ein Standardhinweis', async () => { + await anKaeufer('G11', ctxMit(), bestellung(), {}, JETZT) + const ohneHinweis = einstellungen({ abholung: { aktiv: true, adresse: 'Werkstatt', hinweis: '' } }) + await anKaeufer('G11', ctxMit(ohneHinweis), bestellung({ $id: 'o_zwei' }), {}, JETZT) + await warteAufVersand() + const texte11 = sink.sent.map((m) => m.text) + assert.match(texte11[0], /Abholort: Werkstatt, Hauptstr\. 1/) + assert.match(texte11[0], /Hinweis: Di–Fr 10–16 Uhr/) + assert.match(texte11[1], /Hinweis: Bitte bringen Sie Ihre Bestellnummer mit\./) + assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit') +}) + +test('G12: ohne passende Bestellung, an die Adresse der Erklärung, mit Datum und Uhrzeit; Vorrang-Klasse', async () => { + const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf01', name: 'Kai Käufer', bestellnummer: '26-ZZZZZZ', eingang: JETZT, nachricht: 'Ich widerrufe.' }, JETZT) + assert.equal(erg.ok, true) + assert.equal(erg.eintrag.bookingId, 'w_widerruf01') + await warteAufVersand() + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['kaeufer@example.com']) + assert.equal(mail.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei Muster') + assert.match(mail.text, /Guten Tag Kai Käufer,/) + assert.match(mail.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mail.text, /Bestellnummer: 26-ZZZZZZ/) + assert.match(mail.text, /Ihre Erklärung: Ich widerrufe\./) + assert.deepEqual(warnLog(), []) +}) + +test('G13: erstatteter Betrag in Euro, Grund nur wenn angegeben; ohne Betrag { ok: false }', async () => { + assert.equal((await anKaeufer('G13', ctxMit(), bestellung(), { betragCent: 1250, grund: 'Artikel beschädigt' }, JETZT)).ok, true) + assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_zwei' }), { betragCent: 4140 }, JETZT)).ok, true) + assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_drei' }), {}, JETZT)).ok, false) + assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_vier' }), { betragCent: 12.5 }, JETZT)).ok, false) + await warteAufVersand() + assert.equal(sink.sent.length, 2) + assert.match(sink.sent[0].text, /Erstatteter Betrag: 12,50 €/) + assert.match(sink.sent[0].text, /Grund: Artikel beschädigt/) + assert.match(sink.sent[1].text, /Erstatteter Betrag: 41,40 €/) + assert.doesNotMatch(sink.sent[1].text, /Grund:/) + assert.match(sink.sent[1].text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./) +}) + +test('G14: Zahlung fehlgeschlagen, nichts berechnet', async () => { + assert.equal((await anKaeufer('G14', ctxMit(), bestellung(), {}, JETZT)).ok, true) + await warteAufVersand() + assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen') + assert.match(sink.sent[0].text, /es wurde nichts berechnet/) +}) + +test('anKaeufer wirft nie: unbekannte Art, fehlende Adresse und Speicherfehler ergeben { ok: false }', async () => { + assert.equal((await anKaeufer('G01', ctxMit(), bestellung(), {}, JETZT)).ok, false) + assert.equal((await anKaeufer('G09', ctxMit(), bestellung({ kaeuferEmail: '' }), {}, JETZT)).ok, false) + assert.equal((await anKaeufer('G09', ctxMit(), bestellung(), {}, new Date('kaputt'))).ok, false) + aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' }) + const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) + assert.equal(erg.ok, false) + assert.equal(eintraege().length, 0) + assert.equal(sink.sent.length, 0) +}) + +test('Käufermails zählen als Gästemails: G09 und G12 mit Vorrang, die übrigen normal', async () => { + const { mailKlasse } = await import('../server/services/termine/outbox.js') + assert.deepEqual(['G09', 'G10', 'G11', 'G12', 'G13', 'G14'].map(mailKlasse), ['vorrang', 'normal', 'normal', 'vorrang', 'normal', 'normal']) +}) + +// ---------------------------------------------------------------- Händlermails + +test('K25: geht an das Kundenkonto, ohne BCC ans Team, mit Käufer, Positionen und Portal-Link', async () => { + const { values, blocks } = haendlerK25(ctxMit(), bestellung()) + assert.deepEqual(blocks, { ueberverkauft: false, sepa: false }) + const erg = await anHaendler('K25', ctxMit(), values, blocks) + assert.deepEqual(erg, { ok: true }) + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.deepEqual(mail.to.map((t) => t.address), ['erika@toepferei.example']) + assert.ok(!mail.bcc, 'kein BCC ans Team') + assert.equal(mail.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop') + assert.match(mail.text, /Hallo Erika Muster/) + assert.match(mail.text, /Töpferei Muster/) + assert.match(mail.text, /Käufer: Max Mustermann/) + assert.match(mail.text, /Gesamt: 41,40 €/) + assert.match(mail.html.replaceAll('\u00a0', ' '), /2 × Tasse \(blau\) – 24,00 €
1 × Teller – 12,50 €/) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#webshop\?projekt=p1"/) + assert.doesNotMatch(mail.text, /Achtung|Lastschrift/) + assert.deepEqual(warnLog(), []) +}) + +test('K25: Blöcke ueberverkauft und sepa', async () => { + const { values, blocks } = haendlerK25(ctxMit(), bestellung({}, { zahlungsart: 'sepa_debit' }), { ueberverkauft: true }) + assert.deepEqual(blocks, { ueberverkauft: true, sepa: true }) + assert.equal((await anHaendler('K25', ctxMit(), values, blocks)).ok, true) + assert.match(sink.sent[0].text, /Achtung: Bestand reichte nicht/) + assert.match(sink.sent[0].text, /Zahlung per Lastschrift/) +}) + +test('K26: Widerruf mit Eingang in Berliner Zeit und Erklärung, ohne BCC; ohne Erklärung kein Block', async () => { + const widerruf = { name: 'Kai ', email: 'kai@example.com', bestellnummer: '26-ZZZZZZ', nachricht: 'Ich widerrufe.', eingangAt: JETZT.toISOString() } + const { values, blocks } = haendlerK26(ctxMit(), widerruf) + assert.equal(values.eingang, '05.10.2026, 14:03 Uhr') + assert.deepEqual(blocks, { nachricht: true }) + assert.equal((await anHaendler('K26', ctxMit(), values, blocks)).ok, true) + const ohne = haendlerK26(ctxMit(), { ...widerruf, nachricht: ' ' }) + assert.deepEqual(ohne.blocks, { nachricht: false }) + assert.equal((await anHaendler('K26', ctxMit(), ohne.values, ohne.blocks)).ok, true) + const [mit, kurz] = sink.sent + assert.ok(!mit.bcc) + assert.equal(mit.subject, 'Widerruf in Deinem Webshop eingegangen') + assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) + assert.match(mit.text, /Käufer: Kai /) + assert.match(mit.text, /E-Mail: kai@example\.com/) + assert.match(mit.text, /Erklärung: Ich widerrufe\./) + assert.match(mit.html, /Kai <K>/) + assert.doesNotMatch(kurz.text, /Erklärung:/) +}) + +test('anHaendler wirft nie: Kunde fehlt oder ohne E-Mail, Versandfehler ⇒ { ok: false } und Warnung ohne Adresse', async () => { + const { values, blocks } = haendlerK25(ctxMit(), bestellung()) + const fremd = ctxMit() + fremd.shop.customerId = 'c_gibtsnicht' + assert.equal((await anHaendler('K25', fremd, values, blocks)).ok, false) + aw.seed('customers', { $id: 'c_ohne', name: 'Ohne', email: '' }) + const ohneMail = ctxMit() + ohneMail.shop.customerId = 'c_ohne' + assert.equal((await anHaendler('K25', ohneMail, values, blocks)).ok, false) + setMailTransportForTests({ + async sendMail() { + throw new Error('SMTP erika@toepferei.example kaputt') + }, + }) + assert.deepEqual(await anHaendler('K25', ctxMit(), values, blocks), { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }) + assert.equal(sink.sent.length, 0) + assert.doesNotMatch(warnLog().join('\n'), /erika@toepferei\.example/, 'keine Adresse im Log') + assert.ok(warnLog().some((zeile) => /K25 nicht gesendet/.test(zeile))) +}) diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 4e84318..55d222a 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -3016,3 +3016,90 @@ test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich ( } } }) + +// ---------------------------------------------------------------- Webshop: die Mail-Warteschlange gehört auch dem Shop (E4) + +const SHOP_MODUS_VORHER = config.shop.mode +afterEach(() => { + config.shop.mode = SHOP_MODUS_VORHER +}) + +test('Webshop aktiv, Terminplaner off: der Minutenjob sendet die Warteschlange, der Verfall und alles andere bleibt aus', async () => { + await richteFunktionEin() + await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) }) + legeEintrag('o_faellig', { art: 'G09' }) + legeEintrag('o_spaeter', { art: 'G09', naechsterVersuch: iso(plus(JETZT, 10 * MIN)) }) + config.termine.mode = 'off' + config.shop.mode = 'pilot' + + assert.deepEqual(await minutenJob(JETZT), { eingeschraenkt: true, verfallen: 0, faellig: 1, gesendet: 1, fehler: 0 }) + assert.equal(sink.sent.length, 1) + assert.equal(aw.docs('termineOutbox').get('o_faellig').status, 'gesendet') + assert.equal(aw.docs('termineOutbox').get('o_spaeter').status, 'wartet') + assert.equal(status('b_abgelaufen'), 'gehalten', 'der Verfall der Haltungen bleibt aus') + + // Shop aus, Terminplaner off: wie bisher nichts + config.shop.mode = 'off' + legeEintrag('o_nochmal', { art: 'G09' }) + assert.deepEqual(await minutenJob(JETZT), { aus: true }) + assert.equal(aw.docs('termineOutbox').get('o_nochmal').status, 'wartet') +}) + +test('Webshop aktiv, Terminplaner off und ohne bezahlte Terminplaner: der Tagesjob beendet festgefahrene Mails und räumt die Warteschlange auf, sonst nichts', async () => { + await richteFunktionEin() // aktiv, nicht bezahlt + await legeLoeschFall('b_verfallen', 'verfallen', 1) + legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) }) + legeEintrag('o_gesendet', { art: 'G09', status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) + legeEintrag('o_neu', { art: 'G09' }) + config.termine.mode = 'off' + config.shop.mode = 'on' + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg, { + eingeschraenkt: true, + aboAbgleich: { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }, + abgeschaltet: { funktionen: 0, buchungen: 0 }, + gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }, + geloescht: 0, + aktiveBetriebe: null, + festgefahren: 1, + aufgeraeumt: 1, + fehler: 0, + }) + assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler') + assert.equal(aw.docs('termineOutbox').get('o_gesendet'), undefined, 'gesendeter Eintrag gelöscht') + assert.equal(aw.docs('termineOutbox').get('o_neu').status, 'wartet') + assert.ok(buchung('b_verfallen'), 'keine Löschfristen der Buchungen') + assert.equal( + tagesbericht(erg), + '[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung sowie Mail-Warteschlange): 0 Buchungen gelöscht (abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 1 Outbox, 1 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler', + ) +}) + +test('Webshop aktiv, Terminplaner off, bezahlter Terminplaner: Abo-Abgleich und Löschung laufen wie bisher, dazu die Warteschlange', async () => { + await richteFunktionEin(F1, BEZAHLT) + wartung('active') + legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) }) + config.termine.mode = 'off' + config.shop.mode = 'pilot' + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.equal(erg.aboAbgleich.geprueft, 1) + assert.equal(erg.festgefahren, 1) + assert.equal(erg.aufgeraeumt, 0) + assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler') +}) + +test('Webshop aktiv, Terminplaner off: schlägt die Guard-Abfrage fehl, läuft nur die Warteschlange (Warnung ohne Kennungen)', async () => { + legeEintrag('o_festgefahren', { art: 'G09', createdAt: iso(plus(JETZT, -3 * TAG)) }) + config.termine.mode = 'off' + config.shop.mode = 'on' + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.equal(erg.festgefahren, 1) + assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /Nachbau kaputt/.test(zeile)).length, 1) + assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\bp_|\bc_|@/) +}) diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index abe80bb..faaf274 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -308,10 +308,10 @@ test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verb /** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */ const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra }) -test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { - assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08']) +test('mailKlasse: G01/G02/G08 (Webshop: G09/G12) vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { + assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08', 'G09', 'G12']) assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21']) - const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' } + const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', G09: 'vorrang', G10: 'normal', G11: 'normal', G12: 'vorrang', G13: 'normal', G14: 'normal', K20: 'nachrangig', K21: 'nachrangig' } assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet) assert.equal(mailKlasse('unbekannt'), 'normal') })