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:
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}}. ͏ ͏ ͏</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 →</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. ͏ ͏ ͏</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;">✓ 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. ͏ ͏ ͏</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 →</a></td></tr></table></td></tr>
|
||||
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </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
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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() }))
|
||||
})
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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é <b>&"/)
|
||||
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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user