Zusatzspeicher: Vorschau im flexiblen Abrechnungsmodus, Aufgaben nach Zeitpunkt, keine stillen Abbrüche

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 09:16:07 +00:00
1 parent 188db4b3c1
commit bc20ce0673
9 files changed
+922 -228

No files matched your search

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K13 · E-Mail-Zusatzspeicher (Bestätigung)
Betreff: Ihr E-Mail-Zusatzspeicher – Bestätigung
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang bei einer Bestellung (Plus): Vertragsunterlagen der zugestimmten Fassung (PDF)
Platzhalter: {{name}}, {{pakete}}, {{gesamt_gb}}, {{monatspreis_zusatz}}, {{wirksam_ab}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, minus
Stand Aufgabe 8 (Fix 1): plus = Bestellung (sofort, anteilig, mit Widerrufshinweis und PDF), minus = Verringerung zum Ende des Abrechnungsmonats (Spec 3.4); Endfassung der Texte in Aufgabe 12.
-->
<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>Ihr E-Mail-Zusatzspeicher – Bestätigung</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;">Ihr E-Mail-Zusatzspeicher: {{pakete}} Pakete, {{gesamt_gb}} GB gesamt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; E-Mail-Zusatzspeicher</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;">Ihr E-Mail-Zusatzspeicher</td></tr>
<!-- WENN plus -->
<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 Ihre Bestellung. Hiermit bestätigen wir Ihren E-Mail-Zusatzspeicher.</td></tr>
<!-- ENDE plus -->
<!-- WENN minus -->
<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>hiermit bestätigen wir die Änderung Ihres E-Mail-Zusatzspeichers.</td></tr>
<!-- ENDE minus -->
<tr><td class="px" style="padding:32px 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="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">E-Mail-Zusatzspeicher</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{monatspreis_zusatz}}<span class="mut" style="font-size:15px;font-weight:normal;letter-spacing:0;color:#6b6964;">&nbsp;/ Monat</span></div><div class="mut" style="font-size:12px;line-height:18px;color:#6b6964;">gemäß § 19 UStG ohne Umsatzsteuer</div></td></tr></table></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;">Zusatzspeicher</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{pakete}} Pakete</strong> zu je 5 GB, 1,00 € je Paket und Monat</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;">E-Mail-Speicher gesamt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB in Ihrem Abo enthalten plus Zusatzspeicher)</span></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;">Wirksam ab</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wirksam_ab}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Laufzeit</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">Monatlich, jederzeit zum Ende des Abrechnungsmonats kündbar. Der Zusatzspeicher endet spätestens mit Ihrem Abo.</td></tr></table></td></tr>
<!-- WENN plus -->
<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;">Bereitstellung und Abrechnung</div><div class="mut" style="color:#4a4843;">Wir stellen die Grenzen Ihrer Postfächer in der Regel innerhalb eines Werktags um; bis dahin zeigt das Kundenportal „wird eingerichtet“. Den Rest des laufenden Abrechnungsmonats berechnen wir anteilig mit der nächsten Abbuchung, danach {{monatspreis_zusatz}} im Monat. Pakete, die für den laufenden Monat schon bezahlt waren, berechnen wir nicht noch einmal.</div></td></tr></table></td></tr>
<!-- ENDE plus -->
<!-- WENN minus -->
<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;">Wirksam zum Ende des Abrechnungsmonats</div><div class="mut" style="color:#4a4843;">Ihre Änderung wirkt zum {{wirksam_ab}}, ohne anteilige Erstattung; bis dahin bleibt der bisherige Speicher bestehen. Passen die gespeicherten E-Mails dann nicht mehr hinein, nimmt das betroffene Postfach keine neuen E-Mails an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE minus -->
<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="https://project.webklar.com/dashboard.html" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<!-- WENN plus -->
<tr><td class="px" style="padding:32px 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 Vertragsunterlagen im Anhang (PDF)</div><div class="mut" style="color:#4a4843;">&#128206;&nbsp; Vertragsbedingungen mit den Regeln zum E-Mail-Zusatzspeicher<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular</div></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Sie können diese Bestellung innerhalb von 14 Tagen widerrufen; die Widerrufsbelehrung finden Sie in den beigefügten Vertragsunterlagen. Widerrufen Sie, nehmen wir die Pakete zurück und erstatten alles, was Sie dafür gezahlt haben.</td></tr>
<!-- ENDE plus -->
<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>&nbsp;&nbsp;·&nbsp;&nbsp;<a class="mut" href="https://project.webklar.com/widerrufen.html" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a class="mut" href="https://project.webklar.com/kuendigen.html" style="color:#4a4843;text-decoration:underline;">Vertrag kündigen</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,64 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T13 · Speicher-Aufgabe (E-Mail-Zusatzspeicher in konsoleH)
Betreff: [Speicher] {{aktion}}: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{aktion}}, {{email}}, {{name}}, {{stripe_abo_id}}, {{postfaecher_hinweis}}, {{neue_groesse_gb}}, {{pakete}}, {{faellig}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): einrichten, periodenende, teilweise, nachweis_fehlt
Stand Aufgabe 8 (Fix 1): aktion ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“; einrichten (Plus über die bezahlte Menge, sofort), periodenende (Minus oder Plus bis zur bezahlten Menge, zum Periodenende), teilweise (Schritt 2 bei Stripe fehlgeschlagen), nachweis_fehlt; Endfassung in Aufgabe 12.
-->
<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>[Speicher] {{aktion}}: {{email}}</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;">{{name}}: E-Mail-Zusatzspeicher {{aktion}}, {{neue_groesse_gb}} GB gesamt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">E-Mail-Speicher</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;">Zusatzspeicher {{aktion}}</td></tr>
<tr><td class="px" style="padding:24px 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;">{{name}}</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;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</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;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}}</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;">Postfächer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{postfaecher_hinweis}}</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;">Neue Gesamtgröße</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{neue_groesse_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB inklusive + {{pakete}} Pakete Zusatzspeicher zu 5 GB)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Fällig</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{faellig}}</td></tr></table></td></tr>
<!-- WENN einrichten -->
<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:#eef6ef;border:1px solid #c9e3cd;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;">Jetzt einrichten</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen in konsoleH auf {{neue_groesse_gb}} GB anheben, in der Regel innerhalb eines Werktags; bis dahin zeigt das Portal „wird eingerichtet“. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE einrichten -->
<!-- WENN periodenende -->
<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;">Zum Periodenende</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen zum {{faellig}} auf {{neue_groesse_gb}} GB setzen, nicht früher: Bis dahin ist die bisherige Menge bezahlt, eine Erstattung gibt es nicht. Der Kunde weiß, dass ein volles Postfach danach keine neuen E-Mails annimmt. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE periodenende -->
<!-- WENN teilweise -->
<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;">Schritt 2 fehlgeschlagen – Menge in Stripe von Hand auf {{pakete}} setzen</div><div class="mut" style="color:#4a4843;">Der Kunde hat {{pakete}} Pakete bestellt. Stripe hat nur den ersten Schritt übernommen (die im Monat schon bezahlte Menge, ohne Verrechnung); der zweite Schritt wurde abgelehnt. Bitte in Stripe die Stückzahl der Position „E-Mail-Zusatzspeicher“ auf {{pakete}} setzen, anteilig ab heute (create_prorations). Das Ereignis „speicher“ trägt partial: true; die Einrichtung in konsoleH gilt für die volle Bestellung.</div></td></tr></table></td></tr>
<!-- ENDE teilweise -->
<!-- WENN nachweis_fehlt -->
<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;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „speicher“ (Nachweis der Bestellung und Aufgabe fürs Team) konnte nicht in Appwrite gespeichert werden. Bitte in contractEvents nachtragen; bis dahin ist diese Mail die einzige Aufgabe, im Admin-Bereich erscheint sie nicht.</div></td></tr></table></td></tr>
<!-- ENDE nachweis_fehlt -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+9 -6
View File
@@ -9,7 +9,7 @@ import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent }
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
import {
handlePendingUpdateApplied,
@@ -80,8 +80,11 @@ function publicSubscription(sub, events = []) {
const productKey = productKeyOf(sub)
const own = events.filter((e) => e.subscriptionId === sub.$id)
const openOf = (type) => own.filter((e) => e.type === type && e.status === 'offen')
const pendingPlus = openOf('speicher').find((e) => e.details?.toUnits > e.details?.fromUnits)
const pendingMinus = openOf('speicher').find((e) => e.details?.toUnits < e.details?.fromUnits)
// Offene Speicher-Aufgaben nach Art (Task 8, Fix 1): "einrichten" (Plus ueber die bezahlte Menge, konsoleH
// noch nicht umgestellt) und "periodenende" (Minus oder Plus bis zur bezahlten Menge, gilt ab Periodenende)
const openStorage = openOf('speicher')
const pendingSetup = openStorage.some((e) => storageTaskKind(e) === 'einrichten')
const pendingReduction = openStorage.find((e) => storageTaskKind(e) === 'periodenende') || null
const pendingSwitch = openOf('wechsel')[0] || null
// Plus/Minus und Wechsel nur bei Status active, ohne Kuendigung und ohne offene Wechselzahlung
const cancelled = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
@@ -120,9 +123,9 @@ function publicSubscription(sub, events = []) {
storage: {
units: Number(sub.storageUnits) || 0,
// Plus bestellt, konsoleH noch nicht umgestellt ("wird eingerichtet")
pendingSetup: Boolean(pendingPlus),
// Minus bestellt, wirkt zum Periodenende ("ab TT.MM. X GB")
pendingReduction: pendingMinus ? { units: pendingMinus.details.toUnits, effectiveAt: pendingMinus.dueAt || null } : null,
pendingSetup,
// Zielgroesse ab Periodenende ("ab TT.MM. X GB")
pendingReduction: pendingReduction ? { units: pendingReduction.details?.toUnits ?? null, effectiveAt: pendingReduction.dueAt || null } : null,
},
canChangeStorage,
canSwitch: canChangeStorage && productKey === 'hosting',
+248 -91
View File
@@ -25,12 +25,20 @@ import {
* bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten
* (planStorageChange).
* - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in
* konsoleH sinkt erst zum Ende des Abrechnungsmonats (Aufgabe fuers Team, dueAt = Periodenende).
* - Jede Aenderung ist ein Ereignis `speicher` mit Status offen (= Aufgabe fuers Team in konsoleH); nur ein
* Plus traegt Zustimmungsdaten, ein Minus keine (Rechtsrunde 28.09.2026). Eine neue Aenderung ersetzt
* die aeltere offene Aufgabe desselben Abos (F21): eine Aufgabe je Abo mit Zielgroesse und Datum.
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus gesetzt, sonst
* aus der regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
* konsoleH sinkt erst zum Ende des Abrechnungsmonats.
* - Jede Aenderung ist ein Ereignis `speicher` (Nachweis; Status offen = Aufgabe fuers Team). Die Aufgabe
* hat eine Art (details.kind): "einrichten" (Plus ueber die bezahlte Menge, sofort, Ziel = neue Menge)
* oder "periodenende" (Minus oder Plus bis zur bezahlten Menge, faellig zum Periodenende, Ziel = neue
* Menge). Eine neue Aufgabe ersetzt aeltere offene derselben Art; eine Einrichtung ersetzt ausserdem eine
* geplante Verringerung mit kleinerem Ziel; eine Verringerung laesst eine offene Einrichtung stehen. So
* gibt es hoechstens eine offene Aufgabe je Art und Abo. Nur ein Plus traegt Zustimmungsdaten
* (Rechtsrunde 28.09.2026).
* - Reihenfolge: Ereignis zuerst (offen), dann Stripe. Lehnt Stripe ab, wird das Ereignis "gescheitert"
* und der Kunde sieht den Fehler; nach einer wirksamen Stripe-Aenderung bricht nichts mehr ab
* (Spiegel, Buchung, Aufgaben, Mails: Fehler im Log, T13 sagt es dem Team).
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus max(bezahlt,
* neu), beim Minus max(bezahlt, bisher) - jede Position war fuer den Monat berechnet; sonst aus der
* regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
*/
function httpError(status, message) {
@@ -46,10 +54,16 @@ const fmtDate = (iso) =>
// Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null)
const sameInstant = (a, b) => a === b || new Date(a).getTime() === new Date(b).getTime()
// Appwrite-Fehler: 4xx dauerhaft (Attribut/Collection fehlt, ungueltige Daten), 5xx/Netz voruebergehend (wie Task 7)
const isPermanent = (err) => Boolean(err?.status) && err.status < 500
const shortError = (err) => String(err?.message || err).slice(0, 200)
const unitsOf = (doc) => Number(doc?.storageUnits) || 0
const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit
const monthlyCentsOf = (units) => units * config.mailStorage.amountCents
// Seit API 2025-03 liegt current_period_end am Subscription-Item
const periodEndOf = (stripeSub) => stripeSub?.current_period_end || stripeSub?.items?.data?.[0]?.current_period_end || null
/**
* Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps:
@@ -80,17 +94,26 @@ export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits
/**
* Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd
* der laufende Zeitraum ist (Appwrite gibt Datumswerte mit +00:00 zurueck, der Spiegel schreibt Z: gleicher
* Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere Rechnung des Monats hat sie berechnet).
* der laufende Zeitraum ist (periodEnd, sonst doc.currentPeriodEnd; Appwrite gibt Datumswerte mit +00:00
* zurueck, der Spiegel schreibt Z: gleicher Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere
* Rechnung des Monats hat sie berechnet).
*/
export function paidUnitsOf(doc) {
export function paidUnitsOf(doc, periodEnd = doc?.currentPeriodEnd) {
const paidEnd = doc?.storagePaidPeriodEnd
const periodEnd = doc?.currentPeriodEnd
const samePeriod =
Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime())
const samePeriod = Boolean(paidEnd && periodEnd) && sameInstant(paidEnd, periodEnd)
return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc)
}
/**
* Art der Aufgabe eines Ereignisses `speicher`: details.kind ("einrichten" | "periodenende"); aeltere
* Ereignisse ohne kind (vor Fix 1) nach der Richtung (Plus = einrichten, sonst periodenende).
*/
export function storageTaskKind(event) {
const details = event?.details || {}
if (details.kind === 'einrichten' || details.kind === 'periodenende') return details.kind
return Number(details.toUnits) > Number(details.fromUnits) ? 'einrichten' : 'periodenende'
}
async function requireSubscription(customerId) {
const doc = await getCurrentSubscription(customerId)
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
@@ -111,11 +134,14 @@ function reductionEffectiveAt(doc) {
return periodEnd || cancelAt
}
// Toleranz zwischen dem eigenen proration_date und Stripes Zeilen (Sekundenrundung)
const PRORATION_TOLERANCE_SECONDS = 2
/**
* Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue
* Menge abzueglich Gutschrift fuer die alte, jeweils ab prorationDate. Aeltere anteilige Zeilen (eine
* fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die regulaeren Zeilen des
* naechsten Monats zaehlen nicht.
* Menge abzueglich Gutschrift fuer die alte, genau ab prorationDate (period.start, ±2 s). Aeltere anteilige
* Zeilen (eine fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die
* regulaeren Zeilen des naechsten Monats zaehlen nicht.
*/
function storageProrationCents(preview, priceIds, prorationDate) {
return (preview?.lines?.data || [])
@@ -124,8 +150,7 @@ function storageProrationCents(preview, priceIds, prorationDate) {
const details = parent.subscription_item_details || parent.invoice_item_details || {}
if (details.proration !== true) return false
if (!priceIds.has(line.pricing?.price_details?.price)) return false
// Toleranz fuer Sekunden zwischen Vorschau und Stripe-Rundung
return !line.period?.start || line.period.start >= prorationDate - 300
return Math.abs(Number(line.period?.start) - prorationDate) <= PRORATION_TOLERANCE_SECONDS
})
.reduce((sum, line) => sum + (Number(line.amount) || 0), 0)
}
@@ -134,6 +159,12 @@ function storageProrationCents(preview, priceIds, prorationDate) {
* Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je
* Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung
* und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts.
*
* Abrechnungsmodus (Fix 1): Abos aus dem Checkout laufen im flexiblen Modus (billing_mode.type 'flexible',
* Stripes Standard) - dort schreibt Stripe den zuletzt berechneten Betrag gut, die Vorschau hat die im
* Monat schon bezahlten Pakete also selbst verrechnet und gilt unveraendert. Nur im klassischen Modus
* ('classic') rechnet Stripe von der heutigen Menge aus; dann zaehlen nur die Pakete ueber der bezahlten
* Menge (F19: p × (neu − max(aktuell, bezahlt)) / (neu − aktuell)).
*/
export async function previewStorageChange(customerId, newUnits) {
const doc = await requireSubscription(customerId)
@@ -159,9 +190,10 @@ export async function previewStorageChange(customerId, newUnits) {
// Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein)
const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean))
const previewed = storageProrationCents(preview, priceIds, prorationDate)
// F19: Stripe rechnet die Vorschau von der heutigen Menge (currentUnits) aus; berechnet werden nur die
// Pakete ueber der schon bezahlten Menge, der erste Schritt bis dahin laeuft ohne Verrechnung
prorationCents = Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
const classic = stripeSub.billing_mode?.type === 'classic'
prorationCents = classic
? Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
: previewed
}
return {
direction: plan.direction,
@@ -175,6 +207,13 @@ export async function previewStorageChange(customerId, newUnits) {
}
}
function assertPlanAllowed(plan, consent) {
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) {
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
}
}
/**
* Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der
* auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular
@@ -200,22 +239,19 @@ function assertChangeAllowed(doc, events, direction, viaCancellation) {
}
/**
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team). Aeltere offene Aufgaben desselben
* Abos sind damit ueberholt (F21: erledigt, doneBy 'ersetzt'). Ein Fehler beim Speichern bricht die schon
* bei Stripe wirksame Aenderung nicht ab: er wird protokolliert, T13 traegt die Aufgabe trotzdem zum Team.
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team), Status offen - vor dem Stripe-Aufruf.
* Laesst es sich nicht speichern, laeuft die Aenderung trotzdem: Fehler im Log, T13 traegt die Aufgabe mit
* dem Block nachweis_fehlt zum Team.
*/
async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) {
async function recordStorageEvent({ doc, change, consent, viaCancellation }) {
try {
const now = new Date().toISOString()
for (const e of events.filter((e) => e.type === 'speicher' && e.status === 'offen')) {
await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
}
return await recordContractEvent({
customerId: doc.customerId,
subscriptionId: doc.$id,
type: 'speicher',
productKey: doc.productKey || 'wartung',
details: {
kind: change.kind,
fromUnits: change.fromUnits,
toUnits: change.toUnits,
effectiveAt: change.effectiveAt,
@@ -235,7 +271,7 @@ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCanc
}
: null,
status: 'offen',
dueAt,
dueAt: change.dueAt,
})
} catch (err) {
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
@@ -243,6 +279,23 @@ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCanc
}
}
/**
* Aeltere offene Aufgaben desselben Abos, die die neue Aufgabe ueberholt (F21, Fix 1): eine Einrichtung
* ersetzt offene Einrichtungen und geplante Verringerungen mit hoechstens gleichem Ziel; eine Aufgabe zum
* Periodenende ersetzt nur offene Aufgaben zum Periodenende, nie eine noch nicht erledigte Einrichtung.
* events sind die Ereignisse vor der neuen Aufgabe.
*/
async function supersedeStorageTasks(events, change) {
const now = new Date().toISOString()
const superseded = events.filter((e) => {
if (e.type !== 'speicher' || e.status !== 'offen') return false
const kind = storageTaskKind(e)
if (change.kind === 'einrichten') return kind === 'einrichten' || (Number(e.details?.toUnits) || 0) <= change.toUnits
return kind === 'periodenende'
})
for (const e of superseded) await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
}
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */
async function contractPdfAttachment(termsVersion) {
const { filename, path } = contractAttachmentFor(termsVersion)
@@ -254,14 +307,15 @@ async function contractPdfAttachment(termsVersion) {
}
/**
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team. Ueber das
* Kuendigungsformular bestaetigt K03, K13 entfaellt dort (F31). Ein Mailfehler bricht die Aenderung nie ab.
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team (Aufgabe nach Art, dazu
* die Bloecke teilweise und nachweis_fehlt). Ueber das Kuendigungsformular bestaetigt K03, K13 entfaellt dort
* (F31). Ein Mailfehler bricht die Aenderung nie ab.
*/
async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, proofMissing }) {
async function sendStorageMails({ doc, change, consent, viaCancellation, proofMissing }) {
const plus = change.direction === 'plus'
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
const email = customer?.email || doc.email || ''
const wirksamAb = fmtDate(change.effectiveAt)
const dueLabel = fmtDate(change.dueAt)
if (!viaCancellation && email) {
const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : []
await sendCustomerMail(email, {
@@ -271,7 +325,7 @@ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation,
pakete: change.toUnits,
gesamt_gb: change.totalGb,
monatspreis_zusatz: fmtEuro(change.monthlyCents),
wirksam_ab: wirksamAb,
wirksam_ab: fmtDate(change.effectiveAt),
},
blocks: { plus, minus: !plus },
attachments,
@@ -280,89 +334,169 @@ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation,
await sendAdminNotice({
template: 'T13',
values: {
aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`,
aktion: change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`,
email,
name: customer?.name || '',
stripe_abo_id: doc.$id,
postfaecher_hinweis:
(doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen',
neue_groesse_gb: change.totalGb,
faellig: fmtDate(dueAt),
pakete: change.toUnits,
faellig: dueLabel,
},
blocks: {
einrichten: change.kind === 'einrichten',
periodenende: change.kind === 'periodenende',
teilweise: change.partial,
nachweis_fehlt: proofMissing,
},
blocks: proofMissing ? { nachweis_fehlt: true } : {},
})
}
// Abos mit laufender Aenderung (Doppelklick, zwei Tabs): die zweite gleichzeitige Aenderung bekommt 409
const activeChanges = new Set()
/**
* Menge auf newUnits Pakete aendern: Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0 bestehen), Spiegel und bezahlte Menge
* (Plus: max(paidUnits, newUnits) fuer den laufenden Monat), Ereignis `speicher` (offen; dueAt jetzt bzw.
* Periodenende), K13 und T13. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim Plus
* Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
* Liefert { subscription, event, change }.
* Menge auf newUnits Pakete aendern. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim
* Plus Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
* Ablauf: Vorpruefung mit dem Spiegel (400/404/409, ohne Stripe-Aufruf), Sperre je Abo, frischer Stand aus
* Stripe, Ereignis `speicher` (offen), Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0), Spiegel und bezahlte Menge, ueberholte
* Aufgaben, K13 und T13. Liefert { subscription, event, change }; change = { direction, kind, fromUnits,
* toUnits, effectiveAt, dueAt, totalGb, monthlyCents, deltaMonthlyCents, partial }.
*/
export async function changeStorage(customerId, newUnits, consent, options = {}) {
const viaCancellation = options.viaCancellation === true
const doc = await requireSubscription(customerId)
const currentUnits = unitsOf(doc)
const paidUnits = paidUnitsOf(doc)
// Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf
const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits })
assertPlanAllowed(plan, consent)
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
// Sperre pruefen und setzen ohne await dazwischen
if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.')
activeChanges.add(doc.$id)
try {
return await performChange({ doc, events, newUnits, consent, viaCancellation })
} finally {
activeChanges.delete(doc.$id)
}
}
async function performChange({ doc, events, newUnits, consent, viaCancellation }) {
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
const items = stripeSub.items?.data || []
let { storageItemId } = describeItems(items)
// Frischer Stand aus Stripe (Menge, Periodenende): der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken
const fresh = { ...doc, storageUnits: describeItems(items).storageUnits, currentPeriodEnd: toIso(periodEndOf(stripeSub)) || doc.currentPeriodEnd || null }
const currentUnits = unitsOf(fresh)
const paidUnits = paidUnitsOf(fresh)
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
const plus = plan.direction === 'plus'
if (plus && !(consent?.termsVersion && consent?.consentAt)) {
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
}
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
// Stripe: Speicher-Posten anlegen oder Menge aendern, Schritt fuer Schritt (planStorageChange)
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
let { storageItemId } = describeItems(stripeSub.items?.data)
const prorationDate = Math.floor(Date.now() / 1000)
for (const step of plan.steps) {
const updated = await stripe.subscriptions.update(doc.$id, {
items: [storageItemParam(storageItemId, priceId, step.quantity)],
proration_behavior: step.proration_behavior,
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
})
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
}
// Spiegel (storageUnits aus den Positionen) und bezahlte Menge des laufenden Monats. Die Aenderung ist
// bei Stripe schon wirksam: scheitert die Buchung der bezahlten Menge (z. B. Attribut fehlt, Setup-Skript
// nicht gelaufen), laufen Ereignis und Mails trotzdem; ein Plus nach einem Minus im selben Monat wuerde
// dann anteilig berechnet statt frei bleiben - Fehler im Log.
let subscription = (await syncSubscription(doc.$id)) || doc
if (plus) {
const paid = { storagePaidUnits: Math.max(paidUnits, newUnits), storagePaidPeriodEnd: subscription.currentPeriodEnd || doc.currentPeriodEnd || null }
subscription = await updateDocument(config.collections.subscriptions, doc.$id, paid).catch((err) => {
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
return subscription
})
}
const now = new Date().toISOString()
const effectiveAt = plus ? now : reductionEffectiveAt(subscription)
const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende'
const reductionAt = reductionEffectiveAt(fresh)
const change = {
direction: plan.direction,
kind,
fromUnits: currentUnits,
toUnits: newUnits,
effectiveAt,
// Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist
// sofort faellig, alles andere zum Periodenende
effectiveAt: plus ? now : reductionAt,
dueAt: kind === 'einrichten' ? now : reductionAt,
totalGb: totalGbOf(newUnits),
monthlyCents: monthlyCentsOf(newUnits),
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
partial: false,
}
// Aufgabe fuers Team: Plus sofort einrichten, Minus zum Periodenende reduzieren
const dueAt = plus ? now : effectiveAt
const event = await recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation })
await sendStorageMails({ doc: subscription, change, dueAt, consent, viaCancellation, proofMissing: !event })
// 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen
const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation })
// 2. Stripe, Schritt fuer Schritt (planStorageChange)
const prorationDate = Math.floor(Date.now() / 1000)
let achievedUnits = currentUnits
for (const [index, step] of plan.steps.entries()) {
try {
const updated = await stripe.subscriptions.update(doc.$id, {
items: [storageItemParam(storageItemId, priceId, step.quantity)],
proration_behavior: step.proration_behavior,
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
})
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
achievedUnits = step.quantity
} catch (err) {
if (index === 0) {
// Nichts geaendert: Ereignis gescheitert, Fehler an den Kunden, keine Buchung, keine Mails
console.error(`[speicher] Abo ${doc.$id}: Stripe lehnt die Änderung ab:`, err.message)
if (event) {
await updateContractEvent(event.$id, { status: 'gescheitert', details: { error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht auf gescheitert gesetzt:`, e.message)
)
}
const failure = httpError(502, 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.')
failure.cause = err
throw failure
}
// Schritt 2 nach Schritt 1: Stripe steht auf der bezahlten Menge, die Bestellung bleibt offen - das Team
// setzt die Menge in Stripe von Hand (T13, Block teilweise)
console.error(`[speicher] Abo ${doc.$id}: Schritt ${index + 1} bei Stripe fehlgeschlagen, Menge bleibt ${achievedUnits}:`, err.message)
change.partial = true
if (event) {
await updateContractEvent(event.$id, { details: { partial: true, error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht als teilweise markiert:`, e.message)
)
}
break
}
}
// 3. Spiegel (storageUnits aus den Positionen); scheitert er, holt der Stripe-Webhook das nach
let subscription
try {
subscription = (await syncSubscription(doc.$id)) || { ...fresh, storageUnits: achievedUnits }
} catch (err) {
console.error(`[speicher] Spiegel für Abo ${doc.$id} nicht aktualisiert (Webhook holt das nach):`, err.message)
subscription = { ...fresh, storageUnits: achievedUnits }
}
// 4. Bezahlte Menge des laufenden Monats: Plus max(bezahlt, erreicht), Minus max(bezahlt, bisher) - jede
// Position war fuer den Monat berechnet. Scheitert die Buchung (z. B. Attribut fehlt), laeuft der Rest weiter;
// ein Plus nach einem Minus im selben Monat wuerde dann anteilig berechnet statt frei bleiben.
const paid = {
storagePaidUnits: Math.max(paidUnits, plus ? achievedUnits : currentUnits),
storagePaidPeriodEnd: fresh.currentPeriodEnd,
}
try {
await updateDocument(config.collections.subscriptions, doc.$id, paid)
subscription = { ...subscription, ...paid }
} catch (err) {
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
}
// 5. Ueberholte Aufgaben schliessen, erst nachdem die neue existiert
if (event) {
await supersedeStorageTasks(events, change).catch((err) =>
console.error(`[speicher] Ersetzte Aufgaben für Abo ${doc.$id} nicht geschlossen:`, err.message)
)
}
// 6. Mails
await sendStorageMails({ doc: subscription, change, consent, viaCancellation, proofMissing: !event })
return { subscription, event, change }
}
/**
* Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere
* Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt
* (storagePaidUnits, storagePaidPeriodEnd). Idempotent; fehlt der Spiegel noch (Webhook-Reihenfolge),
* wird er aus Stripe angelegt.
* (storagePaidUnits, storagePaidPeriodEnd). Im selben Monat gilt die groessere Menge (eine verspaetete
* Rechnung senkt eine Buchung nach einem Plus nicht), das Periodenende geht nie zurueck (wiederholte aeltere
* Rechnung). Fehlt der Spiegel noch (Webhook-Reihenfolge), wird er aus Stripe angelegt. Dauerhafte
* Appwrite-Fehler (4xx) werden protokolliert und uebersprungen, voruebergehende (5xx, Netz) geworfen, damit
* Stripe den Webhook wiederholt.
*/
export async function recordPaidStorage(invoice) {
const lines = await invoiceLines(invoice)
@@ -372,13 +506,36 @@ export async function recordPaidStorage(invoice) {
const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
const data = { storagePaidUnits: Number(line.quantity) || 0, storagePaidPeriodEnd: toIso(line.period?.end) }
const periodEnd = toIso(line.period?.end)
if (!periodEnd) return { skipped: 'Speicher-Zeile ohne Zeitraum' }
const units = Number(line.quantity) || 0
const tag = `[speicher] Bezahlte Menge für Abo ${subscriptionId} (Rechnung ${loaded.id || '?'})`
let existing
try {
existing = await getDocument(config.collections.subscriptions, subscriptionId)
} catch (err) {
if (err.status !== 404) {
if (!isPermanent(err)) throw err
console.error(`${tag} nicht lesbar (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
existing = await syncSubscription(subscriptionId)
if (!existing) return { skipped: 'Abo ohne Kundenzuordnung' }
}
const knownEnd = existing.storagePaidPeriodEnd || null
if (knownEnd && new Date(periodEnd) < new Date(knownEnd)) return { skipped: 'ältere Rechnung' }
const samePeriod = Boolean(knownEnd) && sameInstant(knownEnd, periodEnd)
const data = {
storagePaidUnits: samePeriod ? Math.max(Number(existing.storagePaidUnits) || 0, units) : units,
storagePaidPeriodEnd: periodEnd,
}
try {
await updateDocument(config.collections.subscriptions, subscriptionId, data)
} catch (err) {
if (err.status !== 404) throw err
if (!(await syncSubscription(subscriptionId))) return { skipped: 'Abo ohne Kundenzuordnung' }
await updateDocument(config.collections.subscriptions, subscriptionId, data)
if (!isPermanent(err)) throw err
console.error(`${tag} nicht gespeichert (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd }
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd }
}
+443 -128
View File
@@ -4,6 +4,7 @@ import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
@@ -19,13 +20,20 @@ const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage } =
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } =
await import('../server/services/mailStorage.js')
const { listContractEvents, listOpenStorageTasks, recordContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
// F25: Mails landen in der Senke statt bei SMTP - K13 (Kunde, beim Plus mit PDF) und T13 (Team) sind pruefbar
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs: ensurePrice findet sie, die Fixture
@@ -37,6 +45,10 @@ st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lo
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
const PERIOD_END_UNIX = unix(hostingDoc.currentPeriodEnd)
const DAY = 86400
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd)
const K13_SUBJECT = 'Ihr E-Mail-Zusatzspeicher – Bestätigung'
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
@@ -46,10 +58,11 @@ async function clearEvents(customerId) {
* Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen: units Pakete bei Stripe und im
* Spiegel; paidUnits (mit storagePaidPeriodEnd = currentPeriodEnd) als schon bezahlte Menge dieses Monats,
* sonst wie die Fixture (keine Buchung: bezahlt gilt dann die aktuelle Menge). patch aendert den Spiegel,
* stripe das Stripe-Abo (der Spiegel wird nach jeder Aenderung aus Stripe neu geschrieben).
* stripe das Stripe-Abo (der Spiegel wird nach jeder Aenderung aus Stripe neu geschrieben). Leert die Senke.
*/
async function resetSubH({ units = 0, paidUnits = null, patch = {}, stripe = {} } = {}) {
await clearEvents('c1')
mails.sent.length = 0
aw.seed('subscriptions', {
...hostingDoc,
storageUnits: units,
@@ -75,9 +88,14 @@ const previewInvoice = (...lines) => ({
})
const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h')
const previewCalls = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/invoices/create_preview')
const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)
const doc = () => aw.docs('subscriptions').get('sub_h')
const openTasks = async () => (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h')
const k13 = () => mails.sent.find((m) => m.subject === K13_SUBJECT) || null
const t13 = () => mails.sent.find((m) => m.subject.startsWith('[Speicher]')) || null
// ---------------------------------------------------------------------------
// Reine Funktionen: Plan und bezahlte Menge
// Reine Funktionen: Plan, bezahlte Menge, Aufgabenart
// ---------------------------------------------------------------------------
test('Plan: erstes Plus anteilig', () => {
@@ -130,6 +148,18 @@ test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => {
assert.equal(paidUnitsOf({ storageUnits: 2, storagePaidUnits: 0, storagePaidPeriodEnd: null, currentPeriodEnd: null }), 2)
assert.equal(paidUnitsOf({ storageUnits: 2 }), 2)
assert.equal(paidUnitsOf({}), 0)
// Fix 1 (4): frisches Periodenende aus Stripe statt des Spiegels
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }, 'A'), 3)
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }, 'C'), 1)
})
// Fix 1 (2): Aufgabenart aus details.kind; aeltere Ereignisse ohne kind (Aufgabe 7) nach der Richtung
test('storageTaskKind: details.kind, sonst Richtung', () => {
assert.equal(storageTaskKind({ details: { kind: 'einrichten', fromUnits: 3, toUnits: 1 } }), 'einrichten')
assert.equal(storageTaskKind({ details: { kind: 'periodenende', fromUnits: 1, toUnits: 2 } }), 'periodenende')
assert.equal(storageTaskKind({ details: { fromUnits: 0, toUnits: 2 } }), 'einrichten')
assert.equal(storageTaskKind({ details: { fromUnits: 2, toUnits: 1 } }), 'periodenende')
assert.equal(storageTaskKind({}), 'periodenende')
})
// ---------------------------------------------------------------------------
@@ -140,11 +170,12 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n
await resetSubH({ units: 0 })
// Stripe-Vorschau = naechste Rechnung: anteilige Speicher-Zeile fuer den Rest des Monats plus die
// regulaeren Zeilen des naechsten Monats (die zaehlen nicht zum anteiligen Betrag)
st.invoicePreview = previewInvoice(
line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Math.floor(Date.now() / 1000) }),
line('price_hosting', 1500),
line('price_mailStorage', 200, { quantity: 2 })
)
st.invoicePreview = (body) =>
previewInvoice(
line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }),
line('price_hosting', 1500),
line('price_mailStorage', 200, { quantity: 2 })
)
const before = st.calls.length
const p = await previewStorageChange('c1', 2)
assert.equal(p.direction, 'plus')
@@ -167,17 +198,20 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n
assert.deepEqual(subscriptionUpdates(before), [])
})
test('Vorschau Plus mit Posten: Gutschrift und Belastung der Speicher-Zeilen, ältere anteilige Zeilen zählen nicht', async () => {
// Fix 1 (6): nur die Zeilen dieser Vorschau (period.start = proration_date, ±2 s); eine Aenderung von vor
// einer Minute steckt schon als offene Rechnungsposition in der naechsten Rechnung und zaehlt nicht mit
test('Vorschau Plus mit Posten: Gutschrift und Belastung ab proration_date, ältere anteilige Zeilen zählen nicht', async () => {
await resetSubH({ units: 1 })
const now = Math.floor(Date.now() / 1000)
st.invoicePreview = previewInvoice(
// Aenderung von vorhin (schon als offene Rechnungsposition vorgemerkt): nicht Teil dieser Bestellung
line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: now - 3600 }),
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: now }),
line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: now }),
line('price_hosting', 1500),
line('price_mailStorage', 300, { quantity: 3 })
)
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: at - 60 }),
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at - 1 }),
line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: at + 1 }),
line('price_hosting', 1500),
line('price_mailStorage', 300, { quantity: 3 })
)
}
const before = st.calls.length
const p = await previewStorageChange('c1', 3)
assert.equal(p.prorationCents, 133)
@@ -212,18 +246,51 @@ test('Vorschau Minus bei gekündigtem Abo: wirksam mit dem Abo-Ende, wenn das fr
assert.equal((await previewStorageChange('c1', 1)).effectiveAt, cancelAt)
})
// F19: Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon bezahlten Menge (3)
// laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber
test('Vorschau Plus in zwei Schritten: anteilig nur für die Pakete über der bezahlten Menge', async () => {
// Fix 1 (1): Abos aus dem Checkout laufen im flexiblen Abrechnungsmodus (billing_mode.type 'flexible', bei
// Stripe der Standard). Dort schreibt Stripe den zuletzt berechneten Betrag gut - die Vorschau hat die im
// Monat schon bezahlten Pakete also selbst verrechnet und wird unveraendert angezeigt.
test('Vorschau Plus in zwei Schritten, flexibler Modus (Standard): Betrag der Stripe-Vorschau unverändert', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const now = Math.floor(Date.now() / 1000)
// Gutschrift der letzten Berechnung (3 Pakete, -200), Belastung fuer 5 Pakete (333) = 133 fuer die 2 Pakete darueber
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', -200, { quantity: 3, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }),
line('price_hosting', 1500),
line('price_mailStorage', 500, { quantity: 5 })
)
}
const p = await previewStorageChange('c1', 5)
assert.equal(p.prorationCents, 133)
assert.equal(p.direction, 'plus')
assert.equal(p.deltaMonthlyCents, 400)
// Ausdruecklich flexible: ebenfalls keine Umrechnung (F19 gilt nur im klassischen Modus)
await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'flexible' } } })
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at })
)
}
assert.equal((await previewStorageChange('c1', 5)).prorationCents, 266)
})
// F19 (klassischer Modus): Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon
// bezahlten Menge (3) laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber
test('Vorschau Plus in zwei Schritten, klassischer Modus: anteilig nur für die Pakete über der bezahlten Menge', async () => {
await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'classic' } } })
// Vorschau 1 -> 5: Gutschrift fuer 1 Paket (-67), Belastung fuer 5 Pakete (333) = 266 fuer 4 Pakete
st.invoicePreview = previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: now }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: now }),
line('price_hosting', 1500),
line('price_mailStorage', 500, { quantity: 5 })
)
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }),
line('price_hosting', 1500),
line('price_mailStorage', 500, { quantity: 5 })
)
}
const before = st.calls.length
const p = await previewStorageChange('c1', 5)
// 266 × (5 − max(1, 3)) / (5 − 1) = 133
@@ -268,7 +335,7 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(aw.docs('subscriptions').get('sub_h').storagePaidUnits, 2)
assert.equal(doc().storagePaidUnits, 2)
// Erste Position: mit Preis angelegt, nicht mit Posten-ID; anteilig ab jetzt (proration_date)
assert.equal(subscriptionUpdates(before).length, 1)
@@ -276,13 +343,10 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
assert.equal(upd.at(-1).body['items[0][id]'], undefined)
assert.match(upd.at(-1).body['proration_date'], /^\d{10}$/)
// Stripe-Nachbau hat jetzt den Speicher-Posten mit Menge 2, der Spiegel ebenso
const stripeSub = st.subscriptions.get('sub_h')
const storageItem = stripeSub.items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)
assert.equal(storageItem.quantity, 2)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 2)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
// Ereignis: Nachweis mit Zustimmung, Aufgabe faellig jetzt, Ziel und Wirksamkeit in den details
assert.equal(storageItem().quantity, 2)
assert.equal(doc().storageUnits, 2)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
// Ereignis: Nachweis mit Zustimmung, Aufgabe "einrichten" faellig jetzt, Ziel und Wirksamkeit in den details
assert.equal(ev.subscriptionId, 'sub_h')
assert.equal(ev.customerId, 'c1')
assert.equal(ev.productKey, 'hosting')
@@ -291,6 +355,7 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
assert.equal(ev.consentIp, '127.0.0.1')
assert.equal(ev.consentUserAgent, 'test')
assert.equal(ev.earlyStartConsentAt ?? null, null)
assert.equal(ev.details.kind, 'einrichten')
assert.equal(ev.details.fromUnits, 0)
assert.equal(ev.details.toUnits, 2)
assert.equal(ev.details.totalGb, 15)
@@ -301,15 +366,31 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
assert.equal(result.subscription.storageUnits, 2)
assert.equal(result.event.$id, ev.$id)
assert.deepEqual(
{ direction: result.change.direction, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits },
{ direction: 'plus', fromUnits: 0, toUnits: 2 }
{ direction: result.change.direction, kind: result.change.kind, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits, partial: result.change.partial },
{ direction: 'plus', kind: 'einrichten', fromUnits: 0, toUnits: 2, partial: false }
)
// K13 an den Kunden mit den Vertragsunterlagen der zugestimmten Fassung (F22), T13 ans Team
assert.equal(mails.sent.length, 2)
const customerMail = k13()
assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid'])
assert.deepEqual(customerMail.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']])
assert.ok(customerMail.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt')
for (const text of ['Guten Tag Kunde Eins,', '2 Pakete', '15 GB', '2,00 €', 'gemäß § 19 UStG ohne Umsatzsteuer', 'wird eingerichtet', '14 Tagen widerrufen']) {
assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`)
}
assert.doesNotMatch(customerMail.text, /ohne anteilige Erstattung/)
const teamMail = t13()
assert.equal(teamMail.subject, '[Speicher] einrichten: k@example.invalid')
for (const text of ['Kunde Eins', 'sub_h', 'Hosting-Abo: 1 Postfach', '15 GB', '2 Pakete']) {
assert.ok(teamMail.text.includes(text), `T13 ohne "${text}":\n${teamMail.text}`)
}
assert.doesNotMatch(teamMail.text, /Nachweis nicht gespeichert|Schritt 2 fehlgeschlagen/)
})
test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann anteilig', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const before = st.calls.length
await changeStorage('c1', 5, consent)
const result = await changeStorage('c1', 5, consent)
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 2)
assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher')
@@ -320,29 +401,40 @@ test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann ant
assert.equal(upd[1].body['items[0][quantity]'], '5')
assert.equal(upd[1].body['proration_behavior'], 'create_prorations')
assert.match(upd[1].body['proration_date'], /^\d{10}$/)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 5)
assert.equal(doc.storagePaidUnits, 5)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(doc().storageUnits, 5)
assert.equal(doc().storagePaidUnits, 5)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(result.change.kind, 'einrichten')
assert.equal(result.change.partial, false)
})
test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, bezahlte Menge bleibt', async () => {
// Fix 1 (2b): ein Plus bis zur bezahlten Menge kostet nichts und braucht keine Einrichtung - konsoleH steht
// noch auf der bezahlten Menge; zum Periodenende gilt dann die neue Zielgroesse statt der frueheren Verringerung
test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, Aufgabe zum Periodenende, kein Einrichten', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const before = st.calls.length
await changeStorage('c1', 3, consent)
const result = await changeStorage('c1', 3, consent)
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body['items[0][quantity]'], '3')
assert.equal(upd[0].body['proration_behavior'], 'none')
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 3)
assert.equal(doc.storagePaidUnits, 3)
assert.equal(doc().storageUnits, 3)
assert.equal(doc().storagePaidUnits, 3)
const [ev] = await listContractEvents('c1')
assert.equal(ev.details.kind, 'periodenende')
assert.equal(ev.details.toUnits, 3)
assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd)
assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung')
assert.equal(result.change.kind, 'periodenende')
assert.ok(Math.abs(new Date(result.change.effectiveAt).getTime() - Date.now()) < 5000, 'fuer den Kunden sofort wirksam')
assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`)
assert.ok(k13().attachments.length === 1, 'K13 mit PDF (Bestellung)')
})
test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => {
await assert.rejects(changeStorage('c1', 3, null), (e) => e.status === 400)
await assert.rejects(changeStorage('c1', 4, null), (e) => e.status === 400)
aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true })
await assert.rejects(changeStorage('c1', 3, consent), (e) => e.status === 409)
await assert.rejects(changeStorage('c1', 4, consent), (e) => e.status === 409)
})
test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufruf', async () => {
@@ -352,6 +444,7 @@ test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufr
await assert.rejects(changeStorage('c1', 1, {}), (e) => e.status === 400)
assert.equal(st.calls.length, before)
assert.deepEqual(await listContractEvents('c1'), [])
assert.deepEqual(mails.sent, [])
})
test('Keine Änderung bei trialing, past_due, cancelAt oder offenem Wechsel (409)', async () => {
@@ -404,18 +497,28 @@ test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd)
assert.equal(ev.details.kind, 'periodenende')
assert.deepEqual([ev.details.fromUnits, ev.details.toUnits, ev.details.effectiveAt], [3, 1, hostingDoc.currentPeriodEnd])
assert.equal(ev.consentIp ?? null, null)
assert.equal(ev.consentUserAgent ?? null, null)
assert.equal(ev.termsVersion ?? null, null)
assert.equal(ev.consentAt ?? null, null)
// Spiegel: Stripe-Menge sofort 1, bezahlt bleiben 3 fuer diesen Monat (ein Plus zurueck auf 3 kostet nichts)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 1)
assert.equal(doc.storagePaidUnits, 3)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(doc().storageUnits, 1)
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(result.change.direction, 'minus')
assert.equal(result.change.kind, 'periodenende')
assert.equal(result.change.effectiveAt, hostingDoc.currentPeriodEnd)
// K13 ohne Anhang mit dem Hinweis zum Monatsende, T13 "reduzieren zum"
const customerMail = k13()
assert.deepEqual(customerMail.attachments, [])
for (const text of [`zum ${PERIOD_END_LABEL}`, 'ohne anteilige Erstattung', 'keine neuen E-Mails', '10 GB', '1,00 €']) {
assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`)
}
assert.doesNotMatch(customerMail.text, /wird eingerichtet|14 Tagen widerrufen/)
assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`)
assert.ok(t13().text.includes(PERIOD_END_LABEL))
// Plus zurueck auf 3 im selben Monat: ohne Verrechnung
const before2 = st.calls.length
@@ -424,14 +527,29 @@ test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine
assert.equal(subscriptionUpdates(before2)[0].body['proration_behavior'], 'none')
})
// Fix 1 (4): jede Position war fuer den laufenden Monat berechnet - ein Minus haelt das fest, auch ohne
// fruehere Buchung (Altbestand); ein Plus zurueck bis dahin kostet dann nichts
test('Minus bucht die bezahlte Menge des Monats (max aus gebucht und aktuell), auch ohne frühere Buchung', async () => {
await resetSubH({ units: 3 }) // storagePaidPeriodEnd null: bisher galt nur die aktuelle Menge
await changeStorage('c1', 1, null)
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
const before = st.calls.length
await changeStorage('c1', 3, consent)
assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none')
// Eine hoehere Buchung bleibt beim Minus erhalten
await resetSubH({ units: 2, paidUnits: 4 })
await changeStorage('c1', 1, null)
assert.equal(doc().storagePaidUnits, 4)
})
test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wieder', async () => {
await resetSubH({ units: 2, paidUnits: 2 })
const before = st.calls.length
await changeStorage('c1', 0, null)
assert.equal(subscriptionUpdates(before)[0].body['items[0][quantity]'], '0')
const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)
assert.equal(storageItem().quantity, 0)
assert.equal(aw.docs('subscriptions').get('sub_h').storageUnits, 0)
assert.equal(doc().storageUnits, 0)
const before2 = st.calls.length
await changeStorage('c1', 4, consent)
const upd = subscriptionUpdates(before2)
@@ -441,72 +559,167 @@ test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wie
assert.equal(st.subscriptions.get('sub_h').items.data.length, 2, 'kein zweiter Speicher-Posten')
})
// F21: aeltere offene Aufgaben desselben Abos sind ueberholt; fuers Team zaehlt eine Aufgabe je Abo
test('Neue Änderung ersetzt die offene Aufgabe: eine Aufgabe je Abo mit Zielgröße und Datum', async () => {
// ---------------------------------------------------------------------------
// Aufgaben fuers Team nach Zeitpunkt (Fix 1, 2; F21): hoechstens eine offene Aufgabe je Art und Abo
// ---------------------------------------------------------------------------
test('Plus noch nicht eingerichtet, dann Minus: Einrichtung bleibt offen, dazu die Verringerung zum Periodenende', async () => {
await resetSubH({ units: 0 })
await changeStorage('c1', 2, consent)
const [plusEvent] = await listContractEvents('c1')
await changeStorage('c1', 1, null)
const events = await listContractEvents('c1')
assert.equal(events.length, 2)
const superseded = events.find((e) => e.$id === plusEvent.$id)
const tasks = await openTasks()
assert.equal(tasks.length, 2)
const setup = tasks.find((t) => storageTaskKind(t) === 'einrichten')
const reduction = tasks.find((t) => storageTaskKind(t) === 'periodenende')
assert.equal(setup.details.toUnits, 2)
assert.equal(setup.details.totalGb, 15)
assert.ok(Math.abs(new Date(setup.dueAt).getTime() - Date.now()) < 10000)
assert.equal(reduction.details.toUnits, 1)
assert.equal(reduction.details.totalGb, 10)
assert.equal(reduction.dueAt, hostingDoc.currentPeriodEnd)
assert.equal((await listContractEvents('c1')).filter((e) => e.status === 'offen').length, 2)
})
test('Minus, dann Plus bis zur bezahlten Menge: eine offene Aufgabe zum Periodenende mit der neuen Zielgröße', async () => {
await resetSubH({ units: 3, paidUnits: 3 })
await changeStorage('c1', 1, null)
const [minusEvent] = await listContractEvents('c1')
await changeStorage('c1', 2, consent)
const tasks = await openTasks()
assert.equal(tasks.length, 1)
assert.equal(storageTaskKind(tasks[0]), 'periodenende')
assert.equal(tasks[0].details.toUnits, 2)
assert.equal(tasks[0].dueAt, hostingDoc.currentPeriodEnd)
const superseded = (await listContractEvents('c1')).find((e) => e.$id === minusEvent.$id)
assert.equal(superseded.status, 'erledigt')
assert.equal(superseded.doneBy, 'ersetzt')
assert.ok(superseded.doneAt)
assert.deepEqual(superseded.details, plusEvent.details, 'details des ersetzten Ereignisses bleiben')
const open = (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h')
assert.equal(open.length, 1)
assert.equal(open[0].details.toUnits, 1)
assert.equal(open[0].dueAt, hostingDoc.currentPeriodEnd)
// Ein fremdes Abo (c2) bleibt unberuehrt
assert.deepEqual(superseded.details, minusEvent.details, 'details des ersetzten Ereignisses bleiben')
})
test('Plus über die geplante Verringerung hinaus hebt sie auf: nur noch die Einrichtung', async () => {
await resetSubH({ units: 3, paidUnits: 3 })
await changeStorage('c1', 1, null)
await changeStorage('c1', 5, consent)
const tasks = await openTasks()
assert.equal(tasks.length, 1)
assert.equal(storageTaskKind(tasks[0]), 'einrichten')
assert.equal(tasks[0].details.toUnits, 5)
const events = await listContractEvents('c1')
assert.equal(events.filter((e) => e.status === 'erledigt' && e.doneBy === 'ersetzt').length, 1)
})
test('Neues Plus ersetzt die offene Einrichtung; ein fremdes Abo bleibt unberührt', async () => {
await resetSubH({ units: 0 })
await changeStorage('c1', 2, consent)
const [first] = await listContractEvents('c1')
await recordContractEvent({
customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(),
details: { kind: 'einrichten', fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(),
})
await changeStorage('c1', 2, consent)
await changeStorage('c1', 3, consent)
const tasks = await openTasks()
assert.equal(tasks.length, 1)
assert.equal(tasks[0].details.toUnits, 3)
const superseded = (await listContractEvents('c1')).find((e) => e.$id === first.$id)
assert.equal(superseded.status, 'erledigt')
assert.equal(superseded.doneBy, 'ersetzt')
assert.equal((await listContractEvents('c2'))[0].status, 'offen')
await clearEvents('c2')
})
// F20 / Rechtsrunde: ueber das Kuendigungsformular (viaCancellation) sind Verringerungen bei jedem
// laufenden Status und auch bei gekuendigtem Abo erlaubt, Erhoehungen nie; K13 entfaellt dort (F31)
test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie', async () => {
test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie, K13 entfällt', async () => {
await resetSubH({
units: 2,
patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd },
stripe: { cancel_at_period_end: true },
})
const result = await changeStorage('c1', 1, null, { viaCancellation: true })
assert.equal(result.change.direction, 'minus')
assert.equal(doc().storageUnits, 1)
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.viaCancellation, true)
assert.equal(ev.consentIp ?? null, null)
assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)')
assert.ok(t13(), 'T13 geht trotzdem raus')
assert.equal(mails.sent.length, 1)
await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409)
// Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus
assert.equal(doc().cancelAtPeriodEnd, true)
await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409)
// past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr
await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } })
assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0)
assert.equal(doc().status, 'past_due')
await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } })
await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409)
})
// ---------------------------------------------------------------------------
// Fehlerpfade (Fix 1, 3/7/8): nach der Stripe-Aenderung bricht nichts mehr stillschweigend ab
// ---------------------------------------------------------------------------
test('Stripe lehnt die Änderung ab: Ereignis gescheitert, Fehler an den Kunden, nichts gebucht, keine Mails', async () => {
await resetSubH({ units: 0 })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'No such price: price_weg' })
await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 502 && /Stripe/.test(e.message) && /nichts berechnet/.test(e.message))
const events = await listContractEvents('c1')
assert.equal(events.length, 1)
assert.equal(events[0].status, 'gescheitert')
assert.equal(events[0].details.kind, 'einrichten')
assert.match(events[0].details.error, /No such price/)
assert.equal(events[0].termsVersion, consent.termsVersion, 'Nachweis der Bestellung bleibt')
assert.equal(storageItem(), undefined, 'nichts bei Stripe')
assert.equal(doc().storageUnits, 0)
assert.equal(doc().storagePaidUnits, 0)
assert.deepEqual(mails.sent, [])
assert.deepEqual(await openTasks(), [])
// Danach geht eine neue Aenderung wieder (Sperre freigegeben)
assert.equal((await changeStorage('c1', 2, consent)).change.toUnits, 2)
})
test('Spiegel nicht aktualisierbar nach der Stripe-Änderung: Aufruf gelingt, Ereignis offen, K13 und T13', async () => {
await resetSubH({ units: 0 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
const result = await changeStorage('c1', 1, null, { viaCancellation: true })
assert.equal(result.change.direction, 'minus')
assert.equal(aw.docs('subscriptions').get('sub_h').storageUnits, 1)
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.viaCancellation, true)
assert.equal(ev.consentIp ?? null, null)
assert.ok(!errors.some((e) => e.includes('K13')), 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)')
// Erste PATCH nach der Stripe-Aenderung ist der Spiegel (syncSubscription)
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 })
const result = await changeStorage('c1', 2, consent)
assert.equal(result.subscription.storageUnits, 2, 'Rueckgabe zeigt die neue Menge')
assert.equal(result.event.status, 'offen')
assert.equal(storageItem().quantity, 2)
assert.ok(errors.some((e) => /Spiegel/.test(e) && /sub_h/.test(e)))
assert.equal(mails.sent.length, 2)
assert.ok(k13() && t13())
} finally {
console.error = original
}
})
await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409)
// Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus
assert.equal(aw.docs('subscriptions').get('sub_h').cancelAtPeriodEnd, true)
await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409)
// past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr
await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } })
assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0)
assert.equal(aw.docs('subscriptions').get('sub_h').status, 'past_due')
await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } })
await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409)
// Zum Vergleich: ein Minus im Portal versucht K13 (scheitert hier nur am fehlenden SMTP)
errors.length = 0
await resetSubH({ units: 2 })
await changeStorage('c1', 1, null)
assert.ok(errors.some((e) => e.includes('K13')), 'K13 wird im Portal versucht')
test('Ereignis nicht speicherbar: Stripe-Änderung läuft, T13 mit Block nachweis_fehlt', async () => {
await resetSubH({ units: 0 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
const result = await changeStorage('c1', 1, consent)
assert.equal(result.event, null)
assert.equal(result.subscription.storageUnits, 1)
assert.equal(storageItem().quantity, 1)
assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e)))
assert.deepEqual(await listContractEvents('c1'), [])
assert.ok(k13(), 'K13 geht raus')
assert.match(t13().text, /Nachweis nicht gespeichert/)
assert.match(t13().text, /contractEvents/)
} finally {
console.error = original
}
@@ -524,33 +737,59 @@ test('Bezahlte Menge nicht speicherbar: Stripe-Änderung bleibt, Ereignis und Au
assert.equal(result.change.direction, 'plus')
assert.ok(result.event)
assert.equal(result.subscription.storageUnits, 2)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 2)
assert.equal(doc.storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch')
assert.equal(doc().storageUnits, 2)
assert.equal(doc().storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch')
assert.ok(errors.some((e) => /Bezahlte Menge/.test(e) && /sub_h/.test(e) && /400/.test(e)))
assert.equal((await listContractEvents('c1')).length, 1)
assert.equal(mails.sent.length, 2)
} finally {
console.error = original
}
})
test('Ereignis nicht speicherbar: Stripe-Änderung bleibt, Aufruf gelingt, Fehler im Log', async () => {
await resetSubH({ units: 0 })
// Fix 1 (7): scheitert Schritt 2 nach Schritt 1, bleibt die Bestellung offen (partial), das Team setzt die
// Menge in Stripe von Hand; nichts wird stillschweigend verworfen und dem Kunden nichts Falsches gemeldet
test('Zwei Schritte, zweiter scheitert: Menge bleibt bei der bezahlten, Ereignis offen mit partial, T13 mit Handgriff', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
const result = await changeStorage('c1', 1, consent)
assert.equal(result.event, null)
assert.equal(result.subscription.storageUnits, 1)
assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e)))
assert.deepEqual(await listContractEvents('c1'), [])
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, after: 1, message: 'Nachbau: Schritt 2 abgelehnt' })
const result = await changeStorage('c1', 5, consent)
assert.equal(result.change.partial, true)
assert.equal(result.change.toUnits, 5)
assert.equal(storageItem().quantity, 3, 'Schritt 1 gilt')
assert.equal(doc().storageUnits, 3)
assert.equal(doc().storagePaidUnits, 3, 'nichts ueber die bezahlte Menge hinaus gebucht')
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.partial, true)
assert.equal(ev.details.toUnits, 5)
assert.match(ev.details.error, /Schritt 2 abgelehnt/)
assert.ok(errors.some((e) => /Schritt 2/.test(e)))
assert.match(t13().text, /Schritt 2 fehlgeschlagen/)
assert.match(t13().text, /von Hand auf 5 setzen/)
assert.ok(k13(), 'Bestellung bestaetigt')
} finally {
console.error = original
}
})
// Fix 1 (8): Doppelklick oder zwei Tabs - die zweite gleichzeitige Aenderung desselben Abos bekommt 409
test('Doppelte Änderung gleichzeitig: die zweite bekommt 409, danach geht es wieder', async () => {
await resetSubH({ units: 0 })
const results = await Promise.allSettled([changeStorage('c1', 2, consent), changeStorage('c1', 3, consent)])
const ok = results.filter((r) => r.status === 'fulfilled')
const failed = results.filter((r) => r.status === 'rejected')
assert.equal(ok.length, 1)
assert.equal(failed.length, 1)
assert.equal(failed[0].reason.status, 409)
assert.match(failed[0].reason.message, /läuft bereits/)
assert.equal((await listContractEvents('c1')).length, 1)
assert.equal((await changeStorage('c1', 4, consent)).change.toUnits, 4, 'Sperre freigegeben')
})
// ---------------------------------------------------------------------------
// Bezahlte Menge aus Rechnungen (Webhook invoice.paid)
// ---------------------------------------------------------------------------
@@ -570,9 +809,8 @@ test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => {
],
},
})
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storagePaidUnits, 3)
assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
})
test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert nichts', async () => {
@@ -581,9 +819,26 @@ test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert
assert.ok(r1.skipped)
const r2 = await recordPaidStorage({ id: 'in_y', lines: { data: [line('price_hosting', 1500)] } })
assert.ok(r2.skipped)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storagePaidUnits, 2)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(doc().storagePaidUnits, 2)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
})
// Fix 1 (4): eine verspaetete Rechnung des laufenden Monats senkt die nach einem Plus gebuchte Menge nicht,
// eine wiederholte aeltere Rechnung setzt das Periodenende nie zurueck, eine neuere Periode setzt neu
test('invoice.paid: verspätet nach einem Plus (max), ältere Rechnung (übersprungen), neuere Periode (neu)', async () => {
await resetSubH({ units: 5, paidUnits: 5 })
const late = await recordPaidStorage({ id: 'in_late', lines: { data: [line('price_mailStorage', 300, { quantity: 3 })] } })
assert.equal(late.recorded, true)
assert.equal(doc().storagePaidUnits, 5)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
const older = await recordPaidStorage({ id: 'in_alt', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX - 30 * DAY })] } })
assert.ok(older.skipped)
assert.equal(doc().storagePaidUnits, 5)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
const newer = await recordPaidStorage({ id: 'in_neu', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] } })
assert.equal(newer.recorded, true)
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, new Date((PERIOD_END_UNIX + 30 * DAY) * 1000).toISOString())
})
test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', async () => {
@@ -592,16 +847,47 @@ test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', a
try {
const r = await recordPaidStorage({ id: 'in_z', lines: { data: [line('price_mailStorage', 100, { quantity: 1, subscription: subId, periodEnd: 1793000000 })] } })
assert.equal(r.recorded, true)
const doc = aw.docs('subscriptions').get(subId)
assert.equal(doc.customerId, 'c2')
assert.equal(doc.storageUnits, 1)
assert.equal(doc.storagePaidUnits, 1)
assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
const created = aw.docs('subscriptions').get(subId)
assert.equal(created.customerId, 'c2')
assert.equal(created.storageUnits, 1)
assert.equal(created.storagePaidUnits, 1)
assert.equal(created.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
} finally {
await deleteDocument(config.collections.subscriptions, subId).catch(() => {})
}
})
// Fix 1 (5): wie beim Webhook aus Aufgabe 7 - ein dauerhafter Appwrite-Fehler (4xx) wird protokolliert und
// uebersprungen (200), ein voruebergehender (5xx) gibt 500, Stripe wiederholt
test('Webhook invoice.paid: Appwrite 4xx → 200 und übersprungen, 5xx → 500', async () => {
await resetSubH({ units: 2, paidUnits: 2 })
const now = Math.floor(Date.now() / 1000)
st.invoices.set('in_h2', {
id: 'in_h2', object: 'invoice', number: 'WK-h2', created: now, status_transitions: { paid_at: now },
parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } },
lines: { object: 'list', data: [line('price_hosting', 1500, { periodEnd: PERIOD_END_UNIX + 30 * DAY }), line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] },
})
const event = { id: 'evt_in_h2', object: 'event', type: 'invoice.paid', created: now, data: { object: { id: 'in_h2', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } } } }
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 400 })
const permanent = await postWebhook(event)
assert.equal(permanent.status, 200, JSON.stringify(permanent.body))
assert.equal(doc().storagePaidUnits, 2, 'uebersprungen')
assert.ok(errors.some((e) => /Bezahlte Menge|Speicher/.test(e) && /400/.test(e)))
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 })
assert.equal((await postWebhook(event)).status, 500)
assert.equal(doc().storagePaidUnits, 2)
// Wiederholung ohne Stoerung bucht
assert.equal((await postWebhook(event)).status, 200)
assert.equal(doc().storagePaidUnits, 3)
} finally {
console.error = original
}
})
// F18: der Webhook laedt die Rechnung genau einmal frisch (mit allen Zeilen, auch ueber die 10 eingebetteten
// hinaus) und bucht daraus Stundengutschrift und bezahlte Speichermenge
test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde und bezahlte Speichermenge', async () => {
@@ -635,11 +921,11 @@ test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde
assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_w12').length, 1, 'genau einmal frisch geladen')
assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_w12/lines'), 'weitere Zeilen nachgeladen')
assert.equal(aw.docs('hourCredits').get('in_w12').minutes, 60)
const doc = aw.docs('subscriptions').get('sub_w')
assert.equal(doc.productKey, 'wartung')
assert.equal(doc.storageUnits, 2)
assert.equal(doc.storagePaidUnits, 2)
assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
const mirrored = aw.docs('subscriptions').get('sub_w')
assert.equal(mirrored.productKey, 'wartung')
assert.equal(mirrored.storageUnits, 2)
assert.equal(mirrored.storagePaidUnits, 2)
assert.equal(mirrored.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_w').catch(() => {})
await deleteDocument(config.collections.hourCredits, 'in_w12').catch(() => {})
@@ -663,7 +949,8 @@ test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Strip
test('Route Vorschau: { units } → Vorschau; Grenzen 400', async () => {
await resetSubH({ units: 0 })
st.invoicePreview = previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Math.floor(Date.now() / 1000) }))
st.invoicePreview = (body) =>
previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }))
const res = await post('/api/abo/storage/preview', { units: 2 }, { cookie: customerCookie() })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.direction, 'plus')
@@ -692,6 +979,7 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor
assert.equal(plus.body.subscription.withdrawalOpen, true)
assert.deepEqual(plus.body.subscription.withdrawals.map((w) => w.type), ['speicher'])
assert.equal(plus.body.change.direction, 'plus')
assert.equal(plus.body.change.kind, 'einrichten')
assert.equal(plus.body.change.toUnits, 2)
assert.equal(subscriptionUpdates(before).length, 1)
const [ev] = await listContractEvents('c1')
@@ -699,15 +987,16 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor
assert.equal(ev.consentIp, '127.0.0.1')
assert.ok(ev.consentAt)
// Minus ohne Haekchen: Speichern; Antwort zeigt die geplante Verringerung
// Minus ohne Haekchen: Speichern; die Einrichtung ist noch offen, dazu die geplante Verringerung
const minus = await post('/api/abo/storage', { units: 1 }, { cookie })
assert.equal(minus.status, 200, JSON.stringify(minus.body))
assert.deepEqual(minus.body.subscription.storage, {
units: 1,
pendingSetup: false,
pendingSetup: true,
pendingReduction: { units: 1, effectiveAt: minus.body.subscription.currentPeriodEnd },
})
assert.equal(minus.body.change.direction, 'minus')
assert.equal(minus.body.change.kind, 'periodenende')
assert.equal(minus.body.change.effectiveAt, minus.body.subscription.currentPeriodEnd)
// Gekuendigt: 409 mit Fehlertext
@@ -717,3 +1006,29 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor
assert.ok(cancelled.body.error)
await clearEvents('c1')
})
// Fix 1 (3): Stripe-Fehler → 5xx mit Fehlertext, nichts gebucht; Spiegel-Fehler nach Stripe → 200 mit neuer Menge
test('Route Änderung: Stripe-Fehler 502 ohne Mails, Spiegel-Fehler nach der Stripe-Änderung 200', async () => {
await resetSubH({ units: 0 })
const cookie = customerCookie()
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' })
const failed = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie })
assert.equal(failed.status, 502)
assert.match(failed.body.error, /Stripe/)
assert.deepEqual(mails.sent, [])
assert.equal((await listContractEvents('c1'))[0].status, 'gescheitert')
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 })
const ok = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(ok.body.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null })
assert.equal((await listContractEvents('c1')).find((e) => e.status === 'offen').details.toUnits, 2)
assert.equal(mails.sent.length, 2)
} finally {
console.error = original
}
})
+40 -2
View File
@@ -4,13 +4,16 @@ import { test } from 'node:test'
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9; K13/T13 und die Endfassung der Texte folgen in Task 12
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// die Endfassung der Texte folgt in Task 12
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -26,7 +29,8 @@ for (const id of EXPECTED) {
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]')
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')
@@ -200,3 +204,37 @@ test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => {
assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id)
}
})
// Task 8 (Fix 1): K13 (Kunde) und T13 (Team) fuer den Zusatzspeicher, Aufbau wie K12/T12; Endfassung der Texte in Aufgabe 12
test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => {
assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung')
assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab'])
assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'minus'])
assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}')
for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) {
assert.ok(loadTemplate('T13').placeholders.includes(p), p)
}
assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise'])
const values = sampleValues(loadTemplate('K13'))
const plus = renderMail('K13', values, { plus: true })
assert.deepEqual(plus.problems, [])
assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/)
assert.match(plus.text, /wird eingerichtet/)
assert.match(plus.text, /14 Tagen widerrufen/)
assert.match(plus.text, /Vertragsunterlagen im Anhang/)
assert.doesNotMatch(plus.text, /ohne anteilige Erstattung/)
const minus = renderMail('K13', values, { minus: true })
assert.match(minus.text, /ohne anteilige Erstattung/)
assert.match(minus.text, /keine neuen E-Mails/)
assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang/)
const t = loadTemplate('T13')
const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true })
assert.deepEqual(teilweise.problems, [])
assert.match(teilweise.text, /Schritt 2 fehlgeschlagen/)
assert.match(teilweise.text, /von Hand auf 5 setzen/)
assert.match(renderMail('T13', sampleValues(t), { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/)
assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/)
})
+8
View File
@@ -196,3 +196,11 @@ test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein
assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null)
assert.equal(regularStorageLine({}, keys), null)
})
// Task 8 (Fix 1, Testluecke 9): eine Rechnung nur mit Zusatzspeicher bringt keine Stunde, auch nicht als
// "Altbestand" (der Speicherpreis ist bekannt, nur kein Abo-Produkt)
test('Nur Zusatzspeicher auf der Rechnung: keine Stunde', () => {
const inv = (...lines) => ({ lines: { data: lines } })
assert.equal(creditMinutesForInvoice(inv({ ...line('price_s', 300), quantity: 3 }), keys), 0)
assert.equal(creditMinutesForInvoice(inv(line('price_s', 100), line('price_s', 33, true)), keys), 0)
})
+19
View File
@@ -257,3 +257,22 @@ test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => {
await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404)
st.invoicePreview = null
})
// F41d / Task 8 (Fix 1): Fehler im Stripe-Nachbau einplanen - einmalig, nur fuer den passenden Aufruf
// (method, path als String oder RegExp); after passende Aufrufe gehen vorher durch. Der gescheiterte Aufruf
// steht trotzdem in calls; 4xx wiederholt stripe-node nicht (5xx schon, maxNetworkRetries).
test('Stripe-Nachbau: failNext lässt den nächsten passenden Aufruf scheitern', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_f', { id: 'sub_f', object: 'subscription', status: 'active', metadata: {}, items: { object: 'list', data: [] } })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_f', status: 400, message: 'Nachbau: abgelehnt' })
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } }), (e) => e.statusCode === 400 && /abgelehnt/.test(e.message))
assert.equal(st.calls.at(-1).body['metadata[a]'], 'b', 'gescheiterter Aufruf steht in calls')
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, undefined, 'gescheiterter Aufruf aendert nichts')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } })).metadata.a, 'b', 'einmalig')
// Nur der passende Pfad (RegExp) und erst nach `after` passenden Aufrufen
st.failNext({ method: 'POST', path: /\/v1\/subscriptions\/sub_f$/, status: 402, after: 1 })
assert.equal((await stripe.subscriptions.retrieve('sub_f')).id, 'sub_f')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'c' } })).metadata.a, 'c')
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402)
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c')
})
+25 -1
View File
@@ -38,8 +38,24 @@ export async function startFakeStripe() {
let seq = 0
let nextSubscriptionUpdate = null
let invoicePreview = null
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
let pendingFailure = null
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
const plannedFailure = (method, pathname) => {
if (!pendingFailure) return null
if (pendingFailure.method && pendingFailure.method !== method) return null
if (pendingFailure.path && !matchesPath(pendingFailure.path, pathname)) return null
if (pendingFailure.after > 0) {
pendingFailure.after -= 1
return null
}
const { status, message } = pendingFailure
pendingFailure = null
return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }]
}
const flat = (params) => Object.fromEntries(params.entries())
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
@@ -337,7 +353,7 @@ export async function startFakeStripe() {
const url = new URL(req.url, 'http://127.0.0.1')
const body = await readBody(req)
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) })
const [status, payload] = route(req.method, url.pathname, url.searchParams, body)
const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
@@ -380,6 +396,14 @@ export async function startFakeStripe() {
set invoicePreview(value) {
invoicePreview = value
},
/**
* Naechster passender Aufruf (method, path als String oder RegExp) scheitert einmalig mit status; after
* passende Aufrufe gehen vorher durch. Der Aufruf steht trotzdem in calls. 4xx wiederholt stripe-node
* nicht, 5xx schon (maxNetworkRetries) - fuer "Stripe lehnt ab" also 4xx einplanen.
*/
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { method, path, status, message, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}