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

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

No files matched your search

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K13 · E-Mail-Zusatzspeicher (Bestätigung)
Betreff: Ihr E-Mail-Zusatzspeicher – Bestätigung
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang bei einer Bestellung (Plus): Vertragsunterlagen der zugestimmten Fassung (PDF)
Platzhalter: {{name}}, {{pakete}}, {{gesamt_gb}}, {{monatspreis_zusatz}}, {{wirksam_ab}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, minus
Stand Aufgabe 8 (Fix 1): plus = Bestellung (sofort, anteilig, mit Widerrufshinweis und PDF), minus = Verringerung zum Ende des Abrechnungsmonats (Spec 3.4); Endfassung der Texte in Aufgabe 12.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihr E-Mail-Zusatzspeicher – Bestätigung</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ihr E-Mail-Zusatzspeicher: {{pakete}} Pakete, {{gesamt_gb}} GB gesamt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; E-Mail-Zusatzspeicher</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr E-Mail-Zusatzspeicher</td></tr>
<!-- WENN plus -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihre Bestellung. Hiermit bestätigen wir Ihren E-Mail-Zusatzspeicher.</td></tr>
<!-- ENDE plus -->
<!-- WENN minus -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>hiermit bestätigen wir die Änderung Ihres E-Mail-Zusatzspeichers.</td></tr>
<!-- ENDE minus -->
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">E-Mail-Zusatzspeicher</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{monatspreis_zusatz}}<span class="mut" style="font-size:15px;font-weight:normal;letter-spacing:0;color:#6b6964;">&nbsp;/ Monat</span></div><div class="mut" style="font-size:12px;line-height:18px;color:#6b6964;">gemäß § 19 UStG ohne Umsatzsteuer</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zusatzspeicher</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{pakete}} Pakete</strong> zu je 5 GB, 1,00 € je Paket und Monat</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail-Speicher gesamt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB in Ihrem Abo enthalten plus Zusatzspeicher)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Wirksam ab</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wirksam_ab}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Laufzeit</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">Monatlich, jederzeit zum Ende des Abrechnungsmonats kündbar. Der Zusatzspeicher endet spätestens mit Ihrem Abo.</td></tr></table></td></tr>
<!-- WENN plus -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bereitstellung und Abrechnung</div><div class="mut" style="color:#4a4843;">Wir stellen die Grenzen Ihrer Postfächer in der Regel innerhalb eines Werktags um; bis dahin zeigt das Kundenportal „wird eingerichtet“. Den Rest des laufenden Abrechnungsmonats berechnen wir anteilig mit der nächsten Abbuchung, danach {{monatspreis_zusatz}} im Monat. Pakete, die für den laufenden Monat schon bezahlt waren, berechnen wir nicht noch einmal.</div></td></tr></table></td></tr>
<!-- ENDE plus -->
<!-- WENN minus -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wirksam zum Ende des Abrechnungsmonats</div><div class="mut" style="color:#4a4843;">Ihre Änderung wirkt zum {{wirksam_ab}}, ohne anteilige Erstattung; bis dahin bleibt der bisherige Speicher bestehen. Passen die gespeicherten E-Mails dann nicht mehr hinein, nimmt das betroffene Postfach keine neuen E-Mails an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE minus -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<!-- WENN plus -->
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihre Vertragsunterlagen im Anhang (PDF)</div><div class="mut" style="color:#4a4843;">&#128206;&nbsp; Vertragsbedingungen mit den Regeln zum E-Mail-Zusatzspeicher<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular</div></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Sie können diese Bestellung innerhalb von 14 Tagen widerrufen; die Widerrufsbelehrung finden Sie in den beigefügten Vertragsunterlagen. Widerrufen Sie, nehmen wir die Pakete zurück und erstatten alles, was Sie dafür gezahlt haben.</td></tr>
<!-- ENDE plus -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a class="mut" href="https://project.webklar.com/widerrufen.html" style="color:#4a4843;text-decoration:underline;">Vertrag widerrufen</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a class="mut" href="https://project.webklar.com/kuendigen.html" style="color:#4a4843;text-decoration:underline;">Vertrag kündigen</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T13 · Speicher-Aufgabe (E-Mail-Zusatzspeicher in konsoleH)
Betreff: [Speicher] {{aktion}}: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{aktion}}, {{email}}, {{name}}, {{stripe_abo_id}}, {{postfaecher_hinweis}}, {{neue_groesse_gb}}, {{pakete}}, {{faellig}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): einrichten, periodenende, teilweise, nachweis_fehlt
Stand Aufgabe 8 (Fix 1): aktion ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“; einrichten (Plus über die bezahlte Menge, sofort), periodenende (Minus oder Plus bis zur bezahlten Menge, zum Periodenende), teilweise (Schritt 2 bei Stripe fehlgeschlagen), nachweis_fehlt; Endfassung in Aufgabe 12.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Speicher] {{aktion}}: {{email}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{name}}: E-Mail-Zusatzspeicher {{aktion}}, {{neue_groesse_gb}} GB gesamt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">E-Mail-Speicher</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Zusatzspeicher {{aktion}}</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Postfächer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{postfaecher_hinweis}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Neue Gesamtgröße</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{neue_groesse_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB inklusive + {{pakete}} Pakete Zusatzspeicher zu 5 GB)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Fällig</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{faellig}}</td></tr></table></td></tr>
<!-- WENN einrichten -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#eef6ef;border:1px solid #c9e3cd;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Jetzt einrichten</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen in konsoleH auf {{neue_groesse_gb}} GB anheben, in der Regel innerhalb eines Werktags; bis dahin zeigt das Portal „wird eingerichtet“. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE einrichten -->
<!-- WENN periodenende -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zum Periodenende</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen zum {{faellig}} auf {{neue_groesse_gb}} GB setzen, nicht früher: Bis dahin ist die bisherige Menge bezahlt, eine Erstattung gibt es nicht. Der Kunde weiß, dass ein volles Postfach danach keine neuen E-Mails annimmt. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE periodenende -->
<!-- WENN teilweise -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Schritt 2 fehlgeschlagen – Menge in Stripe von Hand auf {{pakete}} setzen</div><div class="mut" style="color:#4a4843;">Der Kunde hat {{pakete}} Pakete bestellt. Stripe hat nur den ersten Schritt übernommen (die im Monat schon bezahlte Menge, ohne Verrechnung); der zweite Schritt wurde abgelehnt. Bitte in Stripe die Stückzahl der Position „E-Mail-Zusatzspeicher“ auf {{pakete}} setzen, anteilig ab heute (create_prorations). Das Ereignis „speicher“ trägt partial: true; die Einrichtung in konsoleH gilt für die volle Bestellung.</div></td></tr></table></td></tr>
<!-- ENDE teilweise -->
<!-- WENN nachweis_fehlt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „speicher“ (Nachweis der Bestellung und Aufgabe fürs Team) konnte nicht in Appwrite gespeichert werden. Bitte in contractEvents nachtragen; bis dahin ist diese Mail die einzige Aufgabe, im Admin-Bereich erscheint sie nicht.</div></td></tr></table></td></tr>
<!-- ENDE nachweis_fehlt -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+9 -6
View File
@@ -9,7 +9,7 @@ import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent }
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
import {
handlePendingUpdateApplied,
@@ -80,8 +80,11 @@ function publicSubscription(sub, events = []) {
const productKey = productKeyOf(sub)
const own = events.filter((e) => e.subscriptionId === sub.$id)
const openOf = (type) => own.filter((e) => e.type === type && e.status === 'offen')
const pendingPlus = openOf('speicher').find((e) => e.details?.toUnits > e.details?.fromUnits)
const pendingMinus = openOf('speicher').find((e) => e.details?.toUnits < e.details?.fromUnits)
// Offene Speicher-Aufgaben nach Art (Task 8, Fix 1): "einrichten" (Plus ueber die bezahlte Menge, konsoleH
// noch nicht umgestellt) und "periodenende" (Minus oder Plus bis zur bezahlten Menge, gilt ab Periodenende)
const openStorage = openOf('speicher')
const pendingSetup = openStorage.some((e) => storageTaskKind(e) === 'einrichten')
const pendingReduction = openStorage.find((e) => storageTaskKind(e) === 'periodenende') || null
const pendingSwitch = openOf('wechsel')[0] || null
// Plus/Minus und Wechsel nur bei Status active, ohne Kuendigung und ohne offene Wechselzahlung
const cancelled = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
@@ -120,9 +123,9 @@ function publicSubscription(sub, events = []) {
storage: {
units: Number(sub.storageUnits) || 0,
// Plus bestellt, konsoleH noch nicht umgestellt ("wird eingerichtet")
pendingSetup: Boolean(pendingPlus),
// Minus bestellt, wirkt zum Periodenende ("ab TT.MM. X GB")
pendingReduction: pendingMinus ? { units: pendingMinus.details.toUnits, effectiveAt: pendingMinus.dueAt || null } : null,
pendingSetup,
// Zielgroesse ab Periodenende ("ab TT.MM. X GB")
pendingReduction: pendingReduction ? { units: pendingReduction.details?.toUnits ?? null, effectiveAt: pendingReduction.dueAt || null } : null,
},
canChangeStorage,
canSwitch: canChangeStorage && productKey === 'hosting',
+248 -91
View File
@@ -25,12 +25,20 @@ import {
* bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten
* (planStorageChange).
* - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in
* konsoleH sinkt erst zum Ende des Abrechnungsmonats (Aufgabe fuers Team, dueAt = Periodenende).
* - Jede Aenderung ist ein Ereignis `speicher` mit Status offen (= Aufgabe fuers Team in konsoleH); nur ein
* Plus traegt Zustimmungsdaten, ein Minus keine (Rechtsrunde 28.09.2026). Eine neue Aenderung ersetzt
* die aeltere offene Aufgabe desselben Abos (F21): eine Aufgabe je Abo mit Zielgroesse und Datum.
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus gesetzt, sonst
* aus der regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
* konsoleH sinkt erst zum Ende des Abrechnungsmonats.
* - Jede Aenderung ist ein Ereignis `speicher` (Nachweis; Status offen = Aufgabe fuers Team). Die Aufgabe
* hat eine Art (details.kind): "einrichten" (Plus ueber die bezahlte Menge, sofort, Ziel = neue Menge)
* oder "periodenende" (Minus oder Plus bis zur bezahlten Menge, faellig zum Periodenende, Ziel = neue
* Menge). Eine neue Aufgabe ersetzt aeltere offene derselben Art; eine Einrichtung ersetzt ausserdem eine
* geplante Verringerung mit kleinerem Ziel; eine Verringerung laesst eine offene Einrichtung stehen. So
* gibt es hoechstens eine offene Aufgabe je Art und Abo. Nur ein Plus traegt Zustimmungsdaten
* (Rechtsrunde 28.09.2026).
* - Reihenfolge: Ereignis zuerst (offen), dann Stripe. Lehnt Stripe ab, wird das Ereignis "gescheitert"
* und der Kunde sieht den Fehler; nach einer wirksamen Stripe-Aenderung bricht nichts mehr ab
* (Spiegel, Buchung, Aufgaben, Mails: Fehler im Log, T13 sagt es dem Team).
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus max(bezahlt,
* neu), beim Minus max(bezahlt, bisher) - jede Position war fuer den Monat berechnet; sonst aus der
* regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
*/
function httpError(status, message) {
@@ -46,10 +54,16 @@ const fmtDate = (iso) =>
// Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null)
const sameInstant = (a, b) => a === b || new Date(a).getTime() === new Date(b).getTime()
// Appwrite-Fehler: 4xx dauerhaft (Attribut/Collection fehlt, ungueltige Daten), 5xx/Netz voruebergehend (wie Task 7)
const isPermanent = (err) => Boolean(err?.status) && err.status < 500
const shortError = (err) => String(err?.message || err).slice(0, 200)
const unitsOf = (doc) => Number(doc?.storageUnits) || 0
const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit
const monthlyCentsOf = (units) => units * config.mailStorage.amountCents
// Seit API 2025-03 liegt current_period_end am Subscription-Item
const periodEndOf = (stripeSub) => stripeSub?.current_period_end || stripeSub?.items?.data?.[0]?.current_period_end || null
/**
* Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps:
@@ -80,17 +94,26 @@ export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits
/**
* Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd
* der laufende Zeitraum ist (Appwrite gibt Datumswerte mit +00:00 zurueck, der Spiegel schreibt Z: gleicher
* Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere Rechnung des Monats hat sie berechnet).
* der laufende Zeitraum ist (periodEnd, sonst doc.currentPeriodEnd; Appwrite gibt Datumswerte mit +00:00
* zurueck, der Spiegel schreibt Z: gleicher Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere
* Rechnung des Monats hat sie berechnet).
*/
export function paidUnitsOf(doc) {
export function paidUnitsOf(doc, periodEnd = doc?.currentPeriodEnd) {
const paidEnd = doc?.storagePaidPeriodEnd
const periodEnd = doc?.currentPeriodEnd
const samePeriod =
Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime())
const samePeriod = Boolean(paidEnd && periodEnd) && sameInstant(paidEnd, periodEnd)
return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc)
}
/**
* Art der Aufgabe eines Ereignisses `speicher`: details.kind ("einrichten" | "periodenende"); aeltere
* Ereignisse ohne kind (vor Fix 1) nach der Richtung (Plus = einrichten, sonst periodenende).
*/
export function storageTaskKind(event) {
const details = event?.details || {}
if (details.kind === 'einrichten' || details.kind === 'periodenende') return details.kind
return Number(details.toUnits) > Number(details.fromUnits) ? 'einrichten' : 'periodenende'
}
async function requireSubscription(customerId) {
const doc = await getCurrentSubscription(customerId)
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
@@ -111,11 +134,14 @@ function reductionEffectiveAt(doc) {
return periodEnd || cancelAt
}
// Toleranz zwischen dem eigenen proration_date und Stripes Zeilen (Sekundenrundung)
const PRORATION_TOLERANCE_SECONDS = 2
/**
* Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue
* Menge abzueglich Gutschrift fuer die alte, jeweils ab prorationDate. Aeltere anteilige Zeilen (eine
* fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die regulaeren Zeilen des
* naechsten Monats zaehlen nicht.
* Menge abzueglich Gutschrift fuer die alte, genau ab prorationDate (period.start, ±2 s). Aeltere anteilige
* Zeilen (eine fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die
* regulaeren Zeilen des naechsten Monats zaehlen nicht.
*/
function storageProrationCents(preview, priceIds, prorationDate) {
return (preview?.lines?.data || [])
@@ -124,8 +150,7 @@ function storageProrationCents(preview, priceIds, prorationDate) {
const details = parent.subscription_item_details || parent.invoice_item_details || {}
if (details.proration !== true) return false
if (!priceIds.has(line.pricing?.price_details?.price)) return false
// Toleranz fuer Sekunden zwischen Vorschau und Stripe-Rundung
return !line.period?.start || line.period.start >= prorationDate - 300
return Math.abs(Number(line.period?.start) - prorationDate) <= PRORATION_TOLERANCE_SECONDS
})
.reduce((sum, line) => sum + (Number(line.amount) || 0), 0)
}
@@ -134,6 +159,12 @@ function storageProrationCents(preview, priceIds, prorationDate) {
* Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je
* Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung
* und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts.
*
* Abrechnungsmodus (Fix 1): Abos aus dem Checkout laufen im flexiblen Modus (billing_mode.type 'flexible',
* Stripes Standard) - dort schreibt Stripe den zuletzt berechneten Betrag gut, die Vorschau hat die im
* Monat schon bezahlten Pakete also selbst verrechnet und gilt unveraendert. Nur im klassischen Modus
* ('classic') rechnet Stripe von der heutigen Menge aus; dann zaehlen nur die Pakete ueber der bezahlten
* Menge (F19: p × (neu − max(aktuell, bezahlt)) / (neu − aktuell)).
*/
export async function previewStorageChange(customerId, newUnits) {
const doc = await requireSubscription(customerId)
@@ -159,9 +190,10 @@ export async function previewStorageChange(customerId, newUnits) {
// Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein)
const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean))
const previewed = storageProrationCents(preview, priceIds, prorationDate)
// F19: Stripe rechnet die Vorschau von der heutigen Menge (currentUnits) aus; berechnet werden nur die
// Pakete ueber der schon bezahlten Menge, der erste Schritt bis dahin laeuft ohne Verrechnung
prorationCents = Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
const classic = stripeSub.billing_mode?.type === 'classic'
prorationCents = classic
? Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
: previewed
}
return {
direction: plan.direction,
@@ -175,6 +207,13 @@ export async function previewStorageChange(customerId, newUnits) {
}
}
function assertPlanAllowed(plan, consent) {
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) {
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
}
}
/**
* Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der
* auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular
@@ -200,22 +239,19 @@ function assertChangeAllowed(doc, events, direction, viaCancellation) {
}
/**
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team). Aeltere offene Aufgaben desselben
* Abos sind damit ueberholt (F21: erledigt, doneBy 'ersetzt'). Ein Fehler beim Speichern bricht die schon
* bei Stripe wirksame Aenderung nicht ab: er wird protokolliert, T13 traegt die Aufgabe trotzdem zum Team.
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team), Status offen - vor dem Stripe-Aufruf.
* Laesst es sich nicht speichern, laeuft die Aenderung trotzdem: Fehler im Log, T13 traegt die Aufgabe mit
* dem Block nachweis_fehlt zum Team.
*/
async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) {
async function recordStorageEvent({ doc, change, consent, viaCancellation }) {
try {
const now = new Date().toISOString()
for (const e of events.filter((e) => e.type === 'speicher' && e.status === 'offen')) {
await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
}
return await recordContractEvent({
customerId: doc.customerId,
subscriptionId: doc.$id,
type: 'speicher',
productKey: doc.productKey || 'wartung',
details: {
kind: change.kind,
fromUnits: change.fromUnits,
toUnits: change.toUnits,
effectiveAt: change.effectiveAt,
@@ -235,7 +271,7 @@ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCanc
}
: null,
status: 'offen',
dueAt,
dueAt: change.dueAt,
})
} catch (err) {
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
@@ -243,6 +279,23 @@ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCanc
}
}
/**
* Aeltere offene Aufgaben desselben Abos, die die neue Aufgabe ueberholt (F21, Fix 1): eine Einrichtung
* ersetzt offene Einrichtungen und geplante Verringerungen mit hoechstens gleichem Ziel; eine Aufgabe zum
* Periodenende ersetzt nur offene Aufgaben zum Periodenende, nie eine noch nicht erledigte Einrichtung.
* events sind die Ereignisse vor der neuen Aufgabe.
*/
async function supersedeStorageTasks(events, change) {
const now = new Date().toISOString()
const superseded = events.filter((e) => {
if (e.type !== 'speicher' || e.status !== 'offen') return false
const kind = storageTaskKind(e)
if (change.kind === 'einrichten') return kind === 'einrichten' || (Number(e.details?.toUnits) || 0) <= change.toUnits
return kind === 'periodenende'
})
for (const e of superseded) await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
}
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */
async function contractPdfAttachment(termsVersion) {
const { filename, path } = contractAttachmentFor(termsVersion)
@@ -254,14 +307,15 @@ async function contractPdfAttachment(termsVersion) {
}
/**
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team. Ueber das
* Kuendigungsformular bestaetigt K03, K13 entfaellt dort (F31). Ein Mailfehler bricht die Aenderung nie ab.
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team (Aufgabe nach Art, dazu
* die Bloecke teilweise und nachweis_fehlt). Ueber das Kuendigungsformular bestaetigt K03, K13 entfaellt dort
* (F31). Ein Mailfehler bricht die Aenderung nie ab.
*/
async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, proofMissing }) {
async function sendStorageMails({ doc, change, consent, viaCancellation, proofMissing }) {
const plus = change.direction === 'plus'
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
const email = customer?.email || doc.email || ''
const wirksamAb = fmtDate(change.effectiveAt)
const dueLabel = fmtDate(change.dueAt)
if (!viaCancellation && email) {
const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : []
await sendCustomerMail(email, {
@@ -271,7 +325,7 @@ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation,
pakete: change.toUnits,
gesamt_gb: change.totalGb,
monatspreis_zusatz: fmtEuro(change.monthlyCents),
wirksam_ab: wirksamAb,
wirksam_ab: fmtDate(change.effectiveAt),
},
blocks: { plus, minus: !plus },
attachments,
@@ -280,89 +334,169 @@ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation,
await sendAdminNotice({
template: 'T13',
values: {
aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`,
aktion: change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`,
email,
name: customer?.name || '',
stripe_abo_id: doc.$id,
postfaecher_hinweis:
(doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen',
neue_groesse_gb: change.totalGb,
faellig: fmtDate(dueAt),
pakete: change.toUnits,
faellig: dueLabel,
},
blocks: {
einrichten: change.kind === 'einrichten',
periodenende: change.kind === 'periodenende',
teilweise: change.partial,
nachweis_fehlt: proofMissing,
},
blocks: proofMissing ? { nachweis_fehlt: true } : {},
})
}
// Abos mit laufender Aenderung (Doppelklick, zwei Tabs): die zweite gleichzeitige Aenderung bekommt 409
const activeChanges = new Set()
/**
* Menge auf newUnits Pakete aendern: Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0 bestehen), Spiegel und bezahlte Menge
* (Plus: max(paidUnits, newUnits) fuer den laufenden Monat), Ereignis `speicher` (offen; dueAt jetzt bzw.
* Periodenende), K13 und T13. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim Plus
* Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
* Liefert { subscription, event, change }.
* Menge auf newUnits Pakete aendern. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim
* Plus Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
* Ablauf: Vorpruefung mit dem Spiegel (400/404/409, ohne Stripe-Aufruf), Sperre je Abo, frischer Stand aus
* Stripe, Ereignis `speicher` (offen), Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0), Spiegel und bezahlte Menge, ueberholte
* Aufgaben, K13 und T13. Liefert { subscription, event, change }; change = { direction, kind, fromUnits,
* toUnits, effectiveAt, dueAt, totalGb, monthlyCents, deltaMonthlyCents, partial }.
*/
export async function changeStorage(customerId, newUnits, consent, options = {}) {
const viaCancellation = options.viaCancellation === true
const doc = await requireSubscription(customerId)
const currentUnits = unitsOf(doc)
const paidUnits = paidUnitsOf(doc)
// Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf
const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits })
assertPlanAllowed(plan, consent)
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
// Sperre pruefen und setzen ohne await dazwischen
if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.')
activeChanges.add(doc.$id)
try {
return await performChange({ doc, events, newUnits, consent, viaCancellation })
} finally {
activeChanges.delete(doc.$id)
}
}
async function performChange({ doc, events, newUnits, consent, viaCancellation }) {
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
const items = stripeSub.items?.data || []
let { storageItemId } = describeItems(items)
// Frischer Stand aus Stripe (Menge, Periodenende): der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken
const fresh = { ...doc, storageUnits: describeItems(items).storageUnits, currentPeriodEnd: toIso(periodEndOf(stripeSub)) || doc.currentPeriodEnd || null }
const currentUnits = unitsOf(fresh)
const paidUnits = paidUnitsOf(fresh)
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
const plus = plan.direction === 'plus'
if (plus && !(consent?.termsVersion && consent?.consentAt)) {
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
}
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
// Stripe: Speicher-Posten anlegen oder Menge aendern, Schritt fuer Schritt (planStorageChange)
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
let { storageItemId } = describeItems(stripeSub.items?.data)
const prorationDate = Math.floor(Date.now() / 1000)
for (const step of plan.steps) {
const updated = await stripe.subscriptions.update(doc.$id, {
items: [storageItemParam(storageItemId, priceId, step.quantity)],
proration_behavior: step.proration_behavior,
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
})
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
}
// Spiegel (storageUnits aus den Positionen) und bezahlte Menge des laufenden Monats. Die Aenderung ist
// bei Stripe schon wirksam: scheitert die Buchung der bezahlten Menge (z. B. Attribut fehlt, Setup-Skript
// nicht gelaufen), laufen Ereignis und Mails trotzdem; ein Plus nach einem Minus im selben Monat wuerde
// dann anteilig berechnet statt frei bleiben - Fehler im Log.
let subscription = (await syncSubscription(doc.$id)) || doc
if (plus) {
const paid = { storagePaidUnits: Math.max(paidUnits, newUnits), storagePaidPeriodEnd: subscription.currentPeriodEnd || doc.currentPeriodEnd || null }
subscription = await updateDocument(config.collections.subscriptions, doc.$id, paid).catch((err) => {
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
return subscription
})
}
const now = new Date().toISOString()
const effectiveAt = plus ? now : reductionEffectiveAt(subscription)
const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende'
const reductionAt = reductionEffectiveAt(fresh)
const change = {
direction: plan.direction,
kind,
fromUnits: currentUnits,
toUnits: newUnits,
effectiveAt,
// Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist
// sofort faellig, alles andere zum Periodenende
effectiveAt: plus ? now : reductionAt,
dueAt: kind === 'einrichten' ? now : reductionAt,
totalGb: totalGbOf(newUnits),
monthlyCents: monthlyCentsOf(newUnits),
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
partial: false,
}
// Aufgabe fuers Team: Plus sofort einrichten, Minus zum Periodenende reduzieren
const dueAt = plus ? now : effectiveAt
const event = await recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation })
await sendStorageMails({ doc: subscription, change, dueAt, consent, viaCancellation, proofMissing: !event })
// 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen
const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation })
// 2. Stripe, Schritt fuer Schritt (planStorageChange)
const prorationDate = Math.floor(Date.now() / 1000)
let achievedUnits = currentUnits
for (const [index, step] of plan.steps.entries()) {
try {
const updated = await stripe.subscriptions.update(doc.$id, {
items: [storageItemParam(storageItemId, priceId, step.quantity)],
proration_behavior: step.proration_behavior,
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
})
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
achievedUnits = step.quantity
} catch (err) {
if (index === 0) {
// Nichts geaendert: Ereignis gescheitert, Fehler an den Kunden, keine Buchung, keine Mails
console.error(`[speicher] Abo ${doc.$id}: Stripe lehnt die Änderung ab:`, err.message)
if (event) {
await updateContractEvent(event.$id, { status: 'gescheitert', details: { error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht auf gescheitert gesetzt:`, e.message)
)
}
const failure = httpError(502, 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.')
failure.cause = err
throw failure
}
// Schritt 2 nach Schritt 1: Stripe steht auf der bezahlten Menge, die Bestellung bleibt offen - das Team
// setzt die Menge in Stripe von Hand (T13, Block teilweise)
console.error(`[speicher] Abo ${doc.$id}: Schritt ${index + 1} bei Stripe fehlgeschlagen, Menge bleibt ${achievedUnits}:`, err.message)
change.partial = true
if (event) {
await updateContractEvent(event.$id, { details: { partial: true, error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht als teilweise markiert:`, e.message)
)
}
break
}
}
// 3. Spiegel (storageUnits aus den Positionen); scheitert er, holt der Stripe-Webhook das nach
let subscription
try {
subscription = (await syncSubscription(doc.$id)) || { ...fresh, storageUnits: achievedUnits }
} catch (err) {
console.error(`[speicher] Spiegel für Abo ${doc.$id} nicht aktualisiert (Webhook holt das nach):`, err.message)
subscription = { ...fresh, storageUnits: achievedUnits }
}
// 4. Bezahlte Menge des laufenden Monats: Plus max(bezahlt, erreicht), Minus max(bezahlt, bisher) - jede
// Position war fuer den Monat berechnet. Scheitert die Buchung (z. B. Attribut fehlt), laeuft der Rest weiter;
// ein Plus nach einem Minus im selben Monat wuerde dann anteilig berechnet statt frei bleiben.
const paid = {
storagePaidUnits: Math.max(paidUnits, plus ? achievedUnits : currentUnits),
storagePaidPeriodEnd: fresh.currentPeriodEnd,
}
try {
await updateDocument(config.collections.subscriptions, doc.$id, paid)
subscription = { ...subscription, ...paid }
} catch (err) {
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
}
// 5. Ueberholte Aufgaben schliessen, erst nachdem die neue existiert
if (event) {
await supersedeStorageTasks(events, change).catch((err) =>
console.error(`[speicher] Ersetzte Aufgaben für Abo ${doc.$id} nicht geschlossen:`, err.message)
)
}
// 6. Mails
await sendStorageMails({ doc: subscription, change, consent, viaCancellation, proofMissing: !event })
return { subscription, event, change }
}
/**
* Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere
* Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt
* (storagePaidUnits, storagePaidPeriodEnd). Idempotent; fehlt der Spiegel noch (Webhook-Reihenfolge),
* wird er aus Stripe angelegt.
* (storagePaidUnits, storagePaidPeriodEnd). Im selben Monat gilt die groessere Menge (eine verspaetete
* Rechnung senkt eine Buchung nach einem Plus nicht), das Periodenende geht nie zurueck (wiederholte aeltere
* Rechnung). Fehlt der Spiegel noch (Webhook-Reihenfolge), wird er aus Stripe angelegt. Dauerhafte
* Appwrite-Fehler (4xx) werden protokolliert und uebersprungen, voruebergehende (5xx, Netz) geworfen, damit
* Stripe den Webhook wiederholt.
*/
export async function recordPaidStorage(invoice) {
const lines = await invoiceLines(invoice)
@@ -372,13 +506,36 @@ export async function recordPaidStorage(invoice) {
const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
const data = { storagePaidUnits: Number(line.quantity) || 0, storagePaidPeriodEnd: toIso(line.period?.end) }
const periodEnd = toIso(line.period?.end)
if (!periodEnd) return { skipped: 'Speicher-Zeile ohne Zeitraum' }
const units = Number(line.quantity) || 0
const tag = `[speicher] Bezahlte Menge für Abo ${subscriptionId} (Rechnung ${loaded.id || '?'})`
let existing
try {
existing = await getDocument(config.collections.subscriptions, subscriptionId)
} catch (err) {
if (err.status !== 404) {
if (!isPermanent(err)) throw err
console.error(`${tag} nicht lesbar (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
existing = await syncSubscription(subscriptionId)
if (!existing) return { skipped: 'Abo ohne Kundenzuordnung' }
}
const knownEnd = existing.storagePaidPeriodEnd || null
if (knownEnd && new Date(periodEnd) < new Date(knownEnd)) return { skipped: 'ältere Rechnung' }
const samePeriod = Boolean(knownEnd) && sameInstant(knownEnd, periodEnd)
const data = {
storagePaidUnits: samePeriod ? Math.max(Number(existing.storagePaidUnits) || 0, units) : units,
storagePaidPeriodEnd: periodEnd,
}
try {
await updateDocument(config.collections.subscriptions, subscriptionId, data)
} catch (err) {
if (err.status !== 404) throw err
if (!(await syncSubscription(subscriptionId))) return { skipped: 'Abo ohne Kundenzuordnung' }
await updateDocument(config.collections.subscriptions, subscriptionId, data)
if (!isPermanent(err)) throw err
console.error(`${tag} nicht gespeichert (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd }
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd }
}