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:
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. ͏ ͏ ͏</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;">✓ 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;"> / 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 →</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;">📎 Vertragsbedingungen mit den Regeln zum E-Mail-Zusatzspeicher<br>📎 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> · <a class="mut" href="https://project.webklar.com/widerrufen.html" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a> · <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. ͏ ͏ ͏</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;"> </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,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
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user