Merge auftraege/l5-bestaetigung: Bestätigung und Mails (Strang L5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:21:11 +00:00
commit 5d5f6333a4
21 files changed
+2040 -30

No files matched your search

@@ -8,7 +8,8 @@
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: E-Mail aus dem Formular · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen, projekt, projekt_unternehmen
projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen): wir prüfen und melden uns, ohne Zusagen zu Erstattung oder Wertersatz; projekt_unternehmen (nur innerhalb von projekt) = die Auftragsbestätigung gehört zu einem Unternehmen, der Zusatz nennt, dass kein gesetzliches Widerrufsrecht besteht.
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -56,6 +57,9 @@ a{color:#111111;}
<!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
<!-- ENDE pruefen -->
<!-- WENN projekt -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.<!-- WENN projekt_unternehmen --> Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.<!-- ENDE projekt_unternehmen --></div></td></tr></table></td></tr>
<!-- ENDE projekt -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K31 · Auftragsbestätigung (Projektauftrag; Variante Erinnerung, Variante Privatperson, Kopie für das Team)
Betreff: Ihre Auftragsbestätigung {{nummer}} – {{titel}}
Absender: „WEBklar“ <Portal-Postfach>; die Kopie für das Team kommt als „WEBklar Kundenportal“
Empfänger: Kunde (E-Mail der Auftragsbestätigung), ohne BCC · Kopie ohne Link an kenso@webklar.com, andrej@webklar.com
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{datum}}, {{betrag}}, {{link}}, {{gueltig_bis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung, privat, festpreis, aufwand, teamkopie, kundenmail
nummer = Nummer der Auftragsbestätigung (AB-JJJJ-NNNN), titel = Projekt bzw. Titel des Auftrags, datum = Tag des Sendens (Berliner Datum, TT.MM.JJJJ).
betrag = Gesamtpreis bei Festpreis (zum Beispiel 800,00 €) bzw. Stundensatz bei Aufwand (zum Beispiel 100,00 € pro Stunde); genau einer der Blöcke festpreis und aufwand ist an.
link = Bestätigungsseite mit dem persönlichen Link (…/auftrag.html#t=…); er steht nur in der Mail an den Kunden und ist in der Kopie für das Team leer. gueltig_bis = letzter Tag, an dem der Link gilt (TT.MM.JJJJ).
erinnerung = Erinnerung des Teams: dieselbe Auftragsbestätigung mit neuem Link, der frühere Link gilt nicht mehr.
privat = der Kunde ist Privatperson: Hinweis auf Widerrufsrecht, Muster-Widerrufsformular (in der angehängten Auftragsbestätigung) und den Beginn der Arbeiten; nicht in der Kopie für das Team.
kundenmail = die Mail an den Kunden: Knopf mit dem Link und Hinweis auf die Anhänge (die Auftragsbestätigung als PDF, <Nummer>.pdf, und die Allgemeinen Geschäftsbedingungen als PDF, WEBklar-AGB-<Fassung>.pdf).
teamkopie = die Kopie für das Team: statt des Knopfs der Hinweis, dass der Bestätigungslink nur in der Mail an den Kunden steht (ohne Link, ohne Anhänge). Genau einer der Blöcke kundenmail und teamkopie ist an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Auftragsbestätigung {{nummer}} – {{titel}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Auftrag</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Auftragsbestätigung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihre Auftragsbestätigung {{nummer}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihren Auftrag. Anbei erhalten Sie die Auftragsbestätigung {{nummer}} zum Projekt „{{titel}}“. Bitte prüfen Sie sie und bestätigen Sie den Auftrag; eine Anmeldung ist nicht nötig.</td></tr>
<!-- WENN erinnerung -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Erinnerung</div><div class="mut" style="color:#4a4843;">Wir haben Ihnen diese Auftragsbestätigung am {{datum}} schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.</div></td></tr></table></td></tr>
<!-- ENDE erinnerung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}} vom {{datum}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><!-- WENN festpreis --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Gesamtpreis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span></td></tr><!-- ENDE festpreis --><!-- WENN aufwand --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Vergütung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag}}</strong> <span class="mut" style="color:#6b6964;">(nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)</span></td></tr><!-- ENDE aufwand --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Bestätigung möglich bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{gueltig_bis}}</td></tr></table></td></tr>
<!-- WENN kundenmail -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auftrag bestätigen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE kundenmail -->
<!-- WENN teamkopie -->
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.</td></tr>
<!-- ENDE teamkopie -->
<!-- WENN kundenmail -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Anhänge</div><div class="mut" style="color:#4a4843;">Die Auftragsbestätigung ({{nummer}}.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail. Bitte bewahren Sie beide auf. Den Auftrag können Sie auch in Ihrem Kundenportal ansehen und bestätigen.</div></td></tr></table></td></tr>
<!-- ENDE kundenmail -->
<!-- WENN privat -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihr Widerrufsrecht</div><div class="mut" style="color:#4a4843;">Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung; widerrufen können Sie auch über das <a href="https://project.webklar.com/widerrufen.html" style="color:#111111;">Widerrufsformular auf unserer Website</a>. Mit der Arbeit beginnen wir erst nach Ablauf der Widerrufsfrist, es sei denn, Sie verlangen beim Bestätigen ausdrücklich einen früheren Beginn.</div></td></tr></table></td></tr>
<!-- ENDE privat -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Bei Fragen antworten Sie einfach auf diese E-Mail.<br>Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K32 · Auftrag bestätigt (Eingangsbestätigung der Bestätigung; Variante Privatperson mit oder ohne früheren Beginn)
Betreff: Ihre Bestätigung zu {{nummer}} ist eingegangen
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail der Auftragsbestätigung) · BCC intern
Platzhalter: {{name}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{fruehester_beginn}}, {{fruehbeginn_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): fruehbeginn, ohne_fruehbeginn
zeitpunkt = Zeitpunkt der Bestätigung (TT.MM.JJJJ, HH:MM, Berliner Zeit). fruehester_beginn = frühester Arbeitsbeginn ohne ausdrücklichen Wunsch (TT.MM.JJJJ, Tag nach Ablauf der Widerrufsfrist), nur im Block ohne_fruehbeginn.
fruehbeginn = die Privatperson hat ausdrücklich verlangt, dass die Arbeit schon vor Ablauf der Widerrufsfrist beginnt; fruehbeginn_text = Wortlaut ihrer Erklärung (FRUEHBEGINN_TEXT aus server/services/auftraege/texte.js), nur in diesem Block, ohne weitere Sätze. ohne_fruehbeginn = sie hat das nicht verlangt. Bei Unternehmen ist keiner der beiden Blöcke an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihre Bestätigung zu {{nummer}} ist eingegangen</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Wir haben Ihre Bestätigung zu {{nummer}} erhalten.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Auftrag bestätigt</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Vielen Dank für Ihre Bestätigung</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wir haben Ihre Bestätigung zu {{nummer}} am {{zeitpunkt}} Uhr erhalten. Bitte bewahren Sie diese E-Mail auf.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Bestätigt am</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeitpunkt}} Uhr</td></tr></table></td></tr>
<!-- WENN fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihre Erklärung zum früheren Beginn</div><div class="mut" style="color:#4a4843;">{{fruehbeginn_text}}</div></td></tr></table></td></tr>
<!-- ENDE fruehbeginn -->
<!-- WENN ohne_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Beginn der Arbeiten</div><div class="mut" style="color:#4a4843;">Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am {{fruehester_beginn}} mit der Arbeit.</div></td></tr></table></td></tr>
<!-- ENDE ohne_fruehbeginn -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -9,7 +9,8 @@
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer).
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen, projekt
projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen), ohne Abo und ohne Vertragsereignis: ereignisse = Ergebnis der Prüfung der Auftragsbestätigung, fristende = Ende der Widerrufsfrist (TT.MM.JJJJ, 24 Uhr), rechtzeitig = Eingang (RECHTZEITIG, NACH Fristende, ungeprüft …); die Zeile „Abo“ zeigt nur die Vertragsart.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -64,6 +65,9 @@ a{color:#111111;}
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten, gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), sofern der Kunde den Beginn vor Fristende ausdrücklich verlangt hat, und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Beim Terminplaner bleibt das Wartungs-Abo unberührt. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Bestellungen dieser Funktion (je Projekt mit eigenem Fristende):<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN projekt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf eines Projektauftrags. Geprüft wurde nur die Auftragsbestätigung (kein Abo, kein Vertragsereignis); die Zeile „Abo“ oben nennt nur die Vertragsart.<br>Ergebnis: {{ereignisse}}<br>Widerrufsfrist bis: {{fristende}}<br>Eingang: <strong>{{rechtzeitig}}</strong><br>Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).</div></td></tr></table></td></tr>
<!-- ENDE projekt -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
<!-- ENDE keine_frist -->
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T22 · Auftrag bestätigt (Hinweis ans Team)
Betreff: [Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde}}, {{nummer}}, {{titel}}, {{zeitpunkt}}, {{ueber}}, {{woms_link}}, {{fruehester_beginn}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): privat_fruehbeginn, privat_ohne_fruehbeginn
kunde = Firma, sonst Name des Kunden. ueber = Weg der Bestätigung (Bestätigungslink oder Kundenportal). zeitpunkt = TT.MM.JJJJ, HH:MM, Berliner Zeit. woms_link = Aufträge und Rechnungen des Kunden im Ticketsystem.
privat_fruehbeginn = Privatperson, früherer Beginn ausdrücklich verlangt (die Arbeit darf sofort starten). privat_ohne_fruehbeginn = Privatperson ohne diesen Wunsch (Arbeitsbeginn erst ab fruehester_beginn, TT.MM.JJJJ). Bei Unternehmen ist keiner der beiden Blöcke an.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde}}: {{nummer}} bestätigt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Aufträge</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Auftrag bestätigt</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Auftrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{nummer}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{titel}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Bestätigt am</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeitpunkt}} Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Weg</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{ueber}}</td></tr></table></td></tr>
<!-- WENN privat_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Privatperson: früher Beginn verlangt</div><div class="mut" style="color:#4a4843;">Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).</div></td></tr></table></td></tr>
<!-- ENDE privat_fruehbeginn -->
<!-- WENN privat_ohne_fruehbeginn -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Privatperson: kein früher Beginn</div><div class="mut" style="color:#4a4843;">Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am {{fruehester_beginn}}, dem Tag nach Ablauf der Widerrufsfrist.</div></td></tr></table></td></tr>
<!-- ENDE privat_ohne_fruehbeginn -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{woms_link}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+61 -13
View File
@@ -5,6 +5,8 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
@@ -727,7 +729,8 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten', 'projekt']
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
@@ -735,7 +738,7 @@ const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspe
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten' | 'projekt'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
@@ -764,6 +767,7 @@ function validDeclaration(body) {
contract,
order,
packages,
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
}
}
@@ -804,7 +808,8 @@ function contractLabel(contract, sub) {
}
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
function orderLabel(order, sub, { customer = false } = {}) {
function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
@@ -1147,6 +1152,37 @@ async function withdrawFeature(d, receivedAt, featureKey) {
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
async function withdrawProjekt(d, receivedAt) {
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
}
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
const ergebnisText = {
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
nach_frist: () => 'nichts geändert',
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
}[ergebnis]
return {
ereignisse: ergebnisText
? ergebnisText()
: AB_NUMMER.test(abNummer)
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
}
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
function describeWithdrawn(events) {
return events
@@ -1168,10 +1204,14 @@ router.post('/public/withdraw', async (req, res) => {
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null
const projekt = d.order === 'projekt'
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try {
if (feature) {
if (projekt) {
pr = await withdrawProjekt(d, receivedAt)
} else if (feature) {
fn = await withdrawFeature(d, receivedAt, featureKey)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else {
@@ -1183,8 +1223,11 @@ router.post('/public/withdraw', async (req, res) => {
}
} catch (err) {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
}
const { inTime } = result
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
// Fuer T09: warum sich die Frist nicht pruefen liess
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
@@ -1192,8 +1235,8 @@ router.post('/public/withdraw', async (req, res) => {
template: 'K04',
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }),
produkt: feature ? funktion.label.name : productLabelOf(sub),
bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),
produkt: produktText,
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
@@ -1204,7 +1247,9 @@ router.post('/public/withdraw', async (req, res) => {
wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature,
pruefen: !inTime && !feature,
pruefen: !inTime && !feature && !projekt,
projekt,
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
},
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
@@ -1215,13 +1260,15 @@ router.post('/public/withdraw', async (req, res) => {
email: d.email,
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub),
produkt: feature ? funktion.label.name : productLabelOf(sub),
bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),
produkt: produktText,
preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: feature
fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: projekt
? projektAngaben.rechtzeitig
: feature
? !fn.customer
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
: !fn.orders.length
@@ -1242,14 +1289,14 @@ router.post('/public/withdraw', async (req, res) => {
: result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),
pruefhinweis,
},
blocks: {
kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: result.canceled,
kein_abo_gefunden: !sub && !feature,
kein_abo_gefunden: !sub && !feature && !projekt,
kuendigung_fehlgeschlagen: result.cancelFailed,
abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel',
@@ -1261,6 +1308,7 @@ router.post('/public/withdraw', async (req, res) => {
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
projekt,
},
})
+81 -4
View File
@@ -1,12 +1,89 @@
/**
* Gerüst von Strang L0: Aufträge des angemeldeten Kunden. Mount: /api/auftraege. requireSession schützt jede später
* ergänzte Route (fail-closed). L5 ergänzt GET /, GET /:id/pdf, POST /:id/bestaetigen; das Bestätigen sperrt der
* Wächter der Kundenansicht von selbst, es kommt kein Eintrag in KUNDENANSICHT_ALLOWED.
* Aufträge des angemeldeten Kunden (Übersicht V3/V5, Strang L5). Mount: /api/auftraege. requireSession schützt jede
* Route (fail-closed). Ein Kunde sieht und bestätigt nur eigene, gesendete Auftragsbestätigungen (keine Entwürfe);
* in der Kundenansicht ist Bestätigen nie angeboten, und der Wächter der Kundenansicht sperrt den POST von selbst
* (es gibt keinen Eintrag in KUNDENANSICHT_ALLOWED).
*/
import { Router } from 'express'
import { requireSession } from '../middleware/session.js'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { getAuftrag, listAuftraege } from '../services/auftraege/auftragStore.js'
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js'
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
import { sendePdf } from './auftrag.js'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const router = Router()
router.use(requireSession)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const ansicht = (a, req) => ({
...oeffentlicheAnsicht(a),
id: a.id,
kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)),
})
/** Eigene, gesendete AB laden; sonst ist 404 schon gesendet und das Ergebnis `null`. */
async function ladeEigene(req, res) {
const nichtGefunden = () => {
res.status(404).json({ error: NICHT_GEFUNDEN })
return null
}
if (!ID_RE.test(String(req.params.id))) return nichtGefunden()
const kundenId = getSessionCustomerId(req)
const kunde = await getDocument(config.collections.customers, kundenId)
if (!auftraegeAktiv(kunde)) return nichtGefunden()
const a = await getAuftrag(req.params.id)
if (!a || a.kundenId !== kundenId || a.status === 'entwurf') return nichtGefunden()
return a
}
router.get('/', async (req, res) => {
try {
const kundenId = getSessionCustomerId(req)
const kunde = await getDocument(config.collections.customers, kundenId)
if (!auftraegeAktiv(kunde)) return res.json({ aktiv: false, auftraege: [] })
const liste = await listAuftraege({ kundenId })
res.json({ aktiv: true, auftraege: liste.filter((a) => a.status !== 'entwurf').map((a) => ansicht(a, req)) })
} catch (err) {
sendeFehler(res, 'auftraege', err)
}
})
router.get('/:id/pdf', async (req, res) => {
try {
const a = await ladeEigene(req, res)
if (a) await sendePdf(res, a)
} catch (err) {
sendeFehler(res, 'auftraege', err)
}
})
router.post('/:id/bestaetigen', async (req, res) => {
try {
const a = await ladeEigene(req, res)
if (!a) return
if (GESPERRT_MELDUNG[a.status]) return res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
const body = req.body && typeof req.body === 'object' ? req.body : {}
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
const neu = await bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true })
res.json({ auftrag: ansicht(neu, req) })
} catch (err) {
if (err instanceof AuftragError && err.status === 409 && Object.values(GESPERRT_MELDUNG).includes(err.message)) {
return res.status(410).json({ error: err.message })
}
sendeFehler(res, 'auftraege', err)
}
})
export default router
+111 -3
View File
@@ -1,10 +1,118 @@
/**
* Gerüst von Strang L0: öffentliche Bestätigung einer Auftragsbestätigung per Token (ohne Anmeldung, das Token steht
* in der Adresse). Mount: /api/auftrag. Kein Sitzungs- und kein Wächter-Eintrag nötig. L5 ergänzt die Ratenbegrenzung
* im Arbeitsspeicher (keine IP-Adressen speichern) und GET /:token, GET /:token/pdf, POST /:token/bestaetigen.
* Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne
* Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag.
* Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder
* protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404).
*/
import { Router } from 'express'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js'
import { lesePdf } from '../services/auftraege/ablage.js'
import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js'
import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js'
import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js'
import { auftraegeAktiv } from '../services/auftraege/schalter.js'
import { hashToken } from '../services/auftraege/token.js'
const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'
const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.'
const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.'
const echteUhr = () => new Date()
let uhr = echteUhr
const jetztMs = () => uhr().getTime()
const getLimiter = createWriteLimiter({ max: 120, now: jetztMs })
const postLimiter = createWriteLimiter({ max: 10, now: jetztMs })
const nachIp = (req) => String(req.ip)
/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */
export function setzeUhrFuerTests(fn) {
uhr = typeof fn === 'function' ? fn : echteUhr
}
/** Nur für Tests: Zähler leeren und echte Uhr. */
export function resetAuftragRoutesForTests() {
getLimiter.reset()
postLimiter.reset()
uhr = echteUhr
}
const router = Router()
router.use((_req, res, next) => {
res.set({
'Cache-Control': 'no-store',
'X-Robots-Tag': 'noindex',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
})
next()
})
/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */
async function ladeNachToken(req, res) {
const nichtGueltig = () => {
res.status(404).json({ error: UNGUELTIG })
return null
}
const hash = hashToken(req.params.token)
if (hash === null) return nichtGueltig()
const a = await findAuftragByTokenHash(hash)
if (!a) return nichtGueltig()
if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig()
if (a.status === 'entwurf') return nichtGueltig()
if (GESPERRT_MELDUNG[a.status]) {
res.status(410).json({ error: GESPERRT_MELDUNG[a.status] })
return null
}
const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN
if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig()
return a
}
/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */
export async function sendePdf(res, auftrag) {
let buffer
try {
buffer = await lesePdf(auftrag.pdfDatei)
} catch (err) {
if (err instanceof AuftragError) throw err
console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`)
throw new AuftragError(404, 'PDF nicht gefunden.')
}
const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung'
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` })
res.end(buffer)
}
router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (a) res.json({ auftrag: oeffentlicheAnsicht(a) })
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (a) await sendePdf(res, a)
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => {
try {
const a = await ladeNachToken(req, res)
if (!a) return
const body = req.body && typeof req.body === 'object' ? req.body : {}
if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN })
const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() })
res.json({ auftrag: oeffentlicheAnsicht(neu) })
} catch (err) {
sendeFehler(res, 'auftrag', err)
}
})
export default router
+76
View File
@@ -0,0 +1,76 @@
/**
* Bestätigen und Widerrufen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`,
* Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen
* nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts.
*/
import { withdrawalDeadline } from '../deadlines.js'
import { withLocks } from '../keyedLock.js'
import { getAuftrag, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { sperreAuftrag } from './konstanten.js'
import { sendeBestaetigtMails } from './mails.js'
import { FRUEHBEGINN_FASSUNG } from './texte.js'
export const GESPERRT_MELDUNG = {
ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.',
storniert: 'Diese Auftragsbestätigung wurde storniert.',
widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.',
}
const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.'
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen']
/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */
export function bestaetigungsLage(auftrag) {
if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt'
return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen'
}
function pruefeZeitpunkt(jetzt) {
if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein')
}
export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) {
// synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst
if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein')
pruefeZeitpunkt(jetzt)
const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const lage = bestaetigungsLage(frisch)
if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR)
if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false }
const privat = frisch.kunde?.kundenart === 'privat'
const patch = {
bestaetigtAm: jetzt.toISOString(),
bestaetigtUeber: ueber,
bestaetigtPdfSha256: frisch.pdfSha256,
fruehbeginn: privat && fruehbeginn === true,
fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '',
}
if (frisch.status === 'versendet') patch.status = 'bestaetigt'
return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true }
})
if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell)
return aktuell
}
/**
* Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre.
* Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre).
* Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'.
*/
export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) {
pruefeZeitpunkt(jetzt)
return withLocks(sperreAuftrag(auftrag.id), async () => {
const frisch = await getAuftrag(auftrag.id)
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null
const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde })
if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen')
if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht')
if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar')
if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist')
return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() }))
})
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne
* Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der
* Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge.
* Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF).
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../../config.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { lesePdf } from './ablage.js'
import { fruehesterBeginn } from './auftragSicht.js'
import { AGB_FASSUNG } from './konstanten.js'
import { FRUEHBEGINN_TEXT } from './texte.js'
import { hashToken } from './token.js'
// server/services/auftraege/ -> drei Ebenen nach oben = Repo
const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url))
let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS
const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/
/** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */
export function setzeAgbVerzeichnisFuerTests(verzeichnis) {
agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS
}
export function agbPdfPfad(fassung = AGB_FASSUNG) {
if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung')
return path.join(agbVerzeichnis, `agb-${fassung}.pdf`)
}
const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function zeitpunkt(iso) {
const datum = new Date(iso)
if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig')
return datum
}
const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso)))
const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}`
const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents))
const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim()
export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) {
const kunde = auftrag.kunde ?? {}
return {
template: 'K31',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
datum: tagText(auftrag.gesendetAm),
betrag: preisText(auftrag),
// die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird
link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`,
gueltig_bis: tagText(auftrag.tokenGueltigBis),
},
blocks: {
erinnerung: Boolean(erinnerung),
privat: kunde.kundenart === 'privat' && !teamkopie,
festpreis: auftrag.art === 'festpreis',
aufwand: auftrag.art === 'aufwand',
teamkopie: Boolean(teamkopie),
kundenmail: !teamkopie,
},
}
}
function frueherBeginn(auftrag) {
const privat = auftrag.kunde?.kundenart === 'privat'
const tag = fruehesterBeginn(auftrag)
return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' }
}
export function k32Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'K32',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
fruehester_beginn: f.beginnText,
fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '',
},
blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne },
}
}
const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' }
export function t22Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'T22',
values: {
kunde: kunde.firma || kunde.name || kunde.email || '',
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
ueber: UEBER[auftrag.bestaetigtUeber] ?? '',
woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`,
fruehester_beginn: f.beginnText,
},
blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne },
}
}
/**
* K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler
* der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler.
*/
export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) {
const adresse = empfaenger(auftrag)
if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung')
if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format')
const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei)
const fassung = auftrag.agbFassung || AGB_FASSUNG
let agbPdf
try {
agbPdf = await readFile(agbPdfPfad(fassung))
} catch (err) {
throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.message}`)
}
await sendCustomerMail(adresse, {
...k31Mail(auftrag, { token, erinnerung }),
attachments: [
{ filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' },
{ filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' },
],
bccTeam: false,
})
const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true }))
if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet: ${kopie.reason}`)
}
/** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */
export async function sendeBestaetigtMails(auftrag) {
const adresse = empfaenger(auftrag)
if (!adresse) {
console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`)
} else {
try {
await sendCustomerMail(adresse, k32Mail(auftrag))
} catch (err) {
console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`)
}
}
try {
const t22 = await sendAdminNotice(t22Mail(auftrag))
if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet: ${t22.reason}`)
} catch (err) {
console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`)
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen),
* gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand.
* Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js).
*/
import { createHash, randomBytes } from 'node:crypto'
import { TOKEN_GUELTIG_TAGE } from './konstanten.js'
const TAG_MS = 86_400_000
const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
export function neuerToken(jetzt = new Date()) {
if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein')
const token = randomBytes(32).toString('base64url')
return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) }
}
/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */
export function hashToken(token) {
return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null
}
+259
View File
@@ -0,0 +1,259 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js')
after(() => {
setMailTransportForTests(null)
return aw.close()
})
const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ)
const ms = (iso) => new Date(iso).getTime()
let sink
let protokoll
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const seed = (over = {}) => {
const ab = abDokument(over)
aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) })
return ab
}
const betreffe = () => sink.sent.map((mail) => mail.subject)
const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen'
const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
const norm = (text) => text.replace(/\s+/g, ' ').trim()
test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => {
const lage = (over) => bestaetigungsLage(abDokument(over))
assert.equal(lage({}), 'offen')
assert.equal(lage({ status: 'abgerechnet' }), 'offen')
assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status)
})
test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => {
const ab = seed()
const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
assert.equal(neu.status, 'bestaetigt')
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
assert.equal(neu.bestaetigtUeber, 'link')
assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256)
assert.equal(neu.fruehbeginn, true)
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG)
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()])
assert.deepEqual(betreffe(), [K32, T22])
assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder')
assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`))
})
test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => {
const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
assert.equal(neu.bestaetigtUeber, 'portal')
assert.equal(neu.fruehbeginn, false)
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten')
assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026'))
assert.ok(sink.sent[1].text.includes('Weg: Kundenportal'))
})
test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => {
const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
assert.equal(neu.fruehbeginn, false)
assert.equal(neu.fruehbeginnFassung, '')
for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/)
assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
})
test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => {
const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
assert.equal(neu.status, 'abgerechnet')
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
assert.equal((await getAuftrag('ab1')).status, 'abgerechnet')
assert.equal(sink.sent.length, 2)
})
test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => {
const ab = seed()
const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) })
assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm))
assert.equal(zweite.bestaetigtUeber, 'link')
assert.equal(zweite.fruehbeginn, true)
assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung')
})
test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => {
const ab = seed()
const [a, b] = await Promise.all([
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }),
])
assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm))
assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt')
assert.equal(b.bestaetigtUeber, 'link')
assert.equal(sink.sent.length, 2)
})
test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => {
const ab = seed()
let freigeben
const gehalten = new Promise((resolve) => {
freigeben = resolve
})
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
let fertig = false
const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => {
fertig = true
return neu
})
await new Promise((resolve) => setTimeout(resolve, 100))
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
assert.equal((await getAuftrag('ab1')).status, 'versendet')
freigeben()
await halter
assert.equal((await bestaetigt).status, 'bestaetigt')
// Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück)
seed()
sink.sent.length = 0
const frei = []
setMailTransportForTests({
async sendMail(mail) {
frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))]))
return sink.sendMail(mail)
},
})
await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
assert.deepEqual(frei, [true, true])
assert.equal(sink.sent.length, 2)
})
test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => {
const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']]
for (const [status, meldung] of faelle) {
const ab = seed({ status })
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status)
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status)
}
assert.equal(sink.sent.length, 0)
})
test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => {
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
assert.equal(neu.status, 'bestaetigt')
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll')
})
test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => {
const ab = seed()
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError)
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError)
await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404)
assert.equal(sink.sent.length, 0)
})
// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC)
const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z')
test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => {
const ab = seed()
const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE })
assert.equal(ok.ergebnis, 'widerrufen')
assert.equal(ok.auftrag.status, 'widerrufen')
assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime())
assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime())
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
seed()
const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) })
assert.equal(spaet.ergebnis, 'nach_frist')
assert.equal(spaet.auftrag.status, 'versendet')
assert.equal((await getAuftrag('ab1')).widerrufenAm, null)
})
test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => {
const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true })
const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT })
assert.equal(r.ergebnis, 'widerrufen')
assert.equal(r.auftrag.fruehbeginn, true)
assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime())
const firma = seed({ kunde: kunde('unternehmen') })
const k = await widerrufeAuftrag(firma, { jetzt: JETZT })
assert.equal(k.ergebnis, 'kein_widerrufsrecht')
assert.equal((await getAuftrag('ab1')).status, 'versendet')
})
test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => {
const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']]
for (const [status, ergebnis] of faelle) {
const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null })
const r = await widerrufeAuftrag(ab, { jetzt: JETZT })
assert.equal(r.ergebnis, ergebnis, status)
const doc = await getAuftrag('ab1')
assert.equal(doc.status, status, status)
assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status)
}
})
test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => {
const veraltet = seed()
let freigeben
const gehalten = new Promise((resolve) => {
freigeben = resolve
})
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
let fertig = false
const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => {
fertig = true
return r
})
await new Promise((resolve) => setTimeout(resolve, 100))
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3)
freigeben()
await halter
const r = await widerruf
assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre')
assert.equal((await getAuftrag('ab1')).status, 'storniert')
assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails')
})
test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => {
const ab = seed()
const [bestaetigt, widerrufen] = await Promise.all([
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
widerrufeAuftrag(ab, { jetzt: JETZT }),
])
assert.equal(bestaetigt.status, 'bestaetigt')
assert.equal(widerrufen.ergebnis, 'widerrufen')
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
seed()
const umgekehrt = await Promise.allSettled([
widerrufeAuftrag(ab, { jetzt: JETZT }),
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
])
assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen')
assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409)
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
})
+44
View File
@@ -0,0 +1,44 @@
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
import { hashToken } from '../server/services/auftraege/token.js'
// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau.
// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv).
const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes
export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) }
export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) }
export const TOKEN = 'T'.repeat(43)
export const TOKEN_NEU = 'U'.repeat(43)
export const SHA = 'a'.repeat(64)
export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'
export const kunde = (kundenart = 'privat', extra = {}) => ({
name: 'Erika Muster',
firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '',
strasse: 'Hauptstr. 1',
plz: '67722',
ort: 'Winnweiler',
land: 'DE',
email: 'k@example.invalid',
kundenart,
kundennummer: 'K-100',
...extra,
})
/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */
export const abDokument = (over = {}) => ({
id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet',
kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [],
art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '',
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA,
gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '',
tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z',
bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
storniertAm: null, widerrufenAm: null,
erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z',
...over,
})
+143
View File
@@ -0,0 +1,143 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
const app = await startTestApp({ '/api/auftraege': kundeRoutes })
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close()])
})
seedFixtures(aw)
const PDF = '%PDF-1.4 Kunden-AB'
for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF))
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' }
const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' }
const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' }
let sink
const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) })
beforeEach(() => {
config.auftraege.mode = 'on'
config.auftraege.pilotEmails = []
sink = mailSink()
setMailTransportForTests(sink)
for (const over of [{}, ENTWURF, AB3, AB4]) seed(over)
})
const cookie = customerCookie('c1')
const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts)
const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body })
test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => {
const res = await get('')
assert.equal(res.status, 200)
assert.equal(res.body.aktiv, true)
assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4'])
const ab1 = res.body.auftraege.find((a) => a.id === 'ab1')
const ab4 = res.body.auftraege.find((a) => a.id === 'ab4')
assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false])
for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern)
assert.equal(res.headers.get('cache-control'), 'no-store')
})
test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => {
const faelle = [
[{}, true],
[{ status: 'abgerechnet' }, true],
[{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
[{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
[{ status: 'ersetzt' }, false],
[{ status: 'storniert' }, false],
[{ status: 'widerrufen' }, false],
]
for (const [over, erwartet] of faelle) {
seed(over)
const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1')
assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`)
assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over))
}
})
test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => {
for (const opts of [{}, { cookie: adminCookie() }]) {
assert.equal((await get('', opts)).status, 401)
assert.equal((await get('/ab1/pdf', opts)).status, 401)
assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401)
}
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
})
test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => {
config.auftraege.mode = 'off'
assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] })
for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }])
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
config.auftraege.mode = 'pilot'
config.auftraege.pilotEmails = ['k@example.invalid']
assert.equal((await get('')).body.aktiv, true)
assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] })
})
test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => {
const eigene = await get('/ab1/pdf')
assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF])
assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
seed({ status: 'ersetzt' })
assert.equal((await get('/ab1/pdf')).status, 200)
for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) {
const res = await get(`/${id}/pdf`)
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
}
})
test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => {
const vorher = Date.now()
const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true })
assert.equal(res.status, 200)
assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false])
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true])
assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000)
assert.equal(sink.sent.length, 2)
const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false })
assert.deepEqual([zweite.status, zweite.body], [200, res.body])
assert.equal(sink.sent.length, 2)
})
test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => {
for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) {
const res = await post('/ab1/bestaetigen', body)
assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }])
}
for (const id of ['ab3', 'ab2', 'gibtsnicht']) {
const res = await post(`/${id}/bestaetigen`, { bestaetigt: true })
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
}
for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) {
seed({ status })
const res = await post('/ab1/bestaetigen', { bestaetigt: true })
assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status)
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status)
}
assert.equal((await getAuftrag('ab3')).bestaetigtAm, null)
assert.equal(sink.sent.length, 0)
})
+89
View File
@@ -0,0 +1,89 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js'
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp, send } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
const { default: tokenRoutes } = await import('../server/routes/auftrag.js')
// Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen
const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes })
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close()])
})
seedFixtures(aw)
await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test'))
const VIEW = 'a'.repeat(32)
const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } })
let sink
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) })
})
const unveraendert = async () => {
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
}
test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => {
for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) {
assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad)
}
})
test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => {
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } })
assert.equal(res.status, 403)
assert.equal(res.body.code, 'view_blocked')
await unveraendert()
})
test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => {
const schreibRouten = kundeRoutes.stack
.filter((schicht) => schicht.route)
.flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`]))
assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']])
for (const [methode, pfad] of schreibRouten) {
const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } })
assert.equal(res.status, 403, `${methode} ${pfad}`)
}
await unveraendert()
})
test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => {
const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } })
assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked'])
const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } })
assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced'])
await unveraendert()
})
test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => {
const liste = await send('GET', '/api/auftraege', kv())
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]])
assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200)
const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') })
assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]])
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } })
assert.equal(res.status, 200)
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
})
+204
View File
@@ -0,0 +1,204 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort.
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-'))
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const { euroDe } = await import('../server/services/finance/format.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js')
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js')
const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-'))
const AB_PDF = Buffer.from('%PDF-1.4 AB-Test')
const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test')
writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF)
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
after(() => {
setzeAgbVerzeichnisFuerTests(null)
setMailTransportForTests(null)
})
let sink
let protokoll
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const adressen = (liste) => liste.map((a) => a.address)
const AB = abDokument()
const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') }
const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over })
const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' }
test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-<Fassung>.pdf; eine ungültige Fassung wirft', () => {
setzeAgbVerzeichnisFuerTests(null)
try {
const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url))
assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`))
assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf'))
for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung)
} finally {
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
}
})
test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => {
assert.deepEqual(k31Mail(AB, { token: TOKEN }), {
template: 'K31',
values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` },
blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true },
})
// auch wenn man das Token mitgibt: die Teamkopie enthält es nie
assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), {
template: 'K31',
values: { ...WERTE_K31, link: '' },
blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false },
})
const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true })
assert.equal(nachts.values.datum, '07.10.2026')
assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`)
assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true })
})
test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => {
const ja = BESTAETIGT({ fruehbeginn: true })
assert.deepEqual(k32Mail(ja), {
template: 'K32',
values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT },
blocks: { fruehbeginn: true, ohne_fruehbeginn: false },
})
const nein = BESTAETIGT({ bestaetigtUeber: 'portal' })
assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026')
assert.equal(k32Mail(nein).values.fruehbeginn_text, '')
assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true })
const firma = BESTAETIGT({ kunde: kunde('unternehmen') })
assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false })
assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', ''])
assert.deepEqual(t22Mail(nein), {
template: 'T22',
values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' },
blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true },
})
assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH')
assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink')
assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false })
})
test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => {
const nachrichten = [
k31Mail(AB, { token: TOKEN }),
k31Mail(AB, { token: TOKEN, teamkopie: true }),
k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }),
k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }),
k32Mail(BESTAETIGT({ fruehbeginn: true })),
k32Mail(BESTAETIGT()),
t22Mail(BESTAETIGT({ fruehbeginn: true })),
t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })),
]
for (const { template, values, blocks } of nachrichten) {
const vorlage = loadTemplate(template)
assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template)
assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template)
assert.deepEqual(renderMail(template, values, blocks).problems, [], template)
}
})
test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => {
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
assert.equal(sink.sent.length, 2)
const [kundenmail, teamkopie] = sink.sent
assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid'])
assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink')
assert.equal(kundenmail.subject, K31_BETREFF)
assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`))
assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']])
assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF))
assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF))
assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails)
assert.equal(teamkopie.subject, K31_BETREFF)
assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.'))
assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem')
const geheim = JSON.stringify(teamkopie)
for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`)
})
test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => {
const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF)
await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true })
assert.equal(sink.sent.length, 2)
for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt'))
assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF))
assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN))
})
test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => {
await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/)
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError)
await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/)
assert.equal(sink.sent.length, 0)
let versuche = 0
setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } })
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/)
assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie')
})
test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => {
let aufrufe = 0
setMailTransportForTests({
async sendMail(mail) {
aufrufe += 1
if (aufrufe === 2) throw new Error('SMTP 451')
return sink.sendMail(mail)
},
})
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
assert.equal(aufrufe, 2)
assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']])
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001')))
})
test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => {
await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true }))
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF])
const [k32, t22] = sink.sent
assert.deepEqual(adressen(k32.to), ['k@example.invalid'])
assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails)
assert.deepEqual(adressen(t22.to), config.chatNotify.emails)
assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr'))
assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`))
})
test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => {
let aufrufe = 0
setMailTransportForTests({
async sendMail(mail) {
aufrufe += 1
if (aufrufe === 1) throw new Error('SMTP 451')
return sink.sendMail(mail)
},
})
await sendeBestaetigtMails(BESTAETIGT())
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001')))
sink.sent.length = 0
setMailTransportForTests(sink)
await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }))
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
})
+35
View File
@@ -0,0 +1,35 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { hashToken, neuerToken } from '../server/services/auftraege/token.js'
const TAG_MS = 24 * 60 * 60 * 1000
test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => {
const jetzt = new Date('2026-10-06T10:00:00.000Z')
const { token, hash, gueltigBis } = neuerToken(jetzt)
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
assert.equal(hash, createHash('sha256').update(token).digest('hex'))
assert.ok(gueltigBis instanceof Date)
assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z')
assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert')
})
test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => {
const vorher = Date.now()
const a = neuerToken()
const b = neuerToken()
const nachher = Date.now()
assert.notEqual(a.token, b.token)
assert.notEqual(a.hash, b.hash)
assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS)
assert.throws(() => neuerToken(new Date('kein Datum')), TypeError)
assert.throws(() => neuerToken('2026-10-06'), TypeError)
})
test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => {
const { token, hash } = neuerToken()
assert.equal(hashToken(token), hash)
const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`]
for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert))
})
+213
View File
@@ -0,0 +1,213 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { hashToken } from '../server/services/auftraege/token.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-'))
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js')
const app = await startTestApp({ '/api/auftrag': tokenRoutes })
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close()])
})
const PDF = '%PDF-1.4 Test-AB'
await speicherePdf('c1', 'ab1', Buffer.from(PDF))
const JETZT = new Date('2026-10-10T09:00:00.000Z')
const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' }
const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }
let sink
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
beforeEach(() => {
resetAuftragRoutesForTests()
setzeUhrFuerTests(() => JETZT)
config.auftraege.mode = 'on'
config.auftraege.pilotEmails = []
sink = mailSink()
setMailTransportForTests(sink)
seed()
})
const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts)
const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body })
const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } })
test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => {
const res = await get()
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['auftrag'])
const a = res.body.auftrag
assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat'])
assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }])
assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/)
for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`)
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(res.headers.get('x-robots-tag'), 'noindex')
})
test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => {
for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) {
for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token)
}
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
seed({ tokenGueltigBis: JETZT.toISOString() })
assert.equal((await get()).status, 200)
})
test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => {
seed({ tokenHash: hashToken(TOKEN_NEU) })
assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200])
})
test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => {
config.auftraege.mode = 'off'
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
config.auftraege.mode = 'pilot'
config.auftraege.pilotEmails = ['andere@example.invalid']
assert.equal((await get()).status, 404)
config.auftraege.pilotEmails = ['k@example.invalid']
assert.equal((await get()).status, 200)
})
test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => {
for (const [status, meldung] of Object.entries(GRUND)) {
seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' })
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status)
}
seed({ status: 'entwurf' })
assert.equal((await get()).status, 404)
})
test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => {
const res = await get(TOKEN, '/pdf')
assert.equal(res.status, 200)
assert.equal(res.headers.get('content-type'), 'application/pdf')
assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(res.body, PDF)
seed({ pdfDatei: 'c1/fehlt.pdf' })
const fehlt = await get(TOKEN, '/pdf')
assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }])
})
test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => {
for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`)
const zuViele = await get(TOKEN, '', ip('203.0.113.9'))
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200)
})
const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }
test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => {
for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) {
const res = await post(body)
assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body))
}
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null])
assert.equal(sink.sent.length, 0)
// die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400
const unbekannt = await post({}, TOKEN_NEU)
assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }])
})
test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => {
const pruefe = async (erwartet, token = TOKEN) => {
const res = await post({ bestaetigt: true }, token)
assert.deepEqual([res.status, res.body], erwartet)
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
}
await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU)
await pruefe([404, { error: UNGUELTIG }], 'kurz')
seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt
await pruefe([404, { error: UNGUELTIG }], TOKEN)
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
await pruefe([404, { error: UNGUELTIG }])
seed({ status: 'ersetzt' })
const ersetzt = await post({ bestaetigt: true })
assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }])
assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0])
seed()
config.auftraege.mode = 'off'
await pruefe([404, { error: UNGUELTIG }])
})
test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => {
const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77'))
assert.equal(res.status, 200)
assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()])
assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag))
const doc = await getAuftrag('ab1')
assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true])
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'])
assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument')
})
test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => {
for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) {
seed({ kunde: kunde(kundenart) })
const res = await post({ bestaetigt: true, fruehbeginn: eingabe })
assert.equal(res.status, 200, `${kundenart} ${eingabe}`)
assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`)
}
})
test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => {
const erste = await post({ bestaetigt: true, fruehbeginn: true })
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000))
const zweite = await post({ bestaetigt: true, fruehbeginn: false })
assert.equal(zweite.status, 200)
assert.deepEqual(zweite.body, erste.body)
assert.equal(sink.sent.length, 2)
seed()
sink.sent.length = 0
const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm)
assert.equal(sink.sent.length, 2)
})
test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => {
seed({ status: 'abgerechnet' })
const res = await post({ bestaetigt: true })
assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet'])
assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null)
})
test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => {
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
const log = mock.method(console, 'error', () => {})
const res = await post({ bestaetigt: true })
assert.equal(res.status, 200)
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
assert.ok(log.mock.callCount() > 0)
mock.restoreAll()
})
test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => {
const kopf = ip('203.0.113.20')
for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`)
const zuViele = await post({ bestaetigt: true }, TOKEN, kopf)
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
assert.equal((await get(TOKEN, '', kopf)).status, 200)
assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200)
})
+156
View File
@@ -0,0 +1,156 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { config } from '../server/config.js'
import { loadTemplate, renderMail } from '../server/services/mailTemplates.js'
import { euroDe } from '../server/services/finance/format.js'
// K31, K32, T22 (Aufträge und Rechnungen, Strang L5) und der Block projekt in K04 und T09 (Task 11). Die allgemeinen
// Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Team-Fuß) prüft test/mailTemplates.test.js.
const eur = (cent) => euroDe(cent).replace(/\u{a0}/gu, ' ') // die Textfassung macht geschützte Leerzeichen zu normalen
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
const alle = (id) => Object.fromEntries(loadTemplate(id).blocks.map((name) => [name, true]))
const pruefeZeilen = (mail, erwartet) => {
const zeilen = mail.text.split('\n')
for (const zeile of erwartet) assert.ok(zeilen.includes(zeile), `Zeile fehlt: ${zeile}\n${mail.text}`)
}
const K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: eur(80000), link: 'https://project.webklar.com/auftrag.html#t=TOKEN', gueltig_bis: '05.12.2026' }
const K32 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '21.10.2026', fruehbeginn_text: 'Ich verlange ausdrücklich den Beginn vor Ablauf der Widerrufsfrist.' }
const T22 = { kunde: 'Muster GmbH', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Bestätigungslink', woms_link: 'https://ticket.webklar.com/customers/c1?tab=invoices', fruehester_beginn: '21.10.2026' }
test('K31, K32, T22: Datei, Betreff, Platzhalter und Blöcke wie im Vertrag V6 (K31 mit dem Gegenblock kundenmail)', () => {
const erwartet = {
K31: { file: 'K31 Auftragsbestaetigung.html', subject: 'Ihre Auftragsbestätigung {{nummer}} – {{titel}}', placeholders: ['name', 'nummer', 'titel', 'datum', 'betrag', 'link', 'gueltig_bis'], blocks: ['erinnerung', 'privat', 'festpreis', 'aufwand', 'teamkopie', 'kundenmail'] },
K32: { file: 'K32 Auftrag bestaetigt.html', subject: 'Ihre Bestätigung zu {{nummer}} ist eingegangen', placeholders: ['name', 'nummer', 'titel', 'zeitpunkt', 'fruehester_beginn', 'fruehbeginn_text'], blocks: ['fruehbeginn', 'ohne_fruehbeginn'] },
T22: { file: 'T22 Auftrag bestaetigt.html', subject: '[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}', placeholders: ['kunde', 'nummer', 'titel', 'zeitpunkt', 'ueber', 'woms_link', 'fruehester_beginn'], blocks: ['privat_fruehbeginn', 'privat_ohne_fruehbeginn'] },
}
for (const [id, soll] of Object.entries(erwartet)) {
const vorlage = loadTemplate(id)
assert.equal(vorlage.file, soll.file, id)
assert.equal(vorlage.subject, soll.subject, id)
assert.deepEqual(vorlage.placeholders, soll.placeholders, id)
assert.deepEqual(vorlage.blocks, soll.blocks, id)
}
})
test('K31 Festpreis an eine Privatperson: Angaben, Knopf mit Link im Fragment, Anhänge, Widerrufshinweis; keine Erinnerung, kein Teamhinweis', () => {
const mail = renderMail('K31', K31, { festpreis: true, privat: true, kundenmail: true })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei')
pruefeZeilen(mail, [
'Guten Tag Erika Muster,',
'Auftrag: AB-2026-0001 vom 06.10.2026',
'Projekt: Website für Musterbäckerei',
'Gesamtpreis: 800,00 € (Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)',
'Bestätigung möglich bis: 05.12.2026',
'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN',
'Viele Grüße',
'Ihr WEBklar-Team',
])
assert.ok(mail.text.includes('Die Auftragsbestätigung (AB-2026-0001.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail.'))
assert.ok(mail.text.includes('Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung;'))
assert.ok(mail.text.includes(`Widerrufsformular auf unserer Website (${config.publicBaseUrl}/widerrufen.html)`))
assert.doesNotMatch(mail.text, /Erinnerung|Vergütung:|Kopie für das Team/)
})
test('K31 Aufwand an ein Unternehmen als Erinnerung: Stundensatz, Hinweis auf den früheren Link, kein Widerrufshinweis', () => {
const mail = renderMail('K31', { ...K31, betrag: `${eur(10000)} pro Stunde` }, { aufwand: true, erinnerung: true, kundenmail: true })
assert.deepEqual(mail.problems, [])
pruefeZeilen(mail, ['Vergütung: 100,00 € pro Stunde (nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)', 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN'])
assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.'))
assert.doesNotMatch(mail.text, /Gesamtpreis|Widerruf|Verbraucher/)
})
test('K31 als Kopie für das Team: Hinweis statt Knopf, kein Link, keine Anhänge, kein Widerrufshinweis; die Erinnerung bleibt erkennbar', () => {
const mail = renderMail('K31', { ...K31, link: '' }, { aufwand: true, erinnerung: true, teamkopie: true })
assert.deepEqual(mail.problems, [])
pruefeZeilen(mail, ['Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.', 'Auftrag: AB-2026-0001 vom 06.10.2026', 'Bestätigung möglich bis: 05.12.2026'])
assert.ok(mail.text.includes('Der Link in der früheren E-Mail gilt nicht mehr.'))
assert.doesNotMatch(mail.text, /Auftrag bestätigen|auftrag\.html|hängen als PDF|Anhänge|Verbraucher/)
assert.doesNotMatch(mail.html, /auftrag\.html|#t=|class="btn"/)
})
test('K32: Eingangsbestätigung mit Datum und Uhrzeit; Frühbeginn ja (wörtliche Erklärung), nein und Unternehmen', () => {
const ja = renderMail('K32', K32, { fruehbeginn: true })
assert.deepEqual(ja.problems, [])
assert.equal(ja.subject, 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen')
pruefeZeilen(ja, ['Guten Tag Erika Muster,', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Ihre Erklärung zum früheren Beginn', K32.fruehbeginn_text, 'Viele Grüße', 'Ihr WEBklar-Team'])
assert.ok(ja.text.includes('wir haben Ihre Bestätigung zu AB-2026-0001 am 07.10.2026, 10:30 Uhr erhalten.'))
assert.doesNotMatch(ja.text, /21\.10\.2026|nicht verlangt|Widerrufsrecht bleibt/)
const nein = renderMail('K32', K32, { ohne_fruehbeginn: true })
assert.ok(nein.text.includes('Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
assert.doesNotMatch(nein.text, /Erklärung zum früheren Beginn/)
assert.doesNotMatch(renderMail('K32', K32, {}).text, /Beginn der Arbeiten|21\.10\.2026|Widerruf|Erklärung/)
})
test('T22: Betreff mit Präfix, Angaben, Knopf ins Ticketsystem; Hinweise nur bei Privatpersonen', () => {
const ja = renderMail('T22', T22, { privat_fruehbeginn: true })
assert.deepEqual(ja.problems, [])
assert.equal(ja.subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
pruefeZeilen(ja, ['Kunde: Muster GmbH', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Weg: Bestätigungslink', 'Im Ticketsystem öffnen: https://ticket.webklar.com/customers/c1?tab=invoices'])
assert.ok(ja.text.includes('Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).'))
assert.doesNotMatch(ja.text, /keinen früheren Beginn|21\.10\.2026/)
const nein = renderMail('T22', T22, { privat_ohne_fruehbeginn: true })
assert.ok(nein.text.includes('Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am 21.10.2026, dem Tag nach Ablauf der Widerrufsfrist.'))
assert.doesNotMatch(renderMail('T22', T22, {}).text, /Privatperson|Wertersatz|frühestens/)
})
test('K31 und K32 stehen in der Sie-Form; Werte werden HTML-sicher eingesetzt', () => {
for (const [id, werte] of [['K31', K31], ['K32', K32]]) {
const mail = renderMail(id, werte, alle(id))
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Du-Form\n${mail.text}`)
assert.match(mail.text, SIE_FORM, `${id}: spricht den Kunden mit Sie an`)
}
const mail = renderMail('K31', { ...K31, titel: 'Café <b>&"' }, { festpreis: true })
assert.match(mail.html, /Café &lt;b&gt;&amp;&quot;/)
assert.doesNotMatch(mail.html, /<b>&/)
})
const K04 = { name: 'Erika Muster', produkt: 'Projektauftrag', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', eingang_zeitpunkt: '07.10.2026, 10:30', email: 'k@example.invalid', kundennummer: 'KD-9' }
const T09 = { email: 'k@example.invalid', name: 'Erika Muster', produkt: 'Projektauftrag', preis: '', kundennummer: 'KD-9', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', stripe_abo_id: '', abo_beginn: '–', fristende: '20.10.2026, 24 Uhr', rechtzeitig: 'RECHTZEITIG', ereignisse: 'Auftragsbestätigung auf „widerrufen“ gesetzt', pruefhinweis: '' }
test('K04 und T09: Block projekt ist deklariert (K04 mit projekt_unternehmen), die Platzhalter bleiben, die anderen Blöcke auch', () => {
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
assert.deepEqual(loadTemplate('K04').placeholders, ['name', 'produkt', 'bestellung', 'eingang_zeitpunkt', 'email', 'kundennummer'])
assert.deepEqual(loadTemplate('T09').blocks.slice(-2), ['bereits_widerrufen', 'projekt'])
assert.deepEqual(loadTemplate('T09').placeholders, ['email', 'name', 'produkt', 'preis', 'kundennummer', 'bestellung', 'stripe_abo_id', 'abo_beginn', 'fristende', 'rechtzeitig', 'ereignisse', 'pruefhinweis'])
})
test('K04 Block projekt: Vertragsart, „wir prüfen“; bei Unternehmen zusätzlich der Hinweis ohne gesetzliches Widerrufsrecht; keine Zusagen', () => {
const privat = renderMail('K04', K04, { kundennummer: true, projekt: true })
assert.deepEqual(privat.problems, [])
assert.equal(privat.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
pruefeZeilen(privat, [
'wir haben Ihren Widerruf der Bestellung „Projektauftrag (Auftragsbestätigung AB-2026-0001)“ am 07.10.2026, 10:30 Uhr erhalten.',
'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
'Kundennummer: KD-9',
'Wie es weitergeht',
'Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.',
])
assert.doesNotMatch(privat.text, /Unternehmen|gesetzliches Widerrufsrecht|Abbuchungen|erstatt|Wertersatz|Widerrufsfrist läuft/)
const firma = renderMail('K04', K04, { projekt: true, projekt_unternehmen: true })
assert.deepEqual(firma.problems, [])
pruefeZeilen(firma, ['Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'])
// der Zusatz gehört zum Block projekt: allein erscheint er nie, und ohne projekt gibt es keinen Kasten
assert.doesNotMatch(renderMail('K04', K04, { projekt_unternehmen: true }).text, /Unternehmen|Wie es weitergeht/)
assert.doesNotMatch(renderMail('K04', K04, {}).text, /Wie es weitergeht|Projektauftrags/)
})
test('T09 Block projekt: Ergebnis, Widerrufsfrist und Eingang als eigene Zeilen, Hinweis auf die Handarbeit; ohne den Block nichts davon', () => {
const mail = renderMail('T09', T09, { kundennummer: true, projekt: true })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
pruefeZeilen(mail, [
'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
'Abo: Projektauftrag',
'Kundennummer: KD-9',
'Zu tun',
'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt',
'Widerrufsfrist bis: 20.10.2026, 24 Uhr',
'Eingang: RECHTZEITIG',
])
assert.ok(mail.text.includes('Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).'))
assert.doesNotMatch(mail.text, /KEIN aktives Abo|Stripe-Abo|Abo in Stripe/)
assert.doesNotMatch(renderMail('T09', T09, { kundennummer: true }).text, /Ergebnis:|Eingang:|Handarbeit|Zu tun/)
})
+185
View File
@@ -0,0 +1,185 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt
process.env.AUFTRAEGE_ENABLED = 'on'
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, send } = await import('./support/testApp.js')
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js')
const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes })
const sink = mailSink()
setMailTransportForTests(sink)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString()
const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
let protokoll
beforeEach(() => {
resetPublicRateLimit()
resetAuftragRoutesForTests()
sink.sent.length = 0
config.auftraege.mode = 'on'
protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body })
const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs'))
const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen'))
const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang))
const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)'
const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)'
const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'
const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen'
const pruefeT09 = (ergebnis, frist, eingang) => {
assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`)
assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`)
assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`)
}
test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => {
const gesendetAm = vor(3)
seed({ gesendetAm })
const stripeVorher = st.calls.length
const res = await widerruf({ customerNumber: 'KD-9' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'])
assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf')
const ab = await getAuftrag('ab1')
assert.equal(ab.status, 'widerrufen')
assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime())
// K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid'])
assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text)
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`)
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'))
assert.ok(k04().text.includes('Kundennummer: KD-9'))
assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/)
// T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang
assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag')
pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG')
assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/)
})
test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => {
seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true })
assert.equal((await widerruf()).status, 200)
const ab = await getAuftrag('ab1')
assert.equal(ab.status, 'widerrufen')
assert.notEqual(ab.bestaetigtAm, null)
assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)')
})
test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => {
seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') })
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'versendet')
assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text)
pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft')
})
test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => {
const gesendetAm = vor(20)
seed({ gesendetAm })
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'versendet')
pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen')
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS))
})
test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => {
const faelle = [
[{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
[{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
[{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER],
[{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER],
[{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER],
[{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER],
]
seed({ gesendetAm: vor(3) })
for (const [body, label, ergebnis] of faelle) {
sink.sent.length = 0
resetPublicRateLimit()
const res = await widerruf(body)
const was = JSON.stringify(body)
assert.equal(res.status, 200, was)
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was)
assert.equal((await getAuftrag('ab1')).status, 'versendet', was)
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was)
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was)
pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft')
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was)
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was)
}
})
test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => {
seed({ gesendetAm: vor(3) })
assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
})
test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => {
const faelle = [
['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'],
['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'],
['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'],
['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'],
]
for (const [status, ergebnis, eingang] of faelle) {
const gesendetAm = vor(3)
seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null })
sink.sent.length = 0
resetPublicRateLimit()
assert.equal((await widerruf()).status, 200, status)
pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang)
assert.equal((await getAuftrag('ab1')).status, status, status)
}
})
test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => {
seed({ gesendetAm: vor(3) })
assert.equal((await widerruf()).status, 200)
const lesen = await send('GET', `/api/auftrag/${TOKEN}`)
assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }])
assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410)
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
})
test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => {
seed({ gesendetAm: vor(3) })
config.auftraege.mode = 'off'
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404)
})
test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => {
seed({ gesendetAm: vor(3) })
aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 })
assert.equal((await widerruf()).status, 200)
assert.equal((await getAuftrag('ab1')).status, 'versendet')
pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft')
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
assert.ok(protokoll.mock.callCount() > 0)
})
+8 -8
View File
@@ -6,9 +6,9 @@ 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); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge])
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]
const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)]
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
const GUEST = ids('G', 8)
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
@@ -27,7 +27,7 @@ for (const id of EXPECTED) {
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Aufträge)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop] oder [Aufträge] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
@@ -446,7 +446,7 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
@@ -613,13 +613,13 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche
})
// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion
test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => {
test('F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)', () => {
assert.ok(loadTemplate('T09').placeholders.includes('preis'))
assert.ok(loadTemplate('T09').placeholders.includes('produkt'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
assert.deepEqual(loadTemplate('T09').blocks, [
'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel',
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen',
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', 'projekt',
])
})
@@ -715,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
})
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21']
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22']
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const id of EXPECTED) {