Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-28 11:30:21 +00:00
1 parent 379cc26dcd
commit 188db4b3c1
15 files changed
+2035 -12

No files matched your search

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K12 · Wechsel ins Wartungs-Abo (Vertragsbestätigung)
Betreff: Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang: Vertragsunterlagen der Fassung 4 (PDF)
Platzhalter: {{name}}, {{wechseltag}}, {{betrag_heute}}, {{monatspreis}}, {{mindestlaufzeit_bis}}, {{bedingungen_version}}, {{zustimmung_zeitpunkt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_offen, lastschrift
Stand Aufgabe 9 (Rechtsrunde 28.09.2026): zahlung_offen bei Karte mit offener Zahlung, lastschrift bei SEPA; 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 Wechsel ins WEBklar Wartungs-Abo – 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 Wechsel ins Wartungs-Abo ist bestellt. Alle Vertragsunterlagen finden Sie im Anhang.&#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;">Vertragsbestä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; Wechsel bestellt</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 Wechsel ins Wartungs-Abo</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihre Bestellung. Hiermit bestätigen wir Ihren Wechsel vom Hosting-Abo ins Wartungs-Abo.</td></tr>
<!-- WENN zahlung_offen -->
<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:#fdf6e3;border:1px solid #efdcaa;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;">Zahlung noch offen</div><div class="mut" style="color:#4a4843;">Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.<br>Die Zahlung schließen Sie im Kundenportal unter „Verträge“ ab.</div></td></tr></table></td></tr>
<!-- ENDE zahlung_offen -->
<!-- WENN lastschrift -->
<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;">Zahlung per SEPA-Lastschrift</div><div class="mut" style="color:#4a4843;">Ihr Wartungs-Abo beginnt schon mit der Bestellung. Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt: Ihr Hosting-Abo läuft dann mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter, Ihr Abbuchungstag bleibt der Wechseltag, und wir informieren Sie per E-Mail.</div></td></tr></table></td></tr>
<!-- ENDE lastschrift -->
<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;">WEBklar Wartungs-Abo</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{monatspreis}}<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;">Wechseltag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wechseltag}}</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;">Heute fällig</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{betrag_heute}}</strong><br><span class="mut" style="color:#6b6964;">Monatsgebühr des Wartungs-Abos mit gebuchtem Zusatzspeicher, abzüglich der Gutschrift für die nicht genutzten Tage Ihres Hosting-Abos</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;">Abbuchung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">monatlich am Kalendertag des Wechsels</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;">Laufzeit</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Mindestlaufzeit 12 Monate bis {{mindestlaufzeit_bis}}.</strong> Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar.</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Enthalten</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">&#8226;&nbsp; Betrieb und Pflege Ihrer Website bzw. Web-Anwendung<br>&#8226;&nbsp; 1 Domain<br>&#8226;&nbsp; beliebig viele E-Mail-Postfächer (zusammen 5 GB)<br>&#8226;&nbsp; 1 Stunde Arbeitszeit je bezahltem Monat <span class="mut" style="color:#6b6964;">(12 Monate nutzbar)</span></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:20px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;color:#4a4843;">Ihre Postfächer und gebuchter Zusatzspeicher bleiben unverändert. Weitere Postfächer richten wir ein, sobald die Wechselrechnung bezahlt ist; dann steht auch Ihre erste Stunde Arbeitszeit bereit.</td></tr>
<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>
<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 Leistungsbeschreibung<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular<br>&#128206;&nbsp; Auftragsverarbeitungsvertrag</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;">Ihre Zustimmung zu den Vertragsbedingungen (Version {{bedingungen_version}}) einschließlich des Auftragsverarbeitungsvertrags haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.<br><br>Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf des Wechsels Wertersatz für die bis dahin erbrachte Leistung zahlen: den anteiligen Mehrpreis des Wartungs-Abos gegenüber dem Hosting-Abo. Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter.</td></tr>
<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,52 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K14 · Wechsel ins Wartungs-Abo nicht zustande gekommen
Betreff: Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{name}}, {{grund}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): lastschrift
Stand Aufgabe 9 (Rechtsrunde 28.09.2026, Bedingungen § 9 Abs. 3): lastschrift bei SEPA-Lastschrift; 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 Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen</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 Hosting-Abo läuft wie bisher weiter.&#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;">Ihr Wechsel</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;">Nicht zustande gekommen</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 Wechsel ins Wartungs-Abo ist nicht zustande gekommen</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>Ihren Wechsel vom Hosting-Abo ins Wartungs-Abo konnten wir leider nicht abschließen, weil die Wechselrechnung nicht bezahlt wurde.</td></tr>
<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-size:16px;line-height:25px;color:#111111;">{{grund}}</div></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter. Die Wechselrechnung ist storniert, Sie müssen sie nicht bezahlen.<!-- WENN lastschrift --> Ihr Abbuchungstag bleibt der Tag des Wechsels.<!-- ENDE lastschrift --></td></tr>
<tr><td class="px mut" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Möchten Sie weiterhin ins Wartungs-Abo wechseln, können Sie den Wechsel im Kundenportal unter „Verträge“ erneut bestellen. Bei Fragen antworten Sie einfach auf diese E-Mail.</td></tr>
<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>
<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>
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T12 · Wechsel ins Wartungs-Abo
Betreff: [Abo] Wechsel ins Wartungs-Abo: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{stripe_abo_id}}, {{wechseltag}}, {{betrag_heute}}, {{monatspreis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): wirksam, lastschrift, gescheitert, lastschrift_gescheitert, nachweis_fehlt
Stand Aufgabe 9: wirksam (Wechsel gilt), lastschrift (SEPA, noch nicht eingelöst), gescheitert (K14 verschickt), lastschrift_gescheitert (Handgriffe), 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>[Abo] Wechsel ins Wartungs-Abo: {{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}}: Wechsel vom Hosting- ins Wartungs-Abo.&#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;">Wechsel</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;">Wechsel ins Wartungs-Abo</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;">Wechseltag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wechseltag}}</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;">Wechselrechnung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{betrag_heute}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Monatspreis Wartung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{monatspreis}}</td></tr></table></td></tr>
<!-- WENN wirksam -->
<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;">Der Wechsel ist wirksam</div><div class="mut" style="color:#4a4843;">Das Abo läuft ab dem Wechseltag als Wartungs-Abo, mit neuer Mindestlaufzeit von 12 Monaten. Die erste Stunde Arbeitszeit bucht das Portal, sobald die Wechselrechnung bezahlt ist.<br>Weitere Postfächer erst einrichten, wenn die Wechselrechnung bezahlt ist (Vertragsbedingungen § 9 Abs. 3).</div></td></tr></table></td></tr>
<!-- ENDE wirksam -->
<!-- WENN lastschrift -->
<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;">SEPA-Lastschrift</div><div class="mut" style="color:#4a4843;">Die Wechselrechnung ist noch nicht eingelöst; das dauert einige Tage. Bis dahin keine weiteren Postfächer einrichten. Scheitert die Lastschrift, kommt ein eigener Hinweis mit den Handgriffen.</div></td></tr></table></td></tr>
<!-- ENDE lastschrift -->
<!-- WENN gescheitert -->
<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;">Wechsel nicht zustande gekommen</div><div class="mut" style="color:#4a4843;">Die Wechselrechnung wurde nicht bezahlt. Das Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter. Der Kunde hat die Mail „Wechsel nicht zustande gekommen“ (K14) bekommen; das Ereignis „wechsel“ steht auf „gescheitert“.</div></td></tr></table></td></tr>
<!-- ENDE gescheitert -->
<!-- WENN lastschrift_gescheitert -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung: Lastschrift gescheitert</div><div class="mut" style="color:#4a4843;">Die SEPA-Lastschrift für die Wechselrechnung ist gescheitert. Der Wechsel gilt damit als nicht erfolgt (Vertragsbedingungen § 9 Abs. 3). Bitte gleich in Stripe:<br>1. Die Wechselrechnung sofort stornieren, damit ein automatischer Einzugsversuch sie nicht mehr einzieht.<br>2. Die Abo-Position ohne anteilige Verrechnung zurück auf den Hosting-Preis setzen. Der Abbuchungstag bleibt der Wechseltag.<br>3. Die Metadaten productKey, termStartAt, switchedAt und switchedFrom auf den Stand vor dem Wechsel zurücksetzen. Die Werte stehen im Ereignis „wechsel“; im Container zeigt sie: node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --pruefen<br>4. Ereignis auf „gescheitert“ setzen und K14 an den Kunden senden: node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --ausfuehren<br>Weitere Postfächer, die der Kunde seit dem Wechsel beauftragt hat, nicht einrichten.</div></td></tr></table></td></tr>
<!-- ENDE lastschrift_gescheitert -->
<!-- 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 „wechsel“ (Nachweis der Zustimmung, Stand der Zahlung) konnte nicht in Appwrite gespeichert werden. Bitte in contractEvents nachtragen; bis dahin schließen die Stripe-Webhooks diesen Wechsel nicht ab. Ob die Zahlung noch offen ist, zeigt das Abo in Stripe (ausstehende Aktualisierung).</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>
+81
View File
@@ -11,6 +11,14 @@ import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js'
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
import {
handlePendingUpdateApplied,
handlePendingUpdateExpired,
handleSwitchInvoicePaid,
handleSwitchPaymentFailed,
previewSwitch,
switchToWartung,
} from '../services/planSwitch.js'
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
@@ -311,6 +319,64 @@ router.post('/storage', requireSession, async (req, res) => {
}
})
// ---------------------------------------------------------------------------
// Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5, Bedingungen § 9): Vorschau und Bestellung (planSwitch.js)
// ---------------------------------------------------------------------------
/** Vorschau fuer die Bestelluebersicht: Gutschrift, heute faellig, Monatspreis, neuer Abbuchungstag, Mindestlaufzeit. */
router.post('/switch/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
}
return res.json(await previewSwitch(getSessionCustomerId(req)))
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' })
}
})
/**
* Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate } ("Zahlungspflichtig bestellen").
* Nachweis: Fassung, Zeitpunkt, IP, Browser und die Zustimmung zum Sofortbeginn (§ 357a Abs. 2 BGB) landen am
* Abo und im Ereignis `wechsel`. Antwort: die Abo-Ansicht wie in GET /api/abo und paymentUrl (Karte: Zahlung
* noch zu bestaetigen, das Portal leitet zur Stripe-Zahlungsseite weiter; sonst null).
*/
router.post('/switch', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' })
}
const { acceptTerms, earlyStart, termsVersion, prorationDate } = req.body || {}
if (acceptTerms !== true) {
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' })
}
if (earlyStart !== true) {
return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' })
}
if (!termsVersion || !Number.isInteger(prorationDate)) {
return res.status(400).json({ error: 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' })
}
if (termsVersion !== config.abo.termsVersion) {
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
const customerId = getSessionCustomerId(req)
const now = new Date().toISOString()
const { subscription, paymentUrl } = await switchToWartung(
customerId,
{ termsVersion, consentAt: now, earlyStartConsentAt: now, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '' },
prorationDate
)
const events = await listContractEvents(customerId).catch((e) => {
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
return []
})
return res.json({ subscription: publicSubscription(subscription, events), paymentUrl })
} catch (err) {
console.error('[abo] Wechsel fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
}
})
// ---------------------------------------------------------------------------
// Stripe-Webhook
// ---------------------------------------------------------------------------
@@ -461,11 +527,17 @@ async function handleStripeEvent(event) {
if (storage.recorded) {
console.log(`[abo] Zusatzspeicher bezahlt: Abo ${storage.subscriptionId}, ${storage.units} Pakete bis ${storage.periodEnd}`)
}
// Wechselrechnung bezahlt (F26): SEPA-Wechsel erledigt; Karte, falls vor pending_update_applied
const switched = await handleSwitchInvoicePaid(fresh)
if (switched.completed) console.log(`[abo] Wechsel ins Wartungs-Abo bezahlt: Ereignis ${switched.eventId}`)
return
}
case 'invoice.payment_failed': {
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
// Wechselrechnung per SEPA-Lastschrift nicht eingeloest: T12 mit den Handgriffen (vor T04, damit eine
// Wiederholung nach einem Fehler hier beide Hinweise nachholt)
await handleSwitchPaymentFailed(obj)
await sendAdminNotice({
template: 'T04',
values: {
@@ -479,6 +551,15 @@ async function handleStripeEvent(event) {
case 'customer.subscription.created':
await syncSubscription(obj.id)
return
// Wechsel ins Wartungs-Abo mit Karte (Rechtsrunde 28.09.2026): Stripe hat die zurueckgehaltene Aenderung
// mit der Zahlung uebernommen (erledigt, T12) oder verworfen (gescheitert, K14, T12). Am Stripe-Endpunkt
// freischalten (Task 20).
case 'customer.subscription.pending_update_applied':
await handlePendingUpdateApplied(obj.id)
return
case 'customer.subscription.pending_update_expired':
await handlePendingUpdateExpired(obj.id)
return
case 'customer.subscription.updated': {
const sub = await syncSubscription(obj.id)
const prev = event.data.previous_attributes || {}
+6
View File
@@ -110,6 +110,12 @@ export async function recordContractEvent({
}
}
/** Ein Ereignis mit geparstem details; fehlt es, 404 wie bei Appwrite. */
export async function getContractEvent(id) {
const doc = await getDocument(COLLECTION(), id)
return { ...doc, details: parseJson(doc.details, {}) }
}
/** Ereignisse eines Kunden, neueste zuerst, mit geparstem details. */
export async function listContractEvents(customerId) {
const docs = await listDocuments(COLLECTION(), [
+11
View File
@@ -7,8 +7,19 @@ const CUSTOMER_SENDER = 'WEBklar'
const TEAM_SENDER = 'WEBklar Kundenportal'
let transporter = null
let testTransport = null
/**
* Nur für Tests (F25): alle Mails über diesen Transport statt per SMTP, z. B. mailSink() aus
* test/support/mailSink.js (nodemailer jsonTransport, gesendete Mails gesammelt). null schaltet zurück.
* Gerendert wird wie im Betrieb; so lassen sich Vorlagen, Blöcke und Anhänge in Routentests prüfen.
*/
export function setMailTransportForTests(transport) {
testTransport = transport || null
}
function getTransporter() {
if (testTransport) return testTransport
if (!config.smtp.host || !config.smtp.user) return null
if (!transporter) {
transporter = nodemailer.createTransport({
+561
View File
@@ -0,0 +1,561 @@
import { readFile } from 'node:fs/promises'
import { config } from '../config.js'
import { getDocument } from './appwriteAdmin.js'
import { eventIdFor, getContractEvent, listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { contractAttachmentFor, describeItems } from './products.js'
import {
addMonths,
ensurePrice,
ensureTestCoupon,
getCurrentSubscription,
getStripe,
subscriptionIdOfInvoice,
syncSubscription,
termStartOf,
} from './stripeBilling.js'
/**
* Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5; Bedingungen Fassung 4 § 9, § 7 Abs. 4, § 2 Abs. 5).
*
* Dasselbe Stripe-Abo wechselt den Preis der Abo-Position von Hosting auf Wartung: neuer Abbuchungstag
* (billing_cycle_anchor now), Gutschrift fuer die nicht genutzten Hosting-Tage (create_prorations mit dem
* proration_date der Vorschau) und 12 neue Monate Mindestlaufzeit ab dem Wechseltag (Metadaten termStartAt,
* switchedAt, switchedFrom).
*
* Rechtsrunde 28.09.2026: Der Wechsel wird erst mit der Zahlung wirksam (§ 9 Abs. 3).
* - Karte (und jede Zahlart ausser SEPA, auch unbekannt): payment_behavior pending_if_incomplete. Ist die
* Zahlung nicht sofort erfolgreich (3-D Secure, Ablehnung), haelt Stripe Preis, Anker, Metadaten und Rabatte
* in pending_update zurueck: Ereignis `wechsel` offen, paymentUrl = Stripe-Zahlungsseite der Rechnung,
* Spiegel unveraendert. Wirksam mit customer.subscription.pending_update_applied (oder invoice.paid, was
* zuerst ankommt); verfaellt die Aenderung (pending_update_expired, Stripe storniert die Rechnung), ist der
* Wechsel gescheitert (markSwitchFailed: K14 an den Kunden, T12 ans Team).
* - SEPA-Lastschrift: Stripe kennt dafuer keine ausstehenden Aktualisierungen, also allow_incomplete - der
* Wechsel gilt sofort, das Ereignis bleibt offen, bis invoice.paid kommt. Scheitert die Lastschrift
* (invoice.payment_failed), bekommt das Team T12 mit den Handgriffen und schliesst den Wechsel danach mit
* scripts/wechsel-gescheitert.mjs ab (markSwitchFailed).
* - K12 (Vertragsbestaetigung mit PDF der Fassung 4) geht sofort nach dem Klick raus, T12 erst, wenn der
* Wechsel wirksam ist. Mailfehler werden protokolliert und brechen den Wechsel nie ab.
*/
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
const fmtDate = (value) =>
value
? new Date(value).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
: '–'
const fmtDateTime = (value) =>
new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
// Preis wie in den Bedingungen: "41,00 €" (normales Leerzeichen)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
const idOf = (ref) => (typeof ref === 'string' ? ref : ref?.id || null)
const DAY_MS = 86400000
// Die Bestelluebersicht rechnet ab dem Zeitpunkt der Vorschau; aelter als 30 Minuten -> Seite neu laden
const PREVIEW_MAX_AGE_SECONDS = 30 * 60
// Uhrabweichung zwischen Browser-Anfrage und Server, die eine Vorschau "aus der Zukunft" noch haben darf
const PREVIEW_CLOCK_SKEW_SECONDS = 5 * 60
// Toleranz fuer Sekunden zwischen proration_date und den Zeilen der Stripe-Vorschau (wie beim Zusatzspeicher)
const PRORATION_TOLERANCE_SECONDS = 300
// invoice.payment_failed einer Wechselrechnung zaehlt als gescheiterte Lastschrift, wenn der Wechsel so lange her ist
const SEPA_FAILURE_WINDOW_MS = 30 * DAY_MS
const PENDING_MESSAGE = 'Ein Wechsel wartet noch auf die Zahlung.'
const ONLY_HOSTING_MESSAGE = 'Nur ein Hosting-Abo kann ins Wartungs-Abo wechseln.'
const NOT_ACTIVE_MESSAGE = 'Der Wechsel ist erst nach Beginn Ihres Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.'
const CANCELLED_MESSAGE = 'Ihr Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.'
/** Grund in K14 ({{grund}}) je nach Weg: Karte (ausstehende Aktualisierung verfallen) oder SEPA (Skript). */
export const SWITCH_FAILED_REASONS = Object.freeze({
abgelaufen:
'Die Zahlung der Wechselrechnung wurde nicht rechtzeitig abgeschlossen, zum Beispiel weil die von Ihrer Bank verlangte Bestätigung nicht erfolgt ist.',
lastschrift: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.',
})
// Metadaten, die der Wechsel ueberschreibt und die bei einer gescheiterten Lastschrift zurueckgesetzt werden (F27)
const RESTORABLE_METADATA = ['productKey', 'termStartAt', 'switchedAt', 'switchedFrom']
/**
* Passt der Zeitpunkt der Vorschau (proration_date, Unix-Sekunden)? Die Bestelluebersicht zeigt Gutschrift,
* Betrag, Abbuchungstag und Mindestlaufzeit ab diesem Zeitpunkt, der Wechsel rechnet mit demselben
* proration_date. null = in Ordnung, sonst { status, message }: keine ganze Zahl oder aus der Zukunft 400;
* aelter als 30 Minuten oder an einem anderen Kalendertag in Deutschland (angezeigter Wechseltag) 409.
*/
export function previewProblem(prorationDate, now = Date.now()) {
if (!Number.isInteger(prorationDate) || prorationDate <= 0) {
return { status: 400, message: 'Die Vorschau fehlt. Bitte laden Sie die Seite neu.' }
}
const nowSeconds = Math.floor(now / 1000)
if (prorationDate > nowSeconds + PREVIEW_CLOCK_SKEW_SECONDS) {
return { status: 400, message: 'Die Vorschau ist ungültig. Bitte laden Sie die Seite neu.' }
}
if (nowSeconds - prorationDate > PREVIEW_MAX_AGE_SECONDS) {
return { status: 409, message: 'Die Vorschau ist älter als 30 Minuten. Bitte Seite neu laden.' }
}
if (fmtDate(prorationDate * 1000) !== fmtDate(now)) {
return { status: 409, message: 'Der Wechseltag hat sich durch den Datumswechsel verschoben. Bitte Seite neu laden.' }
}
return null
}
/**
* Zahlart des Abos (F24): die Standard-Zahlungsmethode des Abos, sonst die des Kunden, aus
* subscriptions.retrieve(id, { expand: ['default_payment_method', 'customer.invoice_settings.default_payment_method'] }).
* Nicht erweitert (nur eine ID), unbekannt oder keine: null - dann gilt der Weg fuer Karten.
*/
export function paymentMethodTypeOf(stripeSub) {
const customer = stripeSub?.customer && typeof stripeSub.customer === 'object' ? stripeSub.customer : null
for (const method of [stripeSub?.default_payment_method, customer?.invoice_settings?.default_payment_method]) {
if (method && typeof method === 'object' && method.type) return method.type
}
return null
}
/** Offenes Ereignis `wechsel` eines Abos (hoechstens eines, siehe 409 in switchToWartung) oder null. */
export async function findOpenSwitchEvent(customerId, subscriptionId) {
if (!customerId || !subscriptionId) return null
const events = await listContractEvents(customerId)
return events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === subscriptionId) || null
}
async function requireSubscription(customerId) {
const doc = await getCurrentSubscription(customerId)
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
return doc
}
const customerOf = (customerId) =>
customerId ? getDocument(config.collections.customers, customerId).catch(() => null) : Promise.resolve(null)
/** Name und E-Mail fuer K12/K14/T12: Kundendaten des Portals, sonst die E-Mail am Abo. */
async function contactOf(customerId, fallbackEmail = '') {
const customer = await customerOf(customerId)
return { name: customer?.name || '', email: customer?.email || fallbackEmail || '' }
}
/**
* Voraussetzungen im Portal (Spiegel und Ereignisse, wie canSwitch in GET /api/abo): Hosting-Abo, Status
* active, nicht gekuendigt (cancelAtPeriodEnd oder cancelAt), kein Wechsel, der auf die Zahlung wartet.
*/
function assertCanSwitch(doc, events) {
if ((doc.productKey || 'wartung') !== 'hosting') throw httpError(409, ONLY_HOSTING_MESSAGE)
if (doc.status !== 'active') throw httpError(409, NOT_ACTIVE_MESSAGE)
if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, CANCELLED_MESSAGE)
if (events.some((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === doc.$id)) {
throw httpError(409, PENDING_MESSAGE)
}
}
// Coupon eines Rabatts (API 2025-10-29.clover: discount.source.coupon; aeltere Versionen: discount.coupon)
const couponIdOf = (discount) => idOf(discount?.source?.coupon ?? discount?.coupon)
/**
* Rabatte eines Testkontos nach dem Wechsel: WEBKLAR-TEST-050-HOSTING faellt weg, WEBKLAR-TEST-050 kommt dazu,
* andere Rabatte (z. B. ein laufendes Angebot) bleiben. Ohne Aufloesung (nur ID) bleibt ein Rabatt ebenfalls.
*/
async function testAccountDiscounts(stripeSub) {
const hostingCoupon = config.products.hosting.testCouponId
const wartungCoupon = await ensureTestCoupon('wartung')
const keep = (stripeSub.discounts || [])
.filter((discount) => discount && ![hostingCoupon, wartungCoupon].includes(couponIdOf(discount)))
.map((discount) => ({ discount: idOf(discount) }))
return [...keep, { coupon: wartungCoupon }]
}
/**
* Stripe-Stand des Abos fuer Vorschau und Wechsel. Stripe ist die Wahrheit: ein veralteter Spiegel (Webhook
* noch unterwegs) darf keinen zweiten Wechsel ausloesen - schon Wartung, zurueckgehaltene Aenderung
* (pending_update), anderer Status oder Kuendigung bei Stripe -> 409.
*/
async function stripeContext(doc, customer) {
const stripe = getStripe()
const [wartungPriceId, stripeSub] = await Promise.all([
ensurePrice('wartung'),
stripe.subscriptions.retrieve(doc.$id, {
expand: ['default_payment_method', 'customer.invoice_settings.default_payment_method', 'discounts'],
}),
])
const items = describeItems(stripeSub.items?.data)
if (items.productKey !== 'hosting' || !items.aboItemId) throw httpError(409, ONLY_HOSTING_MESSAGE)
if (stripeSub.pending_update) throw httpError(409, PENDING_MESSAGE)
if (stripeSub.status !== 'active') throw httpError(409, NOT_ACTIVE_MESSAGE)
if (stripeSub.cancel_at_period_end || stripeSub.cancel_at) throw httpError(409, CANCELLED_MESSAGE)
const aboItem = stripeSub.items.data.find((item) => item.id === items.aboItemId)
const testAccount = config.abo.testEmails.includes(String(customer?.email || doc.email || '').toLowerCase())
return {
stripeSub,
wartungPriceId,
aboItemId: items.aboItemId,
// Beginn des laufenden Abrechnungsmonats (seit API 2025-03 am Posten)
periodStart: aboItem?.current_period_start ?? stripeSub.current_period_start ?? null,
paymentMethod: paymentMethodTypeOf(stripeSub),
discounts: testAccount ? await testAccountDiscounts(stripeSub) : null,
}
}
/** Abo (Spiegel), Kunde und Stripe-Stand fuer Vorschau und Wechsel, nach allen Voraussetzungen (404/409). */
async function switchContext(customerId, current = null) {
const doc = current || (await requireSubscription(customerId))
const [customer, events] = await Promise.all([customerOf(customerId), listContractEvents(customerId)])
assertCanSwitch(doc, events)
return { doc, customer, ...(await stripeContext(doc, customer)) }
}
const isProrationLine = (line) => {
const details = line?.parent?.subscription_item_details || line?.parent?.invoice_item_details || {}
return details.proration === true
}
/**
* Vorschau fuer die Bestelluebersicht: { prorationDate, amountDueCents, creditCents, monthlyCents,
* nextChargeAt, minTermEnd }. Grundlage ist invoices.createPreview mit dem Preiswechsel der Abo-Position,
* billing_cycle_anchor now und create_prorations ab jetzt. creditCents ist die Gutschrift dieses Wechsels (die
* negativen anteiligen Zeilen ab prorationDate: nicht genutzte Hosting-Tage und gebuchter Zusatzspeicher, F29);
* aeltere, schon vorgemerkte Posten stecken nur im faelligen Betrag (amount_due, nach Guthaben und Rabatten).
* Abbuchungstag und Mindestlaufzeit rechnen ab dem Wechseltag (1 bzw. 12 Monate).
*/
export async function previewSwitch(customerId) {
const ctx = await switchContext(customerId)
const prorationDate = Math.floor(Date.now() / 1000)
const preview = await getStripe().invoices.createPreview({
subscription: ctx.doc.$id,
subscription_details: {
items: [{ id: ctx.aboItemId, price: ctx.wartungPriceId }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: prorationDate,
},
// Testkonto: die Rabatte nach dem Wechsel (sonst erbt die Vorschau die Rabatte des Abos)
...(ctx.discounts ? { discounts: ctx.discounts } : {}),
})
const credit = (preview?.lines?.data || [])
.filter((line) => isProrationLine(line) && line.amount < 0)
.filter((line) => !line.period?.start || line.period.start >= prorationDate - PRORATION_TOLERANCE_SECONDS)
.reduce((sum, line) => sum + (Number(line.amount) || 0), 0)
const start = new Date(prorationDate * 1000)
return {
prorationDate,
amountDueCents: Number(preview?.amount_due) || 0,
creditCents: Math.abs(credit),
monthlyCents: config.products.wartung.amountCents,
nextChargeAt: addMonths(start, 1).toISOString(),
minTermEnd: addMonths(start, 12).toISOString(),
}
}
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung; ohne Datei geht K12 ohne Anhang raus (Fehler im Log). */
async function contractPdfAttachment(termsVersion) {
const { filename, path } = contractAttachmentFor(termsVersion)
const content = await readFile(path).catch((err) => {
console.error(`[wechsel] Vertragsunterlagen ${filename} nicht lesbar:`, err.message)
return null
})
return content ? [{ filename, content, contentType: 'application/pdf' }] : []
}
/** K12 an den Kunden: Vertragsbestaetigung mit PDF. Ein Mailfehler bricht den Wechsel nicht ab. */
async function sendSwitchConfirmation(contact, consent, facts, blocks) {
if (!contact.email) {
console.error('[wechsel] K12 nicht verschickt: keine E-Mail-Adresse zum Kunden')
return
}
const attachments = await contractPdfAttachment(consent.termsVersion)
await sendCustomerMail(contact.email, {
template: 'K12',
values: {
name: contact.name,
wechseltag: fmtDate(facts.switchedAt),
betrag_heute: fmtEuro(facts.amountDueCents),
monatspreis: fmtEuro(config.products.wartung.amountCents),
mindestlaufzeit_bis: fmtDate(addMonths(new Date(facts.switchedAt), 12)),
bedingungen_version: consent.termsVersion,
zustimmung_zeitpunkt: fmtDateTime(consent.consentAt),
},
blocks,
attachments,
}).catch((err) => console.error(`[wechsel] K12 an ${contact.email} fehlgeschlagen:`, err.message))
}
/** T12 ans Team (sendAdminNotice protokolliert Fehler selbst und wirft nie). */
async function sendSwitchNotice(contact, subscriptionId, facts, blocks) {
await sendAdminNotice({
template: 'T12',
values: {
email: contact.email,
name: contact.name,
stripe_abo_id: subscriptionId,
wechseltag: fmtDate(facts.switchedAt),
betrag_heute: fmtEuro(facts.amountDueCents),
monatspreis: fmtEuro(config.products.wartung.amountCents),
},
blocks,
})
}
const factsOf = (event) => ({ switchedAt: event.details?.switchedAt || event.consentAt, amountDueCents: event.details?.amountDueCents })
// Ein Wechsel je Abo zur selben Zeit (Doppelklick): der zweite wartet nicht, sondern bekommt 409
const switchesInFlight = new Set()
/**
* Wechsel ins Wartungs-Abo (Button "Zahlungspflichtig bestellen"). consent: { termsVersion, consentAt,
* consentIp, consentUserAgent, earlyStartConsentAt } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16).
* prorationDate: Zeitpunkt der Vorschau (previewProblem). Voraussetzungen wie previewSwitch (409).
* Liefert { subscription, paymentUrl, event }: paymentUrl nur, wenn Stripe die Aenderung bis zur Zahlung
* zurueckhaelt (pending_update, Karte), sonst null - auch bei SEPA, dort zieht Stripe die Lastschrift ein.
*/
export async function switchToWartung(customerId, consent, prorationDate) {
if (!consent?.termsVersion || !consent?.consentAt) {
throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.')
}
if (!consent.earlyStartConsentAt) {
throw httpError(400, 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.')
}
const problem = previewProblem(prorationDate)
if (problem) throw httpError(problem.status, problem.message)
const doc = await requireSubscription(customerId)
if (switchesInFlight.has(doc.$id)) throw httpError(409, 'Ihr Wechsel wird gerade verarbeitet.')
switchesInFlight.add(doc.$id)
try {
return await performSwitch(customerId, doc, consent, prorationDate)
} finally {
switchesInFlight.delete(doc.$id)
}
}
async function performSwitch(customerId, current, consent, prorationDate) {
const ctx = await switchContext(customerId, current)
const { doc, customer, stripeSub } = ctx
// Seit der Vorschau hat ein neuer Abrechnungsmonat begonnen: Gutschrift und Betrag stimmen nicht mehr
if (ctx.periodStart && prorationDate < ctx.periodStart) {
throw httpError(409, 'Ihr Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.')
}
const sepa = ctx.paymentMethod === 'sepa_debit'
const switchedAt = new Date().toISOString()
// Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Produkt, neue Mindestlaufzeit, Nachweis der Zustimmung
const metadata = {
productKey: 'wartung',
termStartAt: switchedAt,
termsVersion: consent.termsVersion,
termsAcceptedAt: consent.consentAt,
earlyStartConsentAt: consent.earlyStartConsentAt,
consentIp: String(consent.consentIp || '').slice(0, 64),
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
switchedFrom: 'hosting',
switchedAt,
}
const updated = await getStripe().subscriptions.update(doc.$id, {
items: [{ id: ctx.aboItemId, price: ctx.wartungPriceId }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: prorationDate,
// Karte: erst mit der Zahlung wirksam; SEPA kennt keine ausstehenden Aktualisierungen (Rechtsrunde)
payment_behavior: sepa ? 'allow_incomplete' : 'pending_if_incomplete',
metadata,
...(ctx.discounts ? { discounts: ctx.discounts } : {}),
expand: ['latest_invoice'],
})
const invoice = updated.latest_invoice && typeof updated.latest_invoice === 'object' ? updated.latest_invoice : null
const pending = Boolean(updated.pending_update)
// Ohne Rechnung ist nichts zu zahlen; eine nicht aufgeloeste Rechnung (nur ID) gilt als offen
const paid = invoice ? invoice.status === 'paid' : !updated.latest_invoice
const effective = !pending
const paymentUrl = pending ? invoice?.hosted_invoice_url || null : null
const amountDueCents = Number(invoice?.amount_due) || 0
// Spiegel erst, wenn Stripe den Wechsel uebernommen hat; ein Fehler hier bricht nichts ab (der Webhook
// customer.subscription.updated spiegelt ohnehin nach)
let subscription = doc
if (effective) {
subscription =
(await syncSubscription(doc.$id).catch((err) => {
console.error(`[wechsel] Spiegel von Abo ${doc.$id} nach dem Wechsel nicht aktualisiert:`, err.message)
return null
})) || doc
}
const event = await recordSwitchEvent({
doc,
customerId,
consent,
status: effective && paid ? 'erledigt' : 'offen',
details: {
from: 'hosting',
amountDueCents,
prorationDate,
switchedAt,
paymentMethod: ctx.paymentMethod,
invoiceId: idOf(updated.latest_invoice),
// F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending)
paymentUrl,
effectiveAt: effective ? switchedAt : null,
paidAt: effective && paid ? switchedAt : null,
// F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann
previousTermStartAt: termStartOf(stripeSub).toISOString(),
previousMetadata: Object.fromEntries(RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] ?? null])),
},
})
const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' }
const facts = { switchedAt, amountDueCents }
const lastschrift = sepa && effective && !paid
// K12 sofort; zahlung_offen nur bei zurueckgehaltener Aenderung (Karte), lastschrift bei SEPA (F37)
await sendSwitchConfirmation(contact, consent, facts, { zahlung_offen: pending, lastschrift })
// T12 erst, wenn der Wechsel wirksam ist - ohne Nachweis sofort, damit das Team ihn von Hand verfolgt
if (effective || !event) {
await sendSwitchNotice(contact, doc.$id, facts, { wirksam: effective, lastschrift, nachweis_fehlt: !event })
}
return { subscription, paymentUrl, event }
}
/**
* Ereignis `wechsel` (Nachweis der Bestellung und Stand der Zahlung). Feste ID je Abo und Wechselzeitpunkt
* (eventIdFor, 36 Zeichen). Ein Fehler beim Speichern bricht den schon bei Stripe ausgefuehrten Wechsel nicht
* ab: er wird protokolliert, T12 meldet ihn dem Team (nachweis_fehlt).
*/
async function recordSwitchEvent({ doc, customerId, consent, status, details }) {
try {
return await recordContractEvent({
id: eventIdFor('wechsel', `${doc.$id}:${details.switchedAt}`),
customerId,
subscriptionId: doc.$id,
type: 'wechsel',
productKey: 'wartung',
details,
consent: {
termsVersion: consent.termsVersion,
consentAt: consent.consentAt,
consentIp: consent.consentIp || null,
consentUserAgent: consent.consentUserAgent || null,
earlyStartConsentAt: consent.earlyStartConsentAt,
},
status,
})
} catch (err) {
console.error(`[wechsel] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
return null
}
}
/**
* Wechsel ist wirksam und bezahlt: Ereignis erledigt (F01, updateContractEvent). War er noch nicht wirksam
* (Karte, Zahlung stand aus), geht jetzt T12 raus; bei SEPA ging es schon beim Klick raus.
*/
async function completeSwitch(event, contact) {
const now = new Date().toISOString()
const wasEffective = Boolean(event.details?.effectiveAt)
await updateContractEvent(event.$id, {
status: 'erledigt',
doneAt: now,
doneBy: 'stripe',
details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now },
})
if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true })
}
/**
* Wechsel ist gescheitert (F27): Ereignis `gescheitert` mit Grund, K14 an den Kunden (bei SEPA mit dem Hinweis,
* dass der Abbuchungstag der Wechseltag bleibt), T12 ans Team. Aufgerufen vom Webhook
* customer.subscription.pending_update_expired und fuer SEPA vom Team ueber scripts/wechsel-gescheitert.mjs
* (options.doneBy 'skript'). Nur ein offenes Ereignis wird geaendert; sonst { skipped } ohne Mails.
*/
export async function markSwitchFailed(eventId, grund, { doneBy = 'stripe' } = {}) {
const event = await getContractEvent(eventId)
if (event.type !== 'wechsel') throw httpError(400, `Ereignis ${eventId} ist kein Wechsel.`)
if (event.status !== 'offen') return { skipped: `Wechsel ist schon ${event.status}`, event }
const reason = String(grund || '').trim() || SWITCH_FAILED_REASONS.abgelaufen
const now = new Date().toISOString()
await updateContractEvent(eventId, { status: 'gescheitert', doneAt: now, doneBy, details: { failedAt: now, grund: reason } })
const mirror = await getDocument(config.collections.subscriptions, event.subscriptionId).catch(() => null)
const contact = await contactOf(event.customerId, mirror?.email)
if (contact.email) {
await sendCustomerMail(contact.email, {
template: 'K14',
values: { name: contact.name, grund: reason },
blocks: { lastschrift: event.details?.paymentMethod === 'sepa_debit' },
}).catch((err) => console.error(`[wechsel] K14 an ${contact.email} fehlgeschlagen:`, err.message))
} else {
console.error(`[wechsel] K14 nicht verschickt: keine E-Mail-Adresse zu Wechsel ${eventId}`)
}
await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { gescheitert: true })
return { failed: true, event: await getContractEvent(eventId) }
}
// ---------------------------------------------------------------------------
// Stripe-Webhooks (routes/abo.js)
// ---------------------------------------------------------------------------
/** customer.subscription.pending_update_applied: Stripe hat den Wechsel mit der Zahlung uebernommen. */
export async function handlePendingUpdateApplied(subscriptionId) {
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const event = await findOpenSwitchEvent(sub.customerId, subscriptionId)
// Kein offenes Ereignis: invoice.paid war schneller (schon erledigt) oder eine Wiederholung
if (!event) return { skipped: 'kein offener Wechsel' }
await completeSwitch(event, await contactOf(sub.customerId, sub.email))
return { completed: true, eventId: event.$id }
}
/** customer.subscription.pending_update_expired: nicht rechtzeitig bezahlt, Stripe hat die Rechnung storniert. */
export async function handlePendingUpdateExpired(subscriptionId) {
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const event = await findOpenSwitchEvent(sub.customerId, subscriptionId)
if (!event) {
console.warn(`[wechsel] pending_update_expired für Abo ${subscriptionId}, aber kein offener Wechsel`)
return { skipped: 'kein offener Wechsel' }
}
return markSwitchFailed(event.$id, SWITCH_FAILED_REASONS.abgelaufen)
}
/**
* invoice.paid (F26) einer Wechselrechnung (billing_reason subscription_update) mit offenem Ereignis: SEPA ->
* erledigt. Karte: kommt invoice.paid vor pending_update_applied, schliesst es den Wechsel ebenso (mit T12),
* aber erst, wenn Stripe die zurueckgehaltene Aenderung uebernommen hat. invoice ist die frisch geladene Rechnung.
*/
export async function handleSwitchInvoicePaid(invoice) {
if (invoice?.billing_reason !== 'subscription_update') return { skipped: 'keine Wechselrechnung' }
const subscriptionId = idOf(subscriptionIdOfInvoice(invoice))
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
const doc =
(await getDocument(config.collections.subscriptions, subscriptionId).catch(() => null)) || (await syncSubscription(subscriptionId))
if (!doc) return { skipped: 'Abo ohne Kundenzuordnung' }
const event = await findOpenSwitchEvent(doc.customerId, subscriptionId)
if (!event) return { skipped: 'kein offener Wechsel' }
if (event.details?.invoiceId && event.details.invoiceId !== invoice.id) return { skipped: 'andere Rechnung' }
const stripeSub = await getStripe().subscriptions.retrieve(subscriptionId)
if (stripeSub.pending_update) return { skipped: 'Stripe hält die Änderung noch zurück' }
if (!event.details?.effectiveAt) await syncSubscription(subscriptionId)
await completeSwitch(event, await contactOf(doc.customerId, doc.email))
return { completed: true, eventId: event.$id }
}
/**
* invoice.payment_failed einer Wechselrechnung bei SEPA-Lastschrift (Abo mit switchedAt in den letzten 30
* Tagen, billing_reason subscription_update, keine zurueckgehaltene Aenderung): T12 mit den Handgriffen fuer
* das Team. Das Ereignis bleibt offen, bis das Team den Wechsel mit dem Skript als gescheitert abschliesst.
* Bei Karte haelt Stripe die Aenderung zurueck (kein switchedAt), dort greift pending_update_expired.
*/
export async function handleSwitchPaymentFailed(invoice) {
if (invoice?.billing_reason !== 'subscription_update') return { skipped: 'keine Wechselrechnung' }
const subscriptionId = idOf(subscriptionIdOfInvoice(invoice))
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
const stripeSub = await getStripe().subscriptions.retrieve(subscriptionId)
const switchedAt = Date.parse(stripeSub.metadata?.switchedAt || '')
if (!switchedAt || Date.now() - switchedAt > SEPA_FAILURE_WINDOW_MS || stripeSub.pending_update) {
return { skipped: 'kein wirksamer Wechsel in den letzten 30 Tagen' }
}
const contact = await contactOf(stripeSub.metadata?.webklarCustomerId, stripeSub.metadata?.email || invoice.customer_email)
await sendSwitchNotice(
contact,
subscriptionId,
{ switchedAt: new Date(switchedAt).toISOString(), amountDueCents: invoice.amount_due },
{ lastschrift_gescheitert: true }
)
return { notified: true }
}
+1 -1
View File
@@ -263,7 +263,7 @@ export async function syncSubscription(subscriptionId) {
return doc
}
function addMonths(date, months) {
export function addMonths(date, months) {
// Monatsende abfangen (31.01. + 1 Monat = 28./29.02., wie bei Stripe)
const d = new Date(date)
const day = d.getUTCDate()