diff --git a/server/mail-templates/K15 Bestellung Funktion.html b/server/mail-templates/K15 Bestellung Funktion.html index 693faee..bec6a18 100644 --- a/server/mail-templates/K15 Bestellung Funktion.html +++ b/server/mail-templates/K15 Bestellung Funktion.html @@ -7,12 +7,14 @@ Betreff: Deine Bestellung: {{funktion}} Absender: „WEBklar“ Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern - Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung + Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}, {{fassung}}, {{lb_url}}, {{avv_url}}, {{widerruf_url}}, {{zustimmungen_text}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, anlagen, anhang, zustimmungen funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen). preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis). danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal. rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat). + anlagen = Vertragsunterlagen der Funktion (nur Funktionen mit Rechtsdokumenten): fassung = Fassung der Texte, lb_url/avv_url/widerruf_url = versionierte Adressen von Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular. anhang (innerhalb von anlagen) = die drei PDFs hängen an der Mail (dauerhafter Datenträger); fehlt der Anhang, entfällt der Satz. + zustimmungen = Wortlaut und Zeitpunkt der erteilten Zustimmungen (zustimmungen_text, mehrzeilig) aus dem Nachweis zur bezahlten Bestellung. --> @@ -41,6 +43,12 @@ a{color:#111111;} Hallo {{kunde_name}},
vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.
Funktion{{funktion}} – Einrichtung
Projekt{{projekt}}
Einmalig bezahlt{{preis}} ({{preis_hinweis}})
Einrichtung{{zeit}}
+ + + + + +
Rechnung ansehen →
diff --git a/server/mail-templates/T16 Funktion bestellt.html b/server/mail-templates/T16 Funktion bestellt.html index 69ef73f..02d2992 100644 --- a/server/mail-templates/T16 Funktion bestellt.html +++ b/server/mail-templates/T16 Funktion bestellt.html @@ -8,7 +8,7 @@ Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{woid}}, {{sitekey}}, {{sitzung}}, {{zustand}}, {{anleitung_url}}, {{gesundheit_text}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, anhang_fehlt funktion = Name der Funktion aus dem Register (zum Beispiel News-Pinnwand, Terminplaner); ohne bekannte Funktion steht „Funktion“. ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt. zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer. @@ -16,6 +16,7 @@ bereits_aktiv = die Zahlung ist da, aber die Funktion war schon in dem Zustand {{zustand}}; es wurde kein Ticket angelegt und nichts eingerichtet (Einbau nachholen oder erstatten); sitzung nennt die Stripe-Sitzung. anleitung = Knopf „Im Ticketsystem öffnen“ zur Einbauanleitung des Projekts (anleitung_url); nur bei Funktionen mit Anleitung und angelegtem Ticket. gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs). + anhang_fehlt = die Vertragsunterlagen (PDF) sind nicht beim Kunden angekommen (Anhang nicht lesbar, Mail nicht versandt oder Unterlagen einer anderen Fassung als bestellt); das Team sendet sie von Hand nach. --> @@ -57,6 +58,9 @@ a{color:#111111;} + + +
Im Ticketsystem öffnen →
diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 9630f98..5376100 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -13,6 +13,7 @@ import { listCustomerFeatures, markOrdered, runActivation, + unterlagenAdressen, } from '../services/siteFeatures.js' import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js' import { termineFuerKunde } from '../services/termine/schalter.js' @@ -115,6 +116,31 @@ async function termineAngebot(email, customerId) { return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis } } +/** + * Vertragsunterlagen je Funktion fuer die Liste (GET /api/funktionen, Feld legal): Funktionen mit Rechtsdokumenten (Terminplaner) + * { version, links: { lb, avv, widerruf } (absolut, versioniert), belehrung } mit der Widerrufsbelehrung samt Muster-Formular aus + * dem Haken belehrung (derselbe Text wie auf der Bestellseite und im Nachweis), alle anderen (News) null. Kann der Haken die + * Belehrung nicht liefern, steht belehrung null: die Liste faellt dafuer nicht aus. + */ +async function rechtlichesJeFunktion() { + const legal = {} + for (const key of featureKeys()) { + const unterlagen = unterlagenAdressen(featureConfig(key)) + if (!unterlagen) { + legal[key] = null + continue + } + let belehrung = null + try { + belehrung = (await featureHaken(key).belehrung?.()) ?? null + } catch (err) { + console.error(`[funktionen] Widerrufsbelehrung (${key}) nicht lesbar: ${err?.name ?? 'Fehler'}`) + } + legal[key] = { version: unterlagen.fassung, links: { lb: unterlagen.lb, avv: unterlagen.avv, widerruf: unterlagen.widerruf }, belehrung } + } + return legal +} + // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet router.get('/', requireSession, async (req, res) => { try { @@ -139,6 +165,8 @@ router.get('/', requireSession, async (req, res) => { features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])), // Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt ustHinweis: config.ustHinweis, + // Vertragsunterlagen und Widerrufsbelehrung je Funktion (Terminplaner: Fassung, versionierte Links, Belehrung; News: null) + legal: await rechtlichesJeFunktion(), setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index e2569f3..fc14d4e 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -38,6 +38,16 @@ async function termineFreischaltbar(doc) { return (await freischaltHindernis(doc)) === null } +/** + * Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der + * aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst + * beim Aufruf. + */ +async function termineUnterlagenBereit() { + const { featureLegalReady } = await import('./legalDocs.js') + return featureLegalReady('termine') +} + /** * Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf * zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen @@ -50,7 +60,7 @@ async function termineBelehrung() { /** * Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit - * (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht + * (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht * und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag * im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert. */ @@ -59,7 +69,7 @@ const STANDARD_HAKEN = { termine: { verfuegbar: (email) => termineFuerKunde(email), bestellpruefung: termineBestellpruefung, - unterlagenBereit: async () => true, + unterlagenBereit: termineUnterlagenBereit, belehrung: termineBelehrung, freischaltbar: termineFreischaltbar, }, diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js index e7a7642..c60a8cb 100644 --- a/server/services/legalDocs.js +++ b/server/services/legalDocs.js @@ -2,13 +2,19 @@ // Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und // Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum. // Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben. -import { access, readFile } from 'node:fs/promises' +import { access, open, readFile, stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { Router } from 'express' import { config } from '../config.js' const publicDir = fileURLToPath(new URL('../../public/', import.meta.url)) +/** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */ +let basisDir = publicDir +/** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */ +export function setLegalDirForTests(dir) { + basisDir = dir ?? publicDir +} const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/ /** @@ -56,7 +62,7 @@ export function legalPdfFiles(satz, version) { const dokumente = satzDokumente(satz) const v = pruefeFassung(version) return Object.fromEntries( - Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(publicDir, 'rechtliches', `${d.slug}-${v}.pdf`) }]) + Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }]) ) } @@ -90,18 +96,36 @@ export async function featureLegalAttachments(featureKey, opts) { return satz ? legalPdfAttachments(satz, aktuelleFassung(satz), opts) : [] } +/** + * Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die + * ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler. + */ +async function istLesbaresPdf(pfad) { + const info = await stat(pfad) + if (!info.isFile() || info.size <= 0) return false + const datei = await open(pfad, 'r') + try { + const kopf = Buffer.alloc(5) + const { bytesRead } = await datei.read(kopf, 0, 5, 0) + return bytesRead === 5 && kopf.toString('latin1') === '%PDF-' + } finally { + await datei.close() + } +} + /** * true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite - * verlinkt) und PDFs (die Mail anhaengt). Unbekannte Funktion und jeder Lesefehler: false (fail-closed). + * verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte + * Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. */ export async function featureLegalReady(featureKey) { try { const satz = funktionSatz(featureKey) if (!satz) return true const v = aktuelleFassung(satz) - await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(publicDir, adresse)))) - await legalPdfAttachments(satz, v) - return true + await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse)))) + const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path))) + return lesbar.every(Boolean) } catch { return false } diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index aa7148b..89874a9 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -7,6 +7,7 @@ import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' import { zustimmungFuerSitzung } from './featureConsents.js' import { aboLage, laeuft } from './aboStatus.js' +import { berlinDateTime } from './berlinTime.js' import { loeschTag } from './termine/frist.js' import { createFeatureTicket } from './ticketAdmin.js' @@ -791,12 +792,27 @@ function einrichtungsPreis(feature) { return eur(amountCents) } +/** + * Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und + * die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News). + * @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null} + */ +export function unterlagenAdressen(feature) { + const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {} + if (!lb?.url || !avv?.url || !widerruf?.url) return null + const absolut = (pfad) => `${config.publicBaseUrl}${pfad}` + return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) } +} + /** * K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag * (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab. - * Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an. + * Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt + * fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText` + * (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen + * Registerhaken dafuer. */ -export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) { +export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '' }) { const { funktion, formen, preisHinweis, danach } = feature.mails return { template: 'K15', @@ -814,11 +830,40 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) { danach, portal_url: portalUrl(feature), rechnung_url: invoiceUrl || '', + fassung: unterlagen?.fassung ?? '', + lb_url: unterlagen?.lb ?? '', + avv_url: unterlagen?.avv ?? '', + widerruf_url: unterlagen?.widerruf ?? '', + zustimmungen_text: zustimmungenText, }, - blocks: { rechnung: Boolean(invoiceUrl) }, + blocks: { rechnung: Boolean(invoiceUrl), anlagen: unterlagen !== null, anhang: unterlagen !== null && anhang === true, zustimmungen: zustimmungenText !== '' }, } } +/** + * Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der + * Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis. + * @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte) + */ +export function zustimmungenText(nachweis) { + let texte + try { + texte = JSON.parse(nachweis?.texte ?? '') + } catch { + return '' + } + if (!Array.isArray(texte)) return '' + const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '') + if (haken.length === 0) return '' + const am = new Date(nachweis.acceptedAt) + const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am)) + const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '') + return [ + `Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`, + ...haken.map((t) => `– ${t.text}`), + ].join('\n') +} + /** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */ export function bereitMail(feature, { kundeName, projekt }) { const { funktion, formen, bereit, knopf } = feature.mails @@ -908,7 +953,16 @@ function sendT16({ funktion, ...werte }, bloecke) { gesundheit_text: '', ...werte, }, - blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, ...bloecke }, + blocks: { + zahlung_fehlgeschlagen: false, + ungeklaert: false, + kein_abo: false, + bereits_aktiv: false, + anleitung: false, + gesundheit: false, + anhang_fehlt: false, + ...bloecke, + }, }) } @@ -948,8 +1002,11 @@ async function gesundheitHinweis(doc) { } } -/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */ -async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) { +/** + * keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). + * anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt). + */ +async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) { const mails = featureConfig(doc.featureKey)?.mails // Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde const anleitung = Boolean(mails?.anleitung) && !failed @@ -965,7 +1022,7 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = { anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '', gesundheit_text: gesundheit, }, - { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '' } + { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt } ) } @@ -1120,6 +1177,45 @@ async function meldeFallZurZahlung(doc, fall, session) { if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`) } +/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */ +const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), anlagen: false, anhang: false, zustimmungen: '', fehlt: false }) + +/** + * Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs der aktuellen Fassung (legalDocs.featureLegalAttachments, + * erst beim Aufruf geladen; kein Registerhaken) und der Wortlaut der Zustimmungen aus dem Nachweis zur bezahlten Sitzung. Gehoert der + * Nachweis zu einer anderen Fassung als der aktuellen (die Fassung wurde zwischen Klick und Zahlung angehoben), gehen weder PDFs noch + * Links raus: es waeren nicht die Unterlagen, denen der Kunde zugestimmt hat; das Team sendet sie von Hand (fehlt). Nicht lesbare PDFs: + * K15 ohne Anhang, fehlt true, Fehler im Log. Wirft nie. + * @returns {Promise<{ attachments: object[], anlagen: boolean, anhang: boolean, zustimmungen: string, fehlt: boolean }>} + */ +async function k15Rechtsunterlagen(doc, feature, session) { + let nachweis = null + try { + nachweis = await zustimmungFuerSitzung(session.id) + } catch (err) { + console.error(`[funktionen] Zustimmungsnachweis zu Sitzung ${session.id} fuer K15 nicht lesbar, der Block zustimmungen entfaellt:`, err.message) + } + const zustimmungen = zustimmungenText(nachweis) + let fassungNachweis = '' + try { + fassungNachweis = nachweis ? JSON.parse(nachweis.fassungen)?.zustimmung ?? '' : '' + } catch { + fassungNachweis = '' + } + if (fassungNachweis && fassungNachweis !== feature.consent.version) { + console.error(`[funktionen] K15 (Funktion ${doc.$id}): Nachweis gehoert zur Fassung ${fassungNachweis}, aktuell ist ${feature.consent.version} - ohne Unterlagen, das Team sendet sie von Hand`) + return { attachments: [], anlagen: false, anhang: false, zustimmungen, fehlt: true } + } + try { + const { featureLegalAttachments } = await import('./legalDocs.js') + const attachments = await featureLegalAttachments(doc.featureKey) + return { attachments, anlagen: true, anhang: attachments.length > 0, zustimmungen, fehlt: attachments.length === 0 } + } catch (err) { + console.error(`[funktionen] K15-Anhaenge fuer Funktion ${doc.$id} nicht lesbar, K15 geht ohne Anhang raus:`, err.message) + return { attachments: [], anlagen: true, anhang: false, zustimmungen, fehlt: true } + } +} + async function onFeaturePaid(session) { const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) const paid = { sessionId: session.id, invoiceId, invoiceUrl } @@ -1138,16 +1234,33 @@ async function onFeaturePaid(session) { } catch (err) { console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message) } + // K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem + // Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team + // "Unterlagen von Hand nachsenden" (Block anhang_fehlt). + const unterlagen = feature ? unterlagenAdressen(feature) : null + let anhangFehlt = false if (customer?.email) { try { if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) - await sendCustomerMail( - customer.email, - bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl }) - ) + const rechtlich = unterlagen ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN + anhangFehlt = rechtlich.fehlt + await sendCustomerMail(customer.email, { + ...bestellMail(feature, { + kundeName: customer.name || '', + projekt: await projectName(doc.projectId), + invoiceUrl, + unterlagen: rechtlich.anlagen ? unterlagen : null, + anhang: rechtlich.anhang, + zustimmungenText: rechtlich.zustimmungen, + }), + attachments: rechtlich.attachments, + }) } catch (err) { console.error('[funktionen] K15 fehlgeschlagen:', err.message) + if (unterlagen) anhangFehlt = true } + } else if (unterlagen) { + anhangFehlt = true } // Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo). // Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500. @@ -1160,7 +1273,7 @@ async function onFeaturePaid(session) { } } try { - await teamNotice(doc, customer, { keinAbo }) + await teamNotice(doc, customer, { keinAbo, anhangFehlt }) } catch (err) { console.error('[funktionen] T16 fehlgeschlagen:', err.message) } diff --git a/test/featureMails.test.js b/test/featureMails.test.js index cb6a394..94d2a04 100644 --- a/test/featureMails.test.js +++ b/test/featureMails.test.js @@ -611,8 +611,13 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => { 'danach', 'portal_url', 'rechnung_url', + 'fassung', + 'lb_url', + 'avv_url', + 'widerruf_url', + 'zustimmungen_text', ]) - assert.deepEqual(k15.blocks, ['rechnung']) + assert.deepEqual(k15.blocks, ['rechnung', 'anlagen', 'anhang', 'zustimmungen']) assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}') const k16 = loadTemplate('K16') assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url']) @@ -624,7 +629,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => { const t16 = loadTemplate('T16') assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}') assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text']) - assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit']) + assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'anhang_fehlt']) const t19 = loadTemplate('T19') assert.equal(t19.file, 'T19 Erstattung faellig.html') assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung') @@ -643,8 +648,9 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', assert.equal(k16.template, 'K16') assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`) assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`) - assert.deepEqual(k15.blocks, { rechnung: true }) - assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false }) + // ohne Rechtsunterlagen im Aufruf (F8: onFeaturePaid übergibt sie für den Terminplaner) bleiben anlagen, anhang und zustimmungen aus + assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false }) + assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false }) for (const nachricht of [k15, k16]) { const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks) assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`) @@ -657,8 +663,12 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', }) // ------------------------------------------------------------------------------------ Anhänge --- -test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => { +// Die Vertragsunterlagen des Terminplaners hängen seit F8 an K15 (legalDocs.featureLegalAttachments, Tests in funktionenRecht.test.js). +// Diese Probe hält fest, dass das nicht über einen Registerhaken läuft: kein mails.anhaenge, und die Haken des Eintrags werden bei der +// Zahlung nicht gerufen. +test('K15 mit Anhängen aus legalDocs: die Termine-Bestellung ruft keinen Registerhaken, mails.anhaenge gibt es nicht', async () => { const termine = config.features.termine + assert.equal(Object.hasOwn(termine.mails, 'anhaenge'), false) const aufrufe = [] const alt = {} const spion = (name) => (...args) => { @@ -672,7 +682,7 @@ test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', asyn termine.mails.anhaenge = spion('mails.anhaenge') try { const { mail } = await bezahle('termine', 'Anhang-Test') - assert.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang') + assert.equal(mail.attachments.length, 3, 'K15 trägt die drei PDFs der Vertragsunterlagen') assert.deepEqual(aufrufe, []) } finally { for (const name of Object.keys(alt)) { diff --git a/test/funktionenRecht.test.js b/test/funktionenRecht.test.js index 44e2dbf..f4b4bd3 100644 --- a/test/funktionenRecht.test.js +++ b/test/funktionenRecht.test.js @@ -1,8 +1,8 @@ -import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { test, after } from 'node:test' +import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' @@ -161,11 +161,11 @@ test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textC }) // ---- K15 (Bestellbestätigung) der News -const paidEvent = (sessionId, projectId) => ({ +const paidEvent = (sessionId, projectId, featureKey = 'news', customerId = 'c1') => ({ id: `evt_${sessionId}`, object: 'event', type: 'checkout.session.completed', - data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'news' } } }, + data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId, projectId, featureKey } } }, }) test('k15_news: Preiszeile mit § 19 UStG statt "brutto"', async () => { @@ -226,3 +226,223 @@ test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speic assert.deepEqual(JSON.parse(aw.docs('featureConsents').get('fc_alt').fassungen), { zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }) assert.equal(JSON.parse(aw.docs('featureConsents').get('fc_alt').texte)[0].text, ALTER_SATZ) }) + + +// ======================================================================================== Vertragsunterlagen (Task F8) +// K15 des Terminplaners trägt Leistungsbeschreibung, AVV und Widerrufsbelehrung als PDF (dauerhafter Datenträger, § 312f BGB); +// der Checkout fragt vorher, ob die Unterlagen abrufbar sind; die Bestellseite zeigt Belehrung und Links ohne Aufklappen. +const consents = await import('../server/services/featureConsents.js') +const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js') +const { featureConfig } = await import('../server/services/featureRegistry.js') +const { legalPaths, legalPdfFiles, setLegalDirForTests } = await import('../server/services/legalDocs.js') +const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const T = config.features.termine +const V = config.termine.avvVersion +const BASE = config.publicBaseUrl +const KLICK = '2026-10-05T08:15:00.000Z' // 10:15 Uhr in Berlin +const stumm = () => mock.method(console, 'error', () => {}) + +let zaehler = 0 +/** + * Terminplaner bestellen (Nachweis wie ihn der Checkout speichert) und bezahlen: liefert K15 an den Kunden, T16 an das Team und das Dokument. + * nachweis false: ohne Zustimmungsnachweis; fassungen: andere Fassungen im Nachweis; customerId: anderer Kunde. + */ +async function bezahleTermine({ nachweis = true, fassungen = null, customerId = 'c1' } = {}) { + zaehler += 1 + const projectId = `ptermine${zaehler}` + const sessionId = `cs_ptermine${zaehler}` + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${zaehler}` }) + await sf.markOrdered({ customerId, projectId, key: 'termine', sessionId, consent: {} }) + if (nachweis) { + await consents.speichereZustimmung({ + customerId, + projectId, + featureKey: 'termine', + stripeSessionId: sessionId, + fassungen: fassungen ?? consents.zustimmungsfassungen(T), + texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), + ip: '127.0.0.1', + userAgent: 'test', + konto: 'Kunde Eins ', + at: KLICK, + }) + } + const vor = mails.sent.length + await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine', customerId)) + const neu = mails.sent.slice(vor) + const k15 = neu.filter((m) => m.subject.startsWith('Deine Bestellung')) + const t16 = neu.filter((m) => m.subject.startsWith('Neue Funktion bestellt')) + return { projectId, sessionId, k15: k15[0] ?? null, k15Anzahl: k15.length, t16: t16[0] ?? null, doc: await sf.getFeatureDoc(customerId, projectId, 'termine') } +} + +const anhangDateien = () => { + const d = legalPdfFiles('termine', V) + return [d.lb, d.avv, d.widerruf] +} + +test('k15_termine_anhaenge: drei PDFs in der Reihenfolge lb, avv, widerruf, Namen aus legalPdfFiles; kein Registerhaken mails.anhaenge', async () => { + assert.equal(Object.hasOwn(featureConfig('termine').mails, 'anhaenge'), false) + const { k15, k15Anzahl, t16 } = await bezahleTermine() + assert.equal(k15Anzahl, 1) + assert.deepEqual(k15.attachments.map((a) => a.filename), anhangDateien().map((f) => f.filename)) + assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${V}.pdf`, `AVV-Terminplaner-${V}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`]) + for (const [i, datei] of anhangDateien().entries()) { + assert.equal(k15.attachments[i].contentType, 'application/pdf') + const bytes = Buffer.from(k15.attachments[i].content, 'base64') + assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-') + assert.deepEqual(bytes, readFileSync(datei.path), 'genau die ausgelieferte Datei') + } + // Anlagen-Block mit dem Satz zu den PDFs, T16 ohne Hinweis auf fehlende Unterlagen + assert.match(k15.text, /Deine Vertragsunterlagen/) + assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./) + assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/) +}) + +test('k15_news_ohne_anhaenge: die News-Bestätigung bleibt unverändert, ohne Anhang und ohne Blöcke für Vertragsunterlagen', async () => { + aw.seed('websiteProjects', { $id: 'pnews8', customerId: 'c1', projectName: 'Bistro Acht' }) + await sf.markOrdered({ customerId: 'c1', projectId: 'pnews8', key: 'news', sessionId: 'cs_news8', consent: {} }) + const vor = mails.sent.length + await sf.handleFeatureCheckoutEvent(paidEvent('cs_news8', 'pnews8')) + const neu = mails.sent.slice(vor) + const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung')) + assert.ok(k15) + assert.ok(!k15.attachments || k15.attachments.length === 0) + assert.doesNotMatch(k15.text, /Vertragsunterlagen|Deine Zustimmungen|hängen als PDF|\/rechtliches\//) + assert.doesNotMatch(k15.html, /\/rechtliches\//) + const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')) + assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/) +}) + +test('k15_links_versioniert: die Adressen der drei Unterlagen tragen die Fassung, keine Adresse ohne Fassung', async () => { + const { k15 } = await bezahleTermine() + const adressen = legalPaths('termine', V) + for (const adresse of Object.values(adressen)) { + assert.ok(adresse.endsWith(`-${V}.html`)) + assert.ok(k15.text.includes(`${BASE}${adresse}`), `Text enthält ${adresse}`) + assert.ok(k15.html.includes(`href="${BASE}${adresse}"`), `HTML verlinkt ${adresse}`) + } + assert.ok(k15.text.includes(`Fassung ${V}: Leistungsbeschreibung`)) + for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) { + assert.doesNotMatch(k15.text, new RegExp(`/rechtliches/${slug}\\.html`), `${slug}: Adresse ohne Fassung`) + assert.doesNotMatch(k15.html, new RegExp(`/rechtliches/${slug}\\.html`)) + } +}) + +test('k15_zustimmungen_text: Wortlaut der vier Haken, Zeitpunkt (Berlin) und Knopf aus dem Nachweis zur bezahlten Sitzung', async () => { + const { k15 } = await bezahleTermine() + assert.ok(k15.text.includes('Deine Zustimmungen')) + assert.ok(k15.text.includes('Du hast am 05.10.2026 um 10:15 Uhr im Kundenportal zugestimmt und mit „Zahlungspflichtig bestellen · 149 €“ bestellt:'), k15.text) + for (const item of T.consent.items) assert.ok(k15.text.includes(`– ${item.text}`), `Haken ${item.id} im Text`) + assert.ok(k15.text.includes('(§ 356 Abs. 5 Nr. 2 BGB)')) + assert.doesNotMatch(k15.text, /Widerrufsrecht\n\nSie haben das Recht/, 'die Belehrung selbst steht als PDF im Anhang, nicht im Text') + // ohne Nachweis (Altbestand) entfällt nur der Block + const ohne = await bezahleTermine({ nachweis: false }) + assert.doesNotMatch(ohne.k15.text, /Deine Zustimmungen/) + assert.match(ohne.k15.text, /Deine Vertragsunterlagen/) + assert.equal(ohne.k15.attachments.length, 3) +}) + +test('k15_anhang_nicht_lesbar: nicht lesbare PDFs => K15 ohne Anhang, T16 mit Block anhang_fehlt, Dokument setup mit Ticket', async () => { + const leer = mkdtempSync(join(tmpdir(), 'k15-leer-')) + const log = stumm() + try { + setLegalDirForTests(leer) + const { k15, k15Anzahl, t16, doc } = await bezahleTermine() + assert.equal(k15Anzahl, 1, 'die Bestätigung geht vor dem Anhang raus') + assert.ok(!k15.attachments || k15.attachments.length === 0) + assert.match(k15.text, /Deine Vertragsunterlagen/, 'die versionierten Links stehen trotzdem in der Mail') + assert.doesNotMatch(k15.text, /hängen als PDF/, 'kein Satz über Anhänge, die es nicht gibt') + assert.match(k15.text, /Deine Zustimmungen/) + assert.ok(t16.text.includes('Unterlagen von Hand nachsenden'), t16.text) + // die Zahlung hat Spur: Dokument in Einrichtung, Ticket angelegt, kein Wurf (der Webhook antwortet 200) + assert.equal(doc.state, 'setup') + assert.ok(doc.ticketId) + assert.ok(aw.docs('workorders').has(doc.ticketId)) + assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('K15-Anhaenge') || String(c.arguments).includes('nicht lesbar'))) + } finally { + setLegalDirForTests(null) + log.mock.restore() + rmSync(leer, { recursive: true, force: true }) + } + // Fassung zwischen Klick und Zahlung angehoben: andere Unterlagen als die bestellten gehen nie raus + const log2 = stumm() + try { + const alt = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-10-02' } }) + assert.ok(!alt.k15.attachments || alt.k15.attachments.length === 0) + assert.doesNotMatch(alt.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//) + assert.match(alt.k15.text, /Deine Zustimmungen/) + assert.ok(alt.t16.text.includes('Unterlagen von Hand nachsenden')) + assert.equal(alt.doc.state, 'setup') + } finally { + log2.mock.restore() + } + // Kunde ohne E-Mail-Adresse: keine K15, aber T16 sagt es dem Team + aw.seed('customers', { $id: 'c9', name: 'Ohne Adresse', email: '' }) + const ohneAdresse = await bezahleTermine({ customerId: 'c9' }) + assert.equal(ohneAdresse.k15Anzahl, 0) + assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden')) +}) + +test('checkout_503_ohne_unterlagen: fehlen die PDFs der aktuellen Fassung, 503 unterlagen_fehlen vor jeder Stripe-Sitzung, ohne ordered-Dokument', async () => { + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc })) + const modus = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails } + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] + T.bestellpruefung = async () => [] + const body = () => ({ projectId: 'p1', zustimmung: { version: T.consent.version, haken: T.consent.items.map((i) => i.id) } }) + const sitzungen = () => st.checkoutSessions.size + const termineDokumente = () => [...aw.docs('portalFeatures').values()].filter((d) => d.featureKey === 'termine') + const nachweise = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'termine') + try { + const vorher = { sitzungen: sitzungen(), docs: termineDokumente().length, nachweise: nachweise().length } + // (a) neue Fassung ohne Dateien + const alt = config.termine.avvVersion + config.termine.avvVersion = '2099-01-01' + try { + const res = await post('/api/funktionen/termine/checkout', body(), { cookie }) + assert.equal(res.status, 503, JSON.stringify(res.body)) + assert.equal(res.body.code, 'unterlagen_fehlen') + assert.equal(res.body.error, 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.') + assert.equal(res.body.url, undefined) + } finally { + config.termine.avvVersion = alt + } + // (b) Verzeichnis ohne Dateien + const leer = mkdtempSync(join(tmpdir(), 'checkout-leer-')) + try { + setLegalDirForTests(leer) + const res = await post('/api/funktionen/termine/checkout', body(), { cookie }) + assert.equal(res.status, 503) + assert.equal(res.body.code, 'unterlagen_fehlen') + } finally { + setLegalDirForTests(null) + rmSync(leer, { recursive: true, force: true }) + } + assert.deepEqual({ sitzungen: sitzungen(), docs: termineDokumente().length, nachweise: nachweise().length }, vorher, 'keine Stripe-Sitzung, kein Dokument, kein Nachweis') + assert.equal(termineDokumente().filter((d) => d.state === 'ordered').length, 0) + // (c) mit den ausgelieferten Dateien geht dieselbe Bestellung durch + const ok = await post('/api/funktionen/termine/checkout', body(), { cookie }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal(sitzungen(), vorher.sitzungen + 1) + assert.equal(termineDokumente().filter((d) => d.state === 'ordered').length, 1) + } finally { + delete T.bestellpruefung + config.termine.mode = modus.mode + config.termine.pilotEmails = modus.pilotEmails + } +}) + +test('bestellseite_statisch: Belehrung als Text im nicht einklappbaren Bereich, Links mit target="_blank" und rel="noopener noreferrer"', () => { + const quelle = lesen('public/funktion-bestellen.js') + const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') + assert.doesNotMatch(code, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|createContextualFragment/, 'kein Markup aus Text') + assert.doesNotMatch(code, / { assert.equal(body.consentText, body.features.news.consent.items[0].text) }) +// Vertragsunterlagen in der Liste (Task F8): Fassung, absolute versionierte Links und die Widerrufsbelehrung des Terminplaners +test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Belehrung der Funktionen; News null', async () => { + const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js') + const { legalPaths } = await import('../server/services/legalDocs.js') + const V = config.termine.avvVersion + const body = await get('/api/funktionen', { cookie }) + assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine']) + assert.equal(body.legal.news, null) + const termine = body.legal.termine + assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version']) + assert.equal(termine.version, V) + assert.deepEqual(Object.keys(termine.links), ['lb', 'avv', 'widerruf']) + const adressen = legalPaths('termine', V) + for (const [doc, link] of Object.entries(termine.links)) { + assert.match(link, /^https:\/\//, `${doc}: https`) + assert.ok(link.endsWith(`-${V}.html`), `${doc}: versioniert`) + assert.equal(link, `${config.publicBaseUrl}${adressen[doc]}`) + } + assert.equal(termine.belehrung, widerrufsbelehrungFunktion()) + assert.match(termine.belehrung, /Muster-Widerrufsformular/) + // dieselben Werte wie die Register-Ansicht der Bestellseite: Fassung der Widerrufsbelehrung und Adresse + assert.deepEqual(body.features.termine.widerruf, { version: V, url: adressen.widerruf }) + assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv }) + assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb }) + assert.deepEqual(body.features.news.widerruf, null) +}) + test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => { const vorher = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails } try { diff --git a/test/legalDocs.test.js b/test/legalDocs.test.js index 0fb5ef1..4076901 100644 --- a/test/legalDocs.test.js +++ b/test/legalDocs.test.js @@ -1,6 +1,8 @@ -import { test, after } from 'node:test' +import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs' +import fsp from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Router } from 'express' @@ -9,7 +11,7 @@ import { useTestEnv } from './support/testEnv.js' useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { startTestApp } = await import('./support/testApp.js') -const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, legalRouter } = await import( +const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, legalRouter, setLegalDirForTests } = await import( '../server/services/legalDocs.js' ) const analyse = await import('../server/services/analyseLegal.js') @@ -125,6 +127,79 @@ test('featureLegalAttachments("termine") liest die Fassung aus config.termine.av }) }) +// R114 (Task F8): featureLegalReady läuft bei jeder Bestellung (Stufe 5b des Checkouts) und prüft je PDF nur stat (Größe > 0) und die +// ersten fünf Bytes "%PDF-", nie den ganzen Inhalt. Die Dateien liegen für den Test in einem Verzeichnis mit Kopien. +test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inhalt lesen; jeder Mangel und jeder Fehler ergibt false, nie einen Wurf', async () => { + const kopien = mkdtempSync(join(tmpdir(), 'legal-ready-')) + mkdirSync(join(kopien, 'rechtliches')) + // Quellen: die ausgelieferten Dateien (vor dem Tausch des Verzeichnisses ermittelt) + const echteHtml = legalPaths('termine', V) + const echtePdf = Object.fromEntries(Object.entries(legalPdfFiles('termine', V)).map(([doc, f]) => [doc, f.path])) + const bereit = () => { + for (const adresse of Object.values(echteHtml)) copyFileSync(root(`public${adresse}`), join(kopien, adresse)) + for (const pfad of Object.values(echtePdf)) copyFileSync(pfad, join(kopien, 'rechtliches', pfad.split('/').at(-1))) + } + const pdfPfad = (doc) => join(kopien, 'rechtliches', echtePdf[doc].split('/').at(-1)) + const htmlPfad = (doc) => join(kopien, echteHtml[doc]) + const lesen = mock.method(fsp, 'readFile') + syncBuiltinESMExports() + try { + setLegalDirForTests(kopien) + bereit() + assert.equal(await featureLegalReady('termine'), true) + assert.equal(lesen.mock.callCount(), 0, 'readFile wird nicht aufgerufen') + + // ein riesiges PDF mit gültigem Kopf (nur Loch im Dateisystem): bereit, ohne es zu lesen + const gross = openSync(pdfPfad('lb'), 'w') + writeSync(gross, '%PDF-1.4\n') + closeSync(gross) + await fsp.truncate(pdfPfad('lb'), 512 * 1024 * 1024) + assert.equal(await featureLegalReady('termine'), true) + assert.equal(lesen.mock.callCount(), 0, 'auch ein 512-MB-PDF wird nicht gelesen') + bereit() + + // Mängel je Datei: fehlend, leer, falscher Kopf, zu kurz, Verzeichnis statt Datei + for (const doc of ['lb', 'avv', 'widerruf']) { + const pfad = pdfPfad(doc) + rmSync(pfad) + assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF fehlt`) + writeFileSync(pfad, '') + assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF leer`) + writeFileSync(pfad, 'kein PDF') + assert.equal(await featureLegalReady('termine'), false, `${doc}: kein %PDF-`) + writeFileSync(pfad, '%PDF') + assert.equal(await featureLegalReady('termine'), false, `${doc}: zu kurz`) + rmSync(pfad) + mkdirSync(pfad) + assert.equal(await featureLegalReady('termine'), false, `${doc}: Verzeichnis statt Datei`) + rmSync(pfad, { recursive: true }) + bereit() + assert.equal(await featureLegalReady('termine'), true, `${doc}: wieder bereit`) + // die HTML-Seite fehlt + rmSync(htmlPfad(doc)) + assert.equal(await featureLegalReady('termine'), false, `${doc}: HTML fehlt`) + bereit() + } + assert.equal(lesen.mock.callCount(), 0, 'nie readFile') + // ohne Verzeichnis, mit unbekannter Funktion und mit unbekannter Fassung: false statt Wurf + await mitFassung('1999-01-01', async () => assert.equal(await featureLegalReady('termine'), false)) + assert.equal(await featureLegalReady('gibtsnicht'), false) + setLegalDirForTests(join(kopien, 'gibtsnicht')) + assert.equal(await featureLegalReady('termine'), false) + // die Anhänge der Mail lesen dagegen die ganzen Dateien (legalPdfAttachments) + setLegalDirForTests(kopien) + bereit() + assert.equal((await featureLegalAttachments('termine')).length, 3) + assert.ok(lesen.mock.callCount() >= 3) + } finally { + setLegalDirForTests(null) + mock.restoreAll() + syncBuiltinESMExports() + rmSync(kopien, { recursive: true, force: true }) + } + assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit') +}) + test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => { assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/) assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index ab29d6c..a35d610 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -74,8 +74,9 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => { const k15 = loadTemplate('K15') assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}') - assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url']) - assert.deepEqual(k15.blocks, ['rechnung']) + // F8: fassung, lb_url, avv_url, widerruf_url, zustimmungen_text und die Blöcke anlagen (mit anhang) und zustimmungen für Funktionen mit Rechtsdokumenten + assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url', 'fassung', 'lb_url', 'avv_url', 'widerruf_url', 'zustimmungen_text']) + assert.deepEqual(k15.blocks, ['rechnung', 'anlagen', 'anhang', 'zustimmungen']) const k16 = loadTemplate('K16') assert.equal(k16.subject, '{{funktion_dein}} ist bereit') assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url']) @@ -89,6 +90,21 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text assert.ok(mail.text.includes(part), `K15 enthält: ${part}\n---\n${mail.text}`) } assert.doesNotMatch(renderMail('K15', sampleValues(k15), { rechnung: false }).text, /Rechnung ansehen/) + // Vertragsunterlagen (F8): anlagen mit den drei versionierten Adressen, der PDF-Satz nur mit anhang, die Zustimmungen mehrzeilig + const werte = { ...sampleValues(k15), fassung: '2026-10-05', lb_url: 'https://p.example/rechtliches/lb-2026-10-05.html', avv_url: 'https://p.example/rechtliches/avv-2026-10-05.html', widerruf_url: 'https://p.example/rechtliches/w-2026-10-05.html', zustimmungen_text: 'Du hast zugestimmt:\n– Erstens\n– Zweitens' } + const aus = renderMail('K15', werte, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false }) + assert.doesNotMatch(aus.text, /Vertragsunterlagen|Zustimmungen|Fassung 2026|hängen als PDF/) + const ohneAnhang = renderMail('K15', werte, { anlagen: true, anhang: false, zustimmungen: false }) + assert.deepEqual(ohneAnhang.problems, []) + for (const part of ['Deine Vertragsunterlagen', 'Fassung 2026-10-05: Leistungsbeschreibung (https://p.example/rechtliches/lb-2026-10-05.html)', 'Auftragsverarbeitungsvertrag (https://p.example/rechtliches/avv-2026-10-05.html)', 'Widerrufsbelehrung mit Muster-Widerrufsformular (https://p.example/rechtliches/w-2026-10-05.html)']) { + assert.ok(ohneAnhang.text.includes(part), `K15 anlagen enthält: ${part}\n---\n${ohneAnhang.text}`) + } + assert.doesNotMatch(ohneAnhang.text, /hängen als PDF|Deine Zustimmungen/) + const mitAnhang = renderMail('K15', werte, { anlagen: true, anhang: true, zustimmungen: true }) + assert.match(mitAnhang.text, /Alle drei hängen als PDF an dieser E-Mail\. Bitte bewahre sie auf\./) + assert.ok(mitAnhang.text.includes('Deine Zustimmungen\nDu hast zugestimmt:\n– Erstens\n– Zweitens'), mitAnhang.text) + // anhang steht nur innerhalb von anlagen + assert.doesNotMatch(renderMail('K15', werte, { anlagen: false, anhang: true }).text, /hängen als PDF/) const bereit = renderMail('K16', sampleValues(k16), {}) assert.deepEqual(bereit.problems, []) assert.equal(bereit.subject, 'Wert-funktion_dein ist bereit') @@ -101,7 +117,7 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => { const t16 = loadTemplate('T16') assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}') - assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit']) + assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'anhang_fehlt']) const t19 = loadTemplate('T19') assert.equal(t19.file, 'T19 Erstattung faellig.html') assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)