Kündigung und Widerruf: Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f66a713531
commit
7ffe95f24a
10 files changed
+992
-87
No files matched your search
@@ -7,8 +7,8 @@
|
||||
Betreff: Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: E-Mail aus dem Formular · BCC intern
|
||||
Platzhalter: {{name}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden
|
||||
Platzhalter: {{name}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{vertrag}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{pakete}}, {{pakete_neu}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, pruefen, abo
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -39,7 +39,7 @@ a{color:#111111;}
|
||||
<!-- WENN kundennummer -->
|
||||
<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;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kundennummer}}</td></tr>
|
||||
<!-- ENDE kundennummer -->
|
||||
<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;">Vertrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">WEBklar Wartungs-Abo</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;">Gewünschter Zeitpunkt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wunschtermin}}</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;">Vertrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{vertrag}}</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;">Gewünschter Zeitpunkt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wunschtermin}}</td></tr>
|
||||
<!-- WENN grund -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Grund</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{grund}}</td></tr>
|
||||
<!-- ENDE grund -->
|
||||
@@ -63,8 +63,16 @@ a{color:#111111;}
|
||||
<!-- WENN kein_abo_gefunden -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wir ordnen Ihre Kündigung zu</div><div class="mut" style="color:#4a4843;">Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE kein_abo_gefunden -->
|
||||
<!-- WENN zusatzspeicher -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">Ihr Zusatzspeicher verringert sich zum</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{vertragsende}}</div><div class="mut" style="color:#4a4843;padding-top:8px;">Sie haben um {{pakete}} Paket(e) gekündigt; danach bleiben {{pakete_neu}} Paket(e) zu je 5 GB. Bis dahin bleibt der Speicher verfügbar. Die Gebühr für den laufenden Abrechnungsmonat wird nicht anteilig erstattet.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE zusatzspeicher -->
|
||||
<!-- WENN pruefen -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wir prüfen Ihre Kündigung</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihre Kündigung und bestätigen Ihnen den Zeitpunkt, zu dem sie wirkt, gesondert per E-Mail.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE pruefen -->
|
||||
|
||||
<!-- WENN abo -->
|
||||
<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;">Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.</td></tr>
|
||||
<!-- ENDE abo -->
|
||||
<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>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
Betreff: Eingangsbestätigung Ihres Widerrufs – WEBklar Wartungs-Abo
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: E-Mail aus dem Formular · BCC intern
|
||||
Platzhalter: {{name}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer
|
||||
Platzhalter: {{name}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, pruefen
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -34,13 +34,24 @@ a{color:#111111;}
|
||||
<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;">Widerruf eingegangen</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;">Wir haben Ihren Widerruf erhalten.</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wir haben Ihren Widerruf des Vertrags „WEBklar Wartungs-Abo“ am {{eingang_zeitpunkt}} Uhr erhalten.</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;">Name</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="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>wir haben Ihren Widerruf der Bestellung „{{bestellung}}“ am {{eingang_zeitpunkt}} Uhr erhalten.</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;">Name</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;">Bestellung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{bestellung}}</td></tr>
|
||||
<!-- WENN kundennummer -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{kundennummer}}</td></tr>
|
||||
<!-- ENDE kundennummer -->
|
||||
</table></td></tr>
|
||||
<!-- WENN abschluss -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Es erfolgen keine weiteren Abbuchungen. Bereits gezahlte Beträge erstatten wir innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE abschluss -->
|
||||
<!-- WENN wechsel -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihr Abo auf das Hosting-Abo mit dem bisherigen Preis und der bisherigen Mindestlaufzeit zurück. Den für den Wechsel gezahlten Betrag erstatten wir innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE wechsel -->
|
||||
<!-- WENN zusatzspeicher -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Den dafür gezahlten Betrag erstatten wir innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE zusatzspeicher -->
|
||||
<!-- WENN pruefen -->
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE pruefen -->
|
||||
<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>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
Betreff: [Abo] Kündigung eingegangen: {{email}}
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden
|
||||
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{vertrag}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}}, {{pakete}}, {{fehler}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, kein_speicher, wechsel_ausstehend, speicher_fehlgeschlagen, nachweis_fehlt
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -38,7 +38,7 @@ a{color:#111111;}
|
||||
<!-- WENN kundennummer -->
|
||||
<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;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kundennummer}}</td></tr>
|
||||
<!-- ENDE kundennummer -->
|
||||
<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;">Art</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kuendigungsart}}</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;">Gewünschter Zeitpunkt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wunschtermin}}</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;">Vertrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{vertrag}}</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;">Art</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kuendigungsart}}</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;">Gewünschter Zeitpunkt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wunschtermin}}</td></tr>
|
||||
<!-- WENN grund -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Grund</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{grund}}</td></tr>
|
||||
<!-- ENDE grund -->
|
||||
@@ -62,6 +62,21 @@ a{color:#111111;}
|
||||
<!-- WENN kein_abo_gefunden -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">KEIN aktives Abo zu dieser E-Mail gefunden. Bitte von Hand zuordnen und Vertragsende bestätigen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE kein_abo_gefunden -->
|
||||
<!-- WENN zusatzspeicher -->
|
||||
<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:#f0f8f1;border:1px solid #cde6d2;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;">Zusatzspeicher verringert</div><div class="mut" style="color:#4a4843;">E-Mail-Zusatzspeicher um {{pakete}} Paket(e) verringert, wirksam zum {{vertragsende}} ({{stripe_abo_id}}). Die Speicher-Aufgabe für konsoleH kommt als eigener [Speicher]-Hinweis.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE zusatzspeicher -->
|
||||
<!-- WENN kein_speicher -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo {{stripe_abo_id}} gefunden, aber kein Zusatzspeicher gebucht. Bei Stripe ist nichts zu tun; der Kunde hat „wir prüfen“ bekommen – bitte kurz antworten.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE kein_speicher -->
|
||||
<!-- WENN wechsel_ausstehend -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren ({{stripe_abo_id}}, gekündigt: {{pakete}} Paket(e)). Solange die Wechselzahlung aussteht, würde eine Änderung des Abos die Wechselrechnung stornieren. Der Kunde hat „wir prüfen“ bekommen; den Zeitpunkt bestätigen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE wechsel_ausstehend -->
|
||||
<!-- WENN speicher_fehlgeschlagen -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Zusatzspeicher NICHT automatisch verringert ({{stripe_abo_id}}, gekündigt: {{pakete}} Paket(e)): {{fehler}} Bitte in Stripe von Hand verringern und dem Kunden den Zeitpunkt bestätigen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE speicher_fehlgeschlagen -->
|
||||
<!-- 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 Ereignis kuendigung konnte nicht in contractEvents angelegt werden. Diese Mail ist der Nachweis der Erklärung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE nachweis_fehlt -->
|
||||
|
||||
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
Betreff: [Abo] WIDERRUF eingegangen: {{email}}
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen
|
||||
Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, keine_frist, nachweis_fehlt
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -34,7 +34,7 @@ a{color:#111111;}
|
||||
<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:#fbe3e1;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a1f17;">Widerruf</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;">Widerruf eingegangen</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;">Name</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="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;">Name</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;">Bestellung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{bestellung}}</td></tr>
|
||||
<!-- WENN kundennummer -->
|
||||
<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;">Kundennummer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kundennummer}}</td></tr>
|
||||
<!-- ENDE kundennummer -->
|
||||
@@ -51,7 +51,21 @@ a{color:#111111;}
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo {{stripe_abo_id}} konnte NICHT automatisch auf Kündigung gesetzt werden. Bitte in Stripe von Hand kündigen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE kuendigung_fehlgeschlagen -->
|
||||
|
||||
<!-- WENN abschluss -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung).</div></td></tr></table></td></tr>
|
||||
<!-- ENDE abschluss -->
|
||||
<!-- WENN wechsel -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Wechsel ins Wartungs-Abo widerrufen: in Stripe den Preis zurück auf das Hosting-Abo setzen und die Metadaten des Wechsels zurücknehmen (termStartAt, productKey, switchedAt – wie in scripts/wechsel-gescheitert.mjs), die Wechselrechnung erstatten (Wertersatz für die Tage im Wartungs-Abo abziehen) innerhalb von 14 Tagen. Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
|
||||
<!-- ENDE wechsel -->
|
||||
<!-- WENN zusatzspeicher -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag erstatten (Wertersatz für die Tage abziehen) innerhalb von 14 Tagen. Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
|
||||
<!-- ENDE zusatzspeicher -->
|
||||
<!-- WENN keine_frist -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE keine_frist -->
|
||||
<!-- 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 Ereignis widerruf konnte nicht in contractEvents angelegt werden. Diese Mail ist der Nachweis der Erklärung.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE nachweis_fehlt -->
|
||||
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
+336
-54
@@ -5,7 +5,7 @@ import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
|
||||
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
|
||||
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
|
||||
import { PRODUCT_KEYS, describeItems, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createAboCheckout,
|
||||
createBillingPortalUrl,
|
||||
getCurrentSubscription,
|
||||
getStripe,
|
||||
grantCreditForInvoice,
|
||||
loadInvoice,
|
||||
minTermMonthsFor,
|
||||
@@ -632,19 +633,67 @@ router.post('/webhook', async (req, res) => {
|
||||
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Ratenbegrenzung: hoechstens 10 Erklaerungen je Stunde und IP-Adresse (Datenschutzerklaerung Abschnitt 7: die
|
||||
// IP-Adresse dient nur dazu, die Zahl der Anfragen zu begrenzen, sie wird nicht mit der Erklaerung gespeichert).
|
||||
// Eintraege, die aelter als eine Stunde sind, fliegen bei jedem Aufruf raus - auch die anderer IP-Adressen (F30).
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000
|
||||
const RATE_MAX = 10
|
||||
const recentRequests = new Map() // ip -> [timestamps]
|
||||
function rateLimited(ip) {
|
||||
const now = Date.now()
|
||||
const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000)
|
||||
list.push(now)
|
||||
recentRequests.set(ip, list)
|
||||
return list.length > 10
|
||||
|
||||
function purgeRateLimit(now) {
|
||||
for (const [ip, list] of recentRequests) {
|
||||
const fresh = list.filter((t) => now - t < RATE_WINDOW_MS)
|
||||
if (fresh.length) recentRequests.set(ip, fresh)
|
||||
else recentRequests.delete(ip)
|
||||
}
|
||||
}
|
||||
|
||||
function rateLimited(ip, now = Date.now()) {
|
||||
purgeRateLimit(now)
|
||||
const list = recentRequests.get(ip) || []
|
||||
list.push(now)
|
||||
recentRequests.set(ip, list)
|
||||
return list.length > RATE_MAX
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: alle Zaehler der Ratenbegrenzung loeschen. */
|
||||
export function resetPublicRateLimit() {
|
||||
recentRequests.clear()
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: Zahl der IP-Adressen, die zum Zeitpunkt now noch gezaehlt wuerden (aendert nichts). */
|
||||
export function publicRateLimitState({ now = Date.now() } = {}) {
|
||||
const ips = [...recentRequests.values()].filter((list) => list.some((t) => now - t < RATE_WINDOW_MS)).length
|
||||
return { ips }
|
||||
}
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher']
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
const name = String(body?.name || '').trim().slice(0, 200)
|
||||
const email = String(body?.email || '').trim().slice(0, 255)
|
||||
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null
|
||||
const contract = CONTRACTS.includes(body?.contract) ? body.contract : 'abo'
|
||||
const order = ORDERS.includes(body?.order) ? body.order : 'abschluss'
|
||||
let packages = null
|
||||
if (contract === 'zusatzspeicher') {
|
||||
const raw = String(body?.packages ?? '').trim()
|
||||
if (raw) {
|
||||
packages = /^\d+$/.test(raw) ? Number(raw) : NaN
|
||||
if (!(packages >= 1 && packages <= config.mailStorage.maxUnits)) {
|
||||
return { error: `Bitte geben Sie 1 bis ${config.mailStorage.maxUnits} Pakete an oder lassen Sie das Feld leer (alle Pakete).` }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
@@ -652,9 +701,30 @@ function validDeclaration(body) {
|
||||
kind: ['ausserordentlich', 'geschaeftsaufgabe'].includes(body?.kind) ? body.kind : 'ordentlich',
|
||||
reason: String(body?.reason || '').trim().slice(0, 2000),
|
||||
effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '',
|
||||
contract,
|
||||
order,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ratenbegrenzung und Erklaerung pruefen; bei 429/400 ist die Antwort schon geschickt (null). */
|
||||
function readDeclaration(req, res) {
|
||||
if (rateLimited(req.ip)) {
|
||||
res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
return null
|
||||
}
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) {
|
||||
res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
return null
|
||||
}
|
||||
if (d.error) {
|
||||
res.status(400).json({ error: d.error })
|
||||
return null
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
async function findActiveSubscriptionByEmail(email) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(email).catch(() => null)) ||
|
||||
@@ -664,51 +734,177 @@ async function findActiveSubscriptionByEmail(email) {
|
||||
return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null
|
||||
}
|
||||
|
||||
router.post('/public/cancel', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
/** Name des Abos aus dem Katalog ("WEBklar Hosting-Abo"), null ohne Abo. */
|
||||
const productNameOf = (sub) => (sub ? config.products[productKeyOf(sub)]?.name || 'WEBklar Abo' : null)
|
||||
|
||||
let sub = null
|
||||
/** Beschriftung des gekuendigten Vertrags fuer K03/T08: das tatsaechliche Abo (Spec 4.5) bzw. der Zusatzspeicher. */
|
||||
function contractLabel(contract, sub) {
|
||||
const abo = productNameOf(sub) || 'WEBklar Abo (Hosting- oder Wartungs-Abo)'
|
||||
return contract === 'zusatzspeicher' ? `E-Mail-Zusatzspeicher${sub ? ` zum ${abo}` : ''}` : abo
|
||||
}
|
||||
|
||||
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
|
||||
function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachweis der Erklaerung als Vertragsereignis kuendigung bzw. widerruf (F31, Datenschutz Abschnitt 7: ohne
|
||||
* IP-Adresse und Browser). Feste ID je Abo und Eingangszeitpunkt (eventIdFor, 36 Zeichen). Laesst es sich
|
||||
* nicht speichern, geht die Erklaerung trotzdem weiter: die Mails sind der Nachweis, T08/T09 sagen es dem Team.
|
||||
*/
|
||||
async function recordDeclarationEvent(sub, type, receivedAt, details, status) {
|
||||
try {
|
||||
return await recordContractEvent({
|
||||
id: eventIdFor(type, `${sub.$id}:${receivedAt.toISOString()}`),
|
||||
customerId: sub.customerId,
|
||||
subscriptionId: sub.$id,
|
||||
type,
|
||||
productKey: productKeyOf(sub),
|
||||
details,
|
||||
consent: null,
|
||||
status,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[abo] Ereignis ${type} für Abo ${sub.$id} nicht gespeichert:`, err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kuendigung des Abos wie bisher: Stripe (ausser ausserordentlich), Eingang am Abo, Ereignis kuendigung. */
|
||||
async function cancelAbo(sub, d, receivedAt) {
|
||||
let effectiveAt = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub && d.kind !== 'ausserordentlich') {
|
||||
if (d.kind !== 'ausserordentlich') {
|
||||
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
|
||||
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton', d.kind, receivedAt)
|
||||
sub = result.subscription
|
||||
sub = result.subscription || sub
|
||||
effectiveAt = result.effectiveAt
|
||||
}
|
||||
if (sub) {
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString() })
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||||
}
|
||||
// Erledigt nur, wenn nichts mehr zu tun ist; ausserordentlich, Geschaeftsaufgabe (Nachweis) und Stripe-Fehler
|
||||
// bleiben als Aufgabe offen
|
||||
const status = effectiveAt && d.kind === 'ordentlich' ? 'erledigt' : 'offen'
|
||||
const event = await recordDeclarationEvent(sub, 'kuendigung', receivedAt, { contract: 'abo', kind: d.kind, effectiveAt }, status)
|
||||
return { sub, effectiveAt, event, outcome: effectiveAt ? 'gekuendigt' : 'offen' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechsel ins Wartungs-Abo, der noch auf die Kartenzahlung wartet (offenes Ereignis wechsel ohne effectiveAt
|
||||
* oder pending_update am Stripe-Abo): eine Aenderung des Abos wuerde die Wechselrechnung stornieren. Ein
|
||||
* abgelaufener Wechsel wird vorher wie in GET /api/abo mit Stripe abgeglichen.
|
||||
*/
|
||||
async function pendingCardSwitch(sub, stripeSub) {
|
||||
const readOpen = async () =>
|
||||
(await listContractEvents(sub.customerId)).find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === sub.$id) || null
|
||||
let open = await readOpen()
|
||||
if (open && (await reconcilePendingSwitch(open))) open = await readOpen()
|
||||
return Boolean((open && !open.details?.effectiveAt) || stripeSub?.pending_update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kuendigung nur des Zusatzspeichers (Bedingungen § 8 Abs. 7/8, § 10 Abs. 5): gebuchte Pakete minus packages
|
||||
* (leer = alle) ueber changeStorage mit viaCancellation (Task 8: nur Verringerung, in jedem laufenden Status,
|
||||
* auch bei gekuendigtem Abo), wirksam zum Ende des Abrechnungsmonats. Ohne gebuchten Speicher, waehrend ein
|
||||
* Kartenwechsel aussteht oder wenn changeStorage scheitert, aendert sich nichts: K03 sagt "wir pruefen", T08
|
||||
* traegt den Grund (F20). Liefert { outcome, error, event, details? } (details nur nach einer Verringerung).
|
||||
*/
|
||||
async function cancelStorage(sub, d, receivedAt) {
|
||||
// Gebuchte Menge frisch aus Stripe (der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken); die
|
||||
// Antwort zeigt auch, ob eine Aenderung bis zur Zahlung zurueckgehalten wird
|
||||
let stripeSub = null
|
||||
let fromUnits = Number(sub.storageUnits) || 0
|
||||
try {
|
||||
stripeSub = await getStripe().subscriptions.retrieve(sub.$id)
|
||||
fromUnits = describeItems(stripeSub.items?.data).storageUnits
|
||||
} catch (err) {
|
||||
console.error(`[abo] Stripe-Abo ${sub.$id} nicht lesbar, Speicherstand aus dem Spiegel:`, err.message)
|
||||
}
|
||||
const base = { contract: 'zusatzspeicher', effectiveAt: null, packages: d.packages, fromUnits, toUnits: null }
|
||||
const pending = async (outcome, error = null) => ({
|
||||
outcome,
|
||||
error,
|
||||
event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, base, 'offen'),
|
||||
})
|
||||
if (fromUnits <= 0) return pending('kein_speicher')
|
||||
// Laesst sich nicht klaeren, ob ein Wechsel aussteht, bleibt Stripe unangetastet
|
||||
try {
|
||||
if (await pendingCardSwitch(sub, stripeSub)) return pending('wechsel_ausstehend')
|
||||
} catch (err) {
|
||||
console.error(`[abo] Wechsel-Prüfung für Abo ${sub.$id} nicht möglich:`, err.message)
|
||||
return pending('fehlgeschlagen', `Vertragsereignisse nicht lesbar: ${err.message}`)
|
||||
}
|
||||
const restUnits = Math.max(0, fromUnits - (d.packages ?? fromUnits))
|
||||
try {
|
||||
const { change } = await changeStorage(sub.customerId, restUnits, null, { viaCancellation: true })
|
||||
const details = {
|
||||
contract: 'zusatzspeicher',
|
||||
effectiveAt: change.effectiveAt,
|
||||
packages: change.fromUnits - change.toUnits,
|
||||
fromUnits: change.fromUnits,
|
||||
toUnits: change.toUnits,
|
||||
}
|
||||
return { outcome: 'reduziert', error: null, details, event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, details, 'erledigt') }
|
||||
} catch (err) {
|
||||
console.error(`[abo] Zusatzspeicher über die Kündigung nicht verringert (Abo ${sub.$id}):`, err.message)
|
||||
return pending('fehlgeschlagen', err.message || 'unbekannter Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/public/cancel', async (req, res) => {
|
||||
const d = readDeclaration(req, res)
|
||||
if (!d) return
|
||||
const receivedAt = new Date()
|
||||
const storage = d.contract === 'zusatzspeicher'
|
||||
|
||||
let sub = null
|
||||
let result = { outcome: 'kein_abo', effectiveAt: null, details: null, event: null, error: null }
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
result = storage ? await cancelStorage(sub, d, receivedAt) : await cancelAbo(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung nicht verarbeitbar:', err.message)
|
||||
}
|
||||
const reduced = result.outcome === 'reduziert'
|
||||
const effectiveAt = storage ? result.details?.effectiveAt || null : result.effectiveAt
|
||||
|
||||
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
|
||||
const common = {
|
||||
name: d.name,
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
wunschtermin: d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
|
||||
vertrag: contractLabel(d.contract, sub),
|
||||
// Eine Verringerung des Speichers wirkt immer zum Ende des Abrechnungsmonats
|
||||
wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
|
||||
grund: d.reason,
|
||||
vertragsende: fmtDate(effectiveAt),
|
||||
// {{pakete}} = gekuendigte Pakete (F31); solange nichts verringert wurde, der Wunsch des Kunden
|
||||
pakete: reduced ? result.details.packages : d.packages ?? 'alle',
|
||||
}
|
||||
|
||||
await sendCustomerMail(d.email, {
|
||||
template: 'K03',
|
||||
values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
|
||||
values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
grund: Boolean(d.reason),
|
||||
automatisch_gekuendigt: Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||
sonderkuendigung: Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||
ausserordentlich: !effectiveAt && d.kind === 'ausserordentlich',
|
||||
kein_abo_gefunden: !effectiveAt && d.kind !== 'ausserordentlich',
|
||||
abo: !storage,
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
|
||||
// Abo: nicht automatisch gekuendigt (kein Abo oder Stripe-Fehler) -> "wir ordnen zu"; Speicher: kein Abo
|
||||
kein_abo_gefunden: storage ? !sub : !effectiveAt && d.kind !== 'ausserordentlich',
|
||||
zusatzspeicher: reduced,
|
||||
pruefen: storage && Boolean(sub) && !reduced,
|
||||
},
|
||||
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
@@ -718,74 +914,160 @@ router.post('/public/cancel', async (req, res) => {
|
||||
...common,
|
||||
kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1),
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
fehler: result.error || '',
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
grund: Boolean(d.reason),
|
||||
automatisch_gekuendigt: Boolean(effectiveAt),
|
||||
sonderkuendigung: d.kind === 'geschaeftsaufgabe',
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt),
|
||||
sonderkuendigung: !storage && d.kind === 'geschaeftsaufgabe',
|
||||
// Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler
|
||||
ausserordentlich: !effectiveAt && Boolean(sub),
|
||||
ausserordentlich: !storage && !effectiveAt && Boolean(sub),
|
||||
kein_abo_gefunden: !sub,
|
||||
zusatzspeicher: reduced,
|
||||
kein_speicher: result.outcome === 'kein_speicher',
|
||||
wechsel_ausstehend: result.outcome === 'wechsel_ausstehend',
|
||||
speicher_fehlgeschlagen: result.outcome === 'fehlgeschlagen',
|
||||
nachweis_fehlt: Boolean(sub) && !result.event,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
/** Spaetestes Fristende einer Liste von Widerrufsfristen (openWithdrawals) oder null. */
|
||||
const latestUntil = (list) => list.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null)
|
||||
|
||||
/**
|
||||
* Widerruf der gewaehlten Bestellung: liegt eine offene Frist des Typs vor (openWithdrawals; Altbestand ohne
|
||||
* Ereignis abschluss: ab startedAt, F14), bekommen alle Bezugsereignisse den Status widerrufen (F03), beim
|
||||
* Abschluss wird das Abo wie bisher auf Kuendigung gesetzt; Wechsel und Zusatzspeicher setzt das Team von Hand
|
||||
* zurueck (T09). Ohne offene Frist aendert sich nichts. Immer entsteht das Ereignis widerruf mit
|
||||
* details { order, eventId, eventIds, inTime }.
|
||||
*/
|
||||
async function withdrawOrder(sub, d, receivedAt) {
|
||||
const type = ORDER_EVENT_TYPE[d.order]
|
||||
const events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type)
|
||||
let open = openWithdrawals(events, receivedAt)
|
||||
let deadline = latestUntil(open)
|
||||
if (type === 'abschluss' && !events.length && sub.startedAt) {
|
||||
const { endsAt } = withdrawalDeadline(sub.startedAt)
|
||||
deadline = endsAt
|
||||
if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }]
|
||||
}
|
||||
if (!deadline) {
|
||||
// Frist schon vorbei: das spaeteste Fristende der Bestellungen dieses Typs fuer den Hinweis ans Team
|
||||
const dates = events.map((e) => e.details?.contractAt ?? e.consentAt).filter(Boolean)
|
||||
deadline = latestUntil(dates.map((at) => ({ until: withdrawalDeadline(at).endsAt })))
|
||||
}
|
||||
const inTime = open.length > 0
|
||||
const ids = open.map((w) => w.eventId).filter(Boolean)
|
||||
const withdrawn = []
|
||||
let canceled = false
|
||||
let cancelFailed = false
|
||||
if (inTime) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await updateContractEvent(id, { status: 'widerrufen', doneAt: receivedAt.toISOString(), doneBy: 'widerrufsbutton' })
|
||||
withdrawn.push(events.find((e) => e.$id === id))
|
||||
} catch (err) {
|
||||
console.error(`[abo] Ereignis ${id} nicht auf widerrufen gesetzt:`, err.message)
|
||||
}
|
||||
}
|
||||
if (type === 'abschluss') {
|
||||
try {
|
||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||
sub = (await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')) || sub
|
||||
canceled = true
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, { withdrawalRequestedAt: receivedAt.toISOString() })
|
||||
} catch (err) {
|
||||
cancelFailed = true
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
|
||||
return { sub, inTime, deadline, withdrawn, canceled, cancelFailed, event }
|
||||
}
|
||||
|
||||
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
|
||||
function describeWithdrawn(events) {
|
||||
return events
|
||||
.map((e) => {
|
||||
const at = fmtDate(e.details?.contractAt ?? e.consentAt)
|
||||
const what = e.type === 'speicher' ? `${e.details?.fromUnits ?? '?'} → ${e.details?.toUnits ?? '?'} Pakete` : `${e.type} ${e.details?.switchedAt ? `vom ${fmtDate(e.details.switchedAt)}` : ''}`.trim()
|
||||
return `${e.$id}: ${what} (bestellt am ${at})`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
router.post('/public/withdraw', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const d = readDeclaration(req, res)
|
||||
if (!d) return
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
let canceled = false
|
||||
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null }
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')
|
||||
canceled = true
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
withdrawalRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
result = await withdrawOrder(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
|
||||
}
|
||||
const { inTime } = result
|
||||
|
||||
await sendCustomerMail(d.email, {
|
||||
template: 'K04',
|
||||
values: { name: d.name, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber },
|
||||
blocks: { kundennummer: Boolean(d.customerNumber) },
|
||||
values: {
|
||||
name: d.name,
|
||||
bestellung: orderLabel(d.order, sub, { customer: true }),
|
||||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
pruefen: !inTime,
|
||||
},
|
||||
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss
|
||||
const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null
|
||||
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zaehlen muss
|
||||
await sendAdminNotice({
|
||||
template: 'T09',
|
||||
values: {
|
||||
email: d.email,
|
||||
name: d.name,
|
||||
kundennummer: d.customerNumber,
|
||||
bestellung: orderLabel(d.order, sub),
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
abo_beginn: fmtDate(sub?.startedAt),
|
||||
fristende: fmtDate(deadline?.endsAt),
|
||||
fristende: fmtDate(result.deadline),
|
||||
rechtzeitig: !sub
|
||||
? 'ungeprüft (kein Abo gefunden)'
|
||||
: !deadline
|
||||
? 'ungeprüft (Abo-Beginn fehlt)'
|
||||
: receivedAt <= deadline.endsAt
|
||||
? 'RECHTZEITIG'
|
||||
: 'NACH Fristende, vor einer Ablehnung bitte prüfen',
|
||||
: inTime
|
||||
? 'RECHTZEITIG'
|
||||
: result.deadline
|
||||
? 'NACH Fristende, vor einer Ablehnung bitte prüfen'
|
||||
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
|
||||
ereignisse: describeWithdrawn(result.withdrawn),
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abo_gefunden: Boolean(sub),
|
||||
auf_kuendigung_gesetzt: canceled,
|
||||
auf_kuendigung_gesetzt: result.canceled,
|
||||
kein_abo_gefunden: !sub,
|
||||
kuendigung_fehlgeschlagen: Boolean(sub) && !canceled,
|
||||
kuendigung_fehlgeschlagen: result.cancelFailed,
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
keine_frist: Boolean(sub) && !inTime,
|
||||
nachweis_fehlt: Boolean(sub) && !result.event,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user