Compare commits

...
42 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 78b4d7b7fe Livegang: Vertragsbedingungen Version 4 und Datenschutzerklärung Stand 30.09.2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:03:39 +00:00
ClaudeandClaude Opus 5.5 46f229804d Plan 2: Livegang-Schritte nach der Gesamtprüfung korrigiert und ergänzt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:46:55 +00:00
ClaudeandClaude Sonnet 5.5 09fc940619 Oberfläche: Speicher-Zusammenfassung nach Backend-Modell (Reduzierung aufheben per Plus)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:42:19 +00:00
ClaudeandClaude Sonnet 5.5 05a59c4d61 Oberfläche: Speicher-Stepper bei geplanter Reduzierung, vollständige Bestellübersicht, Hinweis bei gesperrten Änderungen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:37:03 +00:00
ClaudeandClaude Sonnet 5.5 f7eeda8c19 Wechsel ins Wartungs-Abo: Übersicht, Zustimmung, Bestellung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:24:46 +00:00
ClaudeandClaude Sonnet 5.5 c95695c175 Zusatzspeicher: Plus und Minus mit Bestellübersicht
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:17:54 +00:00
ClaudeandClaude Sonnet 5.5 90a3c57aa3 Bestellseiten für Hosting- und Wartungs-Abo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:13:22 +00:00
ClaudeandClaude Sonnet 5.5 19af93338b Verträge: Mein Vertrag, Vergleich der Abos, Wechsel-Karte, Unterlagen in der zugestimmten Fassung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:09:36 +00:00
ClaudeandClaude Sonnet 5.5 0129ac48e7 Kundenbereich: „Wartung“ heißt jetzt „Verträge“, auch am Desktop in der Navigation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:01:09 +00:00
ClaudeandClaude Sonnet 5.5 36b54f6dab Löschfristen: bei abgeschnittenen Listen abbrechen, Teilfehler melden, Randfälle
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:54:43 +00:00
ClaudeandClaude Opus 5.5 6c8e7703ae Vertragsbedingungen Version 4: Datenverlust-Klausel § 13 Abs. 3 mit Ausnahme für Absatz 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:52:38 +00:00
ClaudeandClaude Sonnet 5.5 9342ba10c8 Löschfristen: Probelauf, Bericht ans Team, Löschen nach drei Jahren
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:51:50 +00:00
ClaudeandClaude Sonnet 5.5 cc42ab16c1 Admin E-Mail-Speicher: belegter Speicher wie in konsoleH, klare Vorgabe je Kunde
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:48:49 +00:00
ClaudeandClaude Fable 5.1 12a24514a4 Mails: Vertragsbestätigung je Fassung, Zustimmung zum AVV, kleinere Textkorrekturen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 14:46:41 +00:00
ClaudeandClaude Fable 5.1 63d3bb11bf Admin: Reiter E-Mail-Speicher, Abo mit Produkt; Schnittstelle fürs Ticketsystem
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 14:37:42 +00:00
ClaudeandClaude Fable 5.1 19f9c747a2 Mails: Produktname statt fest Wartungs-Abo, neue Mails für Wechsel und Zusatzspeicher, UptimeRobot-Hinweis
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:38:43 +00:00
ClaudeandClaude Fable 5.1 0d2254ca20 Kündigung und Widerruf: Paketzahl in jeder Bestätigung, klare Hinweise fürs Team, Tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:15:51 +00:00
ClaudeandClaude Sonnet 5 e5cc2f3c82 Abo-Angebote: für Hosting- oder Wartungs-Abo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:08:59 +00:00
ClaudeandClaude Fable 5.1 7ffe95f24a Kündigung und Widerruf: Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:52:37 +00:00
ClaudeandClaude Fable 5.1 f66a713531 Zusatzspeicher: Plan nach Stripe-Abgleich erneut prüfen, Aufgabenliste unter Sperre, K13 ohne Kosten
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:31:28 +00:00
ClaudeandClaude Opus 5.5 662f8d8144 Wechsel: Lastschrift-Fehler nur für den echten Wechsel, Abgleich ohne Webhook, Gutschrift netto, Skript prüft Stripe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:24:02 +00:00
ClaudeandClaude Fable 5.1 bc20ce0673 Zusatzspeicher: Vorschau im flexiblen Abrechnungsmodus, Aufgaben nach Zeitpunkt, keine stillen Abbrüche
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:16:07 +00:00
ClaudeandClaude Opus 5.5 188db4b3c1 Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:30:21 +00:00
ClaudeandClaude Fable 5.1 379cc26dcd Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 10:48:29 +00:00
ClaudeandClaude Fable 5.1 887e1e54a0 Abo-API: Nachweis-ID nach Appwrite-Regeln, Webhook bei dauerhaften Fehlern, Tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 10:19:05 +00:00
ClaudeandClaude Opus 5.5 7df0b72019 Stripe: Testrabatt-Namen, Produktbild, Hosting-Leistungen, Tests für Bestandskunden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:02:03 +00:00
ClaudeandClaude Fable 5.1 c4607e9c43 Abo-API: Bestellung je Produkt, Katalog, Speicherstand, Wechsel-Möglichkeit
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 09:43:09 +00:00
ClaudeandClaude Opus 5.5 05363d06ad Vertragsereignisse: Speicher-Aufgaben mit gelesenen Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:25:16 +00:00
ClaudeandClaude Opus 5.5 e5cc57cefd Stripe: Preise je Produkt, Testrabatt Hosting, Kundenportal „Abo verwalten“, Mindestlaufzeit ab termStartAt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:21:25 +00:00
ClaudeandClaude Opus 5.5 7799d7c4de Vertragsereignisse: Nachweise, Widerrufsfristen, Speicher-Aufgaben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:59:30 +00:00
ClaudeandClaude Opus 5.5 7e869280f3 Rechtsrunde: Wartung ab wirksamem Wechsel, Wortlaut § 9, Aufbewahrung bis Jahresende
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:45:40 +00:00
ClaudeandClaude Opus 5.5 f856869747 Rechtsrunde: Wartung ab wirksamem Wechsel, Wortlaut § 9, Aufbewahrung bis Jahresende
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:43:17 +00:00
ClaudeandClaude Opus 5.5 71fa0fc8e2 Spec und Plan 2: Aufbewahrung bis Jahresende + 3 Jahre, Hinweise zum Wechsel nach der Textprüfung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:39:27 +00:00
ClaudeandClaude Opus 5.5 478fbae3aa Rechtsrunde: Wechsel erst mit Zahlung, Zusatzspeicher teilweise kündbar, Aufbewahrung nach dem letzten Abo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:19:29 +00:00
ClaudeandClaude Opus 5.5 91d9360dd2 Spec und Plan 2: Entscheidungen aus der Rechtsrunde (Wechsel erst mit Zahlung, Speicher teilweise kündigen, Löschfristen, AGB)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:12:54 +00:00
ClaudeandClaude Fable 5.1 e456c3de69 Übernahmegutschrift: Verrechnung nach Wechsel, Mail K09, Leitfaden, Admin zeigt das Abo, Tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 07:49:51 +00:00
ClaudeandClaude Opus 5.5 0c2e50fc20 Vertragsbedingungen Version 4: Webmail, Übersicht, Löschung bei Vertragsende, Hinweise zum Widerruf, Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:45:29 +00:00
ClaudeandClaude Fable 5.1 6f7daf757f Datenschutz (Entwurf): Übermittlung an UptimeRobot, Bestellungen im Portal, Formulare, Tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:37:06 +00:00
ClaudeandClaude Fable 5.1 a2c1f0d81c Übernahmegutschrift und Leitfaden: für beide Abos (Entwurf)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:23:44 +00:00
ClaudeandClaude Fable 5.1 0098899ac6 Datenschutz (Entwurf): unsere Abos, Überwachung mit UptimeRobot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:05:34 +00:00
ClaudeandClaude Opus 5.5 fead8fdd5a Vertragsbedingungen Version 4: Korrekturen aus der Prüfung (Widerrufsfrist, Kündigung, Bestellübersicht, Postfächer, AVV, § 19 UStG)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:41:22 +00:00
ClaudeandClaude Opus 5.5 f19187cc16 Vertragsbedingungen Version 4 (Entwurf): Hosting- und Wartungs-Abo, Postfächer, Zusatzspeicher, Wechsel; Fassung 3 archiviert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:17:31 +00:00
76 changed files with 12003 additions and 558 deletions

No files matched your search

Binary file not shown.
Binary file not shown.
+27 -15
View File
@@ -4,21 +4,23 @@
<meta charset="utf-8">
<title>Empfehlen für WEBklar – Leitfaden</title>
<style>
@page { size: A4; margin: 13mm 16mm 12mm; }
@page { size: A4; margin: 10mm 13mm 9mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #1d1d1f; font: 10pt/1.45 "DejaVu Sans", system-ui, sans-serif; }
body { margin: 0; color: #1d1d1f; font: 9.5pt/1.38 "DejaVu Sans", system-ui, sans-serif; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 10pt; color: #6d1fd1; }
h1 { font-size: 20pt; line-height: 1.15; margin: 3mm 0 1.5mm; }
.lead { font-size: 10.5pt; color: #444; margin: 0 0 4mm; }
h2 { font-size: 12pt; margin: 4.5mm 0 2mm; }
ol.steps { list-style: none; padding: 0; margin: 0; display: grid; gap: 3mm; }
h1 { font-size: 19pt; line-height: 1.15; margin: 2.5mm 0 1.2mm; }
.lead { font-size: 10.5pt; color: #444; margin: 0 0 2.5mm; }
h2 { font-size: 12pt; margin: 3mm 0 1.2mm; }
ol.steps { list-style: none; padding: 0; margin: 0; display: grid; gap: 1.5mm; }
ol.steps li { display: grid; grid-template-columns: 9mm 1fr; gap: 3mm; align-items: start; }
.num { width: 8mm; height: 8mm; border-radius: 50%; background: #6d1fd1; color: #fff; font-weight: 700; display: flex; align-items: center; justify-content: center; }
ol.steps strong { display: block; }
.box { border: 1px solid #e0dccf; border-radius: 3mm; padding: 3mm 4mm; margin: 3.5mm 0; background: #faf9f6; }
.box { border: 1px solid #e0dccf; border-radius: 3mm; padding: 2mm 4mm; margin: 2.5mm 0; background: #faf9f6; }
.rules { margin: 0; padding-left: 5mm; }
.rules li { margin: 1.4mm 0; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 4mm; }
.rules li { margin: 0.8mm 0; }
.abos { margin: 1mm 0; padding-left: 4.5mm; }
.abos li { margin: 0.6mm 0; }
.grid2 { display: grid; grid-template-columns: 1fr 1.2fr; gap: 4mm; }
.muted { color: #666; font-size: 9pt; }
.contact { font-size: 10pt; }
.do::marker { content: "✓ "; color: #2f7d3b; }
@@ -39,14 +41,24 @@
Frag den Betrieb, ob du seine Kontaktdaten an uns weitergeben darfst. Oder gib ihm unsere Kontaktdaten (unten).
Schick uns dann Name des Betriebs, Ansprechpartner, Telefon oder E-Mail, worum es kurz geht, und dass er mit der Weitergabe einverstanden ist.</div></li>
<li><span class="num">3</span><div><strong>Den Rest machen wir</strong>
Beratung, Angebot und Website übernehmen wir. Für das Wartungs-Abo bekommt der Kunde von uns einen Zugang zum Kundenportal
und schließt es dort <b>selbst online</b> ab: ohne Unterschrift, ohne Termin, Zahlung sicher über Stripe.</div></li>
Beratung, Angebot und Website übernehmen wir. Für das Hosting- oder Wartungs-Abo bekommt der Kunde von uns einen Zugang zum Kundenportal
und schließt es dort im Bereich „Verträge“ <b>selbst online</b> ab: ohne Unterschrift, ohne Termin, Zahlung sicher über Stripe.</div></li>
</ol>
<div class="box">
<strong>Das Wartungs-Abo auf einen Blick</strong><br>
50 € pro Monat, Mindestlaufzeit 12 Monate, danach monatlich kündbar. Enthalten sind Betrieb und Pflege der Website,
1 Domain, 1 E-Mail-Postfach und 1 Stunde Arbeitszeit pro Monat (sammelt sich an, jede Stunde 12 Monate gültig).
<strong>Unsere Abos auf einen Blick</strong> · beide mit Mindestlaufzeit 12 Monate, danach monatlich kündbar
<ul class="abos">
<li><b>Hosting-Abo, 15,00 € pro Monat</b> (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer): nur statische Websites (ohne CMS, Login,
Datenbank oder Formulare); 1 Domain, 1 E-Mail-Postfach mit 5 GB; keine Pflege der Website und kein Stundenkontingent
(Änderungen nur nach Freigabe, gesondert berechnet).</li>
<li><b>Wartungs-Abo, 50,00 € pro Monat</b> (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer): alle Websites und Web-Anwendungen; Betrieb und Pflege,
1 Domain, beliebig viele Postfächer mit zusammen 5 GB, 1 Stunde Arbeitszeit pro Monat (nicht genutzte Zeit bleibt 12 Monate verfügbar).</li>
<li><b>In beiden Abos:</b> E-Mail-Zusatzspeicher je 5 GB für 1,00 € pro Monat (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer).
Außerdem kann der Kunde die freiwillige Übernahmegutschrift beantragen, wenn er Domain, Hosting oder E-Mail beim bisherigen Anbieter
im Voraus bezahlt hat: Nach unserer Prüfung verrechnen wir den doppelt bezahlten Anteil mit seinen Monatsgebühren (keine Auszahlung).</li>
<li><b>Wechsel</b> vom Hosting- ins Wartungs-Abo im Kundenportal unter „Verträge“ (Hosting-Abo läuft, nicht gekündigt, keine offene Zahlung);
es beginnt eine neue Mindestlaufzeit von 12 Monaten.</li>
</ul>
Alle Details stehen in den Abo-Bedingungen: project.webklar.com/abo-bedingungen.html
</div>
@@ -61,7 +73,7 @@
</ul>
<ul class="rules">
<li class="dont">Keine Werbe-E-Mails, WhatsApp- oder SMS-Nachrichten und keine Werbeanrufe an Leute, die dem nicht zugestimmt haben.</li>
<li class="dont">Keine Zusagen zu Preisen, Rabatten, Terminen oder Leistungen, die nicht auf webklar.com oder in unserem Angebot stehen.</li>
<li class="dont">Keine Zusagen zu Preisen, Rabatten, Terminen oder Leistungen, die nicht auf webklar.com, in den Abo-Bedingungen oder in unserem Angebot stehen.</li>
<li class="dont">Nichts für WEBklar unterschreiben, keine Verträge abschließen, kein Geld annehmen.</li>
<li class="dont">Kundendaten nicht sammeln, speichern oder anders verwenden.</li>
</ul>
@@ -24,14 +24,14 @@
- Website: React/Vite mit vitest
- Ticketsystem: React/Vite
**Spec:** `docs/superpowers/specs/2026-09-27-vertraege-hosting-abo-design.md`, Abschnitte 1–13 und Anhang.
**Spec:** `docs/superpowers/specs/2026-09-27-vertraege-hosting-abo-design.md`, Abschnitte 1–14 und Anhang (Abschnitt 14 = Entscheidungen der Rechtsrunde, geht vor).
## Global Constraints
**Preise und Laufzeiten**
- Hosting 15,00 €, Wartung 50,00 €, Zusatzspeicher 1,00 € je 5 GB und Monat. Überall, wo ein Preis steht, steht auch „Gemäß § 19 UStG ohne Umsatzsteuer“.
- Zusatzspeicher: 0 bis 10 Pakete. Plus und Minus gibt es nur bei Stripe-Status `active` und ohne Kündigung (`cancelAtPeriodEnd` oder `cancelAt` gesetzt heißt gekündigt).
- Zusatzspeicher: 0 bis 10 Pakete. Plus und Minus gibt es nur bei Stripe-Status `active` und ohne Kündigung (`cancelAtPeriodEnd` oder `cancelAt` gesetzt heißt gekündigt). Über „Verträge hier kündigen“ (Task 10) sind Verringerungen jederzeit möglich, auch bei gekündigtem Abo oder offener Zahlung.
- **Wechsel:**
- Nur ein Hosting-Abo mit Status `active` kann wechseln, und nur ohne Kündigung.
- Nur mit Sofortbeginn, das Pflicht-Häkchen ist gesetzt.
@@ -58,8 +58,8 @@
**Namen im Code**
- Mailvorlagen: neu sind K12 (Wechsel), K13 (Zusatzspeicher), T12 (Wechsel, intern) und T13 (Speicher-Aufgabe, intern). K11 ist schon vergeben.
- `contractEvents.type` ist `abschluss`, `wechsel`, `speicher`, `kuendigung` oder `widerruf`. `status` ist `offen`, `erledigt` oder `widerrufen`.
- Mailvorlagen: neu sind K12 (Wechsel), K13 (Zusatzspeicher), T12 (Wechsel, intern) und T13 (Speicher-Aufgabe, intern). K11 ist schon vergeben. Nach der Rechtsrunde kommen K14 (Wechsel nicht zustande gekommen) und T14 (Löschfristen, intern) dazu.
- `contractEvents.type` ist `abschluss`, `wechsel`, `speicher`, `kuendigung` oder `widerruf`. `status` ist `offen`, `erledigt`, `widerrufen` oder `gescheitert` (Wechsel ohne Zahlung, nach der Rechtsrunde).
- Stripe-Metadaten am Abo: `webklarCustomerId`, `email`, `productKey`, `termStartAt`, `termsVersion`, `termsAcceptedAt`, `earlyStartConsentAt`, `consentIp`, `consentUserAgent`, `switchedFrom`, `switchedAt`.
- Testrabatte: `WEBKLAR-TEST-050` (Wartung, amount_off 4950, besteht schon) und `WEBKLAR-TEST-050-HOSTING` (Hosting, amount_off 1450).
- lookup_keys werden in Stripe nie übertragen (`transfer_lookup_key`). Ein neuer Preis bekommt einen neuen lookup_key im Katalog, denn Zuordnung und Stundenregel hängen am lookup_key (Plan 1).
@@ -77,10 +77,11 @@
1. **Plus, dann Minus, dann wieder Plus im selben Monat.** Pakete, die für diesen Monat schon bezahlt sind, kosten kein zweites Mal. Test: Task 8, „erneutes Plus bis zur bezahlten Menge kostet nichts“.
2. **Die Wechsel-Rechnung braucht eine Bankbestätigung (3-D Secure) oder scheitert.**
- Das Portal leitet auf die Stripe-Zahlungsseite der Rechnung weiter, bzw. zeigt wie bei jeder Abbuchung das Zahlungsproblem.
- Karte: Der Wechsel bleibt ausstehend (`pending_update`). Das Abo bleibt `active` als Hosting-Abo. Das Portal zeigt „Ihr Wechsel wartet auf die Zahlung“ mit dem Link zur Stripe-Zahlungsseite. Verfällt die ausstehende Aktualisierung, wird das Ereignis `gescheitert` und K14 geht raus (Rechtsrunde).
- SEPA: Der Wechsel gilt sofort. Eine geplatzte Lastschrift führt zu T12 `lastschrift_gescheitert` und wird von Hand rückgängig gemacht.
- Der Wechsel bleibt als Ereignis nachweisbar.
- Test: Task 9, „offene Rechnung liefert paymentUrl“.
3. **Gekündigtes Abo, Status `trialing` oder `past_due`.** Wechsel, Plus und Minus werden mit 409 und klarer Meldung abgelehnt, die Knöpfe sind nicht zu sehen. Tests: Task 8 und Task 9 („gekündigt: 409“), Task 15 (Knöpfe ausgeblendet).
- Test: Task 9, Zahlung ausstehend liefert `paymentUrl`, Ereignis `offen`.
3. **Gekündigtes Abo, Status `trialing` oder `past_due`.** Wechsel, Plus und Minus werden im Portal mit 409 und klarer Meldung abgelehnt, die Knöpfe sind nicht zu sehen. Tests: Task 8 und Task 9 („gekündigt: 409“), Task 15 (Knöpfe ausgeblendet).
4. **Öffentliche Kündigung oder öffentlicher Widerruf ohne passenden Vertrag.** Das gilt für eine unbekannte E-Mail-Adresse, „nur Zusatzspeicher“ ohne gebuchten Speicher und einen Widerruf nach Fristende. Der Kunde bekommt trotzdem eine Eingangsbestätigung („wir prüfen und melden uns“), es passiert keine automatische Änderung, und das Team bekommt einen Hinweis. Tests: Task 10.
5. **Bestandskunde mit Bedingungen Version 3** (oder Testkonto mit v1/v2).
- Der Link „Vertragsbedingungen“ zeigt die Fassung 3, und K01 hängt das v3-PDF an.
@@ -360,6 +361,8 @@ Expected: PASS, Build ohne Fehler
> ⛔ **Gate Rechtsrunde:** Den Nutzer die Texte aus Task 1–4 prüfen lassen: `abo-bedingungen.html`, das PDF, `datenschutz.html`, die Übernahmegutschrift, den Leitfaden, die Website-Seiten und die AGB. Änderungswünsche fließen in diese Tasks zurück. Task 20 startet erst nach der Freigabe.
> ✅ **Rechtsrunde abgeschlossen (28.09.2026):** Der Nutzer hat alle 19 Vorschläge des Übersichtsblatts freigegeben („alle Vorschläge ok“). Die Folgen stehen als Hinweis „Rechtsrunde“ in Task 8, 9, 10, 12 und 20, im neuen Task 21 (Löschfristen) und in Spec Abschnitt 14.
---
## Block B – Server
@@ -599,6 +602,10 @@ test('Widerrufsfristen aus den Ereignissen', async () => {
### Task 8: Zusatzspeicher
> **Rechtsrunde (28.09.2026):**
> - Ein Minus speichert im Ereignis `speicher` keine Zustimmungsdaten (kein `consent`, also keine IP-Adresse und keine Browserkennung). Die Datenschutzerklärung verspricht Nachweisdaten nur für Bestellungen (Abschluss, Wechsel, Plus). Test: Das Ereignis eines Minus hat kein `consentIp`.
> - `changeStorage` bekommt als vierten Parameter `options = {}` mit `viaCancellation`. Mit `viaCancellation: true` sind Reduzierungen auch bei gekündigtem Abo erlaubt (Kündigungsformular, Task 10), Erhöhungen nie. Test: gekündigtes Abo mit `viaCancellation` und weniger Paketen gibt keine 409; mit mehr Paketen gibt es 409.
**Files:**
- Create: `server/services/mailStorage.js`
- Modify: `server/routes/abo.js` (`POST /storage/preview`, `POST /storage`, Webhook `invoice.paid` ruft `recordPaidStorage`)
@@ -692,6 +699,39 @@ test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => {
### Task 9: Wechsel vom Hosting- ins Wartungs-Abo
> **Rechtsrunde (28.09.2026), Entscheidung 2: Der Wechsel wird erst mit der Zahlung wirksam.** Das geht vor die Beschreibung unten.
> - **Zahlart ermitteln:** Standard-Zahlungsmethode des Abos, sonst die des Kunden (`payment_method.type`).
> - **Karte** (`card`, ebenso `link`): Der Aufruf bleibt wie unten, nur mit `payment_behavior: 'pending_if_incomplete'`. Stripe übernimmt Preis, Anker, Metadaten und Rabatte erst, wenn die Wechselrechnung bezahlt ist. `metadata` und `discounts` sind in ausstehenden Aktualisierungen erlaubt (docs.stripe.com/billing/subscriptions/pending-updates-reference, geprüft am 28.09.2026). Eine ausstehende Aktualisierung verfällt nach spätestens 23 Stunden.
> - **SEPA-Lastschrift** (`sepa_debit`): Stripe erlaubt dafür keine ausstehenden Aktualisierungen. Der Aufruf bleibt deshalb mit `allow_incomplete`, und der Wechsel gilt sofort. Scheitert die Lastschrift, machen wir ihn von Hand rückgängig (siehe Webhooks).
> - **Ergebnis:**
> - Hat das Abo danach ein `pending_update` (Karte, Zahlung nicht sofort erfolgreich, etwa wegen 3-D Secure), ist das Ereignis `wechsel` `offen`. `paymentUrl` ist die `hosted_invoice_url`, und Metadaten und Spiegel ändern sich noch nicht.
> - Ohne `pending_update` und mit bezahlter Rechnung ist das Ereignis `erledigt`.
> - Bei SEPA bleibt es `offen`, bis `invoice.paid` kommt.
> - Ein zweiter Wechsel, während einer auf die Zahlung wartet, gibt 409 („Ein Wechsel wartet noch auf die Zahlung“).
> - **Mails:**
> - K12 geht sofort nach dem Klick raus, als Vertragsbestätigung mit PDF.
> - Ist der Wechsel noch nicht wirksam, enthält K12 den Block `zahlung_offen`: „Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.“
> - T12 geht erst raus, wenn der Wechsel wirksam ist.
> - **Webhooks** (dort, wo die bestehenden Stripe-Ereignisse verarbeitet werden):
> - `customer.subscription.pending_update_applied`: Abo spiegeln, Ereignis `wechsel` wird `erledigt`, T12 geht raus.
> - `customer.subscription.pending_update_expired`: Ereignis `wechsel` wird `gescheitert`, K14 geht an den Kunden, T12 mit Block `gescheitert`.
> - `invoice.payment_failed` für eine Wechselrechnung mit SEPA (Abo mit `switchedAt` in den letzten 30 Tagen, `billing_reason` `subscription_update`): T12 mit Block `lastschrift_gescheitert` und diesen Handgriffen:
> - Preis ohne anteilige Verrechnung zurück auf Hosting
> - Wechselrechnung stornieren
> - Ereignis auf `gescheitert` setzen
> - K14 senden
> - Der Abbuchungstag bleibt der Wechseltag.
> - **Tests (ergänzen):**
> - Karte → `pending_if_incomplete`, SEPA → `allow_incomplete`.
> - Antwort mit `pending_update` → Ereignis `offen` und `paymentUrl`.
> - Webhook `pending_update_expired` → `gescheitert` und K14. Webhook `pending_update_applied` → `erledigt` und T12.
> - Wechsel bei ausstehendem Wechsel → 409.
> - **Livegang:** Die neuen Webhook-Ereignisse am Stripe-Endpunkt freischalten (Task 20).
> - **Nach der Prüfung der Rechtsrunden-Texte:**
> - Mails nennen keine feste Frist von 23 Stunden, weil die Frist bei Stripe auch kürzer sein kann.
> - Bei gescheiterter SEPA-Lastschrift die Wechselrechnung sofort stornieren, damit ein automatischer Einzugsversuch sie nicht mehr einzieht.
> - Weitere Postfächer richten wir erst ein, wenn die Wechselrechnung bezahlt ist (Vertragsbedingungen § 9 Abs. 3). T12 nennt das.
**Files:**
- Create: `server/services/planSwitch.js`
- Modify: `server/routes/abo.js` (`POST /switch/preview`, `POST /switch`)
@@ -765,6 +805,19 @@ test('Route: ohne earlyStart 400', async () => {
### Task 10: Öffentliche Kündigung und öffentlicher Widerruf mit Auswahl
> **Rechtsrunde (28.09.2026), Entscheidung 5: Zusatzspeicher auch teilweise kündigen.**
> - Bei der Auswahl „Nur den E-Mail-Zusatzspeicher“ fragt `kuendigen.html` zusätzlich die Zahl der Pakete ab, die gekündigt werden sollen: Feld `packages`, ganze Zahl von 1 bis 10, leer heißt alle gebuchten. `validDeclaration` übernimmt das Feld.
> - Mehr Pakete als gebucht heißt alle. Die Reduzierung wirkt zum Ende des Abrechnungsmonats, wie ein Minus und ohne Zustimmungsdaten.
> - Sie geht auch, wenn das Abo schon gekündigt ist (`viaCancellation`, Task 8).
> - Das Ereignis `kuendigung` bekommt `details: { packages, fromUnits, toUnits }`.
> - Die Reduzierung erscheint wie jedes Minus als Speicher-Aufgabe im Admin (Task 13).
> - K03 nennt die Zahl der Pakete (Block `zusatzspeicher` mit `{{pakete}}`), T08 ebenso.
> - Tests (zusätzlich zu den Tests unten):
> - 2 von 3 Paketen ergibt Menge 1.
> - Ohne `packages` ergibt Menge 0.
> - Gekündigtes Abo: die Menge wird trotzdem reduziert.
> - `packages: 0` oder `11` ergibt 400.
**Files:**
- Modify: `server/routes/abo.js` (`validDeclaration`, `/public/cancel`, `/public/withdraw`), `public/kuendigen.html`, `public/widerrufen.html`, `public/legal-form.js`
- Test: `test/publicForms.test.js`
@@ -773,7 +826,7 @@ test('Route: ohne earlyStart 400', async () => {
- Consumes: `changeStorage` (Task 8), `listContractEvents` und `openWithdrawals` (Task 5), Mails K03, K04, T08, T09 (Task 12)
- Produces:
- **`validDeclaration(body)`:** hat zusätzlich `contract` (`'abo'` oder `'zusatzspeicher'`, Standard `'abo'`) und `order` (`'abschluss'`, `'wechsel'` oder `'zusatzspeicher'`, Standard `'abschluss'`).
- **Kündigung „zusatzspeicher“:** Mit gebuchtem Speicher ruft die Route `changeStorage(customerId, 0, null)` auf. Die Kündigung wirkt zum Periodenende, dazu kommt das Ereignis `kuendigung`. Ohne gebuchten Speicher entsteht keine Änderung, die Eingangsbestätigung sagt „wir prüfen“.
- **Kündigung „zusatzspeicher“:** Mit gebuchtem Speicher ruft die Route `changeStorage(customerId, restUnits, null, { viaCancellation: true })` auf (`restUnits` = gebuchte Pakete minus `packages`, mindestens 0; siehe Hinweis Rechtsrunde). Die Kündigung wirkt zum Periodenende, dazu kommt das Ereignis `kuendigung`. Ohne gebuchten Speicher entsteht keine Änderung, die Eingangsbestätigung sagt „wir prüfen“.
- **Widerruf:** Liegt eine offene Frist des gewählten Typs vor, entsteht das Ereignis `widerruf` mit `details: { order, eventId }`, und das Bezugsereignis bekommt den Status `widerrufen`.
- `abschluss`: wie heute `cancelAtPeriodEnd`.
- `wechsel` und `zusatzspeicher`: T09 nennt die Handgriffe (Preis zurück bzw. Menge zurück, Erstattung).
@@ -866,6 +919,18 @@ test('Offenes Angebot nur fürs passende Produkt; alte Angebote gelten als Wartu
### Task 12: Mails
> **Rechtsrunde (28.09.2026):**
> - **Weitere Vorlagen:** T05, T06 und T08 bekommen `{{produkt}}` statt fest „Wartungs-Abo“ (Spec 9, Zeile „T05, T06, T08, T09“).
> - **Paragraph zum Vertragsende:**
> - K02, K03, T05 und T07 nennen den Paragraphen zum Vertragsende aus der Fassung des Kunden, über den Platzhalter `{{vertragsende_paragraph}}` aus `termsDocFor(subDoc.termsVersion)`: Fassung 3 → „§ 9“, Fassung 4 → „§ 11“. In Fassung 4 ist § 9 der Wechsel.
> - Test: Für ein Abo mit Fassung 4 nennt keine dieser Mails „§ 9 der Vertragsbedingungen“.
> - **Neu:**
> - K14, Betreff „Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen“, Platzhalter `{{name}}` und `{{grund}}`. Der Text sagt, dass das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiterläuft.
> - K12-Block `zahlung_offen` und T12-Blöcke `gescheitert` und `lastschrift_gescheitert` (Task 9).
> - K03- und T08-Platzhalter `{{pakete}}` im Block `zusatzspeicher` (Task 10).
> - T14 „[Datenschutz] Löschfristen fällig“ (Task 21).
> - **Schon erledigt:** K09 wurde in Task 3 angepasst („Ihres Abos“, Verrechnung nach einem Wechsel).
**Files:**
- Modify:
- `server/mail-templates/K01 Vertragsbestaetigung.html`, `K02 …`, `K03 …`, `K04 …`, `K05 …`, `K06 …`, `T02 Neues Wartungs-Abo.html` (umbenennen in `T02 Neues Abo.html`), `T07 Abo beendet.html`, `T09 Widerruf eingegangen.html`
@@ -993,6 +1058,44 @@ test('Ticketsystem-Schnittstelle: Produkt und aktiv', async () => {
---
### Task 21: Löschfristen (Rechtsrunde, Entscheidung 11)
**Files:**
- Create: `server/services/retention.js`, `scripts/loeschfristen.mjs`, `server/mail-templates/T14 Loeschfristen.html`
- Test: `test/retention.test.js`
**Interfaces:**
- Consumes:
- Appwrite-Zugriff wie in `contractEvents.js` (Task 5)
- `renderMail`, Nachbauten und `schemaKeys()` aus `test/support/`
- Collections `subscriptions` (Felder `status`, `endedAt`, `canceledAt`, `currentPeriodEnd`, `customerId`), `contractEvents`, `takeoverCredits`
- Produces:
- **`findDueDeletions(now = new Date())`:** liefert `{ customers: [{ customerId, lastEndAt, subscriptionIds, eventIds }], takeover: [{ id, reason }] }`.
- Ein Kunde ist fällig, wenn alle seine Abos beendet sind und seit dem Ende des Jahres, in dem sein letztes Abo endete, drei Jahre vergangen sind („drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“, Datenschutzerklärung Abschnitt 7).
- Das Skript läuft einmal im Jahr, im Januar.
- Das Ende ist `endedAt`, sonst `canceledAt`. Fehlt beides bei `status: 'canceled'`, zählt `currentPeriodEnd`.
- Anträge auf Übernahmegutschrift sind fällig nach den Fristen in Abschnitt 7 der Datenschutzerklärung. Sie stehen dort im Absatz zur Übernahmegutschrift und werden wörtlich übernommen.
- **`applyDeletions(plan)`:** liefert `{ deleted, manual }`.
- Löscht die Appwrite-Dokumente in `subscriptions` und `contractEvents` der fälligen Kunden.
- Leert bei Stripe die Metadaten `consentIp` und `consentUserAgent` ihrer Abos. Lehnt Stripe die Änderung eines beendeten Abos ab, landet es in `manual` (Hinweis im Bericht).
- Löscht fällige Übernahme-Anträge samt hochgeladenen Nachweisen.
- Rechnungen bleiben (Aufbewahrungspflicht, Abschnitt 9).
- **`scripts/loeschfristen.mjs`:** läuft im Container wie das Setup-Skript.
- Ohne Umgebungsvariable ist es ein Probelauf: Bericht auf der Konsole, es wird nichts gelöscht.
- `LOESCHFRISTEN=mail` schickt den Bericht als T14 an das Team.
- `LOESCHFRISTEN=apply` löscht.
- [ ] **Step 1: Failing test** – `test/retention.test.js` (Appwrite- und Stripe-Nachbau)
- Abo beendet am 15.03.2022 → fällig am 01.01.2026, nicht fällig am 31.12.2025.
- Abo beendet vor zwei Jahren → nicht fällig.
- Altes beendetes Abo und ein laufendes Abo desselben Kunden → nicht fällig.
- `applyDeletions` löscht die Dokumente und schickt an Stripe leere Metadaten (Aufruf im Nachbau geprüft). Eine abgelehnte Stripe-Änderung landet in `manual`.
- Ein Probelauf des Skripts ändert nichts.
- [ ] **Step 2: Run** `node --test test/retention.test.js`. Erwartet: FAIL.
- [ ] **Step 3: Umsetzen:** Dienst, Skript und T14 (Aufbau wie die bestehenden Vorlagen).
- [ ] **Step 4: Run** `npm test`. Erwartet: PASS.
- [ ] **Step 5: Commit** mit der Nachricht „Löschfristen: Probelauf, Bericht ans Team, Löschen nach drei Jahren“.
## Block C – Oberfläche im Kundenportal
Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansichten auf Desktop (1280×800) und Handy (390×844, `hasTouch`) kommen dabei auch per Pixelvergleich mit main (`build/public`).
@@ -1182,6 +1285,13 @@ Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansicht
### Task 20: Livegang (mit dem Nutzer)
> **Rechtsrunde (28.09.2026), zusätzlich beim Livegang:**
> 1. Die AGB der Website bekommen „Stand <Freigabedatum>“ (Entscheidung 19).
> 2. Mit dem neuen `takeover.conditionsVersion` wird der Schlüssel in `TAKEOVER_CONDITIONS_SHA256` (`test/legalTexts.test.js`) mitgezogen.
> 3. Den Auftragsverarbeitungsvertrag von UptimeRobot (uptimerobot.com/dpa) herunterladen und ablegen, denn die Datenschutzerklärung bietet eine Kopie an. Die Angaben zu UptimeRobot noch einmal prüfen.
> 4. Am Stripe-Webhook-Endpunkt `customer.subscription.pending_update_applied`, `customer.subscription.pending_update_expired` und `invoice.payment_failed` freischalten (Task 9). Das nur mit Freigabe des Nutzers.
> 5. Die jährliche Erinnerung an die Löschfristen einrichten (Task 21, Lauf im Januar). Ein Cron-Eintrag auf dem Server nur mit Freigabe des Nutzers.
**Files:** `server/config.js` (`abo.termsVersion`), `public/abo-bedingungen.html` (Stand), `public/datenschutz.html` (Stand), PDFs
**Interfaces:**
@@ -1220,7 +1330,8 @@ Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansicht
```bash
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --anlegen
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen
```
Erwartet: Die Preise für Hosting (1500) und Zusatzspeicher (100) sind vorhanden. Die Beschreibung des Wartungs-Produkts entspricht Fassung 4, die Überschrift des Kundenportals lautet „WEBklar – Abo verwalten“.
@@ -1234,7 +1345,7 @@ Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansicht
- [ ] **Step 9: Ticketsystem.** Der Nutzer pusht selbst auf `test`, und nur auf diesen Branch:
```bash
git -C /home/webklar/apps/ticket.webklar.com/dev-abo-stunden push origin feature/abo-stundenkonto:test
git -C /home/webklar/apps/ticket.webklar.com/dev-abo-stunden push origin feature/abo-stunden-wartung:test
```
- [ ] **Step 10: Live prüfen:**
- `https://project.webklar.com/abo-bedingungen.html` zeigt „Version 4“.
@@ -1247,4 +1358,24 @@ Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansicht
- In UptimeRobot sind alle Abo-Websites als Monitor angelegt.
- Der belegte Speicher in konsoleH ist abgelesen (Paket M, Warnschwelle 40 GB).
- Die Info-Mail an Bestandskunden mit Fassung 3 (beliebig viele Postfächer) ist entworfen, vom Nutzer freigegeben und verschickt.
- [ ] **Step 11a: Zusätzliche Schritte aus Rechtsrunde und Gesamtprüfung (30.09.2026)**
- **Zu Step 1:** Mit `takeover.conditionsVersion` auch den Schlüssel in `TAKEOVER_CONDITIONS_SHA256` (`test/legalTexts.test.js`) umstellen. Den Stand in `datenschutz.html` und `abo-bedingungen.html` auf das Freigabedatum setzen. `termsVersion` muss auf `-v4` enden. Danach die PDFs neu erzeugen.
- **Vor Step 6:** Stripe-Testmodus mit einer eigenen Instanz mit Testschlüssel, nicht im Produktions-Container. Zu prüfen:
- Wechsel mit 3-D-Secure-Testkarte: Metadaten und `termStartAt` bleiben bis zur Zahlung unverändert (F28).
- Die gehostete Rechnungsseite bei einem ausstehenden Wechsel.
- Rabatte in der Wechsel-Vorschau.
- Speicher im flexiblen Abrechnungsmodus: erst Minus, dann Plus über die bezahlte Menge; die Vorschau mit der kommenden Rechnung vergleichen.
- **Vor oder mit Step 7:** Die Info-Mail an die Bestandskunden der Fassung 3 geht raus (beliebig viele Postfächer, Zusatzspeicher nur mit Zustimmung). Grund: `--anlegen` ändert die Beschreibung des Wartungs-Produkts in Stripe.
- **Stripe-Webhook-Endpunkt** (Dashboard, mit Freigabe des Nutzers): `customer.subscription.pending_update_applied` und `customer.subscription.pending_update_expired` freischalten. Außerdem prüfen, dass diese Ereignisse aktiv sind:
- `checkout.session.completed`
- `invoice.paid`
- `invoice.payment_failed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- **Pilot:** Die Routen für Speicher und Wechsel haben keinen Pilot-Filter. Bevor `ABO_PILOT_EMAILS` nicht `*` ist, entscheidet der Nutzer, ob das so bleiben soll.
- **Nach Step 6:** Über „Als Kunde ansehen“ einen echten Kunden mit Wartungs-Abo nach Fassung 3 prüfen: Verträge, Stundenkonto, Fristen. Außerdem muss der Admin-Reiter „E-Mail-Speicher“ laden (Sortierung nach `dueAt` in echtem Appwrite).
- **UptimeRobot:** Den Auftragsverarbeitungsvertrag ablegen und die Angaben zu UptimeRobot prüfen.
- **Löschfristen** (mit Freigabe des Nutzers): Cron nur mit `LOESCHFRISTEN=mail` im Januar. `apply` erst nach Durchsicht des Berichts, von Hand.
- **AGB der Website:** Das Stand-Datum setzen.
- [ ] **Step 12:** Memory aktualisieren: Plan 2 live, Commits, offene Punkte.
@@ -119,7 +119,7 @@ Ein Paket hat 5 GB und kostet 1,00 € pro Monat. Jeder Kunde kann 0 bis 10 Pake
**Kündigung**
- Jederzeit per Minus oder über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“.
- Jederzeit per Minus oder über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“, ganz oder mit der Zahl der Pakete (Abschnitt 14).
- Der Zusatzspeicher endet automatisch mit dem Abo. Bei einem Wechsel ins Wartungs-Abo bleibt er unverändert.
### 3.5 Wechsel vom Hosting- ins Wartungs-Abo
@@ -142,7 +142,8 @@ Ein Paket hat 5 GB und kostet 1,00 € pro Monat. Jeder Kunde kann 0 bis 10 Pake
- Es bleibt dasselbe Stripe-Abo, der Preis wechselt von Hosting auf Wartung.
- Der Abbuchungstag wird der Wechseltag.
- Die ungenutzten Hosting-Tage werden auf der sofort fälligen Rechnung gutgeschrieben.
- **Leistung:** Sie beginnt sofort. Die erste Stunde Arbeitszeit wird gutgeschrieben, sobald die Wechsel-Rechnung bezahlt ist, danach monatlich.
- **Wirksamkeit:** Der Wechsel wird erst mit der Zahlung wirksam (Abschnitt 14).
- **Leistung:** Sie beginnt mit dem wirksamen Wechsel. Die erste Stunde Arbeitszeit wird gutgeschrieben, sobald die Wechsel-Rechnung bezahlt ist, danach monatlich.
- **Mindestlaufzeit:** 12 Monate ab dem Wechseltag.
- **Widerruf des Wechsels:**
- Das Hosting-Abo läuft zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit.
@@ -495,6 +496,35 @@ Vorbelegt mit meinem Vorschlag, bitte bestätigen oder ändern:
Geklärt am 27.09.2026: Wir nutzen das Hetzner-Paket M, und die Erreichbarkeit überwacht UptimeRobot.
## 14. Entscheidungen aus der Rechtsrunde (28.09.2026)
Der Nutzer hat alle 19 Vorschläge des Übersichtsblatts freigegeben. Folgen für Text und Umsetzung:
1. **Wechsel erst mit Zahlung wirksam.** Scheitert die Zahlung, läuft das Hosting-Abo mit Preis und Mindestlaufzeit wie vorher weiter. Der Kunde bekommt dann Mail K14.
- Bei Karte übernimmt Stripe den Wechsel erst nach der Zahlung (`pending_if_incomplete`). Die ausstehende Aktualisierung verfällt nach spätestens 23 Stunden.
- Bei SEPA-Lastschrift geht das nicht, also gilt der Wechsel sofort. Scheitert die Lastschrift, machen wir ihn von Hand rückgängig. Der Abbuchungstag bleibt dabei der Wechseltag.
- Vertragsbedingungen § 2 Abs. 5 und § 9. Umsetzung: Task 9 und 12.
2. **Zusatzspeicher auch teilweise über den Kündigungsbutton kündigen.** Das Formular fragt die Zahl der Pakete ab. Das geht auch bei gekündigtem Abo.
- Vertragsbedingungen § 8 Abs. 7 und 8, § 10 Abs. 5. Umsetzung: Task 8, 10 und 12.
3. **Datenschutz: Aufbewahrung „drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“.** Das ist eine Präzisierung nach der Textprüfung, passend zur Verjährung und zum jährlichen Lauf. Dazu kommt eine Löschroutine mit Probelauf, Bericht ans Team und einem Lauf im Januar (Task 21).
4. **AGB der Website:**
- Modul C Nr. 4 (Haftungsausschluss für externe Anbieter) entfällt. Für die Abos gilt § 13 der Vertragsbedingungen.
- Alte Hosting-Jahresverträge: Nach einer automatischen Verlängerung können Verbraucher mit einer Frist von einem Monat kündigen.
- Die AGB bekommen beim Livegang ein Stand-Datum.
5. **Unverändert bestätigt:**
- Wertersatz beim Widerruf eines Wechsels
- Rückwechsel nur auf Anfrage
- Bestandskunden bleiben bei Fassung 3
- Formulare im Hosting-Abo nur gesondert
- Webmail zugesagt
- § 11 Abs. 3
- Rechtsgrundlagen UptimeRobot (lit. b und lit. f)
- Aufbewahrung von Kündigungs- und Widerrufserklärungen
- Kein Verlauf im Portal
- Gutschrift wird nach einem Wechsel weiter verrechnet
- Gutschrift ab Beginn des ursprünglichen Abos
- Leitfaden auf einer Seite
## Anhang: Dateiliste
**Kundenportal** (`knso/Webklar-Kundenbereich`)
Binary file not shown.
+186 -92
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vertragsbedingungen – WEBklar Wartungs-Abo</title>
<title>Vertragsbedingungen – WEBklar-Abos</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
@@ -35,8 +35,8 @@
@media (max-width: 640px) { .toc { columns: 1; } h1 { font-size: 25px; } }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar Wartungs-Abo · Vertragsunterlagen"; font: 8pt sans-serif; color: #888; }
@top-right { content: "Version 3 · Stand 25.09.2026"; font: 8pt sans-serif; color: #888; }
@top-left { content: "WEBklar-Abos · Vertragsunterlagen"; font: 8pt sans-serif; color: #888; }
@top-right { content: "Version 4 · Stand 30.09.2026"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
@@ -57,21 +57,23 @@
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/kuendigen.html">Verträge hier kündigen</a> · <a href="/widerrufen.html">Vertrag widerrufen</a> · <a href="/datenschutz.html">Datenschutz</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Wartungs-Abo</h1>
<p class="meta">Vertragsbedingungen mit Leistungsbeschreibung, Widerrufsbelehrung und Auftragsverarbeitungsvertrag · Version 3 · Stand 25.09.2026</p>
<p class="screen-only meta"><a href="/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf">Als PDF herunterladen und ausdrucken</a></p>
<h1>Vertragsbedingungen für die WEBklar-Abos</h1>
<p class="meta">Hosting-Abo und Wartungs-Abo · Vertragsbedingungen mit Leistungsbeschreibungen, Widerrufsbelehrung und Auftragsverarbeitungsvertrag · Version 4 · Stand 30.09.2026</p>
<p class="screen-only meta"><a href="/WEBklar-Abo-Vertragsunterlagen.pdf">Als PDF herunterladen und ausdrucken</a></p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
<table>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein (jeder einzeln vertretungsberechtigt) · Telefon 0176 23726355 · E-Mail support@webklar.com</td></tr>
<tr><td>Preis</td><td>50,00 € pro Monat, monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</td></tr>
<tr><td>Enthalten</td><td>Betrieb und Pflege Ihrer Website bzw. Web-Anwendung · 1 Domain · 1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat</td></tr>
<tr><td>Stundenkontingent</td><td>1 Stunde je bezahltem Monat; nicht genutzte Zeit bleibt 12 Monate verfügbar. Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde.</td></tr>
<tr><td>Hosting-Abo</td><td>15,00 € pro Monat · Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland · 1 Domain · 1 E-Mail-Postfach mit 5 GB · Änderungen an der Website nur nach Ihrer Freigabe, 100 € pro Stunde (Leistungsbeschreibung A)</td></tr>
<tr><td>Wartungs-Abo</td><td>50,00 € pro Monat · Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland · 1 Domain · beliebig viele E-Mail-Postfächer mit zusammen 5 GB · 1 Stunde Arbeitszeit pro Monat, nicht genutzte Zeit bleibt 12 Monate verfügbar; Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde (Leistungsbeschreibung B)</td></tr>
<tr><td>E-Mail-Zusatzspeicher</td><td>In beiden Abos: je Paket von 5 GB 1,00 € pro Monat, bis zu 10 Pakete. Im Kundenportal bestellbar und reduzierbar nach Leistungsbeginn, solange das Abo nicht gekündigt ist und keine Zahlung offen ist; ganz oder teilweise kündbar zum Ende des Abrechnungsmonats.</td></tr>
<tr><td>Preise</td><td>Alle Preise sind Endpreise, zahlbar monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Laufzeit</td><td>Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</td></tr>
<tr><td>Kündigen</td><td>project.webklar.com/kuendigen.html (ohne Anmeldung) oder per E-Mail</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen: project.webklar.com/widerrufen.html (siehe Widerrufsbelehrung)</td></tr>
<tr><td>Wechsel</td><td>Vom Hosting- ins Wartungs-Abo im Kundenportal nach Leistungsbeginn, auch während der Mindestlaufzeit, solange das Hosting-Abo nicht gekündigt ist und keine Zahlung offen ist; der Wechsel steht unter der Bedingung, dass die Wechselrechnung bezahlt wird (§ 9 Abs. 3). Mit dem Wechseltag beginnt eine neue Mindestlaufzeit von 12 Monaten. Vom Wartungs- ins Hosting-Abo auf Anfrage nach Ablauf der Mindestlaufzeit.</td></tr>
<tr><td>Kündigen</td><td>project.webklar.com/kuendigen.html (ohne Anmeldung; Auswahl: das Abo oder nur der E-Mail-Zusatzspeicher, ganz oder mit der Zahl der Pakete) oder per E-Mail</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können jede Bestellung (Abo, Wechsel, Zusatzspeicher) innerhalb von 14 Tagen ab Vertragsschluss widerrufen: project.webklar.com/widerrufen.html (siehe Widerrufsbelehrung und Hinweise zum Widerruf)</td></tr>
</table>
</div>
@@ -81,35 +83,37 @@
<li><a href="#p1">Anbieter und Geltungsbereich</a></li>
<li><a href="#p2">Vertragsschluss und Vertragstext</a></li>
<li><a href="#p3">Leistungen</a></li>
<li><a href="#p4">Stundenkontingent</a></li>
<li><a href="#p4">Arbeitszeit und Stundenkontingent</a></li>
<li><a href="#p5">Verfügbarkeit und Wartung</a></li>
<li><a href="#p6">Pflichten des Kunden</a></li>
<li><a href="#p7">Preis und Zahlung</a></li>
<li><a href="#p7">Preise und Zahlung</a></li>
<li><a href="#p8">Laufzeit und Kündigung</a></li>
<li><a href="#p9">Vertragsende</a></li>
<li><a href="#p10">Mängel</a></li>
<li><a href="#p11">Haftung</a></li>
<li><a href="#p12">Datenschutz</a></li>
<li><a href="#p13">Änderungen dieser Bedingungen</a></li>
<li><a href="#p14">Schlussbestimmungen</a></li>
<li><a href="#wechsel">Wechsel zwischen den Abos</a></li>
<li><a href="#zusatzspeicher">E-Mail-Zusatzspeicher</a></li>
<li><a href="#p11">Vertragsende</a></li>
<li><a href="#p12">Mängel</a></li>
<li><a href="#p13">Haftung</a></li>
<li><a href="#p14">Datenschutz</a></li>
<li><a href="#p15">Änderungen dieser Bedingungen</a></li>
<li><a href="#p16">Schlussbestimmungen</a></li>
</ol>
<p class="meta" style="margin-bottom:0">Außerdem: <a href="#widerruf">Widerrufsbelehrung und Muster-Widerrufsformular</a> · <a href="#avv">Anlage 1: Auftragsverarbeitungsvertrag</a> · <a href="#tom">Anlage 2: Technische und organisatorische Maßnahmen</a> · <a href="#sub">Anlage 3: Unterauftragsverarbeiter</a></p>
<p class="meta" style="margin-bottom:0">Außerdem: <a href="#hosting">Leistungsbeschreibung A: Hosting-Abo</a> · <a href="#wartung">Leistungsbeschreibung B: Wartungs-Abo</a> · <a href="#widerruf">Widerrufsbelehrung und Muster-Widerrufsformular</a> · <a href="#widerruf-hinweise">Hinweise zum Widerruf</a> · <a href="#avv">Anlage 1: Auftragsverarbeitungsvertrag</a> · <a href="#tom">Anlage 2: Technische und organisatorische Maßnahmen</a> · <a href="#sub">Anlage 3: Unterauftragsverarbeiter</a></p>
</div>
<h2 id="p1">§ 1 Anbieter und Geltungsbereich</h2>
<ol class="abs">
<li>Diese Bedingungen regeln das Wartungs-Abo zwischen der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler („WEBklar“ oder „wir“), und dem Kunden.</li>
<li>Diese Bedingungen regeln das Hosting-Abo und das Wartungs-Abo (zusammen „Abos“) zwischen der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler („WEBklar“ oder „wir“), und dem Kunden. Welches Abo der Kunde bestellt hat, ergibt sich aus seiner Bestellung und unserer Bestätigungs-E-Mail. Den Leistungsumfang beschreiben Leistungsbeschreibung A (Hosting-Abo) und Leistungsbeschreibung B (Wartungs-Abo); sie sind Teil dieser Bedingungen. Zu beiden Abos kann der Kunde E-Mail-Zusatzspeicher buchen (§ 10).</li>
<li>Sie gelten für Verbraucher (§ 13 BGB) und Unternehmer (§ 14 BGB). Gilt eine Regel nur für eine der beiden Gruppen, ist das ausdrücklich angegeben.</li>
<li>Für das Wartungs-Abo gehen diese Bedingungen den allgemeinen Geschäftsbedingungen von WEBklar (webklar.com/agb) vor. Die dortige Regelung zum jährlichen Hosting (Leistungsmodul C) gilt für das Wartungs-Abo nicht. Die Erstellung einer Website ist nicht Teil des Abos und wird gesondert vereinbart.</li>
<li>Für die Abos gehen diese Bedingungen den allgemeinen Geschäftsbedingungen von WEBklar (webklar.com/agb) vor. Die dortige Regelung zum jährlichen Hosting (Leistungsmodul C) gilt für die Abos nicht. Die Erstellung einer Website ist nicht Teil der Abos und wird gesondert vereinbart.</li>
<li>Abweichende Geschäftsbedingungen des Kunden werden nicht Vertragsbestandteil.</li>
</ol>
<h2 id="p2">§ 2 Vertragsschluss und Vertragstext</h2>
<ol class="abs">
<li>Das Wartungs-Abo wird im Kundenportal unter project.webklar.com bestellt. Den Zugang zum Kundenportal richten wir für den Kunden ein.</li>
<li>Die Bestellung läuft so ab:
<li>Die Abos werden im Kundenportal unter project.webklar.com bestellt. Den Zugang zum Kundenportal richten wir für den Kunden ein.</li>
<li>Der Abschluss eines Abos läuft so ab:
<ol type="a">
<li>Der Kunde wählt auf der Startseite des Kundenportals „Abo abschließen“.</li>
<li>Der Kunde wählt im Kundenportal im Bereich „Verträge“ bei dem gewünschten Abo „Abo abschließen“.</li>
<li>Eine Bestellübersicht zeigt Leistung, Preis, Laufzeit und Zahlungsweise.</li>
<li>Der Kunde bestätigt, dass er diese Bedingungen einschließlich des Auftragsverarbeitungsvertrags (Anlage 1) akzeptiert. Außerdem wählt er, ob wir sofort oder erst nach Ablauf der 14-tägigen Widerrufsfrist mit der Leistung beginnen. Mit der Wahl des sofortigen Beginns verlangt er ausdrücklich, dass wir vor Ablauf der Widerrufsfrist beginnen.</li>
<li>Mit dem Klick auf „Zahlungspflichtig bestellen“ gibt er ein verbindliches Angebot ab.</li>
@@ -117,65 +121,65 @@
</ol></li>
<li>Der Vertrag kommt zustande, sobald der Kunde den Vorgang bei Stripe abgeschlossen hat. Bei sofortigem Beginn setzt das die erfolgreiche Zahlung der ersten Monatsgebühr voraus; scheitert sie, kommt kein Vertrag zustande. Wir bestätigen den Vertragsschluss unverzüglich per E-Mail und senden diese Bedingungen mit Widerrufsbelehrung als PDF mit.</li>
<li>Bis zum Abschluss des Vorgangs bei Stripe kann der Kunde jederzeit abbrechen und seine Angaben im Kundenportal korrigieren.</li>
<li>Vertragssprache ist Deutsch. Wir speichern den Vertragstext sowie Zeitpunkt und Version der Zustimmung des Kunden. Die aktuelle Fassung dieser Bedingungen ist unter project.webklar.com/abo-bedingungen.html abrufbar, die bei Vertragsschluss gültige Fassung erhält der Kunde mit der Bestätigungs-E-Mail.</li>
<li>Der Wechsel vom Hosting- ins Wartungs-Abo (§ 9) und die Bestellung von E-Mail-Zusatzspeicher (§ 10) sind eigene Bestellungen. Der Kunde gibt sie im Kundenportal im Bereich „Verträge“ auf. Direkt über der Schaltfläche zeigt eine Übersicht die Leistung, den Preis pro Monat, beim Wechsel den sofort fälligen Betrag, beim Zusatzspeicher den anteiligen Betrag, der mit der nächsten Abbuchung eingezogen wird, den Tag der nächsten Abbuchung sowie Laufzeit und Kündigungsmöglichkeit. Mit dem Klick auf „Zahlungspflichtig bestellen“ kommt der Vertrag zustande; beim Wechsel steht er unter der Bedingung des § 9 Abs. 3 (Zahlung der Wechselrechnung). Bezahlt wird mit der bei Stripe hinterlegten Zahlungsart; ein neuer Vorgang bei Stripe ist nicht nötig. Verlangt die Bank des Kunden eine Bestätigung der Zahlung, leitet das Kundenportal ihn dafür auf die Zahlungsseite von Stripe weiter. Bis zum Klick kann der Kunde abbrechen und seine Angaben korrigieren. Wir bestätigen die Bestellung unverzüglich per E-Mail und senden diese Bedingungen mit Widerrufsbelehrung als PDF mit.</li>
<li>Vertragssprache ist Deutsch. Wir speichern den Vertragstext sowie Zeitpunkt, Version und bestelltes Abo jeder Zustimmung des Kunden. Die aktuelle Fassung dieser Bedingungen ist unter project.webklar.com/abo-bedingungen.html abrufbar. Die bei Vertragsschluss gültige Fassung erhält der Kunde mit der Bestätigungs-E-Mail; er kann sie außerdem jederzeit im Kundenportal unter „Verträge“ abrufen.</li>
</ol>
<h2 id="p3">§ 3 Leistungen</h2>
<h3>(1) Betrieb und Pflege der Website</h3>
<h3>(1) Leistungsbeschreibungen</h3>
<p>Den Umfang des Hosting-Abos beschreibt Leistungsbeschreibung A, den des Wartungs-Abos Leistungsbeschreibung B. Beide Abos umfassen den Betrieb der Website auf unserem Server, eine Domain, E-Mail-Postfächer, die tägliche Datensicherung und die Servicezeiten nach den Absätzen 2 bis 6. Das Wartungs-Abo umfasst zusätzlich die Pflege der Website und ein Stundenkontingent (§ 4). Das Hosting-Abo ist nur für statische Websites bestimmt (Leistungsbeschreibung A Nr. 2).</p>
<h3>(2) Betrieb der Website</h3>
<ul>
<li>Wir stellen die Website bzw. Web-Anwendung, die wir für den Kunden erstellt oder zur Betreuung übernommen haben, auf einem von uns betriebenen Server bereit. Der Server steht im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland).</li>
<li>Wir stellen die Website bzw. Web-Anwendung, die wir für den Kunden erstellt oder zur Betreuung übernommen haben, auf einem von uns betriebenen Server bereit. Der Server steht im Rechenzentrum der Hetzner Online GmbH (im Folgenden „Hetzner“) in Falkenstein (Deutschland).</li>
<li>Speicherplatz und Datenverkehr stehen im Umfang einer üblichen Firmen-Website zur Verfügung. Ungewöhnlich hohe Last, etwa durch große Downloads oder Streaming, ist nicht umfasst.</li>
<li>Wir spielen Sicherheitsupdates für Betriebssystem, Server-Software und die von uns eingesetzten Software-Komponenten der Website ein. Kritische Sicherheitsupdates in der Regel innerhalb von 3 Werktagen nach ihrer Veröffentlichung, übrige Updates regelmäßig. Aktualisierungen, die für den vertragsgemäßen Betrieb nötig sind, stellen wir während der gesamten Vertragslaufzeit bereit.</li>
<li>Wir spielen Sicherheitsupdates für Betriebssystem und Server-Software ein, kritische Sicherheitsupdates in der Regel innerhalb von 3 Werktagen nach ihrer Veröffentlichung, übrige Updates regelmäßig. Aktualisierungen, die nötig sind, damit unsere Leistungen vertragsgemäß bleiben, stellen wir während der gesamten Vertragslaufzeit bereit. Updates der Software-Komponenten der Website selbst gehören nur zum Wartungs-Abo (Leistungsbeschreibung B Nr. 2).</li>
<li>Wir richten ein SSL/TLS-Zertifikat (HTTPS) ein und verlängern es.</li>
<li>Wir überwachen die Erreichbarkeit automatisch und beheben Störungen in unserem Verantwortungsbereich (§ 5).</li>
</ul>
<h3>(2) Domain</h3>
<h3>(3) Domain</h3>
<ul>
<li>Enthalten ist eine Domain mit einer der Endungen .de, .com, .eu, .net oder .org: Registrierung oder Übernahme einer bestehenden Domain, Verlängerung und DNS-Verwaltung. Premium-Domains und andere Endungen nur nach gesonderter Vereinbarung.</li>
<li>Inhaber der Domain wird der Kunde, wir verwalten sie technisch. Den Umzugscode (AuthCode) erhält der Kunde jederzeit auf Anfrage.</li>
<li>Für die Wahl der Domain und dafür, dass sie keine Rechte Dritter verletzt, ist der Kunde verantwortlich.</li>
</ul>
<h3>(3) E-Mail-Postfach</h3>
<h3>(4) E-Mail-Postfächer</h3>
<ul>
<li>Enthalten ist ein E-Mail-Postfach unter der Domain des Kunden mit verschlüsseltem Zugang per IMAP und SMTP sowie Spam- und Virenfilter. Wir stellen es über die Hetzner Online GmbH (Rechenzentrum Nürnberg, Deutschland) bereit. Der Speicherplatz beträgt mindestens 5 GB.</li>
<li>Die Inhalte des Postfachs sichert der Anbieter nach seinen Bedingungen, nicht wir selbst. Wichtige E-Mails sollte der Kunde zusätzlich bei sich aufbewahren.</li>
<li>Wir richten E-Mail-Postfächer unter der Domain des Kunden ein, mit verschlüsseltem Zugang per IMAP und SMTP sowie über Webmail, mit Spam- und Virenfilter. Wir stellen sie über Hetzner (Rechenzentrum Nürnberg, Deutschland) bereit. Das Hosting-Abo umfasst ein Postfach, das Wartungs-Abo beliebig viele Postfächer.</li>
<li>Für die Postfächer des Kunden stehen zusammen 5 GB Speicher zur Verfügung, zuzüglich gebuchtem Zusatzspeicher (§ 10). Hetzner setzt für jedes Postfach eine Speichergrenze. Die Grenzen aller Postfächer des Kunden ergeben zusammen höchstens den verfügbaren Speicher. Wie der Speicher auf die Postfächer verteilt wird, bestimmt der Kunde; ohne Vorgabe teilen wir ihn gleichmäßig auf.</li>
<li>Ist die Speichergrenze eines Postfachs erreicht, nimmt es keine neuen E-Mails mehr an, bis wieder Platz ist, etwa durch Löschen von E-Mails, eine andere Aufteilung oder Zusatzspeicher.</li>
<li>Das Anlegen und Löschen von Postfächern, neue Passwörter und eine andere Aufteilung des Speichers beauftragt der Kunde über den Chat im Kundenportal (Schaltfläche „Postfach anfragen“) oder per E-Mail. Wir setzen das in der Regel innerhalb eines Werktags um. Diese Arbeiten sind im Abo enthalten.</li>
<li>Zugangsdaten zu den Postfächern geben wir nur an den Kunden oder an Personen, die er uns in Textform benennt.</li>
<li>Die Postfächer dürfen nur der Kunde, seine Beschäftigten und andere für ihn tätige Personen für seine Organisation nutzen. Nicht erlaubt sind die Überlassung an Dritte, auch unentgeltlich, sowie Newsletter- und Massenversand. Es gelten die Versandgrenzen von Hetzner.</li>
<li>Die Inhalte der Postfächer sichert Hetzner nach seinen Bedingungen, nicht wir selbst. Wichtige E-Mails sollte der Kunde zusätzlich bei sich aufbewahren.</li>
<li>Die Zustellung von E-Mails hängt auch von Dritten ab, etwa von Empfangsservern und Spamfiltern, und kann nicht garantiert werden.</li>
</ul>
<h3>(4) Datensicherung</h3>
<h3>(5) Datensicherung</h3>
<ul>
<li>Der Server wird täglich automatisch vollständig gesichert (Backup-Funktion der Hetzner Online GmbH). Die Sicherungen liegen getrennt vom Server in einem anderen Brandabschnitt am selben Rechenzentrumsstandort in Deutschland. Verfügbar sind jeweils die letzten sieben Tagessicherungen.</li>
<li>Datenbanken exportieren wir zusätzlich täglich vor der Serversicherung, damit sie sich in einem einheitlichen Stand wiederherstellen lassen.</li>
<li>Eine Wiederherstellung nach technischen Störungen oder nach Fehlern, die wir zu vertreten haben, ist inklusive. Wünscht der Kunde eine Wiederherstellung aus anderen Gründen, etwa nach eigenen Änderungen, wird sie über das Stundenkontingent abgerechnet.</li>
<li>Der Server wird täglich automatisch vollständig gesichert (Backup-Funktion von Hetzner). Die Sicherungen liegen getrennt vom Server in einem anderen Brandabschnitt am selben Rechenzentrumsstandort in Deutschland. Verfügbar sind jeweils die letzten sieben Tagessicherungen.</li>
<li>Datenbanken einer Web-Anwendung exportieren wir zusätzlich täglich vor der Serversicherung, damit sie sich in einem einheitlichen Stand wiederherstellen lassen.</li>
<li>Eine Wiederherstellung nach technischen Störungen oder nach Fehlern, die wir zu vertreten haben, ist inklusive. Wünscht der Kunde eine Wiederherstellung aus anderen Gründen, etwa nach eigenen Änderungen, wird sie als Arbeitszeit nach § 4 abgerechnet.</li>
<li>Die Sicherung dient der Wiederherstellung des Betriebs. Ein Archiv oder die Herausgabe von Sicherungskopien ist nicht geschuldet.</li>
</ul>
<h3>(5) Stundenkontingent</h3>
<p>Je bezahltem Monat 1 Stunde Arbeitszeit nach § 4.</p>
<h3>(6) Servicezeiten und Reaktion</h3>
<ul>
<li>Servicezeiten sind Montag bis Freitag von 9 bis 17 Uhr, ausgenommen gesetzliche Feiertage in Rheinland-Pfalz. Anfragen außerhalb der Servicezeiten gelten zu Beginn der nächsten Servicezeit als eingegangen.</li>
<li>Auf Anfragen und Störungsmeldungen antworten wir spätestens bis zum Ende des nächsten Werktags. Gemeint ist eine erste fachliche Rückmeldung, nicht zwingend schon die Behebung.</li>
<li>Fällt die Website oder das Postfach vollständig aus, beginnen wir innerhalb der Servicezeiten unverzüglich mit der Behebung. Unser Ziel ist eine Behebung innerhalb von 4 Stunden. Eine Garantie ist damit nicht verbunden.</li>
<li>Änderungswünsche setzen wir in der Regel innerhalb von 5 Werktagen um.</li>
<li>Fällt die Website oder ein Postfach vollständig aus, beginnen wir innerhalb der Servicezeiten unverzüglich mit der Behebung. Unser Ziel ist eine Behebung innerhalb von 4 Stunden. Eine Garantie ist damit nicht verbunden.</li>
<li>Beauftragte Änderungen setzen wir in der Regel innerhalb von 5 Werktagen um.</li>
<li>Betriebsferien kündigen wir mindestens zwei Wochen vorher per E-Mail an.</li>
</ul>
<h3>(7) Nicht enthalten</h3>
<ul>
<li>Neue Funktionen, Redesign oder neue Seiten, soweit sie nicht über das Stundenkontingent beauftragt werden.</li>
<li>Texte, Bilder und Rechtstexte (Impressum, Datenschutzerklärung) für die Website des Kunden, Suchmaschinenoptimierung und Werbung.</li>
<li>Unterstützung für Endgeräte, Betriebssysteme oder E-Mail-Programme des Kunden.</li>
<li>Behebung von Fehlern, die der Kunde oder Dritte durch eigene Änderungen verursacht haben. Das ist über das Stundenkontingent möglich.</li>
</ul>
<h3>(8) Technische Änderungen</h3>
<p>Wir dürfen die technische Umsetzung ändern, etwa das Rechenzentrum, die Server-Software oder den Anbieter des E-Mail-Postfachs wechseln. Voraussetzung ist, dass der vereinbarte Leistungsumfang nicht schlechter wird, dem Kunden keine Kosten entstehen und wir ihn mindestens vier Wochen vorher in Textform informieren. Beeinträchtigt eine Änderung den Zugang zur Leistung oder ihre Nutzbarkeit mehr als nur unerheblich, kann der Kunde den Vertrag innerhalb von 30 Tagen nach der Information bzw. der Änderung unentgeltlich beenden (§ 327r BGB).</p>
<h3>(7) Technische Änderungen</h3>
<p>Wir dürfen die technische Umsetzung ändern, etwa das Rechenzentrum, die Server-Software oder den Anbieter der E-Mail-Postfächer wechseln. Voraussetzung ist, dass der vereinbarte Leistungsumfang nicht schlechter wird, dem Kunden keine Kosten entstehen und wir ihn mindestens vier Wochen vorher in Textform informieren. Beeinträchtigt eine Änderung den Zugang zur Leistung oder ihre Nutzbarkeit mehr als nur unerheblich, kann der Kunde den Vertrag innerhalb von 30 Tagen nach der Information bzw. der Änderung unentgeltlich beenden (§ 327r BGB).</p>
<h2 id="p4">§ 4 Stundenkontingent</h2>
<h2 id="p4">§ 4 Arbeitszeit und Stundenkontingent</h2>
<ol class="abs">
<li>Zum Leistungsumfang gehört ein Kontingent von 60 Minuten Arbeitszeit je bezahltem Abrechnungsmonat. Das erste Kontingent steht mit der ersten Zahlung bereit. Das Kontingent ist Teil der Leistungsbeschreibung und kein Geldguthaben.</li>
<li>Nicht genutzte Zeit bleibt verfügbar: Jedes Monatskontingent kann zwölf Monate lang ab seiner Bereitstellung genutzt werden und entfällt danach. Genutzt wird immer das älteste noch verfügbare Kontingent zuerst. Mit dem Ende des Vertrags entfällt nicht genutzte Zeit. Sie wird weder ausgezahlt noch erstattet und ist nicht übertragbar.</li>
<li>Im Wartungs-Abo gehört zum Leistungsumfang ein Kontingent von 60 Minuten Arbeitszeit je bezahltem Abrechnungsmonat. Das erste Kontingent steht mit der ersten Zahlung bereit, nach einem Wechsel aus dem Hosting-Abo mit der Zahlung der Wechselrechnung (§ 9 Abs. 3). Das Kontingent ist Teil der Leistungsbeschreibung und kein Geldguthaben.</li>
<li>Nicht genutzte Zeit bleibt verfügbar: Jedes Monatskontingent kann zwölf Monate lang ab seiner Bereitstellung genutzt werden und entfällt danach. Genutzt wird immer das älteste noch verfügbare Kontingent zuerst. Mit dem Ende des Wartungs-Abos, auch durch einen Wechsel ins Hosting-Abo, entfällt nicht genutzte Zeit. Sie wird weder ausgezahlt noch erstattet und ist nicht übertragbar.</li>
<li>Das Kontingent gilt für Änderungen, Erweiterungen, Beratung und Unterstützung rund um die im Abo betreute Website, Domain und E-Mail. Aufträge erteilt der Kunde über den Chat im Kundenportal oder per E-Mail. Aufträge von mehr als 4 Stunden in einem Monat setzen wir nach Terminabsprache um.</li>
<li>Angerechnet wird die tatsächlich aufgewendete Zeit je Auftrag, aufgerundet auf volle 15 Minuten. Dazu zählen die Umsetzung und die Abstimmung zum Auftrag, soweit sie länger als 10 Minuten dauert. Nicht angerechnet werden die Leistungen nach § 3 Abs. 1 bis 4 (Betrieb, Updates, Überwachung, Sicherung) und die Behebung von Fehlern, die wir zu vertreten haben.</li>
<li>Angerechnet wird die tatsächlich aufgewendete Zeit je Auftrag, aufgerundet auf volle 15 Minuten. Dazu zählen die Umsetzung und die Abstimmung zum Auftrag, soweit sie länger als 10 Minuten dauert. Nicht angerechnet werden die laufenden Leistungen nach § 3 Abs. 2 bis 5 und die Sicherheitsupdates nach Leistungsbeschreibung B Nr. 2 (Betrieb, Updates, Überwachung, Sicherung, Verwaltung von Domain und Postfächern), die Behebung von Störungen (§ 3 Abs. 6, § 5) sowie die Behebung von Fehlern, die wir zu vertreten haben.</li>
<li>Verfügbare Zeit, Bereitstellungen, Verbrauch und Ablaufdaten sieht der Kunde jederzeit im Kundenportal.</li>
<li>Reicht die verfügbare Zeit für einen Auftrag voraussichtlich nicht aus, nennen wir dem Kunden vorher den geschätzten Mehraufwand. Wir führen ihn nur nach seiner Freigabe aus, etwa im Chat oder per E-Mail, und berechnen 100 € pro Stunde (gemäß § 19 UStG ohne Umsatzsteuer), abgerechnet in Schritten von 15 Minuten. Aufträge mit einem Umfang von mehr als 10 Stunden bieten wir gesondert an.</li>
<li>Reicht die verfügbare Zeit für einen Auftrag voraussichtlich nicht aus, nennen wir dem Kunden vorher den geschätzten Mehraufwand. Wir führen ihn nur nach seiner Freigabe aus, etwa im Chat oder per E-Mail, und berechnen 100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer), abgerechnet in Schritten von 15 Minuten. Aufträge mit einem Umfang von mehr als 10 Stunden bieten wir gesondert an.</li>
<li>Im Hosting-Abo gibt es kein Stundenkontingent. Aufträge, etwa Änderungen an der Website, führen wir nur nach Freigabe des Kunden aus. Vorher nennen wir ihm den geschätzten Aufwand. Wir berechnen 100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer), abgerechnet in Schritten von 15 Minuten; Absatz 4 Satz 1 und 2 gelten entsprechend. Aufträge mit einem Umfang von mehr als 10 Stunden bieten wir gesondert an. Die Behebung von Störungen an Server, Domain und Postfach in unserem Verantwortungsbereich ist auch im Hosting-Abo inklusive.</li>
</ol>
<h2 id="p5">§ 5 Verfügbarkeit und Wartung</h2>
@@ -188,75 +192,156 @@
<li>Angriffe wie DDoS, sofern wir angemessene Schutzmaßnahmen getroffen haben,</li>
<li>höhere Gewalt sowie Störungen, die der Kunde verursacht hat.</li>
</ul></li>
<li>Die gesetzlichen Rechte des Kunden bei Ausfällen, die wir zu vertreten haben, bleiben unberührt. Die Haftung richtet sich nach § 11.</li>
<li>Die gesetzlichen Rechte des Kunden bei Ausfällen, die wir zu vertreten haben, bleiben unberührt. Die Haftung richtet sich nach § 13.</li>
</ol>
<h2 id="p6">§ 6 Pflichten des Kunden</h2>
<ol class="abs">
<li>Der Kunde hält seine Zugangsdaten zum Kundenportal und zum Postfach geheim und informiert uns unverzüglich, wenn er einen Missbrauch vermutet.</li>
<li>Der Kunde stellt nur Inhalte bereit, die er verwenden darf und die nicht gegen Gesetze verstoßen. Offensichtlich rechtswidrige Inhalte dürfen wir nach einem Hinweis vorübergehend sperren. Wir informieren den Kunden darüber unverzüglich.</li>
<li>Der Kunde hält seine Zugangsdaten zum Kundenportal und zu allen Postfächern geheim und informiert uns unverzüglich, wenn er einen Missbrauch vermutet. Gibt er die Zugangsdaten eines Postfachs an Beschäftigte oder andere für ihn tätige Personen weiter, geschieht das in seiner Verantwortung; er sorgt dafür, dass auch diese Personen die Zugangsdaten geheim halten und die Regeln nach § 3 Abs. 4 einhalten. Ein neues Passwort kann er jederzeit über den Chat im Kundenportal anfordern.</li>
<li>Der Kunde stellt nur Inhalte bereit, die er verwenden darf und die nicht gegen Gesetze verstoßen. Das gilt auch für E-Mails, die über seine Postfächer versendet werden. Offensichtlich rechtswidrige Inhalte dürfen wir nach einem Hinweis vorübergehend sperren. Wir informieren den Kunden darüber unverzüglich.</li>
<li>Für Impressum, Datenschutzerklärung und Inhalte seiner Website ist der Kunde verantwortlich. Die Angaben zum Hosting, die er für seine Datenschutzerklärung braucht, stehen in den Anlagen 1 bis 3.</li>
<li>Der Kunde bewahrt Kopien der Inhalte auf, die er uns übergibt oder selbst einpflegt, und teilt uns Änderungen seiner Kontaktdaten mit.</li>
<li>Im Hosting-Abo stellt der Kunde keine Inhalte oder Funktionen bereit, die nach Leistungsbeschreibung A Nr. 2 im Hosting-Abo nicht möglich sind, und beauftragt keine. Braucht seine Website solche Teile, wechselt er vorher ins Wartungs-Abo (§ 9).</li>
</ol>
<h2 id="p7">§ 7 Preis und Zahlung</h2>
<h2 id="p7">§ 7 Preise und Zahlung</h2>
<ol class="abs">
<li>Die Monatsgebühr beträgt 50,00 €. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.</li>
<li>Die Gebühr ist monatlich im Voraus fällig, erstmals mit Beginn der Leistung (bei sofortigem Beginn mit Vertragsschluss, sonst nach Ablauf der Widerrufsfrist), danach jeweils am selben Kalendertag der Folgemonate. Sie wird per Kreditkarte oder SEPA-Lastschrift über die Stripe Payments Europe, Limited (Irland) eingezogen. Rechnungen erhält der Kunde per E-Mail und im Kundenportal unter „Abo verwalten“.</li>
<li>Die Monatsgebühr beträgt im Hosting-Abo 15,00 € und im Wartungs-Abo 50,00 €. E-Mail-Zusatzspeicher kostet je Paket von 5 GB 1,00 € pro Monat (§ 10). Alle Preise sind Endpreise. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.</li>
<li>Die Monatsgebühr ist monatlich im Voraus fällig, erstmals mit Beginn der Leistung (bei sofortigem Beginn mit Vertragsschluss, sonst nach Ablauf der Widerrufsfrist), danach jeweils am selben Kalendertag der Folgemonate (Abrechnungsmonat). Gebuchter Zusatzspeicher wird zusammen mit der Monatsgebühr abgebucht. Die Gebühren werden per Kreditkarte oder SEPA-Lastschrift über die Stripe Payments Europe, Limited (Irland) eingezogen. Rechnungen erhält der Kunde per E-Mail und im Kundenportal über „Abo verwalten &amp; Rechnungen“.</li>
<li>Bestellt der Kunde Zusatzspeicher, berechnen wir für den Rest des laufenden Abrechnungsmonats einen anteiligen Betrag. Er wird in der Bestellübersicht angezeigt und mit der nächsten Abbuchung eingezogen. Ab dem folgenden Abrechnungsmonat gilt der volle Monatspreis. Pakete, die für den laufenden Abrechnungsmonat schon bezahlt sind, berechnen wir nicht noch einmal. Verringert der Kunde den Zusatzspeicher, wirkt das zum Ende des laufenden Abrechnungsmonats; die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li>
<li>Beim Wechsel vom Hosting- ins Wartungs-Abo (§ 9) wird die Monatsgebühr des Wartungs-Abos am Wechseltag fällig, zusammen mit gebuchtem Zusatzspeicher. Die im Voraus bezahlten Gebühren für die nicht genutzten Tage des laufenden Abrechnungsmonats schreiben wir auf dieser Rechnung gut. Die folgenden Abrechnungsmonate beginnen jeweils am Kalendertag des Wechsels. Den fälligen Betrag und den neuen Abbuchungstag zeigt die Bestellübersicht vor der Bestellung. Wird die Wechselrechnung nicht bezahlt, gilt § 9 Abs. 3.</li>
<li>Schlägt eine Zahlung fehl, versucht Stripe den Einzug automatisch erneut und informiert den Kunden per E-Mail.</li>
<li>Ist der Kunde mit einem Betrag von mindestens 100 € (zwei Monatsgebühren) in Verzug, dürfen wir die Leistungen vorübergehend sperren, etwa die Website offline nehmen. Vorher kündigen wir die Sperre in Textform an und setzen eine Frist von mindestens zwei Wochen. Das gilt nicht, solange der Kunde die Forderung mit nachvollziehbaren Gründen bestreitet. Während einer Sperre löschen wir keine Daten. Nach Zahlung heben wir die Sperre unverzüglich auf. Ist der Kunde mit zwei aufeinanderfolgenden Monatsgebühren in Verzug, dürfen wir außerordentlich kündigen.</li>
<li>Ist der Kunde mit einem Betrag in Höhe von zwei Monatsgebühren seines Abos in Verzug, dürfen wir die Leistungen vorübergehend sperren, etwa die Website offline nehmen. Vorher kündigen wir die Sperre in Textform an und setzen eine Frist von mindestens zwei Wochen. Das gilt nicht, solange der Kunde die Forderung mit nachvollziehbaren Gründen bestreitet. Während einer Sperre löschen wir keine Daten. Nach Zahlung heben wir die Sperre unverzüglich auf. Ist der Kunde mit zwei aufeinanderfolgenden Monatsgebühren in Verzug, dürfen wir außerordentlich kündigen.</li>
<li>Preisänderungen kündigen wir mindestens 6 Wochen vor ihrem Wirksamwerden in Textform an. Sie gelten nur, wenn der Kunde zustimmt. Ohne Zustimmung läuft der Vertrag zum bisherigen Preis weiter, und beide Seiten können nach § 8 ordentlich kündigen.</li>
</ol>
<h2 id="p8">§ 8 Laufzeit und Kündigung</h2>
<ol class="abs">
<li>Der Vertrag hat eine Mindestlaufzeit von zwölf Monaten ab Leistungsbeginn, bei sofortigem Beginn also ab Vertragsschluss, sonst ab Ablauf der Widerrufsfrist.</li>
<li>Beide Seiten können zum Ende der Mindestlaufzeit mit einer Frist von einem Monat kündigen. Wird nicht fristgerecht gekündigt, läuft der Vertrag auf unbestimmte Zeit weiter.</li>
<li>Jedes Abo hat eine Mindestlaufzeit von zwölf Monaten ab Leistungsbeginn, bei sofortigem Beginn also ab Vertragsschluss, sonst ab Ablauf der Widerrufsfrist. Nach einem Wechsel vom Hosting- ins Wartungs-Abo beginnt eine neue Mindestlaufzeit von zwölf Monaten ab dem Wechseltag (§ 9 Abs. 3). Das Ende der laufenden Mindestlaufzeit zeigt das Kundenportal unter „Verträge“.</li>
<li>Beide Seiten können zum Ende der Mindestlaufzeit mit einer Frist von einem Monat kündigen. Wird nicht fristgerecht gekündigt, läuft das Abo auf unbestimmte Zeit weiter.</li>
<li>Nach Ablauf der Mindestlaufzeit kann der Kunde jederzeit zum Ende des laufenden Abrechnungsmonats kündigen. Wir können dann mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen.</li>
<li>Gibt der Kunde den Geschäftsbetrieb, für den die Website bestimmt ist, dauerhaft auf, kann er auch während der Mindestlaufzeit mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen. Auf Verlangen weist er die Geschäftsaufgabe nach, etwa durch die Gewerbeabmeldung.</li>
<li>Eine vorzeitige Beendigung im gegenseitigen Einvernehmen ist jederzeit möglich.</li>
<li>Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt unberührt.</li>
<li>Der Kunde kann über die Schaltfläche „Verträge hier kündigen“ unter project.webklar.com/kuendigen.html kündigen (ohne Anmeldung) oder in Textform, etwa per E-Mail. Den Zeitpunkt, zu dem der Vertrag endet, bestätigen wir unverzüglich per E-Mail. Bis dahin bleiben alle Leistungen verfügbar. Die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li>
<li>Der E-Mail-Zusatzspeicher hat keine Mindestlaufzeit. Der Kunde kann ihn jederzeit zum Ende des laufenden Abrechnungsmonats ganz oder teilweise (mit einer geringeren Zahl von Paketen) kündigen (§ 10 Abs. 5). Er endet spätestens mit dem Abo.</li>
<li>Der Kunde kann über die Schaltfläche „Verträge hier kündigen“ unter project.webklar.com/kuendigen.html kündigen (ohne Anmeldung) oder in Textform, etwa per E-Mail. Dort wählt er, ob er sein Abo einschließlich des Zusatzspeichers kündigt oder mit der Auswahl „Nur den E-Mail-Zusatzspeicher“ allein den Zusatzspeicher, ganz oder mit der Zahl der Pakete, die er kündigt; das geht auch, wenn das Abo schon gekündigt ist. Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir unverzüglich per E-Mail. Bis dahin bleiben alle Leistungen verfügbar. Die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li>
</ol>
<h2 id="p9">§ 9 Vertragsende</h2>
<h2 id="wechsel">§ 9 Wechsel zwischen den Abos</h2>
<ol class="abs">
<li>Während der Laufzeit und bis 30 Tage nach Vertragsende stellen wir dem Kunden auf Wunsch eine Kopie seiner Website bzw. Anwendungsdaten (Dateien und Datenbank-Export) und seines Postfachs bereit. Wem Quellcode und Nutzungsrechte an der Website zustehen, richtet sich nach dem Vertrag über ihre Erstellung.</li>
<li>Wir geben den AuthCode der Domain heraus und helfen beim Umzug zu einem anderen Anbieter. Ist die Domain 30 Tage nach Vertragsende nicht umgezogen, dürfen wir ihre Registrierung beenden. Das kündigen wir vorher in Textform an und setzen eine Frist von 14 Tagen.</li>
<li>Nach Ablauf der Frist aus Absatz 1 löschen wir die Daten des Kunden einschließlich des Postfachs, soweit keine gesetzliche Aufbewahrungspflicht besteht. Sicherungskopien werden spätestens mit Ablauf ihrer Aufbewahrungszeit (§ 3 Abs. 4) überschrieben.</li>
<li>Ein Kunde mit laufendem Hosting-Abo kann jederzeit ins Wartungs-Abo wechseln, auch während der Mindestlaufzeit des Hosting-Abos. Voraussetzung ist, dass die Leistung des Hosting-Abos begonnen hat, keine Zahlung offen ist und das Hosting-Abo nicht gekündigt ist.</li>
<li>Der Wechsel ist ein neuer Vertrag über das Wartungs-Abo. Der Kunde bestellt ihn im Bereich „Verträge“ des Kundenportals (§ 2 Abs. 5). Die Bestellübersicht zeigt den Preis des Wartungs-Abos, gebuchten Zusatzspeicher, die Gutschrift für die nicht genutzten Tage des Hosting-Abos, den sofort fälligen Betrag, den neuen Abbuchungstag, das Ende der neuen Mindestlaufzeit und die Kündigungsmöglichkeit nach Ablauf der neuen Mindestlaufzeit. Der Kunde stimmt dabei diesen Bedingungen einschließlich des Auftragsverarbeitungsvertrags (Anlage 1) zu. Wird der Wechsel wirksam, endet das Hosting-Abo im gegenseitigen Einvernehmen.</li>
<li>Der Wechsel steht unter der Bedingung, dass die Wechselrechnung bezahlt wird. Bei Zahlung per Kreditkarte wird er erst mit der erfolgreichen Zahlung wirksam. Gelingt die Zahlung nicht, etwa weil der Kunde eine von seiner Bank verlangte Freigabe nicht bestätigt, wird er nicht wirksam; spätestens 23 Stunden nach der Bestellung verfällt die Wechselrechnung. Bei SEPA-Lastschrift beginnt das Wartungs-Abo schon mit der Bestellung; wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. In diesen Fällen läuft das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter; bei SEPA-Lastschrift bleibt der Abbuchungstag der Wechseltag; wir informieren den Kunden per E-Mail. Das Wartungs-Abo beginnt, sobald der Wechsel wirksam ist; einen späteren Beginn bietet der Wechsel nicht. Der Kunde verlangt bei der Bestellung ausdrücklich, dass wir vor Ablauf der Widerrufsfrist beginnen (siehe Hinweise zum Widerruf). Ab Beginn des Wartungs-Abos gilt Leistungsbeschreibung B. Wechseltag ist der Tag der Bestellung; er ist der neue Abbuchungstag (§ 7 Abs. 4), und mit ihm beginnt eine neue Mindestlaufzeit von zwölf Monaten (§ 8 Abs. 1). Das erste Stundenkontingent stellen wir bereit, sobald die Wechselrechnung bezahlt ist, danach monatlich (§ 4). Postfächer und gebuchter Zusatzspeicher bleiben unverändert; ab Beginn des Wartungs-Abos kann der Kunde weitere Postfächer beauftragen; wir richten sie ein, sobald die Wechselrechnung bezahlt ist.</li>
<li>Die Abrechnung des Wechsels richtet sich nach § 7 Abs. 4. Ein laufender Rabatt aus einem persönlichen Angebot läuft bis zu seinem Ende weiter. Guthaben aus einer Übernahmegutschrift wird weiter verrechnet.</li>
<li>Ein Wechsel vom Wartungs- ins Hosting-Abo ist nach Ablauf der Mindestlaufzeit des Wartungs-Abos zum Ende eines Abrechnungsmonats möglich. Der Kunde teilt uns den Wunsch in Textform mit, etwa im Chat des Kundenportals oder per E-Mail; eine Bestellung im Kundenportal gibt es dafür nicht. Voraussetzung ist, dass die Website statisch ist (Leistungsbeschreibung A Nr. 2). Vor dem Wechsel legt der Kunde fest, welches Postfach bleibt; die Inhalte der übrigen Postfächer stellen wir ihm auf Wunsch vorher als Export bereit, danach löschen wir sie. Nicht genutzte Arbeitszeit entfällt mit dem Wechsel (§ 4 Abs. 2). Es beginnt keine neue Mindestlaufzeit; das Hosting-Abo ist danach nach § 8 Abs. 3 kündbar. Ab dem Wechsel gilt die Monatsgebühr des Hosting-Abos; eine anteilige Verrechnung für den laufenden Abrechnungsmonat gibt es nicht. Wir bestätigen den Wechsel per E-Mail.</li>
</ol>
<h2 id="p10">§ 10 Mängel</h2>
<p>Es gelten die gesetzlichen Regeln, für die digitalen Leistungen (Bereitstellung der Website, E-Mail-Postfach) insbesondere das gesetzliche Mängelhaftungsrecht für digitale Dienstleistungen (§§ 327 ff. BGB). Mängel unserer Leistungen beheben wir nach Meldung innerhalb angemessener Frist.</p>
<h2 id="zusatzspeicher">§ 10 E-Mail-Zusatzspeicher</h2>
<ol class="abs">
<li>In beiden Abos kann der Kunde den Speicher seiner Postfächer erweitern. Ein Paket Zusatzspeicher umfasst 5 GB und kostet 1,00 € pro Monat (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer). Der Kunde kann bis zu 10 Pakete buchen. Der Zusatzspeicher erhöht den Speicher, der allen Postfächern des Kunden zusammen zur Verfügung steht (§ 3 Abs. 4). Bestellungen und Änderungen des Zusatzspeichers im Kundenportal sind möglich, sobald die Leistung des Abos begonnen hat, und nur, solange keine Zahlung offen ist und das Abo nicht gekündigt ist.</li>
<li>Der Kunde bestellt Zusatzspeicher im Kundenportal im Bereich „Verträge“, indem er die Zahl der Pakete erhöht (§ 2 Abs. 5). Die Bestellübersicht zeigt die neue Gesamtgröße, den Mehrpreis pro Monat, den anteiligen Betrag für den Rest des laufenden Abrechnungsmonats, den Tag der nächsten Abbuchung und die Laufzeit (monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo). Mit dem Klick auf „Zahlungspflichtig bestellen“ kommt der Vertrag über den Zusatzspeicher zustande.</li>
<li>Wir stellen den zusätzlichen Speicher in der Regel innerhalb eines Werktags bereit und verteilen ihn nach den Vorgaben des Kunden auf die Postfächer (§ 3 Abs. 4). Bis dahin zeigt das Kundenportal die Bestellung als „wird eingerichtet“ an. Wir beginnen mit der Bereitstellung sofort, auch während der Widerrufsfrist; die Folgen eines Widerrufs stehen in den Hinweisen zum Widerruf.</li>
<li>Die Abrechnung richtet sich nach § 7 Abs. 3.</li>
<li>Der Kunde kann die Zahl der Pakete verringern, im Kundenportal im Bereich „Verträge“ mit der Schaltfläche „Speichern“, solange das Abo nicht gekündigt ist. Den Zusatzspeicher ganz oder teilweise kündigen kann er jederzeit über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“ und der Zahl der Pakete oder in Textform (§ 8 Abs. 8); nach einer Kündigung des Abos kann der Kunde die Zahl der Pakete nur noch über „Verträge hier kündigen“ oder in Textform verringern. Die Änderung wirkt zum Ende des laufenden Abrechnungsmonats; bis dahin zeigt das Kundenportal den Zeitpunkt und die künftige Größe an. Wir bestätigen die Änderung per E-Mail. Passen die gespeicherten E-Mails dann nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist (§ 3 Abs. 4). Der Kunde sollte vorher E-Mails löschen oder sichern.</li>
<li>Der Zusatzspeicher endet spätestens mit dem Abo. Bei einem Wechsel ins Wartungs-Abo läuft er unverändert weiter.</li>
</ol>
<h2 id="p11">§ 11 Haftung</h2>
<h2 id="p11">§ 11 Vertragsende</h2>
<ol class="abs">
<li>Während der Laufzeit und bis 30 Tage nach Vertragsende stellen wir dem Kunden auf Wunsch eine Kopie seiner Website bzw. Anwendungsdaten (Dateien und Datenbank-Export) und der Inhalte aller seiner Postfächer bereit. Wem Quellcode und Nutzungsrechte an der Website zustehen, richtet sich nach dem Vertrag über ihre Erstellung.</li>
<li>Wir geben den AuthCode der Domain heraus und helfen beim Umzug zu einem anderen Anbieter. Ist die Domain 30 Tage nach Vertragsende nicht umgezogen, dürfen wir ihre Registrierung beenden. Das kündigen wir vorher in Textform an und setzen eine Frist von 14 Tagen.</li>
<li>Nach Ablauf der Frist aus Absatz 1 löschen wir die Daten, die wir für den Kunden gespeichert haben (Website bzw. Anwendungsdaten und alle Postfächer). Vertrags- und Abrechnungsdaten bewahren wir auf, wie es die Datenschutzerklärung beschreibt (project.webklar.com/datenschutz.html). Sicherungskopien werden spätestens mit Ablauf ihrer Aufbewahrungszeit (§ 3 Abs. 5) überschrieben.</li>
</ol>
<h2 id="p12">§ 12 Mängel</h2>
<p>Es gelten die gesetzlichen Regeln, für die digitalen Leistungen (Bereitstellung der Website, E-Mail-Postfächer, Zusatzspeicher) insbesondere das gesetzliche Mängelhaftungsrecht für digitale Dienstleistungen (§§ 327 ff. BGB). Mängel unserer Leistungen beheben wir nach Meldung innerhalb angemessener Frist.</p>
<h2 id="p13">§ 13 Haftung</h2>
<ol class="abs">
<li>Wir haften unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit wir eine Garantie übernommen haben.</li>
<li>Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haften wir nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.</li>
<li>Für den Verlust von Daten haften wir nur in Höhe des Aufwands, der zur Wiederherstellung nötig gewesen wäre, wenn der Kunde seine Pflicht aus § 6 Abs. 4 erfüllt hätte.</li>
<li>Außer in den Fällen von Absatz 1 haften wir für den Verlust von Daten nur in Höhe des Aufwands, der zur Wiederherstellung nötig gewesen wäre, wenn der Kunde seine Pflicht aus § 6 Abs. 4 erfüllt hätte.</li>
<li>Die verschuldensunabhängige Haftung für Mängel, die schon bei Vertragsschluss vorhanden waren (§ 536a Abs. 1 BGB), ist ausgeschlossen.</li>
<li>Diese Regeln gelten auch zugunsten unserer Mitarbeiter und Beauftragten.</li>
</ol>
<h2 id="p12">§ 12 Datenschutz</h2>
<h2 id="p14">§ 14 Datenschutz</h2>
<ol class="abs">
<li>Wie wir personenbezogene Daten des Kunden verarbeiten, beschreibt unsere Datenschutzerklärung unter project.webklar.com/datenschutz.html.</li>
<li>Soweit wir im Rahmen des Abos personenbezogene Daten im Auftrag des Kunden verarbeiten, etwa Eingaben in Kontaktformulare, E-Mails oder Server-Protokolle seiner Website, gilt der Auftragsverarbeitungsvertrag in Anlage 1. Er wird mit der Bestellung vereinbart.</li>
<li>Soweit wir im Rahmen eines Abos personenbezogene Daten im Auftrag des Kunden verarbeiten, etwa E-Mails in seinen Postfächern, Eingaben in Kontaktformulare oder Server-Protokolle seiner Website, gilt der Auftragsverarbeitungsvertrag in Anlage 1. Er wird mit der Bestellung des Abos vereinbart und beim Wechsel ins Wartungs-Abo erneut bestätigt.</li>
</ol>
<h2 id="p13">§ 13 Änderungen dieser Bedingungen</h2>
<h2 id="p15">§ 15 Änderungen dieser Bedingungen</h2>
<p>Änderungen teilen wir mindestens 6 Wochen vor ihrem Wirksamwerden in Textform mit. Sie werden nur mit Zustimmung des Kunden wirksam. Ohne Zustimmung gelten die bisherigen Bedingungen weiter, und beide Seiten können nach § 8 kündigen.</p>
<h2 id="p14">§ 14 Schlussbestimmungen</h2>
<h2 id="p16">§ 16 Schlussbestimmungen</h2>
<ol class="abs">
<li>Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Gegenüber Verbrauchern gilt diese Rechtswahl nur, soweit ihnen dadurch nicht der Schutz zwingender Vorschriften des Staates entzogen wird, in dem sie ihren gewöhnlichen Aufenthalt haben.</li>
<li>Ist der Kunde Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen, ist Gerichtsstand der Sitz von WEBklar.</li>
<li>Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</li>
</ol>
<p class="anlage-kopf neue-seite" id="hosting">Leistungsbeschreibung A</p>
<h2>Hosting-Abo</h2>
<p class="hinweis">15,00 € pro Monat · Endpreis, gemäß § 19 UStG ohne Umsatzsteuer · Mindestlaufzeit 12 Monate · für statische Websites</p>
<h3>1. Umfang</h3>
<ul>
<li>Bereitstellung der Website auf unserem Server in Deutschland mit SSL-Zertifikat, Überwachung der Erreichbarkeit und Updates von Betriebssystem und Server-Software (§ 3 Abs. 2)</li>
<li>1 Domain mit Verwaltung (§ 3 Abs. 3)</li>
<li>1 E-Mail-Postfach mit 5 GB Speicher; mehr Speicher über den Zusatzspeicher (§ 3 Abs. 4, § 10)</li>
<li>tägliche Datensicherung (§ 3 Abs. 5)</li>
<li>Servicezeiten und Reaktion (§ 3 Abs. 6); die Behebung von Störungen an Server, Domain und Postfach in unserem Verantwortungsbereich ist inklusive</li>
</ul>
<h3>2. Nur statische Websites</h3>
<ul>
<li>Das Hosting-Abo ist für statische Websites bestimmt, die wir erstellt oder zur Betreuung übernommen haben. Statisch heißt: Der Webserver liefert die Dateien der Website (HTML, CSS, JavaScript, Bilder) unverändert aus. Dazu gehören auch die von uns erstellten React- und Vite-Seiten.</li>
<li>Nicht möglich sind ein Content-Management-System, ein Login, eine Datenbank und eigener serverseitiger Code (etwa PHP). Solche Teile brauchen laufende Updates; ohne sie wären sie ein Sicherheitsrisiko für den Server, auf dem auch andere Websites und unser Kundenportal laufen.</li>
<li>Kontakt über E-Mail-Links auf der Website ist möglich. Formulare, die Eingaben an einen Server übermitteln, sind im Hosting-Abo nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an.</li>
<li>Braucht die Website später dynamische Teile, bieten wir den Wechsel ins Wartungs-Abo an (§ 9).</li>
</ul>
<h3>3. Keine Pflege der Website</h3>
<ul>
<li>Updates und Pflege der Website selbst sind nicht enthalten. Weil statische Websites keinen eigenen Code auf dem Server ausführen, sind sie für den sicheren Betrieb in der Regel nicht nötig.</li>
<li>Änderungen an der Website und andere Aufträge führen wir nur nach Freigabe des Kunden aus und berechnen 100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer) in Schritten von 15 Minuten (§ 4 Abs. 7). Ein Stundenkontingent gibt es im Hosting-Abo nicht.</li>
</ul>
<h3>4. Nicht enthalten</h3>
<ul>
<li>Änderungen, neue Funktionen, Redesign oder neue Seiten ohne gesonderten Auftrag (Nr. 3).</li>
<li>Texte, Bilder und Rechtstexte (Impressum, Datenschutzerklärung) für die Website des Kunden, Suchmaschinenoptimierung und Werbung.</li>
<li>Unterstützung für Endgeräte, Betriebssysteme oder E-Mail-Programme des Kunden.</li>
<li>Behebung von Fehlern, die der Kunde oder Dritte durch eigene Änderungen verursacht haben. Das ist als Auftrag nach Nr. 3 möglich.</li>
</ul>
<p class="anlage-kopf" id="wartung">Leistungsbeschreibung B</p>
<h2>Wartungs-Abo</h2>
<p class="hinweis">50,00 € pro Monat · Endpreis, gemäß § 19 UStG ohne Umsatzsteuer · Mindestlaufzeit 12 Monate, auch nach einem Wechsel · für alle Websites und Web-Anwendungen, die wir erstellt oder zur Betreuung übernommen haben</p>
<h3>1. Umfang</h3>
<ul>
<li>Betrieb der Website bzw. Web-Anwendung auf unserem Server in Deutschland mit SSL-Zertifikat, Überwachung der Erreichbarkeit und Updates von Betriebssystem und Server-Software (§ 3 Abs. 2)</li>
<li>Pflege der Website (Nr. 2)</li>
<li>1 Domain mit Verwaltung (§ 3 Abs. 3)</li>
<li>beliebig viele Postfächer, zusammen 5 GB Speicher; mehr Speicher über den Zusatzspeicher (§ 3 Abs. 4, § 10)</li>
<li>tägliche Datensicherung (§ 3 Abs. 5)</li>
<li>1 Stunde Arbeitszeit je bezahltem Monat; nicht genutzte Zeit bleibt 12 Monate verfügbar (§ 4)</li>
<li>Servicezeiten und Reaktion (§ 3 Abs. 6)</li>
</ul>
<h3>2. Pflege der Website</h3>
<ul>
<li>Zusätzlich zu den Updates nach § 3 Abs. 2 spielen wir Sicherheitsupdates für die von uns eingesetzten Software-Komponenten der Website bzw. Web-Anwendung ein, kritische in der Regel innerhalb von 3 Werktagen nach ihrer Veröffentlichung, übrige regelmäßig. Aktualisierungen, die für den vertragsgemäßen Betrieb nötig sind, stellen wir während der gesamten Vertragslaufzeit bereit.</li>
<li>Das Wartungs-Abo eignet sich für alle Websites und Web-Anwendungen, die wir erstellt oder zur Betreuung übernommen haben, auch mit Content-Management-System, Login, Datenbank oder serverseitigem Code.</li>
<li>Änderungswünsche setzen wir über das Stundenkontingent um (§ 4), in der Regel innerhalb von 5 Werktagen.</li>
</ul>
<h3>3. Nicht enthalten</h3>
<ul>
<li>Neue Funktionen, Redesign oder neue Seiten, soweit sie nicht über das Stundenkontingent beauftragt werden.</li>
<li>Texte, Bilder und Rechtstexte (Impressum, Datenschutzerklärung) für die Website des Kunden, Suchmaschinenoptimierung und Werbung.</li>
<li>Unterstützung für Endgeräte, Betriebssysteme oder E-Mail-Programme des Kunden.</li>
<li>Behebung von Fehlern, die der Kunde oder Dritte durch eigene Änderungen verursacht haben. Das ist über das Stundenkontingent möglich.</li>
</ul>
<div class="neue-seite"></div>
<h2 id="widerruf">Widerrufsbelehrung</h2>
<p class="hinweis">Das Widerrufsrecht gilt nur für Verbraucher. Die folgende Belehrung entspricht dem gesetzlichen Muster.</p>
<p class="hinweis">Das Widerrufsrecht gilt nur für Verbraucher. Die folgende Belehrung entspricht dem gesetzlichen Muster. Sie gilt für jede Bestellung: den Abschluss eines Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Hinweise dazu stehen nach dem Muster-Widerrufsformular.</p>
<h3>Widerrufsrecht</h3>
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>
<p>Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
@@ -280,24 +365,33 @@
<p class="meta">(*) Unzutreffendes streichen.</p>
</div>
<h2 id="widerruf-hinweise">Hinweise zum Widerruf</h2>
<p class="hinweis">Diese Hinweise gehören zu diesen Bedingungen, nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die einzelnen Bestellungen auswirkt.</p>
<ol class="abs">
<li>Das Widerrufsrecht gilt für jede Bestellung gesondert: für den Abschluss eines Abos, für den Wechsel ins Wartungs-Abo und für jede Bestellung von Zusatzspeicher. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss (§ 2 Abs. 3 bzw. Abs. 5). Unter project.webklar.com/widerrufen.html wählt der Kunde aus, welche Bestellung er widerruft. Solange eine Widerrufsfrist läuft, zeigt das Kundenportal unter „Verträge“ den letzten Tag der Frist an.</li>
<li>Widerruf des Abos: Widerruft der Kunde den Abschluss seines Abos, endet damit auch der dazu bestellte Zusatzspeicher. Was der Kunde für den Zusatzspeicher gezahlt hat, erstatten wir vollständig; Wertersatz verlangen wir dafür nicht (Nr. 4).</li>
<li>Widerruf des Wechsels: Widerruft der Kunde den Wechsel ins Wartungs-Abo, läuft sein Hosting-Abo zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit. Wir erstatten den für das Wartungs-Abo gezahlten Mehrbetrag. Mehrbetrag ist, was der Kunde seit dem Wechsel mehr gezahlt hat, als er ohne den Wechsel für das Hosting-Abo gezahlt hätte. Weil der Kunde beim Wechsel den sofortigen Beginn verlangt hat, ziehen wir davon den Wertersatz für die bis zum Widerruf erbrachte Leistung ab (§ 357a Abs. 2 BGB): den anteiligen Mehrpreis des Wartungs-Abos gegenüber dem Hosting-Abo für die Tage vom Beginn des Wartungs-Abos bis zum Widerruf.</li>
<li>Widerruf des Zusatzspeichers: Wir stellen bestellten Zusatzspeicher sofort bereit, ohne dass der Kunde das ausdrücklich verlangen muss. Widerruft er die Bestellung, nehmen wir die Pakete zurück und erstatten alles, was er dafür gezahlt hat. Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist (§ 3 Abs. 4).</li>
</ol>
<p class="anlage-kopf neue-seite" id="avv">Anlage 1</p>
<h2>Auftragsverarbeitungsvertrag nach Art. 28 DSGVO</h2>
<p class="hinweis">zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR als Auftragsverarbeiter („WEBklar“). Der Vertrag wird mit der Bestellung des Wartungs-Abos vereinbart.</p>
<p class="hinweis">zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR als Auftragsverarbeiter („WEBklar“). Der Vertrag wird mit der Bestellung des Hosting-Abos oder des Wartungs-Abos vereinbart; beim Wechsel ins Wartungs-Abo bestätigt der Kunde ihn erneut.</p>
<h3>1. Gegenstand und Dauer</h3>
<p>WEBklar verarbeitet personenbezogene Daten im Auftrag des Kunden, soweit das für die Leistungen des Wartungs-Abos (Betrieb und Pflege der Website, Domain, E-Mail-Postfach, Unterstützung) nötig ist. Der Vertrag gilt für die Dauer des Abos und darüber hinaus, bis alle Daten nach Nummer 8 gelöscht oder zurückgegeben sind.</p>
<p>WEBklar verarbeitet personenbezogene Daten im Auftrag des Kunden, soweit das für die Leistungen des Hosting-Abos oder des Wartungs-Abos (Bereitstellung bzw. Betrieb und Pflege der Website, Domain, E-Mail-Postfächer einschließlich Zusatzspeicher, Unterstützung) sowie für gesondert vereinbarte Zusatzleistungen zum Abo, etwa eine Formularlösung, nötig ist. Der Vertrag gilt für die Dauer des Abos, auch über einen Wechsel zwischen den Abos hinweg, und darüber hinaus, bis alle Daten nach Nummer 8 gelöscht oder zurückgegeben sind.</p>
<h3>2. Art und Zweck der Verarbeitung, Datenarten, Betroffene</h3>
<table>
<tr><td>Art der Verarbeitung</td><td>Speichern, Bereitstellen, Weiterleiten (z. B. Kontaktformular-Eingaben per E-Mail an den Kunden), Sichern, Wiederherstellen und Löschen; Einsicht, soweit sie für Wartung und Unterstützung nötig ist</td></tr>
<tr><td>Zweck</td><td>Betrieb der Website bzw. Web-Anwendung und des Postfachs des Kunden</td></tr>
<tr><td>Datenarten</td><td>Inhalte der Website; Eingaben in Formulare (z. B. Name, Kontaktdaten, Nachricht); Inhalte des Postfachs; Verbindungsdaten in Server-Protokollen (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browserkennung); Benutzerkonten, soweit die Anwendung solche vorsieht</td></tr>
<tr><td>Betroffene</td><td>Besucher der Website; Absender und Empfänger von E-Mails; Kunden, Interessenten und Beschäftigte des Kunden; weitere Personen, deren Daten der Kunde über seine Website verarbeitet</td></tr>
<tr><td>Art der Verarbeitung</td><td>Speichern, Bereitstellen, Übermitteln (Empfang und Versand von E-Mails, Weiterleiten von Kontaktformular-Eingaben per E-Mail an den Kunden), Sichern, Wiederherstellen und Löschen; Einsicht, soweit sie für Wartung und Unterstützung nötig ist</td></tr>
<tr><td>Zweck</td><td>Betrieb der Website bzw. Web-Anwendung und der E-Mail-Postfächer des Kunden sowie gesondert vereinbarter Zusatzleistungen</td></tr>
<tr><td>Datenarten</td><td>Inhalte der Website; Eingaben in Formulare (z. B. Name, Kontaktdaten, Nachricht), soweit die Website solche vorsieht; Inhalte der Postfächer (E-Mails mit Anhängen, Absender, Empfänger, Zeitpunkte); Verbindungsdaten in Server-Protokollen (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browserkennung); Benutzerkonten, soweit die Anwendung solche vorsieht</td></tr>
<tr><td>Betroffene</td><td>Besucher der Website; Nutzer der Postfächer des Kunden, insbesondere seine Beschäftigten; Absender und Empfänger von E-Mails; Kunden, Interessenten und Beschäftigte des Kunden; weitere Personen, deren Daten der Kunde über seine Website oder seine Postfächer verarbeitet</td></tr>
</table>
<p>Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) verarbeitet der Kunde über seine Website nur nach vorheriger Mitteilung an WEBklar.</p>
<p>Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) verarbeitet der Kunde über seine Website oder seine Postfächer nur nach vorheriger Mitteilung an WEBklar.</p>
<h3>3. Weisungen</h3>
<p>WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch bei Übermittlungen in Drittländer, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt. Die Weisungen ergeben sich aus diesem Vertrag und den Bedingungen des Wartungs-Abos. Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin.</p>
<p>WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch bei Übermittlungen in Drittländer, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt. Die Weisungen ergeben sich aus diesem Vertrag und den Bedingungen der Abos. Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin.</p>
<h3>4. Vertraulichkeit</h3>
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
@@ -306,7 +400,7 @@
<p>WEBklar trifft die technischen und organisatorischen Maßnahmen nach Art. 32 DSGVO, die in Anlage 2 beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.</p>
<h3>6. Unterauftragsverarbeiter</h3>
<p>Der Kunde genehmigt die in Anlage 3 genannten Unterauftragsverarbeiter. Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, können beide das Wartungs-Abo zum Zeitpunkt des Wechsels kündigen. WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.</p>
<p>Der Kunde genehmigt die in Anlage 3 genannten Unterauftragsverarbeiter. Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, können beide das Abo zu dem Zeitpunkt kündigen, zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll. WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.</p>
<h3>7. Unterstützung des Kunden</h3>
<ul>
@@ -316,13 +410,13 @@
</ul>
<h3>8. Löschung und Rückgabe</h3>
<p>Nach Ende des Abos gibt WEBklar die Daten nach Wahl des Kunden zurück (§ 9 Abs. 1 der Bedingungen) oder löscht sie, soweit keine gesetzliche Pflicht zur Speicherung besteht. Sicherungskopien werden im Rahmen der regulären Rotation überschrieben.</p>
<p>Nach Ende des Abos gibt WEBklar die Daten nach Wahl des Kunden zurück (§ 11 Abs. 1 der Bedingungen) oder löscht sie, soweit keine gesetzliche Pflicht zur Speicherung besteht. Sicherungskopien werden im Rahmen der regulären Rotation überschrieben.</p>
<h3>9. Nachweise und Kontrollen</h3>
<p>WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind. WEBklar ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Rechenzentrumsbetreibers (Zertifizierung nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.</p>
<h3>10. Schlussbestimmungen</h3>
<p>Für die Haftung gilt Art. 82 DSGVO. Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Bedingungen des Wartungs-Abos vor.</p>
<p>Für die Haftung gilt Art. 82 DSGVO. Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Bedingungen der Abos vor.</p>
<p class="anlage-kopf neue-seite" id="tom">Anlage 2</p>
<h2>Technische und organisatorische Maßnahmen (Art. 32 DSGVO)</h2>
@@ -330,12 +424,12 @@
<tr><th style="width:28%">Bereich</th><th>Maßnahmen</th></tr>
<tr><td>Rechenzentrum und Zutritt</td><td>Betrieb im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert. Zutritt, Stromversorgung, Klimatisierung und Brandschutz regeln die Maßnahmen von Hetzner.</td></tr>
<tr><td>Zugang zu den Systemen</td><td>Serververwaltung nur über verschlüsselte SSH-Verbindung, Zugang nur für die Gesellschafter. Automatische Sperre bei wiederholten Fehlversuchen. Firewall des Rechenzentrumsbetreibers, die nur die benötigten Dienste zulässt. Zwei-Faktor-Anmeldung für die Verwaltungskonten bei Hetzner und Stripe.</td></tr>
<tr><td>Zugriff auf Daten</td><td>Zugriff nur für die Gesellschafter und beauftragte Mitarbeiter, soweit ihre Aufgabe es erfordert. Im Kundenportal sieht jeder Kunde nur seine eigenen Daten. Datenbanken sind nur für die jeweilige Anwendung erreichbar.</td></tr>
<tr><td>Zugriff auf Daten</td><td>Zugriff nur für die Gesellschafter und beauftragte Mitarbeiter, soweit ihre Aufgabe es erfordert. Im Kundenportal sieht jeder Kunde nur seine eigenen Daten. Datenbanken sind nur für die jeweilige Anwendung erreichbar. Zugangsdaten zu E-Mail-Postfächern erhalten nur der Kunde und die von ihm in Textform benannten Personen.</td></tr>
<tr><td>Übertragung</td><td>Verschlüsselte Übertragung (TLS) für alle Websites, das Kundenportal und die Verwaltung. E-Mail-Versand und -Abruf nur verschlüsselt.</td></tr>
<tr><td>Nachvollziehbarkeit</td><td>Änderungen an Websites laufen über eine Versionsverwaltung (Git) und sind nachvollziehbar. Aufträge und Weisungen werden im Ticketsystem bzw. im Chat des Kundenportals dokumentiert.</td></tr>
<tr><td>Verfügbarkeit und Wiederherstellung</td><td>Tägliche Sicherung des gesamten Servers, verfügbar sind die letzten sieben Tage. Tägliche Datenbank-Exporte vor der Serversicherung. Automatische Überwachung der Erreichbarkeit. Automatische Sicherheitsupdates des Betriebssystems. Wiederherstellung wird regelmäßig getestet.</td></tr>
<tr><td>Trennung</td><td>Websites der Kunden in getrennten Verzeichnissen bzw. Containern. Vorschau- und Testversionen sind nur nach Anmeldung erreichbar.</td></tr>
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit. Festgelegter Ablauf für die Meldung von Datenschutzverletzungen. Löschung nach Vertragsende (§ 9 der Bedingungen). Server-Protokolle werden nach 14 Tagen gelöscht. Die Maßnahmen werden mindestens einmal im Jahr überprüft.</td></tr>
<tr><td>Trennung</td><td>Websites der Kunden in getrennten Verzeichnissen bzw. Containern. Vorschau- und Testversionen sind nur nach Anmeldung erreichbar. Postfächer und Speichergrenzen werden je Kunde getrennt verwaltet.</td></tr>
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit. Festgelegter Ablauf für die Meldung von Datenschutzverletzungen. Löschung nach Vertragsende (§ 11 der Bedingungen). Server-Protokolle werden nach 14 Tagen gelöscht. Die Maßnahmen werden mindestens einmal im Jahr überprüft.</td></tr>
</table>
<p class="anlage-kopf" id="sub">Anlage 3</p>
+28
View File
@@ -123,6 +123,12 @@
.credit-input option { color: #111; }
textarea.credit-input { min-height: 80px; resize: vertical; }
.calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); }
/* E-Mail-Speicher: ab der Warnschwelle des Hetzner-Pakets rot */
.calc-result.warn { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.45); color: #fca5a5; }
.storage-task strong { font-size: 15px; }
.storage-partial { color: #fca5a5; }
.storage-failed { margin: 14px 0 0; }
.storage-failed li { margin: 4px 0; font-size: 14px; }
.credit-field { margin-top: 10px; }
.credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.mini-btn:disabled { opacity: 0.45; cursor: default; }
@@ -158,6 +164,7 @@
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
<button class="admin-tab" data-tab="storage">E-Mail-Speicher</button>
</div>
<!-- Übersicht -->
@@ -263,6 +270,27 @@
</div>
<p class="admin-note">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
</section>
<!-- E-Mail-Speicher (Zusatzspeicher-Aufgaben fuers Team, Hetzner-Paket) -->
<section class="admin-section" id="tab-storage">
<div id="storage-summary" class="calc-result">Lade…</div>
<div class="admin-table-wrap no-scroll">
<table class="admin-table">
<colgroup>
<col style="width:14%"><col style="width:22%"><col style="width:34%"><col style="width:18%"><col style="width:12%">
</colgroup>
<thead>
<tr><th>Fällig</th><th>Kunde</th><th>Aufgabe in konsoleH</th><th>Bestellt</th><th></th></tr>
</thead>
<tbody id="storage-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<div id="storage-failed" class="storage-failed hidden">
<h4>Gescheiterte Änderungen (letzte 30 Tage)</h4>
<ul class="recent-list" id="storage-failed-list"></ul>
</div>
<p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p>
</section>
</main>
<script src="/chat-composer.js"></script>
+103 -8
View File
@@ -41,6 +41,10 @@ function fmtEuro(value) {
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo"); Altbestand ohne Produkt gilt als Wartungs-Abo
const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo')
function isLead(customer) {
return String(customer.customerStatus || '').toLowerCase() === 'lead'
}
@@ -634,7 +638,7 @@ function renderCreditDetail(panel, d, message) {
<dl class="kv">
<dt>Kunde</dt><dd>${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}</dd>
<dt>E-Mail</dt><dd>${escapeHtml(c.email || '–')}</dd>
<dt>Wartungs-Abo</dt><dd>${aboPill}</dd>
<dt>Abo</dt><dd>${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}<br><span class="muted">Zusatzspeicher: ${storageLabel(s.storageUnits)}</span>` : aboPill}</dd>
${s ? `
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
@@ -778,6 +782,82 @@ async function applyCredit(id) {
}
}
/* ----------------------------------------------------- E-Mail-Speicher --- */
const GB_INCLUDED = 5
const GB_PER_UNIT = 5
const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
// "2 Pakete (15 GB)" / "kein Paket (5 GB)"
function storageLabel(units) {
const n = Number(units) || 0
const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete`
return `${packages} (${gbOfUnits(n)} GB)`
}
function storageDueHtml(t) {
const overdue = t.dueAt && new Date(t.dueAt).getTime() <= Date.now()
if (t.kind === 'einrichten') {
return `<span class="pill pill-warn">jetzt einrichten</span><br><span class="muted">bestellt ${fmtDateTime(t.createdAt)}</span>`
}
return `<span class="pill ${overdue ? 'pill-warn' : 'pill-muted'}">zum Periodenende</span><br>${fmtDate(t.dueAt)}`
}
function storageTaskHtml(t) {
const action = t.kind === 'einrichten'
? `Postfachgrenze in konsoleH <strong>jetzt</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> setzen`
: `Postfachgrenze in konsoleH <strong>am ${fmtDate(t.dueAt)}</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> senken`
const partial = t.partial
? `<br><span class="storage-partial">Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}</span>`
: ''
const scope = `<br><span class="muted">Gesamtspeicher des Kunden: <strong>${escapeHtml(t.targetGb)} GB</strong>, auf seine Postfächer verteilt nach seiner Vorgabe – § 3 Abs. 4${t.contactDomain ? ` · Kontakt: ${escapeHtml(t.contactDomain)}` : ''}</span>`
return `<div class="storage-task">${action}${scope}<br><span class="muted">${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}</span>${partial}</div>`
}
async function loadStorageTasks() {
const summary = document.getElementById('storage-summary')
const tbody = document.getElementById('storage-rows')
const failedBox = document.getElementById('storage-failed')
const failedList = document.getElementById('storage-failed-list')
try {
const d = await api('/api/portal-admin/storage-tasks')
const warn = Number(d.allocatedGb) >= Number(d.warnGb)
summary.classList.toggle('warn', warn)
summary.innerHTML = `<strong>${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben</strong> (Paket ${escapeHtml(d.packageName)})${
warn ? ` – <strong>Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen.</strong>` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB`
}`
tbody.innerHTML = d.tasks.length
? d.tasks.map((t) => `
<tr data-storage-row="${escapeHtml(t.id)}">
<td>${storageDueHtml(t)}</td>
<td><strong>${escapeHtml(t.customerName)}</strong>${t.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(t.customerCode)}</span>` : ''}${t.email ? `<br><span class="muted">${escapeHtml(t.email).replace('@', '@<wbr>')}</span>` : ''}<br><span class="muted">${escapeHtml(t.productLabel)}</span></td>
<td>${storageTaskHtml(t)}</td>
<td>${fmtDateTime(t.createdAt)}<br><span class="muted">${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}</span></td>
<td><button class="mini-btn primary" data-storage-done="${escapeHtml(t.id)}"${t.partial ? ' data-partial="1"' : ''}>Erledigt</button></td>
</tr>`).join('')
: '<tr><td colspan="5" class="muted">Keine offenen Speicher-Aufgaben.</td></tr>'
failedBox.classList.toggle('hidden', !d.failed.length)
failedList.innerHTML = d.failed.map((f) => `
<li>${fmtDateTime(f.createdAt)} · <strong>${escapeHtml(f.customerName)}</strong> · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))}
<span class="muted">– Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''}</span></li>`).join('')
tbody.querySelectorAll('[data-storage-done]').forEach((btn) => btn.addEventListener('click', () => completeStorageTask(btn.dataset.storageDone, btn)))
} catch (err) {
summary.classList.remove('warn')
summary.textContent = ''
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
async function completeStorageTask(id, btn) {
if (btn.dataset.partial === '1' && !confirm('Stripe-Menge von Hand korrigiert?')) return
btn.disabled = true
try {
await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' })
await loadStorageTasks()
} catch (err) {
btn.disabled = false
alert('Aufgabe konnte nicht als erledigt gespeichert werden: ' + err.message)
}
}
/* ------------------------------------------------------- Abo-Angebote --- */
const OFFER_STATUS = {
offen: ['pill-warn', 'offen'],
@@ -788,6 +868,8 @@ const OFFER_STATUS = {
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
const OFFER_PRODUCT_LABEL = PRODUCT_LABEL
function offerStatusPill(o) {
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
@@ -873,7 +955,7 @@ async function loadOffers() {
<tr>
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
<td><span class="muted">${escapeHtml(productLabelOf(o.productKey))}</span><br>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
@@ -937,6 +1019,7 @@ async function ensureOfferCustomers() {
function offerFormValues(form) {
return {
customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '',
productKey: form.querySelector('#offer-product').value,
monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100),
months: Number(form.querySelector('#offer-months').value),
validUntil: form.querySelector('#offer-valid').value,
@@ -956,12 +1039,14 @@ function renderOfferPreview(form) {
box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.'
return
}
const regular = ctx.regularPriceCents
const running = Boolean(ctx.subscription?.active)
const regular = ctx.products[v.productKey].regularPriceCents
// Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
const openOffer = ctx.openOffers[v.productKey]
const lines = []
let ok = true
if (ctx.openOffer) {
lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.openOffer.text}) – bitte zuerst zurückziehen.`)
if (openOffer) {
lines.push(`Es gibt schon ein offenes ${OFFER_PRODUCT_LABEL[v.productKey]}-Angebot (${openOffer.ref}: ${openOffer.text}) – bitte zuerst zurückziehen.`)
ok = false
}
if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) {
@@ -1016,8 +1101,8 @@ async function saveOffer(form) {
errEl.classList.add('hidden')
const v = offerFormValues(form)
const ctx = offerState.context
const running = Boolean(ctx.subscription?.active)
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, running)
const running = Boolean(ctx.subscription?.active && ctx.subscription.productKey === v.productKey)
const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.products[v.productKey].regularPriceCents, running)
if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}${running ? '' : `\nGültig bis ${fmtYmd(v.validUntil)}`}\n\nDer Coupon wird sofort in Stripe angelegt${running ? ' und auf das laufende Abo angewendet' : ''}.`)) return
const save = form.querySelector('#offer-save')
save.disabled = true
@@ -1065,6 +1150,11 @@ async function openOfferForm(customerId = '') {
<input class="credit-input" id="offer-customer" list="offer-customer-list" autocomplete="off"
placeholder="Name, E-Mail oder Kundennummer tippen …" value="${preselected ? escapeHtml(offerCustomerLabel(preselected)) : ''}">
<datalist id="offer-customer-list">${customers.map((c) => `<option value="${escapeHtml(offerCustomerLabel(c))}"></option>`).join('')}</datalist></div>
<div><label for="offer-product">Produkt</label>
<select class="credit-input" id="offer-product">
<option value="wartung" selected>Wartungs-Abo</option>
<option value="hosting">Hosting-Abo</option>
</select></div>
<div><label for="offer-price">Preis pro Monat im Angebot (€)</label>
<input class="credit-input" id="offer-price" type="number" min="0" step="0.01" value="25.00"></div>
<div><label for="offer-months">für</label>
@@ -1090,6 +1180,7 @@ async function openOfferForm(customerId = '') {
if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form)
})
form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form)))
form.querySelector('#offer-product').addEventListener('change', () => renderOfferPreview(form))
form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form))
form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden'))
if (preselected) loadOfferContext(form)
@@ -1149,6 +1240,10 @@ async function initAdminPage() {
offersLoaded = true
loadOffers()
}
if (tab.dataset.tab === 'storage') {
// Bei jedem Wechsel auf den Reiter neu laden: der Stand aendert sich, sobald jemand bestellt
loadStorageTasks()
}
})
})
+583 -95
View File
@@ -760,16 +760,6 @@ async function pollChatBadge() {
// Wartungs-Abo: Karte in der Seitenleiste + Ansicht (Bestellung bzw. Stundenkonto)
// ---------------------------------------------------------------------------
const ABO_FEATURES = [
['Betrieb &amp; Pflege Ihrer Website/Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
]
// Kurzfassung fuer die Karte in der Seitenleiste
const ABO_SIDE_FEATURES = ['Betrieb & Pflege, Server in Deutschland', '1 Domain inklusive', '1 E-Mail-Postfach, 5 GB', '1 Std. Arbeitszeit pro Monat']
function fmtHours(min) {
const n = Math.max(0, Number(min || 0))
const h = Math.floor(n / 60)
@@ -787,9 +777,19 @@ function fmtDayMonth(value) {
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' })
}
function aboFeaturesHtml() {
return `<ul class="abo-features">${ABO_FEATURES.map(
([t, d]) => `<li>${t}<small>${d}</small></li>`
// Leistungen eines Produkts aus dem Katalog der Server-Antwort (data.products[].features = [Titel, Detail])
function productOf(data, key) {
return (data?.products || []).find((p) => p.key === key) || null
}
function productLabelOf(data, key) {
const product = productOf(data, key)
return (product?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
}
function aboFeaturesHtml(features) {
return `<ul class="abo-features">${(features || []).map(
([t, d]) => `<li>${escapeHtml(t)}<small>${escapeHtml(d)}</small></li>`
).join('')}</ul>`
}
@@ -804,48 +804,57 @@ function aboTermText(sub) {
return 'Monatlich kündbar zum Ende des Abrechnungsmonats.'
}
/* Seitenleiste: Angebot (noch kein Abo) */
/* Seitenleiste: noch kein Abo (Auswahl steht in der Ansicht "Verträge") */
function renderAboSideOffer(card, data, { ended }) {
const offer = data.offer
const cheapest = Math.min(...(data.products || []).map((p) => p.amountCents).filter(Number.isFinite), 1500)
card.classList.add('kb-abo-offer')
card.innerHTML = `
<p class="kb-label">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h3>Wartungs-Abo</h3>
${offer ? '<span class="kb-offer-tag">Ihr persönliches Angebot</span>' : ''}
<p class="kb-price">${offer
? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50&nbsp;€</span>`
: '<strong>50 €</strong><span>pro Monat</span>'}</p>
${offer?.validUntil ? `<p class="kb-offer-until">Angebot gültig bis <strong>${fmtDateShort(offer.validUntil)}</strong></p>` : ''}
<ul class="kb-checks">${ABO_SIDE_FEATURES.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
<button type="button" class="kb-btn-primary kb-btn-block" data-view="abo"${data.available ? '' : ' disabled'}>Abo abschließen</button>
<p class="kb-label">Ihr Vertrag</p>
<h3>${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</h3>
<p class="kb-price"><strong>ab ${euroShort(cheapest)}</strong><span>pro Monat</span></p>
<p class="kb-sub">Hosting-Abo oder Wartungs-Abo, 12 Monate Mindestlaufzeit.</p>
<button type="button" class="kb-btn-primary kb-btn-block" data-view="vertraege">Abos ansehen</button>
<p class="kb-fine">${data.available
? '12 Monate Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
? 'Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</p>`
}
/* Seitenleiste: laufendes Abo */
function renderAboSideActive(card, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0 }
// Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text
const status = sub.cancelAtPeriodEnd ? '<span class="kb-status kb-status-off">gekündigt</span>' : ''
const dateLine = sub.cancelAtPeriodEnd
function aboIsCancelled(sub) {
return Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
}
function aboDateLine(sub) {
return aboIsCancelled(sub)
? `Leistungen bis ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}`
: sub.serviceStartsAt
? `Beginn und erste Abbuchung am ${fmtDateShort(sub.serviceStartsAt)}`
: `nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}`
}
/* Seitenleiste: laufendes Abo ("Ihr Vertrag") */
function renderAboSideActive(card, data) {
const sub = data.subscription
const wartung = sub.productKey === 'wartung'
const l = data.ledger || { balanceMinutes: 0 }
// Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text
const status = aboIsCancelled(sub) ? '<span class="kb-status kb-status-off">gekündigt</span>' : ''
card.classList.remove('kb-abo-offer')
card.innerHTML = `
<p class="kb-label">Ihre Wartung</p>
<p class="kb-label">Ihr Vertrag</p>
${status}
<button type="button" class="kb-hours" data-view="abo" title="Stundenkonto und Verlauf ansehen" aria-label="${fmtHours(l.balanceMinutes)} Arbeitszeit verfügbar – Stundenkonto und Verlauf ansehen">
<h3>${escapeHtml(productLabelOf(data, sub.productKey))}</h3>
${wartung ? `
<button type="button" class="kb-hours" data-view="stunden" title="Stundenkonto und Verlauf ansehen" aria-label="${fmtHours(l.balanceMinutes)} Arbeitszeit verfügbar – Stundenkonto und Verlauf ansehen">
<strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span><em aria-hidden="true">›</em>
</button>
<p class="kb-sub">${dateLine}</p>
</button>` : `
<p class="kb-price"><strong>${fmtEuro(sub.priceAmount / 100)}</strong><span>pro Monat, gemäß § 19 UStG ohne Umsatzsteuer</span></p>`}
<p class="kb-sub">${aboDateLine(sub)}</p>
${sub.switchPending ? '<p class="kb-side-alert">Ihr Wechsel wartet auf die Zahlung.</p>' : ''}
${sub.paymentProblem ? `
<p class="kb-side-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</p>
<button type="button" class="kb-side-btn" id="abo-manage">Zahlungsart aktualisieren ↗</button>` : ''}
<button type="button" class="kb-side-btn" data-view="takeover">Übernahmegutschrift beantragen <span class="kb-badge hidden" id="takeover-badge" title="Rückfrage offen"></span></button>
${sub.withdrawalOpen ? `<a class="kb-side-btn" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
${sub.withdrawalOpen ? `<a class="kb-side-btn kb-withdraw" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
<div class="kb-divider"></div>
<p class="kb-fine" style="margin-top:0">${aboTermText(sub)}</p>
<p class="error hidden" id="abo-error"></p>`
@@ -867,14 +876,31 @@ async function openBillingPortal(btn, errorEl) {
}
}
/* Ansicht "Wartungs-Abo": Bestellung (Bestellübersicht direkt über dem Bestellbutton, § 312j BGB) */
function renderAboOrderView(view, data) {
const available = data.available
const offer = data.offer
/* Bestellseite je Produkt (#bestellen-hosting, #bestellen-wartung): Bestellübersicht direkt über dem Bestellbutton (§ 312j BGB) */
function renderOrderView(view, data, productKey) {
const product = productOf(data, productKey)
const offer = data.offers?.[productKey] || null
const label = productLabelOf(data, productKey)
if (!product || !data.available) {
view.innerHTML = `
<header class="kb-head"><h1>${escapeHtml(label)}</h1></header>
<div class="kb-panel"><p class="abo-note" style="margin:0">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</p></div>`
return
}
const price = fmtEuro(product.amountCents / 100)
const lateStart = data.lateStartAt ? fmtDateShort(data.lateStartAt) : ''
const isWartung = productKey === 'wartung'
// Betrag der ersten Abbuchung: Angebotspreis während des Rabatts, sonst Katalogpreis
const offerMonthly = Number(offer?.monthlyPriceCents)
const offerRegular = Number(offer?.regularPriceCents) || product.amountCents
const offerMonths = Number(offer?.months)
const hasOfferNumbers = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
const firstCents = hasOfferNumbers ? offerMonthly : product.amountCents
const firstAmount = fmtEuro(firstCents / 100)
view.innerHTML = `
<header class="kb-head">
<h1>Wartungs-Abo</h1>
<p>Betrieb, Pflege und Support für Ihre Website – ${offer ? 'mit Ihrem persönlichen Angebot' : 'für 50 € im Monat'}.</p>
<h1>${escapeHtml(label)}</h1>
<p>${isWartung ? 'Betrieb, Pflege und Support für Ihre Website' : 'Ihre Website sicher und schnell online'} – ${offer ? 'mit Ihrem persönlichen Angebot' : `für ${escapeHtml(price)} im Monat`}.</p>
</header>
${offer ? `
<div class="abo-offer-box">
@@ -884,10 +910,9 @@ function renderAboOrderView(view, data) {
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
</div>` : ''}
<div class="kb-panel">
${aboFeaturesHtml()}
${aboFeaturesHtml(product.features)}
<p class="abo-note" style="margin:0">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>
</div>
${available ? `
<div class="kb-panel" id="abo-order">
<h3>Ihre Bestellung</h3>
${data.testAccount ? `<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}</div>` : ''}
@@ -899,18 +924,20 @@ function renderAboOrderView(view, data) {
<label class="abo-check"><input type="radio" name="abo-start-choice" value="sofort">
<span><strong>Sofort.</strong> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle.</span></label>
<label class="abo-check"><input type="radio" name="abo-start-choice" value="nach_frist">
<span><strong>Nach Ablauf der 14-tägigen Widerrufsfrist</strong>${data.lateStartAt ? ` (am ${fmtDateShort(data.lateStartAt)})` : ''}. Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.</span></label>
<span><strong>Nach Ablauf der 14-tägigen Widerrufsfrist</strong>${lateStart ? ` (am ${lateStart})` : ''}. Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.</span></label>
<p class="abo-note">Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.</p>
</fieldset>
<div class="abo-summary-box">
<table class="abo-summary">
<tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb &amp; Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr>
<tr><td>Leistung</td><td>${escapeHtml(product.name)}: ${(product.features || []).map(([t]) => escapeHtml(t)).join(', ')}</td></tr>
<tr><td>Preis</td><td>${offer
? `<strong>${escapeHtml(offer.text)}</strong> (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
: '<strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten'}</td></tr>
? `${hasOfferNumbers ? `<strong>${escapeHtml(fmtEuro(offerMonthly / 100))} pro Monat für die ersten ${escapeHtml(String(offerMonths))} Monate</strong>, danach ${escapeHtml(fmtEuro(offerRegular / 100))} pro Monat` : ''} <span>(individuelles Angebot: ${escapeHtml(offer.text)}; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span>, keine weiteren Kosten`
: `<strong>${escapeHtml(price)} pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`}</td></tr>
<tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr>
<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit 12 Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
<tr><td>Fällig</td><td id="abo-due-summary">Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist</td></tr>
<tr><td>Leistungsbeginn</td><td id="abo-start-summary">Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}</td></tr>
<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit ${escapeHtml(String(product.minTermMonths))} Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com</td></tr>
</table>
@@ -921,8 +948,18 @@ function renderAboOrderView(view, data) {
<button type="button" class="kb-btn-primary" id="abo-buy">Zahlungspflichtig bestellen</button>
<span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.</span>
</div>
</div>` : `
<div class="kb-panel"><p class="abo-note" style="margin:0">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</p></div>`}`
</div>`
view.querySelectorAll('input[name="abo-start-choice"]').forEach((radio) => radio.addEventListener('change', () => {
const el = view.querySelector('#abo-start-summary')
const due = view.querySelector('#abo-due-summary')
if (due) due.textContent = radio.value === 'sofort'
? `Heute fällig: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer)`
: `Erste Abbuchung am ${lateStart || 'Ende der 14-tägigen Widerrufsfrist'}: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer); heute wird nichts abgebucht`
if (el) el.textContent = radio.value === 'sofort'
? 'sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist'
: `nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}; bis dahin keine Leistung und keine Abbuchung`
}))
view.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
const btn = ev.currentTarget
@@ -943,7 +980,7 @@ function renderAboOrderView(view, data) {
try {
const { url } = await api('/api/abo/checkout', {
method: 'POST',
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '', lateStartAt: data.lateStartAt || '' }),
body: JSON.stringify({ productKey, acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '', lateStartAt: data.lateStartAt || '' }),
})
window.location.href = url
} catch (err) {
@@ -954,8 +991,171 @@ function renderAboOrderView(view, data) {
})
}
/* Wechsel ins Wartungs-Abo (#wechsel): Vorschau vom Server, Bestellübersicht direkt über dem Knopf (§ 312j BGB), Zustimmung, Bestellung.
Der Wechsel wirkt erst mit der Zahlung der Wechselrechnung (Bedingungen § 9 Abs. 3). */
const switchUi = { notice: '', loadedAt: 0, busy: false }
// Zurück aus dem Bfcache (z. B. von der Zahlungsseite): eine hängende Bestellung nicht gesperrt lassen
window.addEventListener('pageshow', () => {
if (!switchUi.busy) return
switchUi.busy = false
const view = document.getElementById('view-wechsel')
if (view && aboState.data) renderSwitchView(view, aboState.data)
})
function renderSwitchView(view, data) {
const sub = data.subscription
const wartung = productOf(data, 'wartung')
const head = '<header class="kb-head"><h1>Wechsel ins Wartungs-Abo</h1><p>Aus dem Hosting-Abo in das Wartungs-Abo wechseln.</p></header>'
if (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch || !wartung) {
switchUi.loadedAt = 0
view.innerHTML = `${head}<div class="kb-panel"><p class="abo-note" style="margin:0">Ein Wechsel ist derzeit nicht möglich. Details finden Sie unter <a href="#vertraege" data-view="vertraege">Verträge</a>.</p></div>`
if (view.classList.contains('active')) showView('vertraege')
return
}
const units = Number(sub.storage?.units) || 0
const mail = data.mailStorage || {}
const unitCents = Number(mail.unitPriceCents) || 0
const state = { preview: null, error: '', loading: true, token: 0 }
const paint = () => {
const p = state.preview
const rows = []
if (p) {
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
rows.push(`<tr><td>Wartungs-Abo</td><td><strong>${escapeHtml(fmtEuro(monthly / 100))} pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat</td></tr>`)
if (units > 0) {
rows.push(`<tr><td>Zusatzspeicher</td><td>${units} Paket${units === 1 ? '' : 'e'} (+${escapeHtml(String(units * (Number(mail.gbPerUnit) || 0)))} GB) bleiben gebucht: ${escapeHtml(fmtEuro(units * unitCents / 100))} pro Monat zusätzlich (gemäß § 19 UStG ohne Umsatzsteuer), insgesamt ${escapeHtml(fmtEuro((monthly + units * unitCents) / 100))} pro Monat</td></tr>`)
}
if ((Number(p.creditCents) || 0) > 0) {
rows.push(`<tr><td>Gutschrift für nicht genutzte Tage</td><td>−${escapeHtml(fmtEuro(p.creditCents / 100))} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer)</td></tr>`)
}
rows.push(`<tr><td>Heute fällig</td><td><strong>${escapeHtml(fmtEuro(p.amountDueCents / 100))}</strong> (gemäß § 19 UStG ohne Umsatzsteuer)</td></tr>`)
const nd = new Date(p.nextChargeAt)
const nextDay = Number.isNaN(nd.getTime()) ? '' : nd.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: 'numeric' })
rows.push(`<tr><td>Neuer Abbuchungstag</td><td>${nextDay ? `Ab dem Wechsel jeweils am ${escapeHtml(nextDay)}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDateShort(p.nextChargeAt)}</td></tr>`)
rows.push(`<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit bis ${fmtDateShort(p.minTermEnd)}</strong> (12 Monate ab dem Wechsel), danach monatlich kündbar</td></tr>`)
rows.push('<tr><td>Beginn</td><td>Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei Ihrer Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.</td></tr>')
}
const summary = state.error
? `<p class="kb-storage-error">${escapeHtml(state.error)}</p>`
: state.loading || !p ? '<p class="abo-note">Ihre Übersicht wird berechnet …</p>'
: `<table class="abo-summary">${rows.join('')}</table>`
const checked = (id) => view.querySelector(id)?.checked ? ' checked' : ''
const prevErr = view.querySelector('#switch-error')
const errText = prevErr && !prevErr.classList.contains('hidden') ? prevErr.textContent : ''
const busy = switchUi.busy
view.innerHTML = `${head}
<div class="kb-panel" id="switch-order">
<h3>Ihre Bestellung</h3>
<p class="abo-note" style="margin-top:0">Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.</p>
<label class="abo-check"><input type="checkbox" id="switch-terms"${checked('#switch-terms')}${busy ? ' disabled' : ''}>
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>
<label class="abo-check"><input type="checkbox" id="switch-early"${checked('#switch-early')}${busy ? ' disabled' : ''}>
<span><strong>Sofortbeginn.</strong> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB; siehe Hinweise zum Widerruf). Firmenkunden haben kein Widerrufsrecht.</span></label>
<div class="abo-summary-box">
${summary}
<p class="abo-note">Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a> · <a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a></p>
</div>
<p class="error${errText ? '' : ' hidden'}" id="switch-error">${escapeHtml(errText)}</p>
<div class="kb-actions">
<button type="button" class="kb-btn-primary" id="switch-buy"${busy || state.loading || !p ? ' disabled' : ''}>${busy ? 'Wird bestellt …' : 'Zahlungspflichtig bestellen'}</button>
<span class="abo-note">Bezahlt wird mit der bei Stripe hinterlegten Zahlungsart. Bis zum Klick können Sie abbrechen.</span>
</div>
</div>`
view.querySelector('#switch-buy')?.addEventListener('click', submit)
}
const loadPreview = async () => {
const token = ++state.token
state.loading = true
state.error = ''
paint()
try {
const p = await api('/api/abo/switch/preview', { method: 'POST', body: '{}' })
if (token !== state.token) return
state.preview = p
switchUi.loadedAt = Date.now()
} catch (err) {
if (token !== state.token) return
state.preview = null
state.error = err.message
}
state.loading = false
paint()
}
const submit = async () => {
if (switchUi.busy || !state.preview) return
const errEl = view.querySelector('#switch-error')
hideError(errEl)
if (!view.querySelector('#switch-terms').checked) {
showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
return
}
if (!view.querySelector('#switch-early').checked) {
showError(errEl, 'Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen.')
return
}
const p = state.preview
switchUi.busy = true
paint()
try {
const res = await api('/api/abo/switch', {
method: 'POST',
body: JSON.stringify({ acceptTerms: true, earlyStart: true, termsVersion: data.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents }),
})
if (res.paymentUrl) {
view.querySelector('#switch-buy').textContent = 'Weiter zur Zahlung …'
view.querySelector('#switch-error').classList.remove('hidden')
view.querySelector('#switch-error').classList.remove('error')
view.querySelector('#switch-error').textContent = 'Ihr Wechsel wartet auf die Zahlung. Wir leiten Sie zur Zahlung weiter.'
window.location.href = res.paymentUrl
return
}
switchUi.busy = false
const sepa = Boolean(res.subscription?.switchPending)
switchUi.notice = sepa
? 'Ihr Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen Ihnen den Wechsel per E-Mail.'
: 'Ihr Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.'
if (res.subscription && aboState.data) aboState.data.subscription = res.subscription
const card = document.getElementById('abo-card')
const contracts = document.getElementById('kb-contracts')
if (aboState.data) {
if (card) renderSideCard(card, aboState.data)
if (contracts) renderContractsView(contracts, aboState.data)
renderSwitchView(view, aboState.data)
}
showView('vertraege')
contracts?.insertAdjacentHTML('afterbegin', `<div class="abo-alert" role="status">${escapeHtml(switchUi.notice)}</div>`)
switchUi.notice = ''
} catch (err) {
switchUi.busy = false
paint()
showError(view.querySelector('#switch-error'), err.message)
// Betrag oder Stichtag haben sich geändert: neue Vorschau laden, Fehlerhinweis bleibt stehen
if (err.status === 409) {
const msg = err.message
await loadPreview()
showError(view.querySelector('#switch-error'), msg)
}
}
}
view._switchReload = () => { if (!switchUi.busy) loadPreview() }
loadPreview()
}
/* Wer #wechsel öffnet, ohne wechseln zu können, landet bei "Verträge"; eine alte Vorschau (> 5 Minuten) wird neu berechnet */
document.addEventListener('kb:view', (ev) => {
if (ev.detail.name !== 'wechsel') return
const view = document.getElementById('view-wechsel')
const sub = aboState.data?.subscription
if (aboState.data && (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch)) { showView('vertraege'); return }
if (view?._switchReload && Date.now() - switchUi.loadedAt > 5 * 60 * 1000) view._switchReload()
})
/* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */
function renderAboAccountView(view, data) {
function renderHoursView(view, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, usedMinutes: 0, credits: [], usage: [] }
const usageRows = (l.usage || []).map((u) => `
@@ -968,10 +1168,10 @@ function renderAboAccountView(view, data) {
view.innerHTML = `
<header class="kb-head">
<h1>Wartungs-Abo</h1>
<h1>Stundenkonto</h1>
<p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
</header>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#wartung" data-view="wartung">Wartung</a></span>, damit Ihre Website online bleibt.</div>' : ''}
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#vertraege" data-view="vertraege">Verträge</a></span>, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.</div>` : ''}
<div class="kb-stats">
<div class="kb-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
@@ -982,7 +1182,7 @@ function renderAboAccountView(view, data) {
<p class="abo-note" style="margin-top:0">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. Aufträge schicken Sie uns einfach über den <a href="#chat" data-view="chat">Chat</a>.</p>
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
<p class="abo-note">${aboTermText(sub)}</p>
<p class="abo-note" style="margin-bottom:0"><a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a></p>
<p class="abo-note" style="margin-bottom:0"><a href="${escapeAttr(sub.termsUrl || '/abo-bedingungen.html')}" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a></p>
</div>
<div class="kb-panel">
<h3>Verlauf</h3>
@@ -996,10 +1196,297 @@ function renderAboAccountView(view, data) {
const aboState = { data: null }
async function loadAboCard({ afterCheckout = false } = {}) {
/* Seitenleiste bzw. Karte in der App-Ansicht: "Ihr Vertrag" */
function renderSideCard(card, data) {
if (data.subscription?.active) renderAboSideActive(card, data)
else renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
}
/* Zusatzspeicher: Plus/Minus mit Vorschau vom Server, Bestellübersicht (§ 312j BGB) und Speichern */
const storageUi = { notice: '' }
function renderStoragePanel(container, data) {
const sub = data?.subscription
const conf = data?.mailStorage || {}
container.dataset.units = String(sub?.storage?.units ?? 0)
if (!sub?.active) {
container.innerHTML = ''
return
}
const includedGb = Number(conf.includedGb) || 0
const gbPer = Number(conf.gbPerUnit) || 0
const unitCents = Number(conf.unitPriceCents) || 0
const maxUnits = Number(conf.maxUnits) || 10
const gbOf = (u) => includedGb + u * gbPer
const storage = sub.storage || {}
const pendingReduction = storage.pendingReduction || null
const canChange = sub.canChangeStorage === true
const current = Number(storage.units || 0)
const start = pendingReduction && Number.isInteger(pendingReduction.units) ? pendingReduction.units : current
const state = { target: start, preview: null, previewError: '', loading: start !== current, busy: false, error: '', token: 0 }
const lockedReason = sub.switchPending ? 'Wechsel wartet auf die Zahlung'
: aboIsCancelled(sub) ? 'Abo gekündigt'
: sub.paymentProblem ? 'Zahlung offen' : 'bei Fragen schreiben Sie uns gern im Chat'
const notice = storageUi.notice
storageUi.notice = ''
const paint = () => {
// Kosten, Beschriftung und Zustimmung richten sich nach den gebuchten Paketen; start ist nur die Anfangsstellung
const delta = state.target - current
const p = state.preview
let summary = ''
if (canChange && delta !== 0) {
const plus = delta > 0
const totalCents = state.target * unitCents
const facts = []
const proBad = plus && p && !state.loading && !(p.prorationCents != null && p.prorationCents !== '' && Number.isFinite(Number(p.prorationCents)))
{
facts.push(`<div><dt>Neue Gesamtgröße</dt><dd>${gbOf(state.target)} GB</dd></div>`)
facts.push(`<div><dt>Preis</dt><dd>${fmtEuro(totalCents / 100)} pro Monat für ${state.target} Paket${state.target === 1 ? '' : 'e'} (${plus ? 'Mehrpreis' : 'Ersparnis'} ${fmtEuro(Math.abs(delta) * unitCents / 100)} pro Monat; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)</dd></div>`)
if (state.previewError || proBad) {
facts.push(`<div><dt>Vorschau</dt><dd class="kb-storage-error">${escapeHtml(state.previewError || 'Der Betrag konnte nicht berechnet werden. Bitte versuchen Sie es später erneut.')}</dd></div>`)
} else if (!p || state.loading) {
facts.push('<div><dt>Vorschau</dt><dd>wird berechnet …</dd></div>')
} else if (plus) {
const pro = Number(p.prorationCents) || 0
facts.push(`<div><dt>Zusätzlich jetzt</dt><dd>${pro > 0
? `${fmtEuro(pro / 100)} anteilig für den Rest des laufenden Abrechnungsmonats (gemäß § 19 UStG ohne Umsatzsteuer)`
: 'keine zusätzlichen Kosten, die Pakete sind für den laufenden Abrechnungsmonat schon bezahlt'}</dd></div>`)
facts.push(`<div><dt>Nächste Abbuchung</dt><dd>${fmtDateShort(p.nextChargeAt)}, danach monatlich ${fmtEuro(totalCents / 100)}</dd></div>`)
facts.push(pro === 0 && pendingReduction
? '<div><dt>Geplante Reduzierung</dt><dd>wird aufgehoben oder angepasst; es ist nichts neu einzurichten</dd></div>'
: '<div><dt>Einrichtung</dt><dd>in der Regel innerhalb eines Werktags; bis dahin steht „wird eingerichtet“ da</dd></div>')
} else {
facts.push(`<div><dt>Wirkt zum</dt><dd>${fmtDateShort(p.effectiveAt)}, zum Ende des Abrechnungsmonats</dd></div>`)
}
facts.push('<div><dt>Laufzeit</dt><dd>monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo</dd></div>')
}
const hint = plus
? `<label class="abo-check"><input type="checkbox" id="storage-terms"${state.busy ? ' disabled' : ''}>
<span>Ich habe die <a href="/abo-bedingungen.html#zusatzspeicher" target="_blank" rel="noopener">Regeln zum Zusatzspeicher</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>`
: `<p class="abo-alert" style="margin:10px 0">Wichtig: Passen die gespeicherten E-Mails nach der Verkleinerung nicht mehr in den verbleibenden Speicher, nehmen die Postfächer keine neuen E-Mails mehr an. Bitte räumen Sie vorher auf. Mehr dazu in den <a href="/abo-bedingungen.html#zusatzspeicher" target="_blank" rel="noopener">Regeln zum Zusatzspeicher</a> und in der <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a>.</p>`
summary = `
<div class="kb-storage-summary">
<dl class="kb-contract-facts">${facts.join('')}</dl>
${hint}
<p class="error hidden" id="storage-error"></p>
<button type="button" class="kb-btn-primary" id="storage-submit"${state.busy || (state.loading || !p || state.previewError || proBad) ? ' disabled' : ''}>${state.busy ? 'Wird gespeichert …' : plus ? 'Zahlungspflichtig bestellen' : 'Speichern'}</button>
</div>`
}
const status = []
if (storage.pendingSetup) status.push('<p class="abo-alert" style="margin:12px 0 0">Ihr zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.</p>')
if (pendingReduction) status.push(`<p class="abo-note" style="margin:12px 0 0">Geplant: ab ${fmtDayMonth(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.</p>`)
container.innerHTML = `
<h3>E-Mail-Speicher</h3>
<p class="abo-note" style="margin-top:0">${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.</p>
${notice ? `<p class="abo-alert kb-storage-notice" role="status">${escapeHtml(notice)}</p>` : ''}
${!canChange ? `<p class="abo-note kb-storage-locked" id="storage-locked">Änderungen sind derzeit nicht möglich – ${lockedReason}.</p>` : ''}
${canChange ? `
<div class="kb-storage-stepper">
<button type="button" class="kb-side-btn" id="storage-minus" aria-label="Ein Paket weniger"${state.busy || state.target <= 0 ? ' disabled' : ''}>−</button>
<span class="kb-storage-count" aria-live="polite"><strong>${state.target}</strong> Paket${state.target === 1 ? '' : 'e'}</span>
<button type="button" class="kb-side-btn" id="storage-plus" aria-label="Ein Paket mehr"${state.busy || state.target >= maxUnits ? ' disabled' : ''}>+</button>
</div>` : `<p class="kb-storage-count"><strong>${state.target}</strong> Paket${state.target === 1 ? '' : 'e'}</p>`}
<p class="kb-storage-total">= ${gbOf(state.target)} GB gesamt · + ${fmtEuro(state.target * unitCents / 100)} im Monat</p>
${status.join('')}
${summary}`
container.querySelector('#storage-minus')?.addEventListener('click', () => step(-1))
container.querySelector('#storage-plus')?.addEventListener('click', () => step(1))
container.querySelector('#storage-submit')?.addEventListener('click', submit)
}
const loadPreview = async (token) => {
try {
const p = await api('/api/abo/storage/preview', { method: 'POST', body: JSON.stringify({ units: state.target }) })
if (token !== state.token) return
state.preview = p
} catch (err) {
if (token !== state.token) return
state.previewError = err.message
}
state.loading = false
paint()
}
const step = (d) => {
const next = state.target + d
if (state.busy || next < 0 || next > maxUnits) return
state.target = next
state.preview = null
state.previewError = ''
state.loading = next !== current
const token = ++state.token
paint()
if (next !== current) loadPreview(token)
}
const submit = async () => {
const errEl = container.querySelector('#storage-error')
hideError(errEl)
const plus = state.target > current
const terms = container.querySelector('#storage-terms')
if (plus && !terms?.checked) {
showError(errEl, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
return
}
const body = { units: state.target }
if (plus) Object.assign(body, { acceptTerms: true, termsVersion: data.termsVersion })
const btn = container.querySelector('#storage-submit')
state.busy = true
btn.disabled = true
container.querySelectorAll('#storage-minus, #storage-plus, #storage-terms').forEach((el) => { el.disabled = true })
btn.textContent = 'Wird gespeichert …'
try {
const res = await api('/api/abo/storage', { method: 'POST', body: JSON.stringify(body) })
const ch = res.change || {}
storageUi.notice = plus
? (ch.task === false ? 'Gespeichert. Es entstehen keine zusätzlichen Kosten, die Pakete waren schon bezahlt.' : 'Danke, Ihre Bestellung ist eingegangen. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet.')
: `Gespeichert. Die Verkleinerung wirkt zum ${fmtDateShort(ch.effectiveAt)}.`
if (res.subscription && aboState.data) {
aboState.data.subscription = res.subscription
const card = document.getElementById('abo-card')
const contracts = document.getElementById('kb-contracts')
if (card) renderSideCard(card, aboState.data)
if (contracts) renderContractsView(contracts, aboState.data)
return
}
loadContracts()
} catch (err) {
state.busy = false
paint()
const again = container.querySelector('#storage-error')
showError(again, err.status === 409 ? `${err.message} Bitte laden Sie die Seite neu, falls sich der Stand geändert hat.` : err.message)
if (plus) { const c = container.querySelector('#storage-terms'); if (c) c.checked = true }
}
}
paint()
if (start !== current && canChange) {
loadPreview(++state.token)
}
}
/* "Postfach anfragen": öffnet den Chat mit vorbereiteter Nachricht (Eingabefeld wird nicht automatisch gesendet) */
function requestMailbox() {
showView('chat')
const box = document.querySelector('#chat-composer textarea')
if (!box) return
box.value = 'Bitte legen Sie ein neues Postfach an: '
box.dispatchEvent(new Event('input', { bubbles: true }))
box.focus()
box.setSelectionRange(box.value.length, box.value.length)
}
/* Vergleich der beiden Abos (kein laufendes Abo) */
function renderCompareCard(data, key) {
const product = productOf(data, key)
if (!product) return ''
const offer = data.offers?.[key]
return `
<article class="kb-panel kb-compare-card" data-product="${escapeAttr(key)}">
<h3>${escapeHtml(productLabelOf(data, key))}</h3>
${offer ? `<span class="kb-offer-tag">Ihr persönliches Angebot</span><p class="abo-note" style="margin:0 0 8px">${escapeHtml(offer.text)}${offer.validUntil ? ` · gültig bis ${fmtDateShort(offer.validUntil)}` : ''}</p>` : ''}
<p class="kb-price"><strong>${fmtEuro(product.amountCents / 100)}</strong><span>pro Monat</span></p>
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer. ${product.minTermMonths} Monate Mindestlaufzeit, danach monatlich kündbar.</p>
${aboFeaturesHtml(product.features)}
<button type="button" class="kb-btn-primary kb-btn-block" data-view="bestellen-${escapeAttr(key)}"${data.available ? '' : ' disabled'}>Abo abschließen</button>
</article>`
}
/* Wechsel-Karte (nur Hosting) */
function renderSwitchCard(data) {
const sub = data.subscription
if (sub.productKey !== 'hosting') return ''
const wartung = productOf(data, 'wartung')
if (sub.switchPending) {
const url = sub.switchPending.paymentUrl
return `
<div class="kb-panel kb-switch-card" id="switch-card">
<h3>Ihr Wechsel wartet auf die Zahlung</h3>
<p class="abo-note" style="margin-top:0">Ihr Wechsel zum Wartungs-Abo ist erst abgeschlossen, wenn die Zahlung bestätigt ist.${url ? '' : ' Schreiben Sie uns gern im Chat, wenn Sie Hilfe brauchen.'}</p>
${url ? `<a class="kb-btn-primary" href="${escapeAttr(url)}">Jetzt bezahlen</a>` : ''}
</div>`
}
if (aboIsCancelled(sub)) {
return `
<div class="kb-panel kb-switch-card" id="switch-card">
<h3>Wartungs-Abo</h3>
<p class="abo-note" style="margin:0">Ihr Abo ist gekündigt. Bitte schreiben Sie uns im Chat, wenn Sie zum Wartungs-Abo wechseln möchten.</p>
</div>`
}
if (!sub.canSwitch || !wartung) return ''
return `
<div class="kb-panel kb-switch-card" id="switch-card">
<h3>Mehr als Hosting? Das Wartungs-Abo</h3>
<p class="kb-price"><strong>${fmtEuro(wartung.amountCents / 100)}</strong><span>pro Monat</span></p>
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer. Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat.</p>
<button type="button" class="kb-btn-primary" data-view="wechsel">Zum Wartungs-Abo wechseln</button>
</div>`
}
/* Ansicht "Verträge": Mein Vertrag, Wechsel-Karte, Stundenkonto; ohne Abo der Vergleich */
function renderContractsView(view, data) {
const sub = data.subscription
if (!sub?.active) {
view.innerHTML = `
${sub ? '<div class="abo-alert">Ihr bisheriges Abo ist beendet. Sie können jederzeit ein neues abschließen.</div>' : ''}
${data.available ? '' : '<div class="abo-alert">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</div>'}
<div class="kb-compare">${renderCompareCard(data, 'hosting')}${renderCompareCard(data, 'wartung')}</div>
<p class="abo-note">Alle Preise gemäß § 19 UStG ohne Umsatzsteuer. Es gelten die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>.</p>`
return
}
const product = productOf(data, sub.productKey)
const wartung = sub.productKey === 'wartung'
const l = data.ledger || { balanceMinutes: 0 }
const units = Number(sub.storage?.units || 0)
const gb = (Number(data.mailStorage?.includedGb) || 0) + units * (Number(data.mailStorage?.gbPerUnit) || 0)
const cancelled = aboIsCancelled(sub)
const pill = cancelled
? '<span class="kb-status kb-status-off">gekündigt</span>'
: sub.paymentProblem ? '<span class="kb-status kb-status-warn">Zahlung offen</span>' : '<span class="kb-status kb-status-ok">aktiv</span>'
view.innerHTML = `
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht.</div>` : ''}
<div class="kb-panel" id="contract-card">
<div class="kb-contract-head"><h2>Mein Vertrag</h2>${pill}</div>
<h3>${escapeHtml(productLabelOf(data, sub.productKey))}</h3>
<p class="kb-price"><strong>${fmtEuro(sub.priceAmount / 100)}</strong><span>pro Monat</span></p>
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
<dl class="kb-contract-facts">
<div><dt>${cancelled ? 'Leistungen' : 'Abrechnung'}</dt><dd>${aboDateLine(sub)}</dd></div>
<div><dt>Laufzeit</dt><dd>${aboTermText(sub)}</dd></div>
${sub.offerText ? `<div><dt>Angebot</dt><dd>${escapeHtml(sub.offerText)}</dd></div>` : ''}
<div><dt>E-Mail-Speicher</dt><dd>${gb} GB${units ? ` (${units} Zusatzpaket${units === 1 ? '' : 'e'})` : ''}</dd></div>
</dl>
${aboFeaturesHtml(product?.features)}
<p class="abo-note"><a href="${escapeAttr(sub.termsUrl || '/abo-bedingungen.html')}" target="_blank" rel="noopener">Vertragsbedingungen</a>
· <a href="${escapeAttr(sub.termsPdfUrl || '/WEBklar-Abo-Vertragsunterlagen.pdf')}" target="_blank" rel="noopener">als PDF</a></p>
${sub.withdrawalOpen ? `<a class="kb-side-btn kb-withdraw" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
<div class="kb-contract-actions">
${sub.paymentProblem ? '<button type="button" class="kb-btn-primary" id="contract-manage">Zahlungsart aktualisieren</button>' : ''}
<button type="button" class="kb-side-btn" id="contract-mailbox">Postfach anfragen</button>
<a class="kb-side-btn" href="/kuendigen.html">Verträge hier kündigen</a>
</div>
<p class="error hidden" id="contract-error"></p>
</div>
${wartung ? `
<div class="kb-panel" id="hours-card">
<h3>Stundenkonto</h3>
<p class="kb-price"><strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span></p>
<a href="#stunden" data-view="stunden">Stundenkonto</a> mit Verlauf ansehen
</div>` : ''}
${renderSwitchCard(data)}
<div class="kb-panel" id="storage-panel"></div>`
view.querySelector('#contract-manage')?.addEventListener('click', (ev) => openBillingPortal(ev.currentTarget, view.querySelector('#contract-error')))
view.querySelector('#contract-mailbox')?.addEventListener('click', requestMailbox)
renderStoragePanel(view.querySelector('#storage-panel'), data)
}
async function loadContracts({ afterCheckout = false } = {}) {
const card = document.getElementById('abo-card')
const view = document.getElementById('view-abo')
if (!card || !view) return
const view = document.getElementById('view-stunden')
const contracts = document.getElementById('kb-contracts')
if (!card || !view || !contracts) return
try {
let data = await api('/api/abo')
// Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein
@@ -1010,16 +1497,21 @@ async function loadAboCard({ afterCheckout = false } = {}) {
data = await api('/api/abo')
}
aboState.data = data
if (data.subscription?.active) {
renderAboSideActive(card, data)
renderAboAccountView(view, data)
} else {
renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
renderAboOrderView(view, data)
renderSideCard(card, data)
renderContractsView(contracts, data)
for (const key of ['hosting', 'wartung']) {
const orderView = document.getElementById(`view-bestellen-${key}`)
if (orderView) renderOrderView(orderView, data, key)
}
const switchView = document.getElementById('view-wechsel')
if (switchView) renderSwitchView(switchView, data)
if (data.subscription?.active && data.subscription.productKey === 'wartung') renderHoursView(view, data)
else view.innerHTML = '<header class="kb-head"><h1>Stundenkonto</h1></header><div class="kb-panel"><p class="abo-note" style="margin:0">Ein Stundenkonto gibt es im Wartungs-Abo. Ihre Verträge finden Sie unter <a href="#vertraege" data-view="vertraege">Verträge</a>.</p></div>'
card.classList.remove('hidden')
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
document.getElementById('kb-sheet-abo')?.classList.toggle('hidden', !data.subscription)
// Vertragsbedingungen im Konto-Menue: die Fassung, die fuer das Abo des Kunden gilt
document.getElementById('kb-sheet-terms')?.setAttribute('href', data.subscription?.termsUrl || '/abo-bedingungen.html')
loadTakeoverCard({ abo: data })
document.dispatchEvent(new CustomEvent('kb:abo', { detail: data }))
} catch (err) {
@@ -1043,10 +1535,10 @@ const TAKEOVER_STATUS = {
// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen
const TAKEOVER_CONDITIONS = [
'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
'Die Übernahmegutschrift gibt es für Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfächer) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.',
'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.',
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet, auch nach einem Wechsel in ein anderes Abo. Sie wird nicht ausgezahlt und ist nicht übertragbar; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest.',
'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.',
]
@@ -1098,7 +1590,7 @@ function takeoverFormHtml(limits) {
<div class="tk-services">
<label><input type="checkbox" name="services" value="domain"> Domain</label>
<label><input type="checkbox" name="services" value="hosting"> Hosting / Website-Betrieb</label>
<label><input type="checkbox" name="services" value="email"> E-Mail-Postfach</label>
<label><input type="checkbox" name="services" value="email"> E-Mail-Postfächer</label>
<label><input type="checkbox" name="services" value="sonstiges"> Sonstiges</label>
</div>
</fieldset>
@@ -1242,7 +1734,7 @@ async function loadTakeoverCard({ abo, notice = '' } = {}) {
const requests = data.requests || []
updateTakeoverBadge(requests)
if (!active && !requests.length) {
card.innerHTML = '<div class="kb-panel"><p class="abo-note" style="margin:0">Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft. <a href="#abo" data-view="abo">Zum Wartungs-Abo</a></p></div>'
card.innerHTML = '<div class="kb-panel"><p class="abo-note" style="margin:0">Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft. <a href="#vertraege" data-view="vertraege">Zum Abo</a></p></div>'
return
}
@@ -1392,16 +1884,18 @@ function initPasswordView() {
}
/* ---------------------------------------------------------- Navigation --- */
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift).
// "wartung" gibt es nur in der App-Ansicht (Tab), am Desktop zeigt sie das Wartungs-Abo.
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #vertraege, #gutschrift).
// Die alten Hashes #wartung und #abo fuehren zu #vertraege (HASH_ALIASES).
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild).
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
const loadedViews = new Set()
function viewFromHash() {
const hash = window.location.hash.replace(/^#/, '')
if (HASH_ALIASES[hash]) return HASH_ALIASES[hash]
return Object.keys(VIEW_HASHES).find((v) => VIEW_HASHES[v] && VIEW_HASHES[v] === hash) || ''
}
@@ -1502,11 +1996,12 @@ async function initDashboardPage() {
const aboParam = getQueryParam('abo')
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
loadAboCard({ afterCheckout: aboParam === 'success' })
loadContracts({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices()
const initialView = viewFromHash()
if (initialView) showView(initialView, { updateHash: false })
// Alte Hashes (#wartung, #abo) gleich auf den neuen (#vertraege) umschreiben
if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[window.location.hash.replace(/^#/, '')]) })
const [{ projects }, { features }] = await Promise.all([
api('/api/projects'),
@@ -1551,8 +2046,8 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { abo: 'wartung', takeover: 'wartung', projekt: 'projects' }
const APP_PARENT_LABEL = { wartung: 'Wartung', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' }
const APP_PARENT = { stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
@@ -1573,7 +2068,7 @@ function isIosDevice() {
return /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
// Karten der Seitenleiste: App-Ansicht -> Ansprechpartner ins Konto-Sheet, Abo-Karte in den Tab "Wartung"
// Karten der Seitenleiste: App-Ansicht -> Ansprechpartner ins Konto-Sheet, Abo-Karte in den Tab "Verträge"
function placeSideCards() {
for (const { el, slot, anchor } of appShell.sideCards) {
if (isAppLayout()) {
@@ -1613,11 +2108,6 @@ function updateAppBar() {
}
function onAppViewShown(name) {
// Den Tab "Wartung" gibt es nur in der App-Ansicht; am Desktop direkt das Wartungs-Abo zeigen
if (name === 'wartung' && !isAppLayout()) {
showView('abo', { updateHash: !appShell.fromHistory })
return
}
// Projektdetails als Seite nur in der App-Ansicht und nur mit gewaehltem Projekt (nicht per Direktlink)
if (name === 'projekt' && (!isAppLayout() || !projectPage.project)) {
showView('projects', { updateHash: !appShell.fromHistory })
@@ -1642,7 +2132,7 @@ function onAppViewShown(name) {
})
if (isAppLayout()) {
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zum Tab darüber (Wartung bzw. Projekte)
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zum Tab darüber (Verträge bzw. Projekte)
if (nav === 'push' && !appShell.fromHistory) {
window.history.replaceState(null, '', VIEW_HASHES[parent] ? `#${VIEW_HASHES[parent]}` : window.location.pathname)
window.history.pushState({ kbSub: name }, '', `#${VIEW_HASHES[name]}`)
@@ -1670,10 +2160,10 @@ function revealChatComposer() {
if (Number.isFinite(delta) && Math.abs(delta) > 1) window.scrollBy(0, delta)
}
/* Wartungs-Tab: Hinweis bei fehlgeschlagener Zahlung oder offener Rückfrage zur Gutschrift */
function updateWartungBadge() {
/* Tab "Verträge": Hinweis bei fehlgeschlagener Zahlung oder offener Rückfrage zur Gutschrift */
function updateVertraegeBadge() {
const card = document.getElementById('abo-card')
const badge = document.getElementById('kb-tab-wartung-badge')
const badge = document.getElementById('kb-tab-vertraege-badge')
if (!card || !badge) return
const paymentProblem = Boolean(card.querySelector('.kb-side-alert'))
const questions = Number(card.querySelector('#takeover-badge:not(.hidden)')?.textContent || 0)
@@ -1832,17 +2322,16 @@ function initAppShell() {
document.addEventListener('kb:view', (ev) => onAppViewShown(ev.detail.name))
document.addEventListener('kb:abo', (ev) => {
document.getElementById('kb-tab-wartung')?.classList.toggle('hidden', !ev.detail)
const slot = document.getElementById('kb-abo-slot')
slot?.querySelector(':scope > .kb-empty')?.remove()
if (!ev.detail) slot?.insertAdjacentHTML('afterbegin', '<p class="kb-empty">Die Wartung konnte gerade nicht geladen werden. Bitte später noch einmal versuchen.</p>')
updateWartungBadge()
if (!ev.detail) slot?.insertAdjacentHTML('afterbegin', '<p class="kb-empty">Ihre Verträge konnten gerade nicht geladen werden. Bitte später noch einmal versuchen.</p>')
updateVertraegeBadge()
updateAppBar()
})
const aboCard = document.getElementById('abo-card')
if (aboCard) new MutationObserver(updateWartungBadge).observe(aboCard, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
if (aboCard) new MutationObserver(updateVertraegeBadge).observe(aboCard, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
// Zurueck: ueber den Verlauf, falls die Unterseite ihn angelegt hat, sonst direkt zur Wartung
// Zurueck: ueber den Verlauf, falls die Unterseite ihn angelegt hat, sonst direkt zu den Verträgen
document.getElementById('kb-back')?.addEventListener('click', () => {
if (window.history.state?.kbSub) window.history.back()
else showView(APP_PARENT[appShell.view] || 'projects')
@@ -1946,7 +2435,6 @@ function initAppShell() {
onMediaChange(appLayout, () => {
placeSideCards()
if (!isAppLayout() && appShell.view === 'wartung') showView('abo')
if (!isAppLayout() && appShell.view === 'projekt') showView('projects')
updateAppBar()
renderInstallOffers()
@@ -1956,8 +2444,8 @@ function initAppShell() {
renderInstallOffers()
updateAppBar()
// Nach der Bestellung bei Stripe: Bestaetigung steht auf der Abo-Karte im Tab "Wartung"
if (isAppLayout() && getQueryParam('abo') === 'success') showView('wartung')
// Nach der Bestellung bei Stripe: Bestaetigung steht in der Ansicht "Verträge" (Handy und Desktop)
if (getQueryParam('abo') === 'success') showView('vertraege')
}
// iOS zoomt beim Tippen in ein Feld hinein, wenn dessen Schrift effektiv unter 16 px liegt – z. B. bei
+23 -19
View File
@@ -30,10 +30,10 @@
<div class="kb-glow" aria-hidden="true"></div>
<header class="kb-header">
<!-- App-Ansicht: Zurueck aus Unterseiten (Wartung, Projektdetails; Text setzt updateAppBar), Seitentitel beim Scrollen (initAppShell) -->
<button type="button" class="kb-back hidden" id="kb-back" aria-label="Zurück zu Wartung">
<!-- App-Ansicht: Zurueck aus Unterseiten (Verträge, Projektdetails; Text setzt updateAppBar), Seitentitel beim Scrollen (initAppShell) -->
<button type="button" class="kb-back hidden" id="kb-back" aria-label="Zurück zu Verträge">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
<span>Wartung</span>
<span>Verträge</span>
</button>
<div class="kb-bar-title">
<a class="kb-logo" href="/dashboard.html"><svg class="kb-logo-mark" viewBox="0 0 512 512" aria-hidden="true"><rect width="512" height="512" fill="#fff"/><g transform="translate(256 256) scale(0.9) translate(-256 -256)"><ellipse cx="256" cy="256" rx="210" ry="150" fill="#111" transform="rotate(-28 256 256)"/><circle cx="356" cy="172" r="18" fill="#fff"/><path transform="translate(256 256) rotate(-28)" d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z" fill="#fff"/></g></svg>WEBKLAR</a>
@@ -50,7 +50,7 @@
<!-- Konto: auf dem Handy als Bottom-Sheet, am Desktop als Popover unter dem Avatar (initAccountSheet).
Alles zu den Verträgen und "Abmelden" steht im Kundenbereich nur hier. Einzige Ausnahme: während
der Widerrufsfrist zeigt die Wartungskarte "Vertrag widerrufen" (§ 356a BGB: hervorgehoben platziert).
der Widerrufsfrist zeigt die Vertragskarte "Vertrag widerrufen" (§ 356a BGB: hervorgehoben platziert).
Öffentlich, ohne Anmeldung: Login-Seite und Footer von webklar.com. -->
<dialog class="kb-sheet" id="kb-sheet" aria-label="Mein Konto">
<div class="kb-sheet-panel">
@@ -70,9 +70,10 @@
<div class="kb-install hidden" data-install></div>
<p class="kb-label kb-sheet-label">Verträge</p>
<nav class="kb-sheet-links" aria-label="Verträge">
<!-- Nur mit Wartungs-Abo: Stripe-Kundenportal; Name wie in den Bedingungen § 7 ("Abo verwalten") -->
<button type="button" data-view="vertraege">Mein Vertrag</button>
<!-- Nur mit Abo: Stripe-Kundenportal; Name wie in den Bedingungen § 7 ("Abo verwalten") -->
<button type="button" class="kb-sheet-ext hidden" id="kb-sheet-abo">Abo verwalten &amp; Rechnungen</button>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener" id="kb-sheet-terms">Vertragsbedingungen</a>
<a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a>
<a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
</nav>
@@ -90,8 +91,8 @@
</dialog>
<div class="kb-shell dashboard-content-reveal">
<!-- Seitenleiste: Navigation, Ansprechpartner, Wartungs-Abo. In der App-Ansicht (Handy)
wird die Navigation zur Tab-Leiste unten; die Karten wandern in Konto-Sheet bzw. Tab "Wartung". -->
<!-- Seitenleiste: Navigation, Ansprechpartner, Abo-Karte. In der App-Ansicht (Handy)
wird die Navigation zur Tab-Leiste unten; die Karten wandern in Konto-Sheet bzw. Tab "Verträge". -->
<aside class="kb-side">
<p class="kb-label">Kundenbereich</p>
<nav class="kb-nav" aria-label="Kundenbereich">
@@ -107,10 +108,9 @@
<button type="button" class="kb-nav-item" data-view="chat">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5a2.5 2.5 0 0 1-2.5 2.5H8l-4 4V5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5z"/></svg>
<span class="kb-tab-label">Chat</span> <span class="kb-badge hidden" id="chat-badge" title="ungelesene Nachrichten"></span></button>
<!-- Nur App-Ansicht; am Desktop steht die Wartung als Karte in der Seitenleiste -->
<button type="button" class="kb-nav-item kb-tab-wartung hidden" data-view="wartung" id="kb-tab-wartung">
<button type="button" class="kb-nav-item kb-tab-vertraege" data-view="vertraege" id="kb-tab-vertraege">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v5.5c0 4.3-2.9 8.1-7 9.5-4.1-1.4-7-5.2-7-9.5V6z"/><path d="M9 12l2 2 4-4"/></svg>
<span class="kb-tab-label">Wartung</span> <span class="kb-badge hidden" id="kb-tab-wartung-badge"></span></button>
<span class="kb-tab-label">Verträge</span> <span class="kb-badge hidden" id="kb-tab-vertraege-badge"></span></button>
</nav>
<section class="kb-card kb-contact" aria-label="Ansprechpartner">
@@ -120,7 +120,7 @@
<button type="button" data-view="chat">Nachricht schreiben</button>
</section>
<!-- Wartungs-Abo: Angebot bzw. Status (app.js) -->
<!-- Abo: Angebot bzw. Status (app.js) -->
<section class="kb-abo hidden" id="abo-card" aria-live="polite"></section>
</aside>
@@ -184,18 +184,22 @@
</div>
</section>
<!-- App-Ansicht, Tab "Wartung": die Abo-Karte der Seitenleiste als Übersicht;
Stundenkonto bzw. Bestellung und Übernahmegutschrift öffnen sich als Unterseiten -->
<section class="kb-view" id="view-wartung">
<!-- Tab "Verträge": Mein Vertrag, Vergleich, Wechsel, Zusatzspeicher (app.js); in der App-Ansicht steht die Abo-Karte der Seitenleiste darüber;
Stundenkonto, Bestellung, Wechsel und Übernahmegutschrift öffnen sich als Unterseiten -->
<section class="kb-view" id="view-vertraege">
<header class="kb-head">
<h1>Wartung</h1>
<p>Betrieb, Pflege und Support für Ihre Website.</p>
<h1>Verträge</h1>
<p>Ihre Verträge mit WEBklar: Hosting, Wartung und Zusatzspeicher.</p>
</header>
<div id="kb-abo-slot"><p class="kb-empty">Lade…</p></div>
<div id="kb-contracts"></div>
</section>
<!-- Wartungs-Abo: Bestellung bzw. Stundenkonto (app.js) -->
<section class="kb-view" id="view-abo" aria-live="polite"></section>
<!-- Stundenkonto (Wartungs-Abo), Bestellungen und Wechsel (app.js, Aufgaben 15 bis 18) -->
<section class="kb-view" id="view-stunden" aria-live="polite"></section>
<section class="kb-view" id="view-bestellen-hosting" aria-live="polite"></section>
<section class="kb-view" id="view-bestellen-wartung" aria-live="polite"></section>
<section class="kb-view" id="view-wechsel" aria-live="polite"></section>
<!-- Übernahmegutschrift (app.js) -->
<section class="kb-view" id="view-takeover">
+15 -11
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 27.09.2026</p>
<p class="meta">Stand 30.09.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
@@ -54,13 +54,15 @@
<p>Einen Datenschutzbeauftragten haben wir nicht benannt, weil wir dazu gesetzlich nicht verpflichtet sind. Bei Fragen zum Datenschutz wenden Sie sich an die oben genannten Kontaktdaten.</p>
<h2>2. Geltungsbereich</h2>
<p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere das Wartungs-Abo.</p>
<p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p>
<p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p>
<h2>3. Hosting und Server-Protokolle</h2>
<p>Unsere Websites und das Kundenportal laufen auf einem Server, den wir bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, im Rechenzentrum Falkenstein (Deutschland) betreiben. Mit Hetzner haben wir einen Vertrag über die Auftragsverarbeitung geschlossen. Die Daten werden ausschließlich in der EU verarbeitet.</p>
<h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2>
<p>Unsere Websites und das Kundenportal laufen auf einem Server, den wir bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, im Rechenzentrum Falkenstein (Deutschland) betreiben. Mit Hetzner haben wir einen Vertrag über die Auftragsverarbeitung geschlossen. Hetzner verarbeitet die Daten ausschließlich in der EU.</p>
<p>Bei jedem Aufruf speichert der Server kurzzeitig Protokolldaten: IP-Adresse, Datum und Uhrzeit, aufgerufene Adresse, übertragene Datenmenge, Statuscode sowie Browser und Betriebssystem. Wir brauchen sie, um die Seiten auszuliefern, Angriffe abzuwehren und Fehler zu finden. Wiederholte fehlgeschlagene Anmeldeversuche führen zu einer automatischen, vorübergehenden Sperre der IP-Adresse.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt im sicheren und funktionsfähigen Betrieb. Die Protokolle löschen wir nach 14 Tagen, es sei denn, ein konkreter Sicherheitsvorfall muss aufgeklärt werden.</p>
<p>Ob unsere Websites, das Kundenportal und die Websites, die wir im Rahmen unserer Abos betreiben, erreichbar sind, prüfen wir automatisch von außen. Dafür nutzen wir den Dienst der UptimeRobot s. r. o., Obchodná 507/2, 811 06 Bratislava (Slowakei). UptimeRobot ruft die überwachten Websites in regelmäßigen Abständen auf, so wie ein Besucher, und benachrichtigt uns, wenn eine Website nicht erreichbar ist. Dafür erhält UptimeRobot die Adressen (URLs) der überwachten Websites. Daten der Besucher dieser Websites erhält UptimeRobot nicht.</p>
<p>Mit UptimeRobot haben wir einen Vertrag über die Auftragsverarbeitung geschlossen; er ist Teil der Nutzungsbedingungen von UptimeRobot. UptimeRobot verarbeitet diese Daten auch außerhalb der EU, insbesondere in den USA. Grundlage dafür sind die Standardvertragsklauseln der EU-Kommission (Art. 46 Abs. 2 lit. c DSGVO). Sie sind Teil des Auftragsverarbeitungsvertrags von UptimeRobot (uptimerobot.com/dpa); eine Kopie erhalten Sie auch bei uns. Rechtsgrundlage ist für die Websites unserer Abo-Kunden Art. 6 Abs. 1 lit. b DSGVO, weil die Überwachung zu den Leistungen des Abos gehört, im Übrigen Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt auch hier im sicheren und funktionsfähigen Betrieb. Die Adresse einer Website entfernen wir bei UptimeRobot, sobald wir die Website nicht mehr betreiben.</p>
<h2>4. Cookies und Speicher im Browser</h2>
<p>Wir setzen keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p>
@@ -77,23 +79,24 @@
<p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p>
<h2>6. Kundenportal</h2>
<p>Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen und Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so ansehen, wie Sie es sehen. Eine Nachricht in Ihrem Namen senden können sie dabei nicht.</p>
<p>Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen, Ihre Abos und Bestellungen (Abschnitt 7) sowie gegebenenfalls Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so ansehen, wie Sie es sehen. Eine Nachricht in Ihrem Namen senden können sie dabei nicht.</p>
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
<h2>7. Wartungs-Abo und Zahlung über Stripe</h2>
<p>Schließen Sie das Wartungs-Abo ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
<p>Um den Vertragsschluss nachweisen zu können, speichern wir bei der Bestellung Zeitpunkt, Version der Vertragsbedingungen, Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses. Diese Angaben löschen wir drei Jahre nach Vertragsende.</p>
<h2>7. Abos und Zahlung über Stripe</h2>
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
<p>Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
<p id="uebernahmegutschrift">Beantragen Sie im Kundenportal eine Übernahmegutschrift, verarbeiten wir Ihre Angaben zum bisherigen Anbieter (Anbieter, Leistung, Domain, Rechnungsbetrag, bezahlter Zeitraum), Ihre Nachricht, die hochgeladenen Nachweise wie Rechnung oder Kündigungsbestätigung, die E-Mail-Adresse für unsere Antwort sowie Zeitpunkt und IP-Adresse Ihrer Bestätigung. Die Nachweise speichern wir auf unserem Server; unsere Mitarbeiter erhalten per E-Mail nur einen Hinweis auf den Antrag. Zur Prüfung fragen wir die öffentlichen DNS-Einträge der angegebenen Domain ab (Nameserver, Mailserver, IP-Adresse). Eine genehmigte Gutschrift buchen wir bei Stripe als Guthaben auf Ihr Kundenkonto. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, für den Nachweis Ihrer Bestätigung Art. 6 Abs. 1 lit. f DSGVO. Nachweise zu abgelehnten Anträgen löschen wir sechs Monate nach der Entscheidung. Bei genehmigten Anträgen sind Antrag und Nachweise Beleg für die Gutschrift und werden wie in Abschnitt 9 beschrieben aufbewahrt.</p>
<p id="kuendigung-widerruf">Über die Seiten project.webklar.com/kuendigen.html und project.webklar.com/widerrufen.html können Sie Ihren Vertrag ohne Anmeldung kündigen oder widerrufen (§§ 312k, 356a BGB). Dabei verarbeiten wir Ihren Namen, Ihre E-Mail-Adresse, den gewählten Vertrag, beim E-Mail-Zusatzspeicher die Zahl der Pakete, die Sie kündigen, gegebenenfalls Ihre Kundennummer, bei der Kündigung außerdem Art und gewünschten Zeitpunkt der Kündigung und gegebenenfalls Ihre Begründung, sowie den Zeitpunkt, zu dem Ihre Erklärung bei uns eingegangen ist. Sind Sie im Kundenportal angemeldet, füllen wir Name, E-Mail-Adresse und Kundennummer aus Ihrem Konto vor. Wir nutzen die Angaben, um Ihre Erklärung zu bearbeiten: Anhand der E-Mail-Adresse ordnen wir sie Ihrem Abo zu, vermerken den Eingang beim Abo und tragen die Kündigung oder den Widerruf, soweit möglich, bei Stripe ein. Sie erhalten sofort eine Bestätigung des Eingangs mit Inhalt, Datum und Uhrzeit Ihrer Erklärung an die angegebene E-Mail-Adresse; unser Team erhält eine E-Mail mit diesen Angaben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO (§§ 312k, 356a BGB) in Verbindung mit Art. 6 Abs. 1 lit. b DSGVO. Die IP-Adresse Ihres Aufrufs nutzen wir nur, um die Zahl der Anfragen zu begrenzen (höchstens zehn pro Stunde je IP-Adresse). Dafür merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit Ihrer Erklärung speichern wir die IP-Adresse nicht. Im Übrigen gilt für den Aufruf Abschnitt 3. Ihre Erklärung und unsere Bestätigung bewahren wir wie die übrigen Vertragsdaten drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet auf; soweit die E-Mails Geschäftsbriefe sind, gilt die Frist aus Abschnitt 9.</p>
<h2>8. E-Mail</h2>
<p>Vertragsbestätigungen, Benachrichtigungen und unsere sonstige Korrespondenz versenden und empfangen wir über den E-Mail-Dienst der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen. Die Postfächer liegen in Rechenzentren in Deutschland. Mit Hetzner haben wir einen Vertrag über die Auftragsverarbeitung geschlossen.</p>
<p>Die Übertragung ist verschlüsselt. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, bei sonstiger Korrespondenz lit. f DSGVO. Auch die E-Mail-Postfächer, die wir Kunden im Rahmen des Wartungs-Abos bereitstellen, liegen bei der Hetzner Online GmbH in Deutschland.</p>
<p>Die Übertragung ist verschlüsselt. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, bei sonstiger Korrespondenz lit. f DSGVO. Auch die E-Mail-Postfächer, die wir Kunden im Rahmen unserer Abos bereitstellen, liegen bei der Hetzner Online GmbH in Deutschland (Rechenzentrum Nürnberg). Für die Inhalte dieser Postfächer ist der jeweilige Kunde verantwortlich; wir verarbeiten sie als Auftragsverarbeiter nach dem Auftragsverarbeitungsvertrag in den Abo-Bedingungen (Anlage 1).</p>
<p>Bis September 2026 nutzten wir den E-Mail-Dienst der Porkbun LLC, 11575 SW Pacific Hwy PMB 40649, Tigard, OR 97223, USA. Für Porkbun besteht kein Angemessenheitsbeschluss der EU-Kommission. Die Übermittlung erfolgte, soweit sie zur Durchführung eines Vertrags mit Ihnen oder zur Beantwortung Ihrer Anfrage erforderlich war (Art. 49 Abs. 1 lit. b DSGVO). Ältere E-Mails, die dort noch gespeichert sind, übertragen wir zu Hetzner und löschen sie anschließend bei Porkbun.</p>
<h2>9. Buchhaltung und Rechnungen</h2>
<p>Rechnungen erstellen wir mit einer Buchhaltungssoftware, die wir selbst auf unserem Server betreiben, bzw. für das Wartungs-Abo über Stripe. Soweit erforderlich, geben wir Buchhaltungsdaten an unsere Steuerberatung und an Finanzbehörden weiter. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO.</p>
<p>Rechnungen erstellen wir mit einer Buchhaltungssoftware, die wir selbst auf unserem Server betreiben, bzw. für die Abos über Stripe. Soweit erforderlich, geben wir Buchhaltungsdaten an unsere Steuerberatung und an Finanzbehörden weiter. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO.</p>
<p>Rechnungen und Buchungsbelege bewahren wir acht Jahre auf (§ 147 AO, § 14b UStG), Handels- und Geschäftsbriefe sechs Jahre. Die Fristen beginnen mit dem Ende des jeweiligen Kalenderjahres.</p>
<h2>10. Domains unserer Kunden</h2>
@@ -105,6 +108,7 @@
<h2>12. Empfänger im Überblick</h2>
<ul>
<li>Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)</li>
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
<li>Stripe: Zahlungsabwicklung (Abschnitt 7)</li>
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
+15 -6
View File
@@ -14,6 +14,18 @@
<h1>Verträge hier kündigen</h1>
<form id="decl-form" class="card" novalidate>
<!-- § 312k Abs. 3 BGB: eindeutige Bezeichnung des Vertrags - das Abo (mit Zusatzspeicher) oder nur der Zusatzspeicher -->
<fieldset>
<legend data-label-for="contract" data-label="Vertrag">Vertrag</legend>
<label class="radio"><input type="radio" name="contract" value="abo" data-text="Mein Abo (Hosting- oder Wartungs-Abo, mit Zusatzspeicher)" checked> Mein Abo (Hosting- oder Wartungs-Abo, mit Zusatzspeicher)</label>
<label class="radio"><input type="radio" name="contract" value="zusatzspeicher" data-text="Nur den E-Mail-Zusatzspeicher"> Nur den E-Mail-Zusatzspeicher</label>
</fieldset>
<div id="packages-wrap" class="hidden">
<label for="packages" data-label-for="packages" data-label="Pakete">Zahl der Pakete, die Sie kündigen <small>(je 5 GB; leer = alle gebuchten Pakete)</small></label>
<input id="packages" name="packages" type="number" inputmode="numeric" min="1" max="10" step="1" placeholder="alle">
<p class="muted">Die Kündigung des Zusatzspeichers wirkt zum Ende des laufenden Abrechnungsmonats. Das geht auch, wenn Ihr Abo schon gekündigt ist.</p>
</div>
<fieldset>
<legend data-label-for="kind" data-label="Art der Kündigung">Art der Kündigung</legend>
<label class="radio"><input type="radio" name="kind" value="ordentlich" data-text="Ordentliche Kündigung" checked> Ordentliche Kündigung</label>
@@ -34,10 +46,7 @@
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional)</small></label>
<input id="customerNumber" name="customerNumber" maxlength="50">
<label>Vertrag</label>
<input value="WEBklar Wartungs-Abo" disabled>
<fieldset>
<fieldset id="when-wrap">
<legend>Zeitpunkt</legend>
<label class="radio"><input type="radio" name="when" value="naechstmoeglich" checked> zum nächstmöglichen Zeitpunkt</label>
<label class="radio"><input type="radio" name="when" value="datum"> zu einem bestimmten Datum</label>
@@ -52,13 +61,13 @@
<h2 style="margin-top:0">Ihre Kündigung ist eingegangen</h2>
<p>Eingang: <strong id="done-at"></strong></p>
<dl id="done-list"></dl>
<p>Die Bestätigung mit dem Zeitpunkt, zu dem Ihr Vertrag endet, haben wir an Ihre E-Mail-Adresse geschickt.</p>
<p>Eine Eingangsbestätigung mit diesen Angaben haben wir an Ihre E-Mail-Adresse geschickt. Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail.</p>
<button type="button" class="print-btn" id="print-btn">Diese Bestätigung drucken oder als PDF speichern</button>
</div>
</main>
<script src="/legal-form.js"></script>
<script>
initLegalForm({ endpoint: '/api/abo/public/cancel', fields: ['kind', 'reason', 'name', 'email', 'customerNumber', 'effective'] })
initLegalForm({ endpoint: '/api/abo/public/cancel', fields: ['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'] })
</script>
</body>
</html>
+51 -16
View File
@@ -533,7 +533,7 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
}
/* Elemente, die es nur in der App-Ansicht gibt */
.kb-back, .kb-logo-mark, .kb-app-title, .kb-tab-ico, .kb-nav-item.kb-tab-wartung, .kb-app-only, .kb-install { display: none; }
.kb-back, .kb-logo-mark, .kb-app-title, .kb-tab-ico, .kb-app-only, .kb-install { display: none; }
@keyframes kb-pop-in { from { opacity: 0; transform: translateY(-6px) scale(0.98); } }
@keyframes kb-sheet-up { from { transform: translateY(100%); } }
@@ -552,7 +552,7 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
/* ----------------------------------------------------- App-Ansicht (Handy) --- */
/* Unter 880px (und Handys quer): Kopfzeile als App-Leiste (Titel erscheint beim Scrollen),
Navigation als Tab-Leiste unten, Karten der Seitenleiste im Konto-Sheet bzw. Tab "Wartung".
Navigation als Tab-Leiste unten, Karten der Seitenleiste im Konto-Sheet bzw. Tab "Verträge".
Dieselbe Bedingung steht in app.js (appLayout). */
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kb-page {
@@ -625,7 +625,6 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
padding: 4px 0 2px; border: none; border-radius: 12px; background: none; color: rgba(255, 255, 255, 0.55);
font-size: 10.5px; font-weight: 600; letter-spacing: 0; text-align: center; touch-action: manipulation;
}
.kb-nav-item.kb-tab-wartung { display: flex; }
.kb-nav-item::before {
position: absolute; top: 3px; left: 50%; width: 56px; height: 30px; margin-left: -28px; border-radius: 999px;
background: transparent; box-shadow: none; transition: background-color 0.2s;
@@ -726,18 +725,18 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
/* Eingabe des Chat-Composers als Pille, passend zu dessen runden Knoepfen (Senden-Knopf: chat-composer.css) */
.chat-input-row textarea { box-sizing: border-box; min-height: 46px; padding: 11px 16px; border-radius: 23px; font-size: 16px; line-height: 1.35; }
/* Tab "Wartung": Abo-Karte gross; darunter Unterseiten Stundenkonto/Bestellung und Gutschrift */
/* Tab "Verträge": Abo-Karte gross; darunter Unterseiten Stundenkonto/Bestellung und Gutschrift */
#kb-abo-slot:has(> .kb-abo:not(.hidden)) > .kb-empty { display: none; }
#view-wartung .kb-abo { padding: 20px 18px 18px; border-radius: 20px; }
#view-wartung .kb-abo h3 { font-size: 22px; }
#view-wartung .kb-price strong, #view-wartung .kb-hours strong { font-size: 32px; }
#view-wartung .kb-price span, #view-wartung .kb-hours span, #view-wartung .kb-sub, #view-wartung .kb-offer-until { font-size: 14px; }
#view-wartung .kb-checks { gap: 9px; font-size: 14.5px; }
#view-wartung .kb-fine { font-size: 12.5px; }
#view-wartung .kb-side-alert { font-size: 13.5px; }
#view-wartung .kb-side-btn { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 14px 16px; border-radius: 14px; font-size: 15px; }
#view-wartung .kb-side-btn .kb-badge { float: none; flex: none; margin: 0 0 0 auto; }
#view-wartung .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
#view-vertraege .kb-abo { padding: 20px 18px 18px; border-radius: 20px; }
#view-vertraege .kb-abo h3 { font-size: 22px; }
#view-vertraege .kb-price strong, #view-vertraege .kb-hours strong { font-size: 32px; }
#view-vertraege .kb-price span, #view-vertraege .kb-hours span, #view-vertraege .kb-sub, #view-vertraege .kb-offer-until { font-size: 14px; }
#view-vertraege .kb-checks { gap: 9px; font-size: 14.5px; }
#view-vertraege .kb-fine { font-size: 12.5px; }
#view-vertraege .kb-side-alert { font-size: 13.5px; }
#view-vertraege .kb-side-btn { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 14px 16px; border-radius: 14px; font-size: 15px; }
#view-vertraege .kb-side-btn .kb-badge { float: none; flex: none; margin: 0 0 0 auto; }
#view-vertraege .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
.kb-stats { gap: 10px; }
.kb-stat strong { font-size: 24px; }
.abo-summary, .abo-summary tbody, .abo-summary tr, .abo-summary td { display: block; width: auto !important; }
@@ -746,8 +745,8 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.abo-summary td:first-child {
margin-bottom: 3px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--kb-faint);
}
#abo-order .kb-actions { flex-direction: column; align-items: stretch; }
#abo-order .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
#abo-order .kb-actions, #switch-order .kb-actions { flex-direction: column; align-items: stretch; }
#abo-order .kb-btn-primary, #switch-order .kb-btn-primary { padding: 15px 22px; font-size: 15px; }
.abo-actions .abo-btn { flex: 1 1 auto; }
.kb-legal { justify-content: center; gap: 10px 16px; margin-top: 32px; text-align: center; }
@@ -811,3 +810,39 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.kb-shell.dashboard-content-reveal, .kb-view.active, .kb-sheet[open] .kb-sheet-panel, #loading:not(.hidden), .pd-status::before { animation: none !important; }
.kb-header, .kb-nav, .kb-logo, .kb-app-title, .kb-sheet-panel, .kb-details-chev { transition: none !important; }
}
/* ------------------------------------------------ Ansicht "Verträge" (Aufgabe 15) --- */
#kb-contracts { display: grid; gap: 16px; margin-top: 16px; }
#kb-contracts > .kb-panel + .kb-panel, #kb-contracts .kb-compare .kb-panel + .kb-panel { margin-top: 0; }
#kb-abo-slot:has(> .kb-abo:not(.hidden)) + #kb-contracts { margin-top: 16px; }
.kb-contract-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.kb-contract-head h2 { margin: 0; font-size: 20px; }
#contract-card h3 { margin: 4px 0 6px; }
.kb-status-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.kb-status-warn { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.kb-contract-facts { margin: 14px 0; display: grid; gap: 10px; }
.kb-contract-facts div { display: grid; grid-template-columns: 9.5em minmax(0, 1fr); gap: 12px; font-size: 14px; }
.kb-contract-facts dt { color: var(--kb-muted); }
.kb-contract-facts dd { margin: 0; }
.kb-contract-actions { display: grid; gap: 8px; margin-top: 12px; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.kb-contract-actions .kb-side-btn { margin-top: 0; text-align: center; }
.kb-withdraw { margin-top: 12px; border-color: rgba(251, 191, 36, 0.65); background: rgba(251, 191, 36, 0.12); }
.kb-compare { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kb-compare .abo-features { grid-template-columns: 1fr; }
.kb-switch-card .kb-btn-primary { display: inline-block; text-decoration: none; }
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kb-compare { grid-template-columns: 1fr; }
.kb-contract-facts div { grid-template-columns: 1fr; gap: 2px; }
#view-vertraege .kb-abo.kb-abo-offer { display: none; }
#kb-contracts { margin-top: 16px; }
}
#storage-panel:empty { display: none; }
.kb-storage-stepper { display: flex; align-items: center; gap: 14px; margin: 14px 0 6px; }
.kb-storage-stepper .kb-side-btn { width: 44px; height: 44px; padding: 0; font-size: 22px; line-height: 1; justify-content: center; }
.kb-storage-stepper .kb-side-btn:disabled { opacity: 0.4; cursor: default; }
.kb-storage-count { font-size: 15px; min-width: 6em; text-align: center; }
.kb-storage-stepper + .kb-storage-total, .kb-storage-total { margin: 0; font-size: 14px; color: var(--kb-muted); }
.kb-storage-summary { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--kb-border, rgba(127, 127, 127, 0.25)); }
.kb-storage-summary .kb-btn-primary { width: 100%; }
.kb-storage-error { color: #b3261e; }
@media (min-width: 881px) { .kb-storage-summary .kb-btn-primary { width: auto; } }
+31 -10
View File
@@ -1,6 +1,8 @@
// Oeffentliche Erklaerungsseiten: Kuendigung (§ 312k BGB) und Widerruf (§ 356a BGB).
// Ohne Login erreichbar, Formular sofort sichtbar (keine Zwischenschritte).
// Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar.
// Der Kunde waehlt den Vertrag (Kuendigung: Abo oder nur der Zusatzspeicher, ganz oder mit Paketzahl) bzw. die
// Bestellung (Widerruf: Abschluss, Wechsel, Zusatzspeicher); die Auswahl geht in den Request und steht in der
// Bestaetigung. Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar.
function initLegalForm({ endpoint, fields }) {
const form = document.getElementById('decl-form')
const done = document.getElementById('done')
@@ -21,23 +23,34 @@ function initLegalForm({ endpoint, fields }) {
const reasonWrap = document.getElementById('reason-wrap')
form.querySelectorAll('[name="kind"]').forEach((r) =>
r.addEventListener('change', () => reasonWrap?.classList.toggle('hidden', form.kind.value !== 'ausserordentlich'))
r.addEventListener('change', () => reasonWrap?.classList.toggle('hidden', form.elements.kind.value !== 'ausserordentlich'))
)
const dateInput = document.getElementById('effective-date')
form.querySelectorAll('[name="when"]').forEach((r) =>
r.addEventListener('change', () => {
const onDate = form.when.value === 'datum'
const onDate = form.elements.when.value === 'datum'
dateInput?.classList.toggle('hidden', !onDate)
if (onDate) dateInput?.focus()
})
)
// Nur der Zusatzspeicher: Paketzahl abfragen; der Zeitpunkt entfaellt (Ende des Abrechnungsmonats)
const storageOnly = () => form.elements.contract?.value === 'zusatzspeicher'
const packagesWrap = document.getElementById('packages-wrap')
const whenWrap = document.getElementById('when-wrap')
form.querySelectorAll('[name="contract"]').forEach((r) =>
r.addEventListener('change', () => {
packagesWrap?.classList.toggle('hidden', !storageOnly())
whenWrap?.classList.toggle('hidden', storageOnly())
})
)
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
errEl.classList.add('hidden')
const payload = {}
for (const f of fields) payload[f] = String(form.elements[f]?.value ?? '').trim()
if (form.when?.value === 'datum') payload.effective = dateInput?.value || ''
if (form.elements.when?.value === 'datum' && !storageOnly()) payload.effective = dateInput?.value || ''
if (form.elements.contract && !storageOnly()) delete payload.packages
const btn = form.querySelector('button[type="submit"]')
btn.disabled = true
try {
@@ -50,17 +63,25 @@ function initLegalForm({ endpoint, fields }) {
if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`)
const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'medium' })
document.getElementById('done-at').textContent = at
const rows = [['Vertrag', 'WEBklar Wartungs-Abo']]
// Zeile "Vertrag": die gewaehlte Beschriftung (data-text des Radioknopfs) statt eines festen Namens
const rows = []
for (const f of fields) {
if (!payload[f] || f === 'effective') continue
if (f === 'effective') continue
if (f === 'packages') {
if (storageOnly()) rows.push(['Pakete', payload.packages ? `${payload.packages} Paket(e) zu je 5 GB` : 'alle gebuchten Pakete'])
continue
}
if (!payload[f]) continue
const label = form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f
const value = form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
rows.push([label, value])
}
if (form.when) {
rows.push(['Zeitpunkt', payload.effective
? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)`
: 'zum nächstmöglichen Zeitpunkt'])
if (form.elements.when) {
rows.push(['Zeitpunkt', storageOnly()
? 'zum Ende des laufenden Abrechnungsmonats'
: payload.effective
? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)`
: 'zum nächstmöglichen Zeitpunkt'])
}
document.getElementById('done-list').innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')
form.classList.add('hidden')
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vertragsbedingungen – WEBklar Wartungs-Abo</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 40px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
h3 { font-size: 16.5px; margin: 26px 0 6px; }
p, li { margin: 6px 0; }
ol, ul { padding-left: 24px; }
ol.abs > li { padding-left: 4px; }
ol.abs > li::marker { content: "(" counter(list-item) ") "; font-weight: 600; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
.box > strong:first-child { display: block; margin-bottom: 6px; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
.toc { columns: 2; column-gap: 28px; font-size: 14.5px; }
.toc a { text-decoration: none; }
.toc li { break-inside: avoid; }
.anlage-kopf { font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; color: #6d1fd1; margin-top: 44px; }
.anlage-kopf + h2 { margin-top: 4px; border-top: none; padding-top: 0; }
.hinweis { font-size: 14px; color: #555; }
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
@media (max-width: 640px) { .toc { columns: 1; } h1 { font-size: 25px; } }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar Wartungs-Abo · Vertragsunterlagen"; font: 8pt sans-serif; color: #888; }
@top-right { content: "Version 3 · Stand 25.09.2026"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top, .screen-only { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
h3 { font-size: 11pt; break-after: avoid; }
.box { border-radius: 4pt; padding: 8pt 10pt; }
.neue-seite { break-before: page; }
a { color: inherit; text-decoration: none; }
.box, tr, ol.abs > li { break-inside: avoid; }
li, p { orphans: 3; widows: 3; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/kuendigen.html">Verträge hier kündigen</a> · <a href="/widerrufen.html">Vertrag widerrufen</a> · <a href="/datenschutz.html">Datenschutz</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Wartungs-Abo</h1>
<p class="meta">Vertragsbedingungen mit Leistungsbeschreibung, Widerrufsbelehrung und Auftragsverarbeitungsvertrag · Version 3 · Stand 25.09.2026</p>
<p class="screen-only meta"><a href="/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf">Als PDF herunterladen und ausdrucken</a></p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
<table>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein (jeder einzeln vertretungsberechtigt) · Telefon 0176 23726355 · E-Mail support@webklar.com</td></tr>
<tr><td>Preis</td><td>50,00 € pro Monat, monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</td></tr>
<tr><td>Enthalten</td><td>Betrieb und Pflege Ihrer Website bzw. Web-Anwendung · 1 Domain · 1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat</td></tr>
<tr><td>Stundenkontingent</td><td>1 Stunde je bezahltem Monat; nicht genutzte Zeit bleibt 12 Monate verfügbar. Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde.</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Laufzeit</td><td>Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</td></tr>
<tr><td>Kündigen</td><td>project.webklar.com/kuendigen.html (ohne Anmeldung) oder per E-Mail</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen: project.webklar.com/widerrufen.html (siehe Widerrufsbelehrung)</td></tr>
</table>
</div>
<div class="box">
<strong>Inhalt</strong>
<ol class="toc">
<li><a href="#p1">Anbieter und Geltungsbereich</a></li>
<li><a href="#p2">Vertragsschluss und Vertragstext</a></li>
<li><a href="#p3">Leistungen</a></li>
<li><a href="#p4">Stundenkontingent</a></li>
<li><a href="#p5">Verfügbarkeit und Wartung</a></li>
<li><a href="#p6">Pflichten des Kunden</a></li>
<li><a href="#p7">Preis und Zahlung</a></li>
<li><a href="#p8">Laufzeit und Kündigung</a></li>
<li><a href="#p9">Vertragsende</a></li>
<li><a href="#p10">Mängel</a></li>
<li><a href="#p11">Haftung</a></li>
<li><a href="#p12">Datenschutz</a></li>
<li><a href="#p13">Änderungen dieser Bedingungen</a></li>
<li><a href="#p14">Schlussbestimmungen</a></li>
</ol>
<p class="meta" style="margin-bottom:0">Außerdem: <a href="#widerruf">Widerrufsbelehrung und Muster-Widerrufsformular</a> · <a href="#avv">Anlage 1: Auftragsverarbeitungsvertrag</a> · <a href="#tom">Anlage 2: Technische und organisatorische Maßnahmen</a> · <a href="#sub">Anlage 3: Unterauftragsverarbeiter</a></p>
</div>
<h2 id="p1">§ 1 Anbieter und Geltungsbereich</h2>
<ol class="abs">
<li>Diese Bedingungen regeln das Wartungs-Abo zwischen der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler („WEBklar“ oder „wir“), und dem Kunden.</li>
<li>Sie gelten für Verbraucher (§ 13 BGB) und Unternehmer (§ 14 BGB). Gilt eine Regel nur für eine der beiden Gruppen, ist das ausdrücklich angegeben.</li>
<li>Für das Wartungs-Abo gehen diese Bedingungen den allgemeinen Geschäftsbedingungen von WEBklar (webklar.com/agb) vor. Die dortige Regelung zum jährlichen Hosting (Leistungsmodul C) gilt für das Wartungs-Abo nicht. Die Erstellung einer Website ist nicht Teil des Abos und wird gesondert vereinbart.</li>
<li>Abweichende Geschäftsbedingungen des Kunden werden nicht Vertragsbestandteil.</li>
</ol>
<h2 id="p2">§ 2 Vertragsschluss und Vertragstext</h2>
<ol class="abs">
<li>Das Wartungs-Abo wird im Kundenportal unter project.webklar.com bestellt. Den Zugang zum Kundenportal richten wir für den Kunden ein.</li>
<li>Die Bestellung läuft so ab:
<ol type="a">
<li>Der Kunde wählt auf der Startseite des Kundenportals „Abo abschließen“.</li>
<li>Eine Bestellübersicht zeigt Leistung, Preis, Laufzeit und Zahlungsweise.</li>
<li>Der Kunde bestätigt, dass er diese Bedingungen einschließlich des Auftragsverarbeitungsvertrags (Anlage 1) akzeptiert. Außerdem wählt er, ob wir sofort oder erst nach Ablauf der 14-tägigen Widerrufsfrist mit der Leistung beginnen. Mit der Wahl des sofortigen Beginns verlangt er ausdrücklich, dass wir vor Ablauf der Widerrufsfrist beginnen.</li>
<li>Mit dem Klick auf „Zahlungspflichtig bestellen“ gibt er ein verbindliches Angebot ab.</li>
<li>Anschließend hinterlegt er beim Zahlungsdienstleister Stripe eine Zahlungsart (Kreditkarte oder SEPA-Lastschrift) und schließt den Vorgang dort ab.</li>
</ol></li>
<li>Der Vertrag kommt zustande, sobald der Kunde den Vorgang bei Stripe abgeschlossen hat. Bei sofortigem Beginn setzt das die erfolgreiche Zahlung der ersten Monatsgebühr voraus; scheitert sie, kommt kein Vertrag zustande. Wir bestätigen den Vertragsschluss unverzüglich per E-Mail und senden diese Bedingungen mit Widerrufsbelehrung als PDF mit.</li>
<li>Bis zum Abschluss des Vorgangs bei Stripe kann der Kunde jederzeit abbrechen und seine Angaben im Kundenportal korrigieren.</li>
<li>Vertragssprache ist Deutsch. Wir speichern den Vertragstext sowie Zeitpunkt und Version der Zustimmung des Kunden. Die aktuelle Fassung dieser Bedingungen ist unter project.webklar.com/abo-bedingungen.html abrufbar, die bei Vertragsschluss gültige Fassung erhält der Kunde mit der Bestätigungs-E-Mail.</li>
</ol>
<h2 id="p3">§ 3 Leistungen</h2>
<h3>(1) Betrieb und Pflege der Website</h3>
<ul>
<li>Wir stellen die Website bzw. Web-Anwendung, die wir für den Kunden erstellt oder zur Betreuung übernommen haben, auf einem von uns betriebenen Server bereit. Der Server steht im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland).</li>
<li>Speicherplatz und Datenverkehr stehen im Umfang einer üblichen Firmen-Website zur Verfügung. Ungewöhnlich hohe Last, etwa durch große Downloads oder Streaming, ist nicht umfasst.</li>
<li>Wir spielen Sicherheitsupdates für Betriebssystem, Server-Software und die von uns eingesetzten Software-Komponenten der Website ein. Kritische Sicherheitsupdates in der Regel innerhalb von 3 Werktagen nach ihrer Veröffentlichung, übrige Updates regelmäßig. Aktualisierungen, die für den vertragsgemäßen Betrieb nötig sind, stellen wir während der gesamten Vertragslaufzeit bereit.</li>
<li>Wir richten ein SSL/TLS-Zertifikat (HTTPS) ein und verlängern es.</li>
<li>Wir überwachen die Erreichbarkeit automatisch und beheben Störungen in unserem Verantwortungsbereich (§ 5).</li>
</ul>
<h3>(2) Domain</h3>
<ul>
<li>Enthalten ist eine Domain mit einer der Endungen .de, .com, .eu, .net oder .org: Registrierung oder Übernahme einer bestehenden Domain, Verlängerung und DNS-Verwaltung. Premium-Domains und andere Endungen nur nach gesonderter Vereinbarung.</li>
<li>Inhaber der Domain wird der Kunde, wir verwalten sie technisch. Den Umzugscode (AuthCode) erhält der Kunde jederzeit auf Anfrage.</li>
<li>Für die Wahl der Domain und dafür, dass sie keine Rechte Dritter verletzt, ist der Kunde verantwortlich.</li>
</ul>
<h3>(3) E-Mail-Postfach</h3>
<ul>
<li>Enthalten ist ein E-Mail-Postfach unter der Domain des Kunden mit verschlüsseltem Zugang per IMAP und SMTP sowie Spam- und Virenfilter. Wir stellen es über die Hetzner Online GmbH (Rechenzentrum Nürnberg, Deutschland) bereit. Der Speicherplatz beträgt mindestens 5 GB.</li>
<li>Die Inhalte des Postfachs sichert der Anbieter nach seinen Bedingungen, nicht wir selbst. Wichtige E-Mails sollte der Kunde zusätzlich bei sich aufbewahren.</li>
<li>Die Zustellung von E-Mails hängt auch von Dritten ab, etwa von Empfangsservern und Spamfiltern, und kann nicht garantiert werden.</li>
</ul>
<h3>(4) Datensicherung</h3>
<ul>
<li>Der Server wird täglich automatisch vollständig gesichert (Backup-Funktion der Hetzner Online GmbH). Die Sicherungen liegen getrennt vom Server in einem anderen Brandabschnitt am selben Rechenzentrumsstandort in Deutschland. Verfügbar sind jeweils die letzten sieben Tagessicherungen.</li>
<li>Datenbanken exportieren wir zusätzlich täglich vor der Serversicherung, damit sie sich in einem einheitlichen Stand wiederherstellen lassen.</li>
<li>Eine Wiederherstellung nach technischen Störungen oder nach Fehlern, die wir zu vertreten haben, ist inklusive. Wünscht der Kunde eine Wiederherstellung aus anderen Gründen, etwa nach eigenen Änderungen, wird sie über das Stundenkontingent abgerechnet.</li>
<li>Die Sicherung dient der Wiederherstellung des Betriebs. Ein Archiv oder die Herausgabe von Sicherungskopien ist nicht geschuldet.</li>
</ul>
<h3>(5) Stundenkontingent</h3>
<p>Je bezahltem Monat 1 Stunde Arbeitszeit nach § 4.</p>
<h3>(6) Servicezeiten und Reaktion</h3>
<ul>
<li>Servicezeiten sind Montag bis Freitag von 9 bis 17 Uhr, ausgenommen gesetzliche Feiertage in Rheinland-Pfalz. Anfragen außerhalb der Servicezeiten gelten zu Beginn der nächsten Servicezeit als eingegangen.</li>
<li>Auf Anfragen und Störungsmeldungen antworten wir spätestens bis zum Ende des nächsten Werktags. Gemeint ist eine erste fachliche Rückmeldung, nicht zwingend schon die Behebung.</li>
<li>Fällt die Website oder das Postfach vollständig aus, beginnen wir innerhalb der Servicezeiten unverzüglich mit der Behebung. Unser Ziel ist eine Behebung innerhalb von 4 Stunden. Eine Garantie ist damit nicht verbunden.</li>
<li>Änderungswünsche setzen wir in der Regel innerhalb von 5 Werktagen um.</li>
<li>Betriebsferien kündigen wir mindestens zwei Wochen vorher per E-Mail an.</li>
</ul>
<h3>(7) Nicht enthalten</h3>
<ul>
<li>Neue Funktionen, Redesign oder neue Seiten, soweit sie nicht über das Stundenkontingent beauftragt werden.</li>
<li>Texte, Bilder und Rechtstexte (Impressum, Datenschutzerklärung) für die Website des Kunden, Suchmaschinenoptimierung und Werbung.</li>
<li>Unterstützung für Endgeräte, Betriebssysteme oder E-Mail-Programme des Kunden.</li>
<li>Behebung von Fehlern, die der Kunde oder Dritte durch eigene Änderungen verursacht haben. Das ist über das Stundenkontingent möglich.</li>
</ul>
<h3>(8) Technische Änderungen</h3>
<p>Wir dürfen die technische Umsetzung ändern, etwa das Rechenzentrum, die Server-Software oder den Anbieter des E-Mail-Postfachs wechseln. Voraussetzung ist, dass der vereinbarte Leistungsumfang nicht schlechter wird, dem Kunden keine Kosten entstehen und wir ihn mindestens vier Wochen vorher in Textform informieren. Beeinträchtigt eine Änderung den Zugang zur Leistung oder ihre Nutzbarkeit mehr als nur unerheblich, kann der Kunde den Vertrag innerhalb von 30 Tagen nach der Information bzw. der Änderung unentgeltlich beenden (§ 327r BGB).</p>
<h2 id="p4">§ 4 Stundenkontingent</h2>
<ol class="abs">
<li>Zum Leistungsumfang gehört ein Kontingent von 60 Minuten Arbeitszeit je bezahltem Abrechnungsmonat. Das erste Kontingent steht mit der ersten Zahlung bereit. Das Kontingent ist Teil der Leistungsbeschreibung und kein Geldguthaben.</li>
<li>Nicht genutzte Zeit bleibt verfügbar: Jedes Monatskontingent kann zwölf Monate lang ab seiner Bereitstellung genutzt werden und entfällt danach. Genutzt wird immer das älteste noch verfügbare Kontingent zuerst. Mit dem Ende des Vertrags entfällt nicht genutzte Zeit. Sie wird weder ausgezahlt noch erstattet und ist nicht übertragbar.</li>
<li>Das Kontingent gilt für Änderungen, Erweiterungen, Beratung und Unterstützung rund um die im Abo betreute Website, Domain und E-Mail. Aufträge erteilt der Kunde über den Chat im Kundenportal oder per E-Mail. Aufträge von mehr als 4 Stunden in einem Monat setzen wir nach Terminabsprache um.</li>
<li>Angerechnet wird die tatsächlich aufgewendete Zeit je Auftrag, aufgerundet auf volle 15 Minuten. Dazu zählen die Umsetzung und die Abstimmung zum Auftrag, soweit sie länger als 10 Minuten dauert. Nicht angerechnet werden die Leistungen nach § 3 Abs. 1 bis 4 (Betrieb, Updates, Überwachung, Sicherung) und die Behebung von Fehlern, die wir zu vertreten haben.</li>
<li>Verfügbare Zeit, Bereitstellungen, Verbrauch und Ablaufdaten sieht der Kunde jederzeit im Kundenportal.</li>
<li>Reicht die verfügbare Zeit für einen Auftrag voraussichtlich nicht aus, nennen wir dem Kunden vorher den geschätzten Mehraufwand. Wir führen ihn nur nach seiner Freigabe aus, etwa im Chat oder per E-Mail, und berechnen 100 € pro Stunde (gemäß § 19 UStG ohne Umsatzsteuer), abgerechnet in Schritten von 15 Minuten. Aufträge mit einem Umfang von mehr als 10 Stunden bieten wir gesondert an.</li>
</ol>
<h2 id="p5">§ 5 Verfügbarkeit und Wartung</h2>
<ol class="abs">
<li>Wir streben eine Erreichbarkeit der Website von 99 % im Jahresmittel an, gemessen am Übergang des Rechenzentrums ins Internet. Eine ununterbrochene Erreichbarkeit ist technisch nicht möglich und wird nicht geschuldet.</li>
<li>Nicht eingerechnet werden:
<ul>
<li>angekündigte Wartungsarbeiten, möglichst zwischen 22 und 6 Uhr und mindestens 24 Stunden vorher angekündigt; dringende Sicherheitsupdates auch kurzfristig,</li>
<li>Störungen außerhalb unseres Einflussbereichs, etwa im Internet, bei der Domain-Vergabestelle oder bei vorgelagerten Netzbetreibern,</li>
<li>Angriffe wie DDoS, sofern wir angemessene Schutzmaßnahmen getroffen haben,</li>
<li>höhere Gewalt sowie Störungen, die der Kunde verursacht hat.</li>
</ul></li>
<li>Die gesetzlichen Rechte des Kunden bei Ausfällen, die wir zu vertreten haben, bleiben unberührt. Die Haftung richtet sich nach § 11.</li>
</ol>
<h2 id="p6">§ 6 Pflichten des Kunden</h2>
<ol class="abs">
<li>Der Kunde hält seine Zugangsdaten zum Kundenportal und zum Postfach geheim und informiert uns unverzüglich, wenn er einen Missbrauch vermutet.</li>
<li>Der Kunde stellt nur Inhalte bereit, die er verwenden darf und die nicht gegen Gesetze verstoßen. Offensichtlich rechtswidrige Inhalte dürfen wir nach einem Hinweis vorübergehend sperren. Wir informieren den Kunden darüber unverzüglich.</li>
<li>Für Impressum, Datenschutzerklärung und Inhalte seiner Website ist der Kunde verantwortlich. Die Angaben zum Hosting, die er für seine Datenschutzerklärung braucht, stehen in den Anlagen 1 bis 3.</li>
<li>Der Kunde bewahrt Kopien der Inhalte auf, die er uns übergibt oder selbst einpflegt, und teilt uns Änderungen seiner Kontaktdaten mit.</li>
</ol>
<h2 id="p7">§ 7 Preis und Zahlung</h2>
<ol class="abs">
<li>Die Monatsgebühr beträgt 50,00 €. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.</li>
<li>Die Gebühr ist monatlich im Voraus fällig, erstmals mit Beginn der Leistung (bei sofortigem Beginn mit Vertragsschluss, sonst nach Ablauf der Widerrufsfrist), danach jeweils am selben Kalendertag der Folgemonate. Sie wird per Kreditkarte oder SEPA-Lastschrift über die Stripe Payments Europe, Limited (Irland) eingezogen. Rechnungen erhält der Kunde per E-Mail und im Kundenportal unter „Abo verwalten“.</li>
<li>Schlägt eine Zahlung fehl, versucht Stripe den Einzug automatisch erneut und informiert den Kunden per E-Mail.</li>
<li>Ist der Kunde mit einem Betrag von mindestens 100 € (zwei Monatsgebühren) in Verzug, dürfen wir die Leistungen vorübergehend sperren, etwa die Website offline nehmen. Vorher kündigen wir die Sperre in Textform an und setzen eine Frist von mindestens zwei Wochen. Das gilt nicht, solange der Kunde die Forderung mit nachvollziehbaren Gründen bestreitet. Während einer Sperre löschen wir keine Daten. Nach Zahlung heben wir die Sperre unverzüglich auf. Ist der Kunde mit zwei aufeinanderfolgenden Monatsgebühren in Verzug, dürfen wir außerordentlich kündigen.</li>
<li>Preisänderungen kündigen wir mindestens 6 Wochen vor ihrem Wirksamwerden in Textform an. Sie gelten nur, wenn der Kunde zustimmt. Ohne Zustimmung läuft der Vertrag zum bisherigen Preis weiter, und beide Seiten können nach § 8 ordentlich kündigen.</li>
</ol>
<h2 id="p8">§ 8 Laufzeit und Kündigung</h2>
<ol class="abs">
<li>Der Vertrag hat eine Mindestlaufzeit von zwölf Monaten ab Leistungsbeginn, bei sofortigem Beginn also ab Vertragsschluss, sonst ab Ablauf der Widerrufsfrist.</li>
<li>Beide Seiten können zum Ende der Mindestlaufzeit mit einer Frist von einem Monat kündigen. Wird nicht fristgerecht gekündigt, läuft der Vertrag auf unbestimmte Zeit weiter.</li>
<li>Nach Ablauf der Mindestlaufzeit kann der Kunde jederzeit zum Ende des laufenden Abrechnungsmonats kündigen. Wir können dann mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen.</li>
<li>Gibt der Kunde den Geschäftsbetrieb, für den die Website bestimmt ist, dauerhaft auf, kann er auch während der Mindestlaufzeit mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen. Auf Verlangen weist er die Geschäftsaufgabe nach, etwa durch die Gewerbeabmeldung.</li>
<li>Eine vorzeitige Beendigung im gegenseitigen Einvernehmen ist jederzeit möglich.</li>
<li>Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt unberührt.</li>
<li>Der Kunde kann über die Schaltfläche „Verträge hier kündigen“ unter project.webklar.com/kuendigen.html kündigen (ohne Anmeldung) oder in Textform, etwa per E-Mail. Den Zeitpunkt, zu dem der Vertrag endet, bestätigen wir unverzüglich per E-Mail. Bis dahin bleiben alle Leistungen verfügbar. Die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li>
</ol>
<h2 id="p9">§ 9 Vertragsende</h2>
<ol class="abs">
<li>Während der Laufzeit und bis 30 Tage nach Vertragsende stellen wir dem Kunden auf Wunsch eine Kopie seiner Website bzw. Anwendungsdaten (Dateien und Datenbank-Export) und seines Postfachs bereit. Wem Quellcode und Nutzungsrechte an der Website zustehen, richtet sich nach dem Vertrag über ihre Erstellung.</li>
<li>Wir geben den AuthCode der Domain heraus und helfen beim Umzug zu einem anderen Anbieter. Ist die Domain 30 Tage nach Vertragsende nicht umgezogen, dürfen wir ihre Registrierung beenden. Das kündigen wir vorher in Textform an und setzen eine Frist von 14 Tagen.</li>
<li>Nach Ablauf der Frist aus Absatz 1 löschen wir die Daten des Kunden einschließlich des Postfachs, soweit keine gesetzliche Aufbewahrungspflicht besteht. Sicherungskopien werden spätestens mit Ablauf ihrer Aufbewahrungszeit (§ 3 Abs. 4) überschrieben.</li>
</ol>
<h2 id="p10">§ 10 Mängel</h2>
<p>Es gelten die gesetzlichen Regeln, für die digitalen Leistungen (Bereitstellung der Website, E-Mail-Postfach) insbesondere das gesetzliche Mängelhaftungsrecht für digitale Dienstleistungen (§§ 327 ff. BGB). Mängel unserer Leistungen beheben wir nach Meldung innerhalb angemessener Frist.</p>
<h2 id="p11">§ 11 Haftung</h2>
<ol class="abs">
<li>Wir haften unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit wir eine Garantie übernommen haben.</li>
<li>Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haften wir nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.</li>
<li>Für den Verlust von Daten haften wir nur in Höhe des Aufwands, der zur Wiederherstellung nötig gewesen wäre, wenn der Kunde seine Pflicht aus § 6 Abs. 4 erfüllt hätte.</li>
<li>Die verschuldensunabhängige Haftung für Mängel, die schon bei Vertragsschluss vorhanden waren (§ 536a Abs. 1 BGB), ist ausgeschlossen.</li>
<li>Diese Regeln gelten auch zugunsten unserer Mitarbeiter und Beauftragten.</li>
</ol>
<h2 id="p12">§ 12 Datenschutz</h2>
<ol class="abs">
<li>Wie wir personenbezogene Daten des Kunden verarbeiten, beschreibt unsere Datenschutzerklärung unter project.webklar.com/datenschutz.html.</li>
<li>Soweit wir im Rahmen des Abos personenbezogene Daten im Auftrag des Kunden verarbeiten, etwa Eingaben in Kontaktformulare, E-Mails oder Server-Protokolle seiner Website, gilt der Auftragsverarbeitungsvertrag in Anlage 1. Er wird mit der Bestellung vereinbart.</li>
</ol>
<h2 id="p13">§ 13 Änderungen dieser Bedingungen</h2>
<p>Änderungen teilen wir mindestens 6 Wochen vor ihrem Wirksamwerden in Textform mit. Sie werden nur mit Zustimmung des Kunden wirksam. Ohne Zustimmung gelten die bisherigen Bedingungen weiter, und beide Seiten können nach § 8 kündigen.</p>
<h2 id="p14">§ 14 Schlussbestimmungen</h2>
<ol class="abs">
<li>Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Gegenüber Verbrauchern gilt diese Rechtswahl nur, soweit ihnen dadurch nicht der Schutz zwingender Vorschriften des Staates entzogen wird, in dem sie ihren gewöhnlichen Aufenthalt haben.</li>
<li>Ist der Kunde Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen, ist Gerichtsstand der Sitz von WEBklar.</li>
<li>Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</li>
</ol>
<div class="neue-seite"></div>
<h2 id="widerruf">Widerrufsbelehrung</h2>
<p class="hinweis">Das Widerrufsrecht gilt nur für Verbraucher. Die folgende Belehrung entspricht dem gesetzlichen Muster.</p>
<h3>Widerrufsrecht</h3>
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>
<p>Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
<p>Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon 0176 23726355, E-Mail support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.</p>
<p>Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
<h3>Folgen des Widerrufs</h3>
<p>Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.</p>
<p>Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.</p>
<p class="meta">Ende der Widerrufsbelehrung</p>
<h3 id="formular">Muster-Widerrufsformular</h3>
<div class="box">
<p>(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)</p>
<p>– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:</p>
<p>– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)</p>
<p>– Bestellt am (*)/erhalten am (*)</p>
<p>– Name des/der Verbraucher(s)</p>
<p>– Anschrift des/der Verbraucher(s)</p>
<p>– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)</p>
<p>– Datum</p>
<p class="meta">(*) Unzutreffendes streichen.</p>
</div>
<p class="anlage-kopf neue-seite" id="avv">Anlage 1</p>
<h2>Auftragsverarbeitungsvertrag nach Art. 28 DSGVO</h2>
<p class="hinweis">zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR als Auftragsverarbeiter („WEBklar“). Der Vertrag wird mit der Bestellung des Wartungs-Abos vereinbart.</p>
<h3>1. Gegenstand und Dauer</h3>
<p>WEBklar verarbeitet personenbezogene Daten im Auftrag des Kunden, soweit das für die Leistungen des Wartungs-Abos (Betrieb und Pflege der Website, Domain, E-Mail-Postfach, Unterstützung) nötig ist. Der Vertrag gilt für die Dauer des Abos und darüber hinaus, bis alle Daten nach Nummer 8 gelöscht oder zurückgegeben sind.</p>
<h3>2. Art und Zweck der Verarbeitung, Datenarten, Betroffene</h3>
<table>
<tr><td>Art der Verarbeitung</td><td>Speichern, Bereitstellen, Weiterleiten (z. B. Kontaktformular-Eingaben per E-Mail an den Kunden), Sichern, Wiederherstellen und Löschen; Einsicht, soweit sie für Wartung und Unterstützung nötig ist</td></tr>
<tr><td>Zweck</td><td>Betrieb der Website bzw. Web-Anwendung und des Postfachs des Kunden</td></tr>
<tr><td>Datenarten</td><td>Inhalte der Website; Eingaben in Formulare (z. B. Name, Kontaktdaten, Nachricht); Inhalte des Postfachs; Verbindungsdaten in Server-Protokollen (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browserkennung); Benutzerkonten, soweit die Anwendung solche vorsieht</td></tr>
<tr><td>Betroffene</td><td>Besucher der Website; Absender und Empfänger von E-Mails; Kunden, Interessenten und Beschäftigte des Kunden; weitere Personen, deren Daten der Kunde über seine Website verarbeitet</td></tr>
</table>
<p>Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) verarbeitet der Kunde über seine Website nur nach vorheriger Mitteilung an WEBklar.</p>
<h3>3. Weisungen</h3>
<p>WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch bei Übermittlungen in Drittländer, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt. Die Weisungen ergeben sich aus diesem Vertrag und den Bedingungen des Wartungs-Abos. Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin.</p>
<h3>4. Vertraulichkeit</h3>
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
<h3>5. Sicherheit der Verarbeitung</h3>
<p>WEBklar trifft die technischen und organisatorischen Maßnahmen nach Art. 32 DSGVO, die in Anlage 2 beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.</p>
<h3>6. Unterauftragsverarbeiter</h3>
<p>Der Kunde genehmigt die in Anlage 3 genannten Unterauftragsverarbeiter. Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, können beide das Wartungs-Abo zum Zeitpunkt des Wechsels kündigen. WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.</p>
<h3>7. Unterstützung des Kunden</h3>
<ul>
<li>WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter und unterstützt ihn bei der Beantwortung, soweit das mit den eingesetzten Systemen möglich ist.</li>
<li>WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO (Sicherheit, Meldung von Datenschutzverletzungen, Datenschutz-Folgenabschätzung).</li>
<li>Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.</li>
</ul>
<h3>8. Löschung und Rückgabe</h3>
<p>Nach Ende des Abos gibt WEBklar die Daten nach Wahl des Kunden zurück (§ 9 Abs. 1 der Bedingungen) oder löscht sie, soweit keine gesetzliche Pflicht zur Speicherung besteht. Sicherungskopien werden im Rahmen der regulären Rotation überschrieben.</p>
<h3>9. Nachweise und Kontrollen</h3>
<p>WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind. WEBklar ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Rechenzentrumsbetreibers (Zertifizierung nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.</p>
<h3>10. Schlussbestimmungen</h3>
<p>Für die Haftung gilt Art. 82 DSGVO. Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Bedingungen des Wartungs-Abos vor.</p>
<p class="anlage-kopf neue-seite" id="tom">Anlage 2</p>
<h2>Technische und organisatorische Maßnahmen (Art. 32 DSGVO)</h2>
<table>
<tr><th style="width:28%">Bereich</th><th>Maßnahmen</th></tr>
<tr><td>Rechenzentrum und Zutritt</td><td>Betrieb im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert. Zutritt, Stromversorgung, Klimatisierung und Brandschutz regeln die Maßnahmen von Hetzner.</td></tr>
<tr><td>Zugang zu den Systemen</td><td>Serververwaltung nur über verschlüsselte SSH-Verbindung, Zugang nur für die Gesellschafter. Automatische Sperre bei wiederholten Fehlversuchen. Firewall des Rechenzentrumsbetreibers, die nur die benötigten Dienste zulässt. Zwei-Faktor-Anmeldung für die Verwaltungskonten bei Hetzner und Stripe.</td></tr>
<tr><td>Zugriff auf Daten</td><td>Zugriff nur für die Gesellschafter und beauftragte Mitarbeiter, soweit ihre Aufgabe es erfordert. Im Kundenportal sieht jeder Kunde nur seine eigenen Daten. Datenbanken sind nur für die jeweilige Anwendung erreichbar.</td></tr>
<tr><td>Übertragung</td><td>Verschlüsselte Übertragung (TLS) für alle Websites, das Kundenportal und die Verwaltung. E-Mail-Versand und -Abruf nur verschlüsselt.</td></tr>
<tr><td>Nachvollziehbarkeit</td><td>Änderungen an Websites laufen über eine Versionsverwaltung (Git) und sind nachvollziehbar. Aufträge und Weisungen werden im Ticketsystem bzw. im Chat des Kundenportals dokumentiert.</td></tr>
<tr><td>Verfügbarkeit und Wiederherstellung</td><td>Tägliche Sicherung des gesamten Servers, verfügbar sind die letzten sieben Tage. Tägliche Datenbank-Exporte vor der Serversicherung. Automatische Überwachung der Erreichbarkeit. Automatische Sicherheitsupdates des Betriebssystems. Wiederherstellung wird regelmäßig getestet.</td></tr>
<tr><td>Trennung</td><td>Websites der Kunden in getrennten Verzeichnissen bzw. Containern. Vorschau- und Testversionen sind nur nach Anmeldung erreichbar.</td></tr>
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit. Festgelegter Ablauf für die Meldung von Datenschutzverletzungen. Löschung nach Vertragsende (§ 9 der Bedingungen). Server-Protokolle werden nach 14 Tagen gelöscht. Die Maßnahmen werden mindestens einmal im Jahr überprüft.</td></tr>
</table>
<p class="anlage-kopf" id="sub">Anlage 3</p>
<h2>Unterauftragsverarbeiter</h2>
<table>
<tr><th>Unternehmen</th><th>Leistung</th><th>Ort der Verarbeitung</th></tr>
<tr><td>Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen</td><td>Server-Hosting und Datensicherung</td><td>Deutschland (Rechenzentrum Falkenstein)</td></tr>
<tr><td>Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen</td><td>E-Mail-Postfächer (Webhosting)</td><td>Deutschland (Rechenzentrum Nürnberg)</td></tr>
</table>
<p class="hinweis">Die Domain registrieren wir über die Hetzner Online GmbH als Domain-Registrar. Für die Daten des Domaininhabers sind Registrar und Vergabestelle (bei .de-Domains die DENIC eG) eigenständig verantwortlich; sie sind insoweit keine Unterauftragsverarbeiter. Die Zahlungsabwicklung über Stripe betrifft das Vertragsverhältnis zwischen Kunde und WEBklar und ist nicht Gegenstand dieses Auftragsverarbeitungsvertrags.</p>
</main>
</body>
</html>
+9 -4
View File
@@ -14,12 +14,17 @@
<h1>Vertrag widerrufen</h1>
<form id="decl-form" class="card" novalidate>
<!-- § 356a BGB: welche Bestellung widerrufen wird (jede hat ihre eigene Widerrufsfrist) -->
<fieldset>
<legend data-label-for="order" data-label="Vertrag">Vertrag</legend>
<label class="radio"><input type="radio" name="order" value="abschluss" data-text="Abschluss meines Abos" checked> Abschluss meines Abos</label>
<label class="radio"><input type="radio" name="order" value="wechsel" data-text="Wechsel ins Wartungs-Abo"> Wechsel ins Wartungs-Abo</label>
<label class="radio"><input type="radio" name="order" value="zusatzspeicher" data-text="E-Mail-Zusatzspeicher"> E-Mail-Zusatzspeicher</label>
</fieldset>
<label for="name" data-label-for="name" data-label="Name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="200">
<label>Vertrag</label>
<input value="WEBklar Wartungs-Abo" disabled>
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional)</small></label>
<input id="customerNumber" name="customerNumber" maxlength="50">
@@ -40,7 +45,7 @@
</main>
<script src="/legal-form.js"></script>
<script>
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['name', 'customerNumber', 'email'] })
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['order', 'name', 'customerNumber', 'email'] })
</script>
</body>
</html>
+2 -1
View File
@@ -14,5 +14,6 @@ pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF)
}
pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf
pdf public/abo-bedingungen.html public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf
# Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt
pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf
pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Loeschfristen (Datenschutzerklaerung Abschnitt 7): Abo-Daten drei Jahre nach Ablauf des Jahres loeschen, in dem
* das letzte Abo eines Kunden endet. Einmal im Jahr im Januar ausfuehren, im Container als Datei:
* docker exec -w /app <container> node scripts/loeschfristen.mjs (Probelauf: Bericht, loescht nichts)
* docker exec -w /app -e LOESCHFRISTEN=mail <container> node scripts/loeschfristen.mjs (Bericht als T14 ans Team)
* docker exec -w /app -e LOESCHFRISTEN=apply <container> node scripts/loeschfristen.mjs (loescht, Bericht danach)
*/
import 'dotenv/config'
import { applyDeletions, findDueDeletions, formatReport } from '../server/services/retention.js'
import { sendAdminNotice } from '../server/services/mailer.js'
const MODES = new Set(['', 'mail', 'apply'])
/** mode: '' (Probelauf) | 'mail' | 'apply'. Liefert { plan, result, applied }. */
export async function run({ mode = '', now = new Date(), log = console.log } = {}) {
if (!MODES.has(mode)) throw new Error(`LOESCHFRISTEN="${mode}" ist unbekannt: leer (Probelauf), mail oder apply`)
const plan = await findDueDeletions(now)
if (mode === 'apply') {
const result = await applyDeletions(plan)
log('Löschfristen: gelöscht')
log(formatReport(plan, result))
return { plan, result, applied: true }
}
const report = formatReport(plan)
log('Löschfristen: Probelauf, es wird nichts gelöscht')
log(report)
if (mode === 'mail') {
const sent = await sendAdminNotice({
template: 'T14',
values: { modus: 'Probelauf', anzahl_kunden: plan.customers.length, bericht: report },
})
if (!sent.sent) log(`Bericht nicht verschickt: ${sent.reason}`)
else log('Bericht als T14 ans Team geschickt.')
}
return { plan, result: null, applied: false }
}
if (import.meta.url === `file://${process.argv[1]}`) {
run({ mode: process.env.LOESCHFRISTEN || '' }).catch((err) => {
console.error('❌', err.message)
process.exit(1)
})
}
+36
View File
@@ -119,6 +119,11 @@ await ensureCollection(
{ type: 'string', key: 'offerText', size: 300 },
// Plan 1: Produkt des Abos (wartung | hosting)
{ type: 'string', key: 'productKey', size: 16 },
// Plan 2 (Hosting-Abo + Zusatzspeicher): Wechseltag (Beginn der neuen Mindestlaufzeit) und bezahlter Speicherstand
{ type: 'datetime', key: 'termStartAt' },
{ type: 'integer', key: 'storageUnits' },
{ type: 'integer', key: 'storagePaidUnits' },
{ type: 'datetime', key: 'storagePaidPeriodEnd' },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
@@ -186,6 +191,8 @@ await ensureCollection(
'Abo-Angebote',
[
{ type: 'string', key: 'customerId', size: 64 },
// Plan 2: Angebot gilt fuer dieses Produkt (wartung | hosting)
{ type: 'string', key: 'productKey', size: 16 },
{ type: 'string', key: 'status', size: 20 },
{ type: 'string', key: 'scope', size: 16 },
{ type: 'integer', key: 'monthlyPriceCents' },
@@ -208,6 +215,35 @@ await ensureCollection(
]
)
// Vertragsereignisse: ein Dokument je Bestellung/Aenderung (Neuabschluss, Wechsel, Zusatzspeicher,
// Kuendigung, Widerruf) mit den Nachweisen der Zustimmung, der Widerrufsfrist und - bei Zusatzspeicher -
// dem Stand der Aufgabe fuers Team.
await ensureCollection(
'contractEvents',
'Vertragsereignisse',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'subscriptionId', size: 64 },
{ type: 'string', key: 'type', size: 16 },
{ type: 'string', key: 'productKey', size: 16 },
{ type: 'string', key: 'details', size: 2000 },
{ type: 'string', key: 'termsVersion', size: 32 },
{ type: 'datetime', key: 'consentAt' },
{ type: 'string', key: 'consentIp', size: 64 },
{ type: 'string', key: 'consentUserAgent', size: 300 },
{ type: 'datetime', key: 'earlyStartConsentAt' },
{ type: 'string', key: 'status', size: 16 },
{ type: 'datetime', key: 'dueAt' },
{ type: 'datetime', key: 'doneAt' },
{ type: 'string', key: 'doneBy', size: 255 },
{ type: 'datetime', key: 'confirmationSentAt' },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
{ key: 'idx_type_status', type: 'key', attributes: ['type', 'status'] },
]
)
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
await ensure('worksheets.countsToPlan', () =>
aw(`${col('worksheets')}/attributes/boolean`, {
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher): legt fehlende
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
* Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt.
*
* Usage (im Container):
* node scripts/stripe-products.mjs --pruefen (nur anzeigen, was fehlt oder abweicht, keine Schreibzugriffe)
* node scripts/stripe-products.mjs --anlegen (anlegen/anpassen)
*
* Fix round 1, Minor 7: genau einer der beiden Schalter ist Pflicht - kein stiller Livegang bei
* fehlendem oder falsch geschriebenem Schalter.
*/
import 'dotenv/config'
import { config } from '../server/config.js'
import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
import { PRODUCT_KEYS } from '../server/services/products.js'
const KEYS = [...PRODUCT_KEYS, 'mailStorage'] // ['wartung', 'hosting', 'mailStorage']
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
export function parseArgs(argv) {
const args = argv.slice(2)
if (args.length === 1 && args[0] === '--pruefen') return 'pruefen'
if (args.length === 1 && args[0] === '--anlegen') return 'anlegen'
return null
}
function catalogEntry(key) {
return key === 'mailStorage' ? config.mailStorage : config.products[key]
}
async function findPrice(lookupKey) {
const { data } = await getStripe().prices.list({ lookup_keys: [lookupKey], active: true, limit: 1 })
return data[0] || null
}
async function checkPrice(key) {
const catalog = catalogEntry(key)
const price = await findPrice(catalog.lookupKey)
if (!price) return { ok: false, note: `Preis ${catalog.lookupKey} fehlt bei Stripe` }
const mismatches = []
if (price.unit_amount !== catalog.amountCents) mismatches.push(`Betrag ${price.unit_amount} statt ${catalog.amountCents}`)
if (price.recurring?.interval !== 'month') mismatches.push(`recurring.interval "${price.recurring?.interval}" statt "month"`)
if (price.tax_behavior !== 'inclusive') mismatches.push(`tax_behavior "${price.tax_behavior}" statt "inclusive"`)
// Important 2: Produkt ohne Bild faellt in --pruefen auf (Checkout zeigt sonst kein Bild)
const productId = typeof price.product === 'string' ? price.product : price.product?.id
if (productId) {
const product = await getStripe().products.retrieve(productId)
if (!product.images?.length) mismatches.push('kein Produktbild')
}
return { ok: mismatches.length === 0, note: mismatches.length ? mismatches.join('; ') : `OK (${price.id})` }
}
async function checkWartungDescription() {
const price = await findPrice(config.products.wartung.lookupKey)
if (!price) return { ok: false, note: 'Preis fehlt, Produkt kann nicht geprüft werden' }
const productId = typeof price.product === 'string' ? price.product : price.product.id
const product = await getStripe().products.retrieve(productId)
const ok = product.description === config.products.wartung.description
return { ok, note: ok ? 'OK' : 'Produktbeschreibung weicht vom Katalogtext ab' }
}
async function checkPortalConfig() {
const { data } = await getStripe().billingPortal.configurations.list({ active: true, limit: 20 })
const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo')
if (!own) return { ok: false, note: 'Konfiguration fehlt bei Stripe' }
const ok = own.business_profile?.headline === PORTAL_HEADLINE
return { ok, note: ok ? 'OK' : `Überschrift "${own.business_profile?.headline}" statt "${PORTAL_HEADLINE}"` }
}
async function pruefen() {
let allOk = true
for (const key of KEYS) {
const r = await checkPrice(key)
allOk = allOk && r.ok
console.log(`${r.ok ? '✅' : '❌'} Preis ${key}: ${r.note}`)
}
const desc = await checkWartungDescription()
allOk = allOk && desc.ok
console.log(`${desc.ok ? '✅' : '❌'} Wartung-Produktbeschreibung: ${desc.note}`)
const portal = await checkPortalConfig()
allOk = allOk && portal.ok
console.log(`${portal.ok ? '✅' : '❌'} Kundenportal-Konfiguration: ${portal.note}`)
if (!allOk) {
console.log('')
console.log('Zum Anlegen/Anpassen: node scripts/stripe-products.mjs --anlegen')
// Minor 7: --pruefen bleibt rein lesend, meldet Luecken aber ueber den Exit-Code (fuer Cron/CI)
process.exitCode = 1
}
}
async function anlegen() {
for (const key of KEYS) {
const priceId = await ensurePrice(key)
console.log(`✅ Preis ${key}: ${priceId}`)
}
const price = await findPrice(config.products.wartung.lookupKey)
const productId = typeof price.product === 'string' ? price.product : price.product.id
await getStripe().products.update(productId, { description: config.products.wartung.description })
console.log('✅ Wartung-Produktbeschreibung auf Katalogtext gesetzt')
const configurationId = await ensurePortalConfiguration()
console.log(`✅ Kundenportal-Konfiguration: ${configurationId}`)
}
async function main() {
const mode = parseArgs(process.argv)
if (!mode) {
console.error(`❌ ${USAGE}`)
process.exit(1)
}
if (!config.stripe.secretKey) {
console.error('❌ STRIPE_SECRET_KEY fehlt')
process.exit(1)
}
console.log(mode === 'pruefen' ? 'Modus: --pruefen (nur lesen, keine Änderungen)' : 'Modus: --anlegen (anlegen/anpassen)')
console.log('')
if (mode === 'pruefen') await pruefen()
else await anlegen()
}
// Nur beim direkten Aufruf laufen lassen, nicht wenn das Modul importiert wird (z. B. fuer
// parseArgs in einem Test) - main() wuerde sonst ueber process.exit den Prozess mitreissen.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error('❌', err.message)
process.exit(1)
})
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Wechsel ins Wartungs-Abo als gescheitert abschließen, wenn die SEPA-Lastschrift der Wechselrechnung nicht
* eingelöst wurde (Vertragsbedingungen § 9 Abs. 3, Rechtsrunde 28.09.2026). Das Team bekommt dann T12 mit den
* Handgriffen: Wechselrechnung stornieren, Preis ohne Verrechnung zurück auf Hosting, Metadaten zurücksetzen.
* Danach setzt dieses Skript das Ereignis `wechsel` auf `gescheitert` und schickt K14 an den Kunden und T12
* (gescheitert) ans Team (markSwitchFailed in server/services/planSwitch.js, wie beim Webhook
* customer.subscription.pending_update_expired für Karten).
*
* Usage (im Container, als Datei):
* node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --pruefen (nur anzeigen: offener Wechsel,
* Stand bei Stripe, Metadaten vor dem Wechsel, offene Handgriffe)
* node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --ausfuehren [Grund] (Ereignis gescheitert, K14, T12)
* --ausfuehren prueft vorher bei Stripe, dass die Wechselrechnung storniert ist, die Abo-Position wieder den
* Hosting-Preis hat und die Metadaten zurueckgesetzt sind; sonst nennt es die fehlenden Schritte, verschickt
* nichts und endet mit Exit-Code 1 (Fix 1). Ohne Grund nennt K14: "Die SEPA-Lastschrift für die Wechselrechnung
* wurde nicht eingelöst."; ein Grund hat hoechstens 300 Zeichen und keine Woerter mit "--" (Tippfehler bei
* Schaltern landen sonst in der Kundenmail).
*/
import 'dotenv/config'
import { config } from '../server/config.js'
import { getDocument } from '../server/services/appwriteAdmin.js'
import { findOpenSwitchEvent, markSwitchFailed, SWITCH_FAILED_REASONS } from '../server/services/planSwitch.js'
import { describeItems } from '../server/services/products.js'
import { getStripe } from '../server/services/stripeBilling.js'
const USAGE = 'Usage: node scripts/wechsel-gescheitert.mjs <Stripe-Abo-ID> --pruefen | --ausfuehren [Grund]'
const MAX_REASON = 300
/** { subscriptionId, mode: 'pruefen' | 'ausfuehren', grund? } oder null bei falschem Aufruf. */
export function parseArgs(argv) {
const [subscriptionId, mode, ...rest] = argv.slice(2)
if (!/^sub_\w+$/.test(subscriptionId || '')) return null
if (mode === '--pruefen' && rest.length === 0) return { subscriptionId, mode: 'pruefen' }
if (mode === '--ausfuehren') {
if (rest.some((token) => token.startsWith('--'))) return null
return { subscriptionId, mode: 'ausfuehren', grund: (rest.join(' ').trim() || SWITCH_FAILED_REASONS.lastschrift).slice(0, MAX_REASON) }
}
return null
}
/**
* Stand bei Stripe zum Wechsel: Wechselrechnung, Produkt der Abo-Position, Metadaten. missing = Handgriffe aus
* T12, die noch fehlen (Rechnung nicht storniert, Preis nicht Hosting, Metadaten nicht auf dem Stand vor dem Wechsel).
*/
async function stripeState(event) {
const details = event.details || {}
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(event.subscriptionId)
const invoice = details.invoiceId ? await stripe.invoices.retrieve(details.invoiceId).catch((err) => ({ error: err.message })) : null
const productKey = describeItems(sub.items?.data).productKey
const metadata = sub.metadata || {}
const wrongKeys = Object.entries(details.previousMetadata || {})
.filter(([key, value]) => (metadata[key] ?? null) !== value)
.map(([key]) => key)
const missing = []
if (details.invoiceId && invoice?.status !== 'void') {
missing.push(`1. Wechselrechnung ${details.invoiceId} stornieren (jetzt: ${invoice?.status || invoice?.error || 'unbekannt'})`)
}
if (productKey !== 'hosting') missing.push(`2. Abo-Position ohne anteilige Verrechnung zurück auf den Hosting-Preis (jetzt: ${productKey || 'unbekannt'})`)
if (wrongKeys.length) missing.push(`3. Metadaten zurücksetzen: ${wrongKeys.join(', ')}`)
return { invoice, productKey, metadata, missing }
}
/** Offenen Wechsel des Abos zeigen und (ausfuehren) als gescheitert abschließen. Liefert { ok, event? }. */
export async function run({ subscriptionId, mode, grund }, log = console.log) {
const sub = await getDocument(config.collections.subscriptions, subscriptionId).catch(() => null)
if (!sub) {
log(`❌ Abo ${subscriptionId} ist im Kundenportal nicht bekannt.`)
return { ok: false }
}
const event = await findOpenSwitchEvent(sub.customerId, subscriptionId)
if (!event) {
log(`❌ Kein offener Wechsel für Abo ${subscriptionId}.`)
return { ok: false }
}
const details = event.details || {}
const euro = `${(Number(details.amountDueCents || 0) / 100).toFixed(2).replace('.', ',')} €`
log(`Offener Wechsel ${event.$id} (Kunde ${event.customerId}, ${sub.email || 'ohne E-Mail'})`)
log(` Wechsel am ${details.switchedAt || '–'}, Zahlart ${details.paymentMethod || 'unbekannt'}, Rechnung ${details.invoiceId || '–'} über ${euro}`)
log(` Mindestlaufzeit des Hosting-Abos vor dem Wechsel ab ${details.previousTermStartAt || '–'}`)
const state = await stripeState(event)
log('Stand bei Stripe:')
log(` Rechnung ${details.invoiceId || '(im Ereignis nicht bekannt, in Stripe prüfen)'}: ${state.invoice?.status || state.invoice?.error || '–'}`)
log(` Abo-Position: ${state.productKey || 'unbekannt'}`)
log(`Metadaten am Stripe-Abo ${subscriptionId} auf den Stand vor dem Wechsel zurücksetzen (Soll, dahinter der Stand jetzt):`)
for (const [key, value] of Object.entries(details.previousMetadata || {})) {
log(` ${key} = ${value ?? '(löschen)'} (${key}: jetzt ${state.metadata[key] ?? '(leer)'})`)
}
if (state.missing.length) {
log('Noch offene Handgriffe:')
for (const step of state.missing) log(` ${step}`)
}
if (mode === 'pruefen') {
log('')
log(`Nur geprüft, nichts geändert. Abschließen: node scripts/wechsel-gescheitert.mjs ${subscriptionId} --ausfuehren`)
return { ok: true, event, missing: state.missing }
}
if (state.missing.length) {
log('❌ Erst die offenen Handgriffe in Stripe erledigen; K14 und T12 sind nicht verschickt, das Ereignis bleibt offen.')
return { ok: false, missing: state.missing }
}
const result = await markSwitchFailed(event.$id, grund, { doneBy: 'skript' })
if (!result.failed) {
log(`❌ Nichts geändert: ${result.skipped}`)
return { ok: false }
}
log(`✅ Wechsel ${event.$id} ist gescheitert: K14 an den Kunden und T12 ans Team verschickt (Fehler stehen im Log).`)
return { ok: true, event: result.event }
}
async function main() {
const args = parseArgs(process.argv)
if (!args) {
console.error(`❌ ${USAGE}`)
process.exit(1)
}
const result = await run(args)
if (!result.ok) process.exitCode = 1
}
// Nur beim direkten Aufruf laufen lassen, nicht wenn ein Test das Modul importiert
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error('❌', err.message)
process.exit(1)
})
}
+35 -7
View File
@@ -31,14 +31,24 @@ const products = {
name: 'WEBklar Wartungs-Abo',
lookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
amountCents: 5000,
// Version 4 (Rechtsrunde 28.09.2026): beliebig viele E-Mail-Postfächer statt 1 E-Mail-Postfach (F07;
// Fix round 1, item 11: ausgeschrieben "E-Mail-Postfächer" statt bloß "Postfächer")
description:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, beliebig viele E-Mail-Postfächer (zusammen 5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG',
priceNickname: 'Wartungs-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 60,
// Testrabatt: id passend zur amount_off-Regel (amountCents - 50), siehe global-constraints.md
testCouponId: 'WEBKLAR-TEST-050',
// Leistungen fuers Kundenportal (Vergleich, Bestellseite): [Titel, Detail], Spec 3.1 / Fassung 4
features: [
['Betrieb & Pflege Ihrer Website bzw. Web-Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
['beliebig viele Postfächer', 'zusammen 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
],
},
// Noch nicht bestellbar: Die Beschreibung geht erst mit den Vertragsbedingungen Version 4 an Stripe
hosting: {
key: 'hosting',
name: 'WEBklar Hosting-Abo',
@@ -50,6 +60,16 @@ const products = {
priceNickname: 'Hosting-Abo monatlich',
minTermMonths: 12,
minutesPerMonth: 0,
testCouponId: 'WEBKLAR-TEST-050-HOSTING',
// Fix round 1, Important 3: row 1 war wörtlich von Wartung kopiert und suggerierte Website-Pflege
// (die es im Hosting-Abo nicht gibt); row 4 nennt jetzt die Abrechnung in 15-Minuten-Schritten.
features: [
['Bereitstellung Ihrer statischen Website', 'Server in Deutschland, SSL, Updates von Betriebssystem und Server-Software, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', '.de, .com, .eu, .net oder .org, Registrierung und DNS-Verwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['Keine Pflege der Website', 'Pflege und Updates der Website nur im Wartungs-Abo'],
['Änderungen nur nach Freigabe', '100 € pro Stunde in 15-Minuten-Schritten (gemäß § 19 UStG ohne Umsatzsteuer)'],
],
},
}
@@ -63,6 +83,9 @@ const mailStorage = {
includedGb: 5,
maxUnits: 10,
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
// Hetzner-Paket (konsoleH), auf dem alle Postfaecher liegen: Gesamtgroesse und die Schwelle, ab der
// der Admin-Reiter "E-Mail-Speicher" rot warnt (Task 13)
hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 },
}
export const config = {
@@ -94,6 +117,7 @@ export const config = {
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
},
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
@@ -111,8 +135,9 @@ export const config = {
priceLookupKey: products.wartung.lookupKey,
priceAmountCents: products.wartung.amountCents,
productName: products.wartung.name,
productDescription: products.wartung.description,
statementDescriptor: products.wartung.statementDescriptor,
// Generisches Logo, allen drei Stripe-Produkten mitgegeben (ensurePrice); ein Katalogeintrag
// kann das mit einem eigenen "image"-Feld ueberschreiben
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
invoiceFooter:
@@ -122,7 +147,7 @@ export const config = {
abo: {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-25-v3',
termsVersion: '2026-09-30-v4',
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
minTermMonths: products.wartung.minTermMonths,
minutesPerMonth: products.wartung.minutesPerMonth,
@@ -139,7 +164,9 @@ export const config = {
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
testCouponId: 'WEBKLAR-TEST-050',
// Fix round 1, Minor 8: einzige Quelle ist der Katalog (products.wartung.testCouponId); Hosting
// hat sein eigenes unter products.hosting.testCouponId
testCouponId: products.wartung.testCouponId,
},
// Individuelle Abo-Angebote (z. B. erste 2 Monate je 25 €): je Angebot ein einmal einloesbarer Stripe-Coupon
@@ -151,8 +178,9 @@ export const config = {
// Uebernahmegutschrift: beim bisherigen Anbieter bereits bezahlte Zeit wird mit den Abo-Gebuehren verrechnet
takeover: {
// Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert)
conditionsVersion: '2026-09-25',
// Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert).
// Entwurf vom 27.09.2026 (Gutschrift fuer beide Abos); das Freigabedatum wird beim Livegang eingetragen.
conditionsVersion: '2026-09-30',
// Nachweise liegen ausserhalb von public/ (gitignored, ueberlebt Deploys wie preview-data/)
uploadDir: process.env.TAKEOVER_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'takeover-credits'),
maxFiles: 3,
@@ -3,17 +3,20 @@
<head>
<meta charset="utf-8">
<!--
K01 · Vertragsbestätigung Wartungs-Abo
Betreff: Ihr WEBklar Wartungs-Abo – Vertragsbestätigung
K01 · Vertragsbestätigung (Hosting-Abo oder Wartungs-Abo)
Betreff: Ihr WEBklar {{produkt}} – Vertragsbestätigung
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus der Bestellung) · BCC intern
Platzhalter: {{name}}, {{angebot_text}}, {{vertragsschluss}}, {{leistungsbeginn}}, {{mindestlaufzeit_bis}}, {{bedingungen_version}}, {{zustimmung_zeitpunkt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, sofortbeginn, beginn_nach_widerrufsfrist
Empfänger: Kunde (E-Mail aus der Bestellung) · BCC intern · Anhang: Vertragsunterlagen der zugestimmten Fassung (PDF, contractAttachmentFor)
Platzhalter: {{name}}, {{produkt}}, {{preis}}, {{angebot_text}}, {{vertragsschluss}}, {{leistungsbeginn}}, {{mindestlaufzeit_bis}}, {{bedingungen_version}}, {{zustimmung_zeitpunkt}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, hosting, wartung, wartung_v3, sofortbeginn, beginn_nach_widerrufsfrist
produkt = Katalogname ohne „WEBklar “ („Hosting-Abo“, „Wartungs-Abo“), preis z. B. „15,00 € / Monat“; genau einer der Blöcke hosting/wartung/wartung_v3 bringt die Liste „Enthalten“:
hosting und wartung nach Fassung 4 (Spec 3.1), wartung_v3 für ein Wartungs-Abo nach Fassung 3 (termsDocFor nicht v4: „1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat“ wie in den Bedingungen v3 § 3).
bedingungen_version und das PDF im Anhang kommen aus derselben Quelle (termsDocFor(subDoc.termsVersion).version): ohne gespeicherte Fassung beides Fassung 3.
-->
<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 WEBklar Wartungs-Abo – Vertragsbestätigung</title>
<title>Ihr WEBklar {{produkt}} – Vertragsbestätigung</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -22,7 +25,7 @@ a{color:#111111;}
</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 Wartungs-Abo ist abgeschlossen. Alle Vertragsunterlagen finden Sie im Anhang.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ihr {{produkt}} ist abgeschlossen. 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;">
@@ -33,18 +36,36 @@ a{color:#111111;}
<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; Abo abgeschlossen</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 Wartungs-Abo ist bestätigt.</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 den Abschluss Ihres Wartungs-Abos.</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="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;">50,00 €<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>
<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 {{produkt}} ist bestätigt.</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 den Abschluss Ihres {{produkt}}s.</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="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">WEBklar {{produkt}}</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{preis}}</div><div class="mut" style="font-size:12px;line-height:18px;color:#6b6964;">gemäß § 19 UStG ohne Umsatzsteuer</div>
<!-- WENN angebot -->
<div class="ink" style="margin-top:14px;padding-top:12px;border-top:1px solid #e8e6e0;color:#111111;"><strong>Ihr Angebot:</strong> {{angebot_text}} (wie vereinbart)</div>
<!-- ENDE angebot -->
</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;">Vertragsschluss</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{vertragsschluss}}</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;">Leistungsbeginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{leistungsbeginn}}</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; 1 E-Mail-Postfach<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" 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;">Vertragsschluss</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{vertragsschluss}}</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;">Leistungsbeginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{leistungsbeginn}}</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;">
<!-- WENN hosting -->
&#8226;&nbsp; Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland <span class="mut" style="color:#6b6964;">(SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software)</span><br>&#8226;&nbsp; 1 Domain<br>&#8226;&nbsp; 1 E-Mail-Postfach <span class="mut" style="color:#6b6964;">(5 GB; mehr über den Zusatzspeicher)</span><br>&#8226;&nbsp; Keine Pflege der Website; Änderungen nur nach Freigabe, 100 € pro Stunde in 15-Minuten-Schritten <span class="mut" style="color:#6b6964;">(gemäß § 19 UStG ohne Umsatzsteuer)</span>
<!-- ENDE hosting -->
<!-- WENN wartung -->
&#8226;&nbsp; Betrieb und Pflege Ihrer Website bzw. Web-Anwendung<br>&#8226;&nbsp; 1 Domain<br>&#8226;&nbsp; beliebig viele Postfächer <span class="mut" style="color:#6b6964;">(zusammen 5 GB; mehr über den Zusatzspeicher)</span><br>&#8226;&nbsp; 1 Stunde Arbeitszeit je bezahltem Monat <span class="mut" style="color:#6b6964;">(12 Monate nutzbar)</span>
<!-- ENDE wartung -->
<!-- WENN wartung_v3 -->
&#8226;&nbsp; Betrieb und Pflege Ihrer Website bzw. Web-Anwendung<br>&#8226;&nbsp; 1 Domain<br>&#8226;&nbsp; 1 E-Mail-Postfach <span class="mut" style="color:#6b6964;">(mindestens 5 GB)</span><br>&#8226;&nbsp; 1 Stunde Arbeitszeit pro Monat <span class="mut" style="color:#6b6964;">(nicht genutzte Zeit bleibt 12 Monate verfügbar)</span>
<!-- ENDE wartung_v3 -->
</td></tr></table></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>
<!-- WENN wartung -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Dort sehen Sie jederzeit Ihr aktuelles Stundenkontingent.</td></tr>
<!-- ENDE wartung -->
<!-- WENN wartung_v3 -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Dort sehen Sie jederzeit Ihr aktuelles Stundenkontingent.</td></tr>
<!-- ENDE wartung_v3 -->
<!-- WENN hosting -->
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Dort sehen Sie jederzeit Ihre Verträge und Rechnungen.</td></tr>
<!-- ENDE hosting -->
<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}}) haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.<br><br>
<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>
<!-- WENN sofortbeginn -->
Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahlen.
<!-- ENDE sofortbeginn -->
@@ -4,16 +4,17 @@
<meta charset="utf-8">
<!--
K02 · Kündigungsbestätigung (direkt bei Stripe)
Betreff: Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo
Betreff: Bestätigung Ihrer Kündigung – WEBklar {{produkt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail am Abo) · BCC intern
Platzhalter: {{name}}, {{eingang_zeitpunkt}}, {{vertragsende}}
Platzhalter: {{name}}, {{produkt}}, {{eingang_zeitpunkt}}, {{vertragsende}}, {{vertragsende_paragraph}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): wartung
produkt = Katalogname ohne „WEBklar “; vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung = Hinweis zum Stundenkontingent.
-->
<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>Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo</title>
<title>Bestätigung Ihrer Kündigung – WEBklar {{produkt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -22,7 +23,7 @@ a{color:#111111;}
</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 Wartungs-Abo endet zum {{vertragsende}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ihr {{produkt}} endet zum {{vertragsende}}.&#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;">
@@ -34,10 +35,10 @@ 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;">Kündigung bestätigt</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;">Ihre Kündigung ist bestätigt.</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 bestätigen Ihre Kündigung des WEBklar Wartungs-Abos vom {{eingang_zeitpunkt}} Uhr.</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 bestätigen Ihre Kündigung des WEBklar {{produkt}}s vom {{eingang_zeitpunkt}} Uhr.</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="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">Ihr Abo endet 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></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;">Bis dahin bleiben alle Leistungen verfügbar, danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.</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;">Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.</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;">Bis dahin bleiben alle Leistungen verfügbar, danach erfolgen keine weiteren Abbuchungen. Gebuchter E-Mail-Zusatzspeicher endet zusammen mit dem Abo.<!-- WENN wartung --> Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.<!-- ENDE wartung --></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;">Ihre Daten, den Domain-AuthCode und die Inhalte Ihrer Postfächer stellen wir Ihnen auf Wunsch gemäß {{vertragsende_paragraph}} der Vertragsbedingungen bereit.</td></tr>
<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:#fdf6e7;border:1px solid #f0dcb0;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;">Sie haben das nicht veranlasst?</div><div class="mut" style="color:#4a4843;">Dann melden Sie sich bitte umgehend bei uns: <a class="mut" href="mailto:support@webklar.com" style="color:#4a4843;">support@webklar.com</a> oder 0176 23726355.</div></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>
@@ -4,16 +4,17 @@
<meta charset="utf-8">
<!--
K03 · Eingangsbestätigung Kündigung (Kündigungsseite)
Betreff: Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo
Betreff: Eingangsbestätigung Ihrer Kündigung – WEBklar {{produkt}}
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}}, {{produkt}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{vertrag}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{vertragsende_paragraph}}, {{pakete}}, {{pakete_neu}}, {{pakete_angabe}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, pruefen, abo, wartung, speicher_angaben, speicher_kind_pruefung
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung (innerhalb von abo) = Hinweis auf nicht genutzte Arbeitszeit aus dem Stundenkontingent (kein Geldguthaben, v4 § 4 Abs. 1).
-->
<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>Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo</title>
<title>Eingangsbestätigung Ihrer Kündigung – WEBklar {{produkt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -39,7 +40,10 @@ 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>
<!-- WENN speicher_angaben -->
<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;">Pakete</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{pakete_angabe}}</td></tr>
<!-- ENDE speicher_angaben -->
<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 +67,19 @@ 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 speicher_kind_pruefung -->
<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 die Art Ihrer Kündigung</div><div class="mut" style="color:#4a4843;">Ihre {{kuendigungsart}} wird geprüft. Den Zusatzspeicher haben wir unabhängig davon wie oben zum Ende des Abrechnungsmonats verringert; ergibt die Prüfung ein früheres Ende, bestätigen wir es Ihnen gesondert.</div></td></tr></table></td></tr>
<!-- ENDE speicher_kind_pruefung -->
<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>
<!-- 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;"><!-- WENN wartung -->Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende. <!-- ENDE wartung -->Ihre Daten, den Domain-AuthCode und die Inhalte Ihrer Postfächer stellen wir Ihnen auf Wunsch gemäß {{vertragsende_paragraph}} der Vertragsbedingungen bereit. Gebuchter E-Mail-Zusatzspeicher endet zusammen mit dem Abo.</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>
@@ -4,16 +4,17 @@
<meta charset="utf-8">
<!--
K04 · Eingangsbestätigung Widerruf
Betreff: Eingangsbestätigung Ihres Widerrufs – WEBklar Wartungs-Abo
Betreff: Eingangsbestätigung Ihres Widerrufs – WEBklar {{produkt}}
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}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, pruefen
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
-->
<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>Eingangsbestätigung Ihres Widerrufs – WEBklar Wartungs-Abo</title>
<title>Eingangsbestätigung Ihres Widerrufs – WEBklar {{produkt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -34,13 +35,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>
<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>
<!-- 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; haben Sie den sofortigen Beginn verlangt, ziehen wir einen anteiligen Wertersatz für die bis zum Widerruf erbrachten Leistungen ab (§ 357a Abs. 2 BGB). Mit dem Abschluss endet auch dazu bestellter E-Mail-Zusatzspeicher; was Sie dafür gezahlt haben, erstatten wir vollständig; Wertersatz verlangen wir dafür nicht. 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;">Ihr Hosting-Abo läuft zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit; den Preis stellen wir entsprechend zurück. Den für das Wartungs-Abo gezahlten Mehrbetrag erstatten wir innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel, abzüglich des Wertersatzes für die bis zum Widerruf erbrachte Leistung (§ 357a Abs. 2 BGB): den anteiligen Mehrpreis des Wartungs-Abos gegenüber dem Hosting-Abo für die Tage vom Beginn des Wartungs-Abos bis zum Widerruf. 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. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</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>
@@ -4,16 +4,16 @@
<meta charset="utf-8">
<!--
K05 · Persönliches Angebot (Kunde ohne Abo)
Betreff: Ihr persönliches Angebot für das WEBklar Wartungs-Abo
Betreff: Ihr persönliches Angebot für das WEBklar {{produkt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus den Kundendaten) · BCC intern
Platzhalter: {{name}}, {{angebot_text}}, {{ersparnis}}, {{gueltig_bis}}, {{hinweis}}
Platzhalter: {{name}}, {{produkt}}, {{angebot_text}}, {{ersparnis}}, {{gueltig_bis}}, {{hinweis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): hinweis
-->
<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 persönliches Angebot für das WEBklar Wartungs-Abo</title>
<title>Ihr persönliches Angebot für das WEBklar {{produkt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -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:#efe6fb;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#5a2a91;">Persönliches Angebot</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 persönliches Angebot liegt bereit.</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>wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt.</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>wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar {{produkt}} hinterlegt.</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="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">Ihr Angebot</div><div class="ink" style="font-size:20px;line-height:28px;font-weight:bold;color:#111111;padding-top:6px;">{{angebot_text}}</div><div style="padding-top:10px;"><span style="background:#e4f2e6;color:#1d5b2c;border-radius:100px;padding:4px 10px;font-size:12px;font-weight:bold;">Sie sparen {{ersparnis}}</span></div></td></tr></table></td></tr>
<tr><td class="px" style="padding:20px 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;">Gültig bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{gueltig_bis}}</td></tr>
<!-- WENN hinweis -->
@@ -4,16 +4,16 @@
<meta charset="utf-8">
<!--
K06 · Angebot für ein laufendes Abo
Betreff: Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet
Betreff: Ihr Angebot für das WEBklar {{produkt}} ist eingerichtet
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail aus den Kundendaten) · BCC intern
Platzhalter: {{name}}, {{naechste_abbuchung}}, {{angebot_text}}, {{ersparnis}}, {{hinweis}}
Platzhalter: {{name}}, {{produkt}}, {{naechste_abbuchung}}, {{angebot_text}}, {{ersparnis}}, {{hinweis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): hinweis
-->
<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 Angebot für das WEBklar Wartungs-Abo ist eingerichtet</title>
<title>Ihr Angebot für das WEBklar {{produkt}} ist eingerichtet</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -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:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Angebot eingerichtet</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 Rabatt ist eingerichtet.</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>wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung am {{naechste_abbuchung}}:</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>wie besprochen gilt für Ihr WEBklar {{produkt}} ab der nächsten Abbuchung am {{naechste_abbuchung}}:</td></tr>
<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 Angebot</div><div class="ink" style="font-size:20px;line-height:28px;font-weight:bold;color:#111111;padding-top:6px;">{{angebot_text}}</div><div style="padding-top:10px;"><span style="background:#e4f2e6;color:#1d5b2c;border-radius:100px;padding:4px 10px;font-size:12px;font-weight:bold;">Sie sparen {{ersparnis}}</span></div></td></tr></table></td></tr>
<!-- WENN hinweis -->
@@ -42,8 +42,8 @@ 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:#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;">{{anmerkung}}</div></td></tr></table></td></tr>
<!-- ENDE anmerkung -->
<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;">Die Gutschrift verrechnen wir automatisch mit den nächsten Monatsgebühren Ihres Wartungs-Abos. Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben.</td></tr>
<tr><td class="px mut" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Eine Auszahlung ist nicht möglich; ein bei Vertragsende noch nicht verrechneter Rest entfällt.</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;">Die Gutschrift verrechnen wir automatisch mit den nächsten Monatsgebühren Ihres Abos. Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben.</td></tr>
<tr><td class="px mut" style="padding:12px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Eine Auszahlung ist nicht möglich. Wechseln Sie in ein anderes Abo, verrechnen wir die Gutschrift weiter; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest.</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>
@@ -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, zusatzspeicher
zahlung_offen bei Karte mit zurückgehaltener Zahlung, lastschrift bei SEPA (Bedingungen § 9 Abs. 3); zusatzspeicher, wenn der Kunde Zusatzspeicher gebucht hat – monatspreis ist dann die Monatsgebühr mit Zusatzspeicher (Spec 3.5).
-->
<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. Bleibt sie aus, verfällt die Wechselrechnung, und wir informieren Sie per E-Mail.</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;">Monatsgebühr des Wartungs-Abos<!-- WENN zusatzspeicher --> einschließlich Ihres gebuchten E-Mail-Zusatzspeichers<!-- ENDE zusatzspeicher -->, 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<!-- WENN zusatzspeicher --> mit gebuchtem Zusatzspeicher<!-- ENDE 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 für die Tage bis zum Widerruf. Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit; den gezahlten Mehrbetrag erstatten wir abzüglich dieses Wertersatzes.</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,69 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K13 · E-Mail-Zusatzspeicher (Bestätigung)
Betreff: Ihr E-Mail-Zusatzspeicher – Bestätigung
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern · Anhang bei einer Bestellung (Plus): Vertragsunterlagen der zugestimmten Fassung (PDF)
Platzhalter: {{name}}, {{pakete}}, {{gesamt_gb}}, {{monatspreis_zusatz}}, {{wirksam_ab}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): plus, plus_bezahlt, bestellung, minus
plus = Bestellung über die schon bezahlte Menge (Einrichtung, anteilige Berechnung), plus_bezahlt = Bestellung bis zur schon bezahlten Menge (keine Kosten, nichts einzurichten), bestellung = Unterlagen und Widerrufshinweis (bei beiden Bestellungen), minus = Verringerung zum Ende des Abrechnungsmonats (Spec 3.4) (Bedingungen § 7 Abs. 3, § 10).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Ihr E-Mail-Zusatzspeicher – Bestätigung</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Ihr E-Mail-Zusatzspeicher: {{pakete}} Pakete, {{gesamt_gb}} GB gesamt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; E-Mail-Zusatzspeicher</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Ihr E-Mail-Zusatzspeicher</td></tr>
<!-- WENN bestellung -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>vielen Dank für Ihre Bestellung. Hiermit bestätigen wir Ihren E-Mail-Zusatzspeicher.</td></tr>
<!-- ENDE bestellung -->
<!-- WENN minus -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Guten Tag {{name}},<br>hiermit bestätigen wir die Änderung Ihres E-Mail-Zusatzspeichers.</td></tr>
<!-- ENDE minus -->
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">E-Mail-Zusatzspeicher</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{monatspreis_zusatz}}<span class="mut" style="font-size:15px;font-weight:normal;letter-spacing:0;color:#6b6964;">&nbsp;/ Monat</span></div><div class="mut" style="font-size:12px;line-height:18px;color:#6b6964;">gemäß § 19 UStG ohne Umsatzsteuer</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zusatzspeicher</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{pakete}} Pakete</strong> zu je 5 GB, 1,00 € je Paket und Monat</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail-Speicher gesamt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{gesamt_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB in Ihrem Abo enthalten plus Zusatzspeicher)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Wirksam ab</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{wirksam_ab}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Laufzeit</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">Monatlich, jederzeit zum Ende des Abrechnungsmonats kündbar. Der Zusatzspeicher endet spätestens mit Ihrem Abo.</td></tr></table></td></tr>
<!-- WENN plus -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bereitstellung und Abrechnung</div><div class="mut" style="color:#4a4843;">Wir stellen die Grenzen Ihrer Postfächer in der Regel innerhalb eines Werktags um; bis dahin zeigt das Kundenportal „wird eingerichtet“. Den Rest des laufenden Abrechnungsmonats berechnen wir anteilig mit der nächsten Abbuchung, danach {{monatspreis_zusatz}} im Monat. Pakete, die für den laufenden Monat schon bezahlt waren, berechnen wir nicht noch einmal.</div></td></tr></table></td></tr>
<!-- ENDE plus -->
<!-- WENN plus_bezahlt -->
<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;">Keine zusätzlichen Kosten in diesem Monat</div><div class="mut" style="color:#4a4843;">Die Pakete sind für den laufenden Monat schon bezahlt; es entstehen keine zusätzlichen Kosten. Ab dem nächsten Abrechnungsmonat gelten {{pakete}} Pakete.</div></td></tr></table></td></tr>
<!-- ENDE plus_bezahlt -->
<!-- WENN minus -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wirksam zum Ende des Abrechnungsmonats</div><div class="mut" style="color:#4a4843;">Ihre Änderung wirkt zum {{wirksam_ab}}, ohne anteilige Erstattung; bis dahin bleibt der bisherige Speicher bestehen. Passen die gespeicherten E-Mails dann nicht mehr hinein, nimmt das betroffene Postfach keine neuen E-Mails an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE minus -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<!-- WENN bestellung -->
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ihre Vertragsunterlagen im Anhang (PDF)</div><div class="mut" style="color:#4a4843;">&#128206;&nbsp; Vertragsbedingungen mit den Regeln zum E-Mail-Zusatzspeicher<br>&#128206;&nbsp; Widerrufsbelehrung mit Muster-Widerrufsformular</div></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:28px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#6b6964;">Sie können diese Bestellung innerhalb von 14 Tagen widerrufen; die Widerrufsbelehrung finden Sie in den beigefügten Vertragsunterlagen. Widerrufen Sie, nehmen wir die Pakete zurück und erstatten alles, was Sie dafür gezahlt haben. Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</td></tr>
<!-- ENDE bestellung -->
<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
lastschrift bei SEPA-Lastschrift (Bedingungen § 9 Abs. 3: der Abbuchungstag bleibt der Wechseltag); grund aus SWITCH_FAILED_REASONS (planSwitch.js).
-->
<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>
@@ -3,17 +3,17 @@
<head>
<meta charset="utf-8">
<!--
T02 · Neues Wartungs-Abo
Betreff: [Abo] Neues Wartungs-Abo: {{email}}
T02 · Neues Abo (Hosting-Abo oder Wartungs-Abo)
Betreff: [Abo] Neues {{produkt}}: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{kunde_name}}, {{appwrite_id}}, {{stripe_abo_id}}, {{angebot_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler
Platzhalter: {{produkt}}, {{email}}, {{kunde_name}}, {{appwrite_id}}, {{stripe_abo_id}}, {{angebot_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler, nachweis_fehlt
-->
<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] Neues Wartungs-Abo: {{email}}</title>
<title>[Abo] Neues {{produkt}}: {{email}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -22,7 +22,7 @@ a{color:#111111;}
</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;">{{kunde_name}} hat das Wartungs-Abo abgeschlossen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}} hat das {{produkt}} abgeschlossen.&#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;">
@@ -33,8 +33,8 @@ a{color:#111111;}
<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; Neues Abo</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;">Neues Wartungs-Abo abgeschlossen</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;">{{kunde_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;">Appwrite-Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{appwrite_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;">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="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;">Neues {{produkt}} abgeschlossen</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;">{{kunde_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;">Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{produkt}}</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;">Appwrite-Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{appwrite_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;">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>
<!-- WENN angebot -->
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Angebot</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{angebot_text}}</td></tr>
<!-- ENDE angebot -->
@@ -44,6 +44,11 @@ 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;">Das Angebot konnte nicht automatisch angewendet werden. Bitte in Stripe prüfen und den Rabatt von Hand am Abo hinterlegen.</div></td></tr></table></td></tr>
<!-- ENDE angebot_fehler -->
<!-- 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 „Abschluss“ (Nachweis der Zustimmung, Beginn der Widerrufsfrist) konnte nicht in Appwrite gespeichert werden. Bitte prüfen, ob die Collection contractEvents mit allen Attributen angelegt ist (scripts/setup-abo-collections.mjs), und den Nachweis zu diesem Abo nachtragen. Vertragsbestätigung und Angebot sind davon nicht betroffen.</div></td></tr></table></td></tr>
<!-- ENDE 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:#fdf6e7;border:1px solid #f0dcb0;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;">Website als Monitor in UptimeRobot anlegen.</div></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
@@ -7,8 +7,8 @@
Betreff: [Abo] Kündigung (Stripe): {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{stripe_abo_id}}, {{vertragsende}}
Platzhalter: {{email}}, {{produkt}}, {{stripe_abo_id}}, {{vertragsende}}, {{vertragsende_paragraph}}
produkt = Katalogname ohne „WEBklar “; vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -34,9 +34,9 @@ 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;">Kündigung</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;">Abo gekündigt</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;">{{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;color:#6b6964;">Vertragsende</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{vertragsende}}</td></tr></table></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;">{{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;">Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{produkt}}</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;color:#6b6964;">Vertragsende</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{vertragsende}}</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;">Die Bestätigung an den Kunden ist raus.</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:#fdf6e7;border:1px solid #f0dcb0;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;">Hosting, Domain und Postfach gemäß § 9 der Vertragsbedingungen abwickeln (Export, AuthCode).</div></td></tr></table></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:#fdf6e7;border:1px solid #f0dcb0;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;">Zum Vertragsende Website, Domain und Postfächer gemäß {{vertragsende_paragraph}} der Vertragsbedingungen abwickeln (Export, AuthCode); gebuchter Zusatzspeicher endet mit dem Abo.</div></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
@@ -7,8 +7,8 @@
Betreff: [Abo] Kündigung zurückgenommen: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{stripe_abo_id}}
Platzhalter: {{email}}, {{produkt}}, {{stripe_abo_id}}
produkt = Katalogname ohne „WEBklar “.
-->
<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:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Kündigung aufgehoben</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;">Kündigung zurückgenommen</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;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{stripe_abo_id}}</td></tr></table></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;">{{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;">Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{produkt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{stripe_abo_id}}</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;">Das Abo läuft wieder normal weiter. Der Kunde bekommt dazu keine Mail.</td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
+6 -5
View File
@@ -7,8 +7,9 @@
Betreff: [Abo] Abo beendet: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{stripe_abo_id}}, {{status}}
Platzhalter: {{email}}, {{produkt}}, {{stripe_abo_id}}, {{status}}, {{vertragsende_paragraph}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): wartung
produkt = Katalogname ohne „WEBklar “; vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung = Hinweis auf nicht genutzte Arbeitszeit (nur Wartungs-Abo).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -34,9 +35,9 @@ 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;">Abo beendet</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;">Abo beendet</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;">{{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;color:#6b6964;">Status</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{status}}</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;">Ein Restguthaben verfällt.</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:#fdf6e7;border:1px solid #f0dcb0;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;">Hosting, Domain und Postfach gemäß § 9 der Vertragsbedingungen abwickeln (Export, AuthCode).</div></td></tr></table></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;">{{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;">Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{produkt}}</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;color:#6b6964;">Status</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{status}}</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;"><!-- WENN wartung -->Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit dem Vertragsende. <!-- ENDE wartung -->Gebuchter Zusatzspeicher endet mit dem Abo.</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:#fdf6e7;border:1px solid #f0dcb0;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;">Website, Domain und Postfächer gemäß {{vertragsende_paragraph}} der Vertragsbedingungen abwickeln (Export bis 30 Tage nach Vertragsende, AuthCode).<br>Monitor in UptimeRobot entfernen.</div></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</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}}, {{produkt}}, {{kundennummer}}, {{vertrag}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}}, {{pakete}}, {{pakete_angabe}}, {{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, speicher_angaben, speicher_kind_pruefung
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -34,11 +34,14 @@ 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:#fbefd5;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#7a4b00;">Kündigung 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;">Kündigung über die Kündigungsseite</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;">Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{produkt}}</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 -->
<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 speicher_angaben -->
<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;">Pakete</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{pakete_angabe}}</td></tr>
<!-- ENDE speicher_angaben -->
<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 +65,24 @@ 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_angabe}}). 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_angabe}}): {{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 -->
<!-- WENN speicher_kind_pruefung -->
<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;">{{kuendigungsart}} des Zusatzspeichers: Grund prüfen, ggf. früher beenden und den Rest erstatten. Die Verringerung zum Periodenende läuft unabhängig davon (siehe oben).</div></td></tr></table></td></tr>
<!-- ENDE speicher_kind_pruefung -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</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}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
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, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -34,12 +34,12 @@ 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><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;">Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{produkt}}</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 -->
<!-- WENN abo_gefunden -->
<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}}<!-- WENN auf_kuendigung_gesetzt --> <span class="mut" style="color:#6b6964;">(auf Kündigung gesetzt)</span><!-- ENDE auf_kuendigung_gesetzt --></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;">Abo-Beginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abo_beginn}}</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;">Widerrufsfrist bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{fristende}}, 24 Uhr</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingang</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{rechtzeitig}}</strong></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}}<!-- WENN auf_kuendigung_gesetzt --> <span class="mut" style="color:#6b6964;">(auf Kündigung gesetzt)</span><!-- ENDE auf_kuendigung_gesetzt --></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;">Abo-Beginn</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{abo_beginn}}</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;">Widerrufsfrist bis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{fristende}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Eingang</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{rechtzeitig}}</strong></td></tr>
<!-- ENDE abo_gefunden -->
</table></td></tr>
@@ -51,7 +51,33 @@ 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 -->
<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>
<!-- 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, nur bei Sofortbeginn) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung). Dazu bestellter Zusatzspeicher endet mit; was der Kunde dafür gezahlt hat, vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 2).</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), den Mehrbetrag innerhalb von 14 Tagen erstatten: was der Kunde seit dem Wechsel mehr gezahlt hat als im Hosting-Abo, abzüglich Wertersatz (anteiliger Mehrpreis für die Tage im Wartungs-Abo bis zum Widerruf, Hinweise zum Widerruf Nr. 3). 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 innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). 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 wechsel_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:#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;">Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird (Stripe → Rechnung stornieren; die zurückgehaltene Änderung verfällt damit).</div></td></tr></table></td></tr>
<!-- ENDE wechsel_offen -->
<!-- WENN abschluss_verspaetet -->
<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;">Hinweis</div><div class="mut" style="color:#4a4843;">Widerruf des Abschlusses nach der Frist: ggf. als ordentliche Kündigung behandeln (Mindestlaufzeit beachten) und dem Kunden das Ergebnis mitteilen.</div></td></tr></table></td></tr>
<!-- ENDE abschluss_verspaetet -->
<!-- WENN nicht_pruefbar -->
<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;">Widerrufsfrist nur eingeschränkt prüfbar: {{pruefhinweis}}. Beim Abschluss zählt ersatzweise der Abo-Beginn; ohne offene Frist wurde nichts automatisch geändert und der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
<!-- ENDE nicht_pruefbar -->
<!-- WENN bereits_widerrufen -->
<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;">Hinweis</div><div class="mut" style="color:#4a4843;">Diese Bestellung ist schon widerrufen (Ereignis widerrufen). Nichts weiter zu tun, sofern die Rückabwicklung läuft; dem Kunden kurz antworten.</div></td></tr></table></td></tr>
<!-- ENDE bereits_widerrufen -->
<!-- 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;">&nbsp;</td></tr>
</table>
</td></tr>
+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
wirksam (Wechsel gilt), lastschrift (SEPA, noch nicht eingelöst), gescheitert (K14 verschickt), lastschrift_gescheitert (Handgriffe), nachweis_fehlt; monatspreis = Monatsgebühr des Wartungs-Abos mit gebuchtem Zusatzspeicher (Spec 3.5).
-->
<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 ab Wechsel</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{monatspreis}} <span class="mut" style="color:#6b6964;">(Wartungs-Abo, ggf. mit gebuchtem Zusatzspeicher)</span></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, switchedFrom und die Zustimmungsdaten (termsVersion, termsAcceptedAt, earlyStartConsentAt, consentIp, consentUserAgent) auf den Stand vor dem Wechsel zurücksetzen. Die Werte stehen im Ereignis „wechsel“; im Container zeigt sie mit dem aktuellen Stand bei Stripe: node scripts/wechsel-gescheitert.mjs {{stripe_abo_id}} --pruefen<br>4. Ereignis auf „gescheitert“ setzen und K14 an den Kunden senden (das Skript prüft vorher die Schritte 1 bis 3): 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>
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T13 · Speicher-Aufgabe (E-Mail-Zusatzspeicher in konsoleH)
Betreff: [Speicher] {{aktion}}: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{aktion}}, {{email}}, {{name}}, {{stripe_abo_id}}, {{postfaecher_hinweis}}, {{neue_groesse_gb}}, {{pakete}}, {{faellig}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): einrichten, periodenende, teilweise, nachweis_fehlt
aktion ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“; einrichten (Plus über die bezahlte Menge, sofort), periodenende (Minus oder Plus bis zur bezahlten Menge, zum Periodenende), teilweise (Schritt 2 bei Stripe fehlgeschlagen), nachweis_fehlt.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Speicher] {{aktion}}: {{email}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{name}}: E-Mail-Zusatzspeicher {{aktion}}, {{neue_groesse_gb}} GB gesamt.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">E-Mail-Speicher</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Zusatzspeicher {{aktion}}</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Stripe-Abo</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{stripe_abo_id}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Postfächer</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{postfaecher_hinweis}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Neue Gesamtgröße</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{neue_groesse_gb}} GB</strong> <span class="mut" style="color:#6b6964;">(5 GB inklusive + {{pakete}} Pakete Zusatzspeicher zu 5 GB)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Fällig</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{faellig}}</td></tr></table></td></tr>
<!-- WENN einrichten -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#eef6ef;border:1px solid #c9e3cd;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Jetzt einrichten</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen in konsoleH auf {{neue_groesse_gb}} GB anheben, in der Regel innerhalb eines Werktags; bis dahin zeigt das Portal „wird eingerichtet“. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE einrichten -->
<!-- WENN periodenende -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zum Periodenende</div><div class="mut" style="color:#4a4843;">Die Postfachgrenzen zum {{faellig}} auf {{neue_groesse_gb}} GB setzen, nicht früher: Bis dahin ist die bisherige Menge bezahlt, eine Erstattung gibt es nicht. Der Kunde weiß, dass ein volles Postfach danach keine neuen E-Mails annimmt. Danach die Aufgabe im Admin-Bereich als erledigt markieren.</div></td></tr></table></td></tr>
<!-- ENDE periodenende -->
<!-- WENN teilweise -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Schritt 2 fehlgeschlagen – Menge in Stripe von Hand auf {{pakete}} setzen</div><div class="mut" style="color:#4a4843;">Der Kunde hat {{pakete}} Pakete bestellt. Stripe hat nur den ersten Schritt übernommen (die im Monat schon bezahlte Menge, ohne Verrechnung); der zweite Schritt wurde abgelehnt. Bitte in Stripe die Stückzahl der Position „E-Mail-Zusatzspeicher“ auf {{pakete}} setzen, anteilig ab heute (create_prorations). Das Ereignis „speicher“ trägt partial: true; die Einrichtung in konsoleH gilt für die volle Bestellung.</div></td></tr></table></td></tr>
<!-- ENDE teilweise -->
<!-- WENN nachweis_fehlt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „speicher“ (Nachweis der Bestellung und Aufgabe fürs Team) konnte nicht in Appwrite gespeichert werden. Bitte in contractEvents nachtragen; bis dahin ist diese Mail die einzige Aufgabe, im Admin-Bereich erscheint sie nicht.</div></td></tr></table></td></tr>
<!-- ENDE nachweis_fehlt -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T14 · Löschfristen fällig (Datenschutz)
Betreff: [Datenschutz] Löschfristen fällig
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{modus}}, {{anzahl_kunden}}, {{bericht}}
modus ist „Probelauf“ (Bericht vor dem Löschen) oder „Gelöscht“; bericht ist der Text aus scripts/loeschfristen.mjs (Zeilenumbrüche bleiben).
-->
<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>[Datenschutz] Löschfristen fällig</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;">{{modus}}: {{anzahl_kunden}} Kunde(n) mit abgelaufener Löschfrist.&#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;">Datenschutz</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;">Löschfristen fällig</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;">Lauf</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{modus}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Kunden mit abgelaufener Frist</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{anzahl_kunden}}</td></tr></table></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-weight:bold;color:#111111;padding-bottom:6px;">Bericht</div><div class="mut" style="color:#4a4843;">{{bericht}}</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#4a4843;" ><span class="mut" style="color:#4a4843;">Gelöscht wird nur mit <strong>LOESCHFRISTEN=apply</strong> (im Container: <code>node scripts/loeschfristen.mjs</code>). Rechnungen bleiben (Aufbewahrung acht Jahre).</span></td></tr>
<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>
+731 -85
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -1,5 +1,7 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { PRODUCT_KEYS } from '../../services/products.js'
import { ACTIVE_STATUSES, getCurrentSubscription } from '../../services/stripeBilling.js'
import {
createCustomerWithPortalAccess,
deleteCustomerWithPortalAccess,
@@ -41,6 +43,23 @@ router.patch('/:customerId', async (req, res) => {
}
})
// Schnittstelle fuers Ticketsystem (Task 13): Produkt und Stand des Abos eines Kunden. WOMS liest daraus,
// ob der Kunde ein Wartungs-Abo hat (Arbeitszeit-Kontingent). Altbestand ohne productKey gilt als
// Wartungs-Abo (wie offers.js/takeoverCredits.js); ohne Abo productKey null.
router.get('/:customerId/abo', async (req, res) => {
try {
const sub = await getCurrentSubscription(req.params.customerId)
if (!sub) return res.json({ productKey: null, active: false })
return res.json({
productKey: PRODUCT_KEYS.includes(sub.productKey) ? sub.productKey : 'wartung',
active: ACTIVE_STATUSES.has(sub.status),
})
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Abo konnte nicht geladen werden' })
}
})
router.delete('/:customerId', async (req, res) => {
try {
return res.json(await deleteCustomerWithPortalAccess(req.params.customerId))
+3
View File
@@ -27,6 +27,7 @@ import {
uploadRateLimited,
} from '../services/chatAttachments.js'
import { offersAdminRouter } from './offers.js'
import { storageTasksAdminRouter } from './storageTasks.js'
import { takeoverAdminRouter } from './takeoverCredits.js'
const router = Router()
@@ -37,6 +38,8 @@ router.use(requireAdminSession)
router.use('/takeover-credits', takeoverAdminRouter)
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
router.use('/offers', offersAdminRouter)
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
router.use('/storage-tasks', storageTasksAdminRouter)
function norm(value) {
return String(value || '').toLowerCase()
+146
View File
@@ -0,0 +1,146 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
import { completeContractEvent, getContractEvent, listContractEventsOf, listOpenStorageTasks } from '../services/contractEvents.js'
import { storageTaskKind } from '../services/mailStorage.js'
import { reconcilePendingSwitch } from '../services/planSwitch.js'
import { PRODUCT_KEYS, productLabel } from '../services/products.js'
import { ACTIVE_STATUSES } from '../services/stripeBilling.js'
/**
* Admin-Reiter "E-Mail-Speicher" (Task 13): offene Zusatzspeicher-Aufgaben fuers Team (Postfachgrenze in
* konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen der letzten
* Wochen. Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/storage-tasks, dort hinter
* requireAdminSession.
*
* Jede Aufgabe traegt ihre Art (details.kind, storageTaskKind): "einrichten" = jetzt auf das Ziel setzen,
* "periodenende" = zum Faelligkeitstag auf das Ziel setzen. Hoechstens eine offene Aufgabe je Art und Abo
* (mailStorage.js). Ein teilweise gescheitertes Plus (details.partial: Stripe steht auf der bezahlten
* Menge) bleibt eine offene Aufgabe, das Team setzt die Menge in Stripe von Hand nach (T13).
*
* Beim Laden gleicht der Reiter ausserdem offene Wechsel mit verfallener Zahlung mit Stripe ab
* (reconcilePendingSwitch): ein verlorener Stripe-Webhook faellt so auch ohne den Kunden auf.
*/
export const storageTasksAdminRouter = Router()
// Gescheiterte Aenderungen (Stripe lehnte ab, nichts berechnet): so lange sichtbar
const FAILED_VISIBLE_MS = 30 * 24 * 60 * 60 * 1000
const staffEmailOf = (req) => req.session.adminEmail || req.session.email || ''
function sendError(res, err, fallback) {
const status = err.status || 500
if (status >= 500) console.error('[speicher-aufgaben]', err.message)
return res.status(status).json({ error: status >= 500 ? fallback : err.message || fallback })
}
/** Namen und Adressen der Kunden zu den Ereignissen, ein Abruf je Kunde. */
async function customersOf(events) {
const ids = [...new Set(events.map((e) => e.customerId).filter(Boolean))]
const docs = await Promise.all(ids.map((id) => getDocument(config.collections.customers, id).catch(() => null)))
return new Map(ids.map((id, index) => [id, docs[index]]))
}
const gbOf = (units) => config.mailStorage.includedGb + config.mailStorage.gbPerUnit * (Number(units) || 0)
/** Ansicht eines Ereignisses `speicher` fuer den Reiter. */
function taskView(event, customer) {
const d = event.details || {}
const productKey = PRODUCT_KEYS.includes(event.productKey) ? event.productKey : null
return {
id: event.$id,
customerId: event.customerId,
customerName: customer?.name || customer?.companyName || customer?.code || event.customerId,
customerCode: customer?.code || '',
email: customer?.email || '',
// Das Portal kennt keine eigene Mail-Domain des Kunden; gezeigt wird die Domain der Kontakt-Adresse
contactDomain: String(customer?.email || '').split('@')[1] || '',
subscriptionId: event.subscriptionId,
productKey,
productLabel: productLabel(productKey),
kind: storageTaskKind(event),
fromUnits: Number(d.fromUnits) || 0,
toUnits: Number(d.toUnits) || 0,
targetGb: gbOf(d.toUnits),
dueAt: event.dueAt || null,
createdAt: event.$createdAt || null,
status: event.status,
doneAt: event.doneAt || null,
doneBy: event.doneBy || '',
partial: Boolean(d.partial),
viaCancellation: Boolean(d.viaCancellation),
error: d.error || '',
}
}
/**
* Auf dem Hetzner-Paket vergebene GB, so wie konsoleH sie haelt (F40): 5 GB je laufendem Abo plus 5 GB je
* Paket. Eine Verringerung gilt in konsoleH erst, wenn die Aufgabe "zum Periodenende" erledigt ist; bis
* dahin zaehlt je Abo die groessere Menge aus storageUnits und der Ausgangsmenge (fromUnits) der Aufgabe.
*/
async function allocatedGb(openTasks) {
const held = new Map()
for (const e of openTasks) {
if (storageTaskKind(e) !== 'periodenende') continue
held.set(e.subscriptionId, Math.max(held.get(e.subscriptionId) || 0, Number(e.details?.fromUnits) || 0))
}
const subs = await listDocuments(config.collections.subscriptions, [
Query.equal('status', [...ACTIVE_STATUSES]),
Query.limit(1000),
])
return subs.reduce((sum, sub) => sum + gbOf(Math.max(Number(sub.storageUnits) || 0, held.get(sub.$id) || 0)), 0)
}
/** Offene Wechsel mit Stripe abgleichen; reconcilePendingSwitch prueft selbst, ob die Zahlung verfallen ist. */
async function reconcileOpenSwitches() {
try {
const open = await listContractEventsOf('wechsel', 'offen')
for (const event of open) await reconcilePendingSwitch(event)
} catch (err) {
console.error('[speicher-aufgaben] Wechsel-Abgleich beim Laden fehlgeschlagen:', err.message)
}
}
storageTasksAdminRouter.get('/', async (_req, res) => {
try {
await reconcileOpenSwitches()
const since = new Date(Date.now() - FAILED_VISIBLE_MS).toISOString()
const [open, failedAll] = await Promise.all([
listOpenStorageTasks(),
listContractEventsOf('speicher', 'gescheitert'),
])
const gb = await allocatedGb(open)
const failed = failedAll.filter((e) => String(e.$createdAt || '') >= since)
const customers = await customersOf([...open, ...failed])
const { name, gb: packageGb, warnGb } = config.mailStorage.hetznerPackage
return res.json({
tasks: open.map((e) => taskView(e, customers.get(e.customerId))),
failed: failed.map((e) => taskView(e, customers.get(e.customerId))),
allocatedGb: gb,
packageGb,
warnGb,
packageName: name,
})
} catch (err) {
return sendError(res, err, 'Speicher-Aufgaben konnten nicht geladen werden')
}
})
storageTasksAdminRouter.post('/:id/done', async (req, res) => {
try {
const staffEmail = staffEmailOf(req)
if (!staffEmail) return res.status(400).json({ error: 'E-Mail-Adresse des Mitarbeiters fehlt in der Sitzung' })
const event = await getContractEvent(req.params.id).catch((err) => {
if (err.status === 404) return null
throw err
})
if (!event || event.type !== 'speicher') return res.status(404).json({ error: 'Speicher-Aufgabe nicht gefunden' })
if (event.status !== 'offen') return res.status(409).json({ error: `Diese Aufgabe ist nicht offen (Status: ${event.status})` })
await completeContractEvent(event.$id, staffEmail)
const done = await getContractEvent(event.$id)
const customer = await getDocument(config.collections.customers, done.customerId).catch(() => null)
return res.json({ task: taskView(done, customer) })
} catch (err) {
return sendError(res, err, 'Aufgabe konnte nicht als erledigt gespeichert werden')
}
})
+194
View File
@@ -0,0 +1,194 @@
import { createHash } from 'node:crypto'
import { config } from '../config.js'
import { createDocument, getDocument, ID, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { withdrawalDeadline } from './deadlines.js'
/**
* Vertragsereignisse: ein Dokument je Bestellung/Aenderung (Neuabschluss, Wechsel, Zusatzspeicher,
* Kuendigung, Widerruf). Jedes Ereignis traegt seinen eigenen Nachweis (Bedingungen-Version,
* Zeitpunkt der Zustimmung, IP, User-Agent), seine eigene Widerrufsfrist (§ 355 BGB) und - bei
* Zusatzspeicher - den Stand der Aufgabe fuers Team (Postfachgroesse bei Konsolehost anpassen).
*
* Eine Speicher-Verringerung (Minus) hat kein Pflicht-Haekchen und damit keine Zustimmungsdaten
* (Rechtsrunde 28.09.2026): recordContractEvent wird dafuer mit consent: null aufgerufen und
* speichert dann keine consentIp/consentUserAgent.
*/
const COLLECTION = () => config.collections.contractEvents
function parseJson(value, fallback) {
try {
return JSON.parse(value || '') ?? fallback
} catch {
return fallback
}
}
// Ereignistypen, zu denen ueberhaupt eine Widerrufsfrist gehoert (Kuendigung/Widerruf nicht)
const WITHDRAWABLE_TYPES = new Set(['abschluss', 'wechsel', 'speicher'])
// Ereignis ohne (weitere) Wirkung: widerrufen (Kunde) oder gescheitert (Wechsel ohne Zahlung) -
// in beiden Faellen ist keine Frist mehr offen
const CLOSED_STATUSES = new Set(['widerrufen', 'gescheitert'])
function isWithdrawable(event) {
if (!WITHDRAWABLE_TYPES.has(event.type) || CLOSED_STATUSES.has(event.status)) return false
// Zusatzspeicher: nur eine Erhoehung (Plus) hat ein Widerrufsrecht, eine Verringerung (Minus) nicht
if (event.type === 'speicher') return event.details?.toUnits > event.details?.fromUnits
return true
}
// Appwrite-Dokument-IDs: hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich,
// nicht mit einem Sonderzeichen beginnend (Appwrite 1.8, Validator UID)
const DOCUMENT_ID_MAX = 36
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
export function isValidDocumentId(id) {
return typeof id === 'string' && DOCUMENT_ID.test(id)
}
/**
* Feste Ereignis-ID (F05): "<typ>-" + sha256 des Schluessels als Hex, insgesamt genau 36 Zeichen
* (bei abschluss 26 Hex-Zeichen). Gleicher Schluessel -> gleiche ID, ein Stripe-Webhook, der zweimal
* ankommt, legt kein zweites Dokument an. key ist die Stripe-Abo-ID (abschluss: eines je Abo) oder
* Abo-ID plus Rechnungs-/Zeitstempel, wenn es mehrere Ereignisse eines Typs je Abo gibt. Die Abo-ID
* selbst darf nicht in die ID: "abschluss-sub_..." (sub_ + 24 Zeichen) waeren 38 Zeichen.
*/
export function eventIdFor(type, key) {
if (!/^[a-z]+$/.test(String(type))) throw new Error(`eventIdFor: ungueltiger Typ "${type}"`)
if (!key) throw new Error('eventIdFor: Schluessel fehlt')
const hashLength = DOCUMENT_ID_MAX - type.length - 1
return `${type}-${createHash('sha256').update(String(key)).digest('hex').slice(0, hashLength)}`
}
/**
* Ereignis anlegen (Bestellung/Aenderung). consent hat die Form { termsVersion, consentAt,
* consentIp, consentUserAgent, earlyStartConsentAt? } oder ist null (Speicher-Minus, siehe oben).
* details wird als JSON gespeichert. Mit id (aus eventIdFor): erneutes Speichern derselben id ist
* kein Fehler (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B.
* wenn ein Stripe-Webhook zweimal ankommt). Eine id, die Appwrite nicht annimmt, wird vorher mit
* Status 400 abgelehnt statt erst als unklarer Appwrite-Fehler aufzufallen.
*/
export async function recordContractEvent({
customerId,
subscriptionId,
type,
productKey,
details = {},
consent = null,
status = 'erledigt',
dueAt = null,
id = null,
}) {
if (id !== null && !isValidDocumentId(id)) {
const err = new Error(
`Ungueltige Ereignis-ID "${id}": Appwrite erlaubt hoechstens ${DOCUMENT_ID_MAX} Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, nicht mit Sonderzeichen beginnend (eventIdFor benutzen)`
)
err.status = 400
throw err
}
const documentId = id || ID.unique()
const data = {
customerId,
subscriptionId,
type,
productKey,
details: JSON.stringify(details),
status,
dueAt,
termsVersion: consent?.termsVersion || null,
consentAt: consent?.consentAt || null,
consentIp: consent?.consentIp || null,
consentUserAgent: consent?.consentUserAgent || null,
earlyStartConsentAt: consent?.earlyStartConsentAt || null,
}
try {
return await createDocument(COLLECTION(), data, documentId)
} catch (err) {
// Schon gespeichert (gleiche id, z. B. Webhook-Wiederholung): das vorhandene Dokument gilt als Nachweis
if (err.status === 409) return getDocument(COLLECTION(), documentId)
throw err
}
}
/** 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(), [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
}
/**
* Offene Widerrufsfristen je Bestellung (Neuabschluss, Wechsel, Speicher-Plus). events kommen aus
* listContractEvents (details schon geparst). Die Frist beginnt mit dem Vertragsschluss bei Stripe
* (details.contractAt); solange dieses Feld fehlt (Altbestand vor Aufgabe 7), zaehlt ersatzweise
* der Zeitpunkt der Zustimmung im Portal (consentAt).
*/
export function openWithdrawals(events, now = new Date()) {
return events
.filter(isWithdrawable)
.map((event) => ({
type: event.type,
eventId: event.$id,
until: withdrawalDeadline(event.details?.contractAt ?? event.consentAt).endsAt,
}))
.filter((entry) => now <= entry.until)
}
/** Offene Zusatzspeicher-Aufgaben fuers Team, faelligste zuerst, mit geparstem details. */
export async function listOpenStorageTasks() {
const docs = await listDocuments(COLLECTION(), [
Query.equal('type', 'speicher'),
Query.equal('status', 'offen'),
Query.orderAsc('dueAt'),
Query.limit(1000),
])
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
}
/**
* Ereignisse eines Typs in einem oder mehreren Status (z. B. offene Wechsel fuer den Abgleich, gescheiterte
* Speicher-Aenderungen fuer den Admin-Reiter), neueste zuerst, mit geparstem details.
*/
export async function listContractEventsOf(type, status) {
const docs = await listDocuments(COLLECTION(), [
Query.equal('type', type),
Query.equal('status', Array.isArray(status) ? status : [status]),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
}
/** Zusatzspeicher-Aufgabe erledigt (Team hat die Postfachgroesse angepasst). */
export async function completeContractEvent(id, staffEmail) {
return updateDocument(COLLECTION(), id, {
status: 'erledigt',
doneAt: new Date().toISOString(),
doneBy: staffEmail,
})
}
/**
* Ereignis aendern (z. B. status, doneAt/doneBy oder weitere Felder). patch.details wird mit den
* schon gespeicherten details zusammengefuehrt statt sie zu ersetzen, damit z. B. eine spaeter
* bekannte paymentUrl oder ein Ablehnungsgrund ergaenzt werden kann, ohne fromUnits/toUnits (oder
* andere schon gespeicherte Angaben) zu verlieren.
*/
export async function updateContractEvent(id, patch = {}) {
const { details, ...rest } = patch
const data = { ...rest }
if (details !== undefined) {
const existing = await getDocument(COLLECTION(), id)
data.details = JSON.stringify({ ...parseJson(existing.details, {}), ...details })
}
return updateDocument(COLLECTION(), id, data)
}
+571
View File
@@ -0,0 +1,571 @@
import { readFile } from 'node:fs/promises'
import { config } from '../config.js'
import { getDocument, updateDocument } from './appwriteAdmin.js'
import { listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { contractAttachmentFor, describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js'
import {
ACTIVE_STATUSES,
ensurePrice,
getCurrentSubscription,
getStripe,
invoiceLines,
resolvePriceKeys,
subscriptionIdOfInvoice,
syncSubscription,
} from './stripeBilling.js'
/**
* E-Mail-Zusatzspeicher (Spec 3.4, Bedingungen Fassung 4 § 10): Pakete zu 5 GB fuer 1,00 € je Monat als
* zweite Position (Stueckzahl) im Stripe-Abo des Kunden, 0 bis 10 Pakete, in beiden Abos.
*
* - Plus (Bestellung, "Zahlungspflichtig bestellen"): sofort. Den Rest des laufenden Monats berechnet
* Stripe anteilig mit der naechsten Abbuchung (proration_behavior create_prorations). Pakete, die fuer
* den laufenden Monat schon bezahlt sind (nach einem Minus im selben Monat), kosten nicht noch einmal:
* bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten
* (planStorageChange).
* - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in
* konsoleH sinkt erst zum Ende des Abrechnungsmonats.
* - Jede Aenderung ist ein Ereignis `speicher` (Nachweis; Status offen = Aufgabe fuers Team). Die Aufgabe
* hat eine Art (details.kind): "einrichten" (Plus ueber die bezahlte Menge, sofort, Ziel = neue Menge)
* oder "periodenende" (Minus oder Plus bis zur bezahlten Menge, faellig zum Periodenende, Ziel = neue
* Menge). Eine neue Aufgabe ersetzt aeltere offene derselben Art; eine Einrichtung ersetzt ausserdem eine
* geplante Verringerung mit kleinerem Ziel; eine Verringerung laesst eine offene Einrichtung stehen. So
* gibt es hoechstens eine offene Aufgabe je Art und Abo. Nur ein Plus traegt Zustimmungsdaten
* (Rechtsrunde 28.09.2026).
* - Reihenfolge: Ereignis zuerst (offen), dann Stripe. Lehnt Stripe ab, wird das Ereignis "gescheitert"
* und der Kunde sieht den Fehler; nach einer wirksamen Stripe-Aenderung bricht nichts mehr ab
* (Spiegel, Buchung, Aufgaben, Mails: Fehler im Log, T13 sagt es dem Team).
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus max(bezahlt,
* neu), beim Minus max(bezahlt, bisher) - jede Position war fuer den Monat berechnet; sonst aus der
* regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
*/
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
const fmtDate = (iso) =>
iso
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
: '–'
// Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null)
const sameInstant = (a, b) => a === b || new Date(a).getTime() === new Date(b).getTime()
// Appwrite-Fehler: 4xx dauerhaft (Attribut/Collection fehlt, ungueltige Daten), 5xx/Netz voruebergehend (wie Task 7)
const isPermanent = (err) => Boolean(err?.status) && err.status < 500
const shortError = (err) => String(err?.message || err).slice(0, 200)
const unitsOf = (doc) => Number(doc?.storageUnits) || 0
const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit
const monthlyCentsOf = (units) => units * config.mailStorage.amountCents
// Seit API 2025-03 liegt current_period_end am Subscription-Item
const periodEndOf = (stripeSub) => stripeSub?.current_period_end || stripeSub?.items?.data?.[0]?.current_period_end || null
/**
* Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps:
* [{ quantity, proration_behavior }] }. Ein Plus bis zur schon bezahlten Menge (paidUnits) laeuft ohne
* Verrechnung (none); darueber hinaus anteilig (create_prorations), bei currentUnits < paidUnits in zwei
* Schritten. Ein Minus laeuft ohne Erstattung (none). newUnits muss eine ganze Zahl von 0 bis maxUnits sein.
*/
export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits }) {
if (!Number.isInteger(newUnits) || newUnits < 0 || newUnits > maxUnits) {
throw httpError(400, `Bitte wählen Sie zwischen 0 und ${maxUnits} Paketen Zusatzspeicher.`)
}
const current = Number(currentUnits) || 0
const paid = Number(paidUnits) || 0
if (newUnits === current) return { direction: 'none', steps: [] }
if (newUnits < current) return { direction: 'minus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] }
if (newUnits <= paid) return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] }
if (current < paid) {
return {
direction: 'plus',
steps: [
{ quantity: paid, proration_behavior: 'none' },
{ quantity: newUnits, proration_behavior: 'create_prorations' },
],
}
}
return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'create_prorations' }] }
}
/**
* Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd
* der laufende Zeitraum ist (periodEnd, sonst doc.currentPeriodEnd; Appwrite gibt Datumswerte mit +00:00
* zurueck, der Spiegel schreibt Z: gleicher Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere
* Rechnung des Monats hat sie berechnet).
*/
export function paidUnitsOf(doc, periodEnd = doc?.currentPeriodEnd) {
const paidEnd = doc?.storagePaidPeriodEnd
const samePeriod = Boolean(paidEnd && periodEnd) && sameInstant(paidEnd, periodEnd)
return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc)
}
/**
* Art der Aufgabe eines Ereignisses `speicher`: details.kind ("einrichten" | "periodenende"); aeltere
* Ereignisse ohne kind (vor Fix 1) nach der Richtung (Plus = einrichten, sonst periodenende).
*/
export function storageTaskKind(event) {
const details = event?.details || {}
if (details.kind === 'einrichten' || details.kind === 'periodenende') return details.kind
return Number(details.toUnits) > Number(details.fromUnits) ? 'einrichten' : 'periodenende'
}
async function requireSubscription(customerId) {
const doc = await getCurrentSubscription(customerId)
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
return doc
}
// Position fuer subscriptions.update / invoices.createPreview: vorhandener Speicher-Posten per ID, sonst neu per Preis
const storageItemParam = (storageItemId, priceId, quantity) => (storageItemId ? { id: storageItemId, quantity } : { price: priceId, quantity })
/**
* Ein Minus wirkt zum Ende des Abrechnungsmonats; endet das Abo frueher (cancelAt), endet der Speicher
* mit ihm (Spec 3.4 "Der Zusatzspeicher endet automatisch mit dem Abo").
*/
function reductionEffectiveAt(doc) {
const periodEnd = doc.currentPeriodEnd || null
const cancelAt = doc.cancelAt || null
if (periodEnd && cancelAt && new Date(cancelAt) < new Date(periodEnd)) return cancelAt
return periodEnd || cancelAt
}
// Toleranz zwischen dem eigenen proration_date und Stripes Zeilen (Sekundenrundung)
const PRORATION_TOLERANCE_SECONDS = 2
/**
* Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue
* Menge abzueglich Gutschrift fuer die alte, genau ab prorationDate (period.start, ±2 s). Aeltere anteilige
* Zeilen (eine fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die
* regulaeren Zeilen des naechsten Monats zaehlen nicht.
*/
function storageProrationCents(preview, priceIds, prorationDate) {
return (preview?.lines?.data || [])
.filter((line) => {
const parent = line.parent || {}
const details = parent.subscription_item_details || parent.invoice_item_details || {}
if (details.proration !== true) return false
if (!priceIds.has(line.pricing?.price_details?.price)) return false
return Math.abs(Number(line.period?.start) - prorationDate) <= PRORATION_TOLERANCE_SECONDS
})
.reduce((sum, line) => sum + (Number(line.amount) || 0), 0)
}
/**
* Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je
* Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung
* und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts.
*
* Abrechnungsmodus (Fix 1): Abos aus dem Checkout laufen im flexiblen Modus (billing_mode.type 'flexible',
* Stripes Standard) - dort schreibt Stripe den zuletzt berechneten Betrag gut, die Vorschau hat die im
* Monat schon bezahlten Pakete also selbst verrechnet und gilt unveraendert. Nur im klassischen Modus
* ('classic') rechnet Stripe von der heutigen Menge aus; dann zaehlen nur die Pakete ueber der bezahlten
* Menge (F19: p × (neu − max(aktuell, bezahlt)) / (neu − aktuell)).
*/
export async function previewStorageChange(customerId, newUnits) {
const doc = await requireSubscription(customerId)
const currentUnits = unitsOf(doc)
const paidUnits = paidUnitsOf(doc)
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
const now = new Date()
let prorationCents = 0
if (plan.direction === 'plus' && newUnits > paidUnits) {
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
const items = stripeSub.items?.data || []
const { storageItemId } = describeItems(items)
const prorationDate = Math.floor(now.getTime() / 1000)
const preview = await stripe.invoices.createPreview({
subscription: doc.$id,
subscription_details: {
items: [storageItemParam(storageItemId, priceId, newUnits)],
proration_behavior: 'create_prorations',
proration_date: prorationDate,
},
})
// Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein)
const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean))
const previewed = storageProrationCents(preview, priceIds, prorationDate)
const classic = stripeSub.billing_mode?.type === 'classic'
prorationCents = classic
? Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
: previewed
}
return {
direction: plan.direction,
units: newUnits,
totalGb: totalGbOf(newUnits),
monthlyCents: monthlyCentsOf(newUnits),
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
prorationCents,
nextChargeAt: doc.currentPeriodEnd || null,
effectiveAt: plan.direction === 'minus' ? reductionEffectiveAt(doc) : now.toISOString(),
}
}
/**
* Richtung und Zustimmung pruefen: unveraendert 400, Plus ohne Zustimmung 400. reconciled: der Plan wurde nach
* dem Abgleich mit Stripe neu berechnet (Spiegel und Stripe wichen ab) - dann sagt die Meldung, dass die
* Seite neu geladen werden muss; ueber das Kuendigungsformular ist dann nur ein Minus erlaubt (409).
*/
function assertPlanAllowed(plan, consent, { reconciled = false, viaCancellation = false } = {}) {
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
if (reconciled && viaCancellation && plan.direction !== 'minus') {
throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.')
}
if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) {
throw httpError(
400,
reconciled
? 'Ihr Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.'
: 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.'
)
}
}
/**
* Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der
* auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular
* (viaCancellation, Task 10; Bedingungen § 10 Abs. 5 "jederzeit") ist eine Verringerung in jedem laufenden
* Status erlaubt, auch bei gekuendigtem Abo oder offener Zahlung - eine Erhoehung dort nie (F20).
*/
function assertChangeAllowed(doc, events, direction, viaCancellation) {
if (viaCancellation) {
if (direction !== 'minus') throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.')
if (!ACTIVE_STATUSES.has(doc.status)) throw httpError(409, 'Das Abo läuft nicht mehr.')
return
}
if (doc.status !== 'active') {
throw httpError(
409,
'Der Zusatzspeicher lässt sich nur bei laufendem Abo ändern, nicht in der Wartezeit bis zum Leistungsbeginn und nicht bei einem Zahlungsproblem.'
)
}
if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, 'Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.')
if (events.some((e) => e.type === 'wechsel' && e.status === 'offen')) {
throw httpError(409, 'Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.')
}
}
/**
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team), Status offen - vor dem Stripe-Aufruf.
* Laesst es sich nicht speichern, laeuft die Aenderung trotzdem: Fehler im Log, T13 traegt die Aufgabe mit
* dem Block nachweis_fehlt zum Team.
*/
async function recordStorageEvent({ doc, change, consent, viaCancellation }) {
try {
const event = await recordContractEvent({
customerId: doc.customerId,
subscriptionId: doc.$id,
type: 'speicher',
productKey: doc.productKey || 'wartung',
details: {
kind: change.kind,
fromUnits: change.fromUnits,
toUnits: change.toUnits,
effectiveAt: change.effectiveAt,
totalGb: change.totalGb,
monthlyCents: change.monthlyCents,
deltaMonthlyCents: change.deltaMonthlyCents,
...(viaCancellation ? { viaCancellation: true } : {}),
},
// Nur eine Bestellung (Plus) traegt Zustimmungsdaten; ein Minus keine (Rechtsrunde 28.09.2026)
consent:
change.direction === 'plus'
? {
termsVersion: consent.termsVersion,
consentAt: consent.consentAt,
consentIp: consent.consentIp || null,
consentUserAgent: consent.consentUserAgent || null,
}
: null,
status: change.task ? 'offen' : 'erledigt',
dueAt: change.dueAt,
})
if (change.task) return event
// Kein Auftrag fuers Team: als erledigt markiert, damit die Aufgabenliste (Task 13) es nicht zeigt
return await updateContractEvent(event.$id, { doneAt: new Date().toISOString(), doneBy: 'keine Aufgabe' }).catch((e) => {
console.error(`[speicher] Ereignis ${event.$id} nicht als erledigt markiert:`, e.message)
return event
})
} catch (err) {
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
return null
}
}
/**
* Aeltere offene Aufgaben desselben Abos, die die neue Aufgabe ueberholt (F21, Fix 1): eine Einrichtung
* ersetzt offene Einrichtungen und geplante Verringerungen mit hoechstens gleichem Ziel; eine Aufgabe zum
* Periodenende ersetzt nur offene Aufgaben zum Periodenende, nie eine noch nicht erledigte Einrichtung.
* events sind die Ereignisse vor der neuen Aufgabe.
*/
async function supersedeStorageTasks(events, change) {
const now = new Date().toISOString()
const superseded = events.filter((e) => {
if (e.type !== 'speicher' || e.status !== 'offen') return false
const kind = storageTaskKind(e)
if (change.kind === 'einrichten') return kind === 'einrichten' || (Number(e.details?.toUnits) || 0) <= change.toUnits
return kind === 'periodenende'
})
for (const e of superseded) await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
}
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */
async function contractPdfAttachment(termsVersion) {
const { filename, path } = contractAttachmentFor(termsVersion)
const content = await readFile(path).catch((err) => {
console.error(`[speicher] Vertragsunterlagen ${filename} nicht lesbar:`, err.message)
return null
})
return content ? [{ filename, content, contentType: 'application/pdf' }] : []
}
/**
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team (Aufgabe nach Art, dazu
* die Bloecke teilweise und nachweis_fehlt). Ueber das Kuendigungsformular bestaetigt K03, K13 entfaellt dort
* (F31). Ein Mailfehler bricht die Aenderung nie ab.
*/
async function sendStorageMails({ doc, change, consent, viaCancellation, proofMissing }) {
const plus = change.direction === 'plus'
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
const email = customer?.email || doc.email || ''
const dueLabel = fmtDate(change.dueAt)
if (!viaCancellation && email) {
const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : []
await sendCustomerMail(email, {
template: 'K13',
values: {
name: customer?.name || '',
pakete: change.toUnits,
gesamt_gb: change.totalGb,
monatspreis_zusatz: fmtEuro(change.monthlyCents),
wirksam_ab: fmtDate(change.effectiveAt),
},
// plus: Einrichtung mit anteiliger Berechnung; plus_bezahlt: bis zur schon bezahlten Menge (keine Kosten);
// bestellung: Unterlagen und Widerruf bei beiden; minus: Verringerung zum Periodenende
blocks: { plus: plus && change.kind === 'einrichten', plus_bezahlt: plus && change.kind !== 'einrichten', bestellung: plus, minus: !plus },
attachments,
}).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message))
}
// Ohne Auftrag fuers Team (Plus genau zurueck auf die bezahlte Menge) kein T13 - es gibt nichts zu tun
if (!change.task && !proofMissing) return
await sendAdminNotice({
template: 'T13',
values: {
aktion: change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`,
email,
name: customer?.name || '',
stripe_abo_id: doc.$id,
postfaecher_hinweis:
(doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen',
neue_groesse_gb: change.totalGb,
pakete: change.toUnits,
faellig: dueLabel,
},
blocks: {
einrichten: change.kind === 'einrichten',
periodenende: change.kind === 'periodenende',
teilweise: change.partial,
nachweis_fehlt: proofMissing,
},
})
}
// Abos mit laufender Aenderung (Doppelklick, zwei Tabs): die zweite gleichzeitige Aenderung bekommt 409
const activeChanges = new Set()
/**
* Menge auf newUnits Pakete aendern. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim
* Plus Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
* Ablauf: Vorpruefung mit dem Spiegel (400/404/409, ohne Stripe-Aufruf), Sperre je Abo, frischer Stand aus
* Stripe, Ereignis `speicher` (offen), Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0), Spiegel und bezahlte Menge, ueberholte
* Aufgaben, K13 und T13. Liefert { subscription, event, change }; change = { direction, kind, fromUnits,
* toUnits, effectiveAt, dueAt, totalGb, monthlyCents, deltaMonthlyCents, partial }.
*/
export async function changeStorage(customerId, newUnits, consent, options = {}) {
const viaCancellation = options.viaCancellation === true
const doc = await requireSubscription(customerId)
// Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf
const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits })
assertPlanAllowed(plan, consent)
// Sperre pruefen und setzen ohne await dazwischen; die offenen Ereignisse erst unter der Sperre lesen, damit
// das Ersetzen aelterer Aufgaben nie auf einer veralteten Liste arbeitet
if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.')
activeChanges.add(doc.$id)
try {
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
return await performChange({ doc, events, newUnits, consent, viaCancellation })
} finally {
activeChanges.delete(doc.$id)
}
}
async function performChange({ doc, events, newUnits, consent, viaCancellation }) {
const stripe = getStripe()
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
const items = stripeSub.items?.data || []
let { storageItemId } = describeItems(items)
// Frischer Stand aus Stripe (Menge, Periodenende): der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken
const fresh = { ...doc, storageUnits: describeItems(items).storageUnits, currentPeriodEnd: toIso(periodEndOf(stripeSub)) || doc.currentPeriodEnd || null }
const currentUnits = unitsOf(fresh)
const paidUnits = paidUnitsOf(fresh)
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
// Weicht der frische Stand vom Spiegel ab, kann aus einem Minus ein Plus werden: Zustimmung und
// Kuendigungsweg erneut pruefen, bevor irgendetwas geschrieben wird (Fix 2)
assertPlanAllowed(plan, consent, { reconciled: true, viaCancellation })
const plus = plan.direction === 'plus'
const now = new Date().toISOString()
const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende'
// Ein Plus genau zurueck auf die bezahlte Menge: konsoleH steht schon darauf, zum Periodenende ist nichts zu
// tun - kein offener Auftrag fuers Team, das Ereignis bleibt als Nachweis (erledigt)
const task = !(plus && newUnits === paidUnits)
const reductionAt = reductionEffectiveAt(fresh)
const change = {
direction: plan.direction,
kind,
task,
fromUnits: currentUnits,
toUnits: newUnits,
// Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist
// sofort faellig, alles andere zum Periodenende
effectiveAt: plus ? now : reductionAt,
dueAt: kind === 'einrichten' ? now : reductionAt,
totalGb: totalGbOf(newUnits),
monthlyCents: monthlyCentsOf(newUnits),
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
partial: false,
}
// 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen (ohne Aufgabe: erledigt)
const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation })
// 2. Stripe, Schritt fuer Schritt (planStorageChange)
const prorationDate = Math.floor(Date.now() / 1000)
let achievedUnits = currentUnits
for (const [index, step] of plan.steps.entries()) {
try {
const updated = await stripe.subscriptions.update(doc.$id, {
items: [storageItemParam(storageItemId, priceId, step.quantity)],
proration_behavior: step.proration_behavior,
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
})
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
achievedUnits = step.quantity
} catch (err) {
if (index === 0) {
// Nichts geaendert: Ereignis gescheitert, Fehler an den Kunden, keine Buchung, keine Mails
console.error(`[speicher] Abo ${doc.$id}: Stripe lehnt die Änderung ab:`, err.message)
if (event) {
await updateContractEvent(event.$id, { status: 'gescheitert', details: { error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht auf gescheitert gesetzt:`, e.message)
)
}
const failure = httpError(502, 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.')
failure.cause = err
throw failure
}
// Schritt 2 nach Schritt 1: Stripe steht auf der bezahlten Menge, die Bestellung bleibt offen - das Team
// setzt die Menge in Stripe von Hand (T13, Block teilweise)
console.error(`[speicher] Abo ${doc.$id}: Schritt ${index + 1} bei Stripe fehlgeschlagen, Menge bleibt ${achievedUnits}:`, err.message)
change.partial = true
if (event) {
await updateContractEvent(event.$id, { details: { partial: true, error: shortError(err) } }).catch((e) =>
console.error(`[speicher] Ereignis ${event.$id} nicht als teilweise markiert:`, e.message)
)
}
break
}
}
// 3. Spiegel (storageUnits aus den Positionen); scheitert er, holt der Stripe-Webhook das nach
let subscription
try {
subscription = (await syncSubscription(doc.$id)) || { ...fresh, storageUnits: achievedUnits }
} catch (err) {
console.error(`[speicher] Spiegel für Abo ${doc.$id} nicht aktualisiert (Webhook holt das nach):`, err.message)
subscription = { ...fresh, storageUnits: achievedUnits }
}
// 4. Bezahlte Menge des laufenden Monats: Plus max(bezahlt, erreicht), Minus max(bezahlt, bisher) - jede
// Position war fuer den Monat berechnet. Scheitert die Buchung (z. B. Attribut fehlt), laeuft der Rest weiter;
// ein Plus nach einem Minus im selben Monat wuerde dann anteilig berechnet statt frei bleiben.
const paid = {
storagePaidUnits: Math.max(paidUnits, plus ? achievedUnits : currentUnits),
storagePaidPeriodEnd: fresh.currentPeriodEnd,
}
try {
await updateDocument(config.collections.subscriptions, doc.$id, paid)
subscription = { ...subscription, ...paid }
} catch (err) {
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
}
// 5. Ueberholte Aufgaben schliessen, erst nachdem die neue existiert
if (event) {
await supersedeStorageTasks(events, change).catch((err) =>
console.error(`[speicher] Ersetzte Aufgaben für Abo ${doc.$id} nicht geschlossen:`, err.message)
)
}
// 6. Mails
await sendStorageMails({ doc: subscription, change, consent, viaCancellation, proofMissing: !event })
return { subscription, event, change }
}
/**
* Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere
* Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt
* (storagePaidUnits, storagePaidPeriodEnd). Im selben Monat gilt die groessere Menge (eine verspaetete
* Rechnung senkt eine Buchung nach einem Plus nicht), das Periodenende geht nie zurueck (wiederholte aeltere
* Rechnung). Fehlt der Spiegel noch (Webhook-Reihenfolge), wird er aus Stripe angelegt. Dauerhafte
* Appwrite-Fehler (4xx) werden protokolliert und uebersprungen, voruebergehende (5xx, Netz) geworfen, damit
* Stripe den Webhook wiederholt.
*/
export async function recordPaidStorage(invoice) {
const lines = await invoiceLines(invoice)
const loaded = { ...invoice, lines: { ...(invoice?.lines || {}), data: lines, has_more: false } }
const line = regularStorageLine(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded)))
if (!line) return { skipped: 'keine reguläre Speicher-Zeile' }
const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
const periodEnd = toIso(line.period?.end)
if (!periodEnd) return { skipped: 'Speicher-Zeile ohne Zeitraum' }
const units = Number(line.quantity) || 0
const tag = `[speicher] Bezahlte Menge für Abo ${subscriptionId} (Rechnung ${loaded.id || '?'})`
let existing
try {
existing = await getDocument(config.collections.subscriptions, subscriptionId)
} catch (err) {
if (err.status !== 404) {
if (!isPermanent(err)) throw err
console.error(`${tag} nicht lesbar (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
existing = await syncSubscription(subscriptionId)
if (!existing) return { skipped: 'Abo ohne Kundenzuordnung' }
}
const knownEnd = existing.storagePaidPeriodEnd || null
if (knownEnd && new Date(periodEnd) < new Date(knownEnd)) return { skipped: 'ältere Rechnung' }
const samePeriod = Boolean(knownEnd) && sameInstant(knownEnd, periodEnd)
const data = {
storagePaidUnits: samePeriod ? Math.max(Number(existing.storagePaidUnits) || 0, units) : units,
storagePaidPeriodEnd: periodEnd,
}
try {
await updateDocument(config.collections.subscriptions, subscriptionId, data)
} catch (err) {
if (!isPermanent(err)) throw err
console.error(`${tag} nicht gespeichert (${err.status}):`, err.message)
return { skipped: `Appwrite ${err.status}` }
}
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd }
}
+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({
+56 -16
View File
@@ -3,6 +3,7 @@ import { config } from '../config.js'
import { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { chargesFrom, redeemedDiscount } from './offerDiscount.js'
import { getProduct, PRODUCT_KEYS } from './products.js'
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
import { shortRef } from './takeoverCredits.js'
@@ -55,6 +56,12 @@ const adminLabel = (admin) =>
const isTestEmail = (email) => config.abo.testEmails.includes(String(email || '').toLowerCase())
// Altbestand ohne productKey (vor Aufgabe 11) gilt als Wartungs-Abo/-Angebot (F32)
const productKeyOf = (doc) => doc?.productKey || 'wartung'
/** Katalogname ohne "WEBklar " (z. B. "Hosting-Abo"), fuer {{produkt}} in Mails (F33). */
const productLabel = (key) => getProduct(key).name.replace(/^WEBklar\s+/, '')
// ---------------------------------------------------------------------------
// Texte (eine Quelle fuer Portal, Bestelluebersicht, Checkout, Mails und Stripe)
// ---------------------------------------------------------------------------
@@ -87,6 +94,7 @@ export function offerView(doc) {
createdAt: doc.$createdAt,
status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status,
scope: doc.scope,
productKey: productKeyOf(doc),
months: doc.months,
monthlyPriceCents: doc.monthlyPriceCents,
regularPriceCents: doc.regularPriceCents,
@@ -150,23 +158,28 @@ export async function listOffers() {
return listDocuments(COLLECTION(), [Query.orderDesc('$createdAt'), Query.limit(1000)])
}
/** Offenes, noch gueltiges Angebot eines Kunden (hoechstens eines, siehe createOffer). */
export async function getOpenOffer(customerId) {
if (!customerId) return null
/** Offenes, noch gueltiges Angebot eines Kunden fuer ein Produkt (hoechstens eines je Produkt, siehe createOffer). */
export async function getOpenOffer(customerId, productKey) {
if (!customerId || !productKey) return null
const docs = await listDocuments(COLLECTION(), [
Query.equal('customerId', customerId),
Query.equal('status', OFFER_STATUS.open),
Query.orderDesc('$createdAt'),
Query.limit(20),
])
return docs.find((d) => !isExpired(d)) || null
return docs.find((d) => !isExpired(d) && productKeyOf(d) === productKey) || null
}
/** Fuer das Angebotsformular: Abo-Stand, offenes Angebot, Portalzugang, Testkonto. */
/** Fuer das Angebotsformular: Abo-Stand, offene Angebote je Produkt, Portalzugang, Testkonto. */
export async function offerContext(customerId) {
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
const [sub, open] = await Promise.all([getCurrentSubscription(customerId), getOpenOffer(customerId)])
const [sub, hostingOpen, wartungOpen] = await Promise.all([
getCurrentSubscription(customerId),
getOpenOffer(customerId, 'hosting'),
getOpenOffer(customerId, 'wartung'),
])
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
return {
customer: {
id: customer.$id,
@@ -179,16 +192,22 @@ export async function offerContext(customerId) {
subscription: sub
? {
status: sub.status,
active: ACTIVE_STATUSES.has(sub.status),
active: running,
// Nur fuer dieses Produkt kann ein Angebot sofort aufs Abo angewendet werden (F32)
productKey: productKeyOf(sub),
currentPeriodEnd: sub.currentPeriodEnd || null,
// Laufendes Abo: [0] = erste rabattierte Abbuchung, [N] = Rabattende bei N Angebotsmonaten
upcomingCharges: ACTIVE_STATUSES.has(sub.status)
? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1)
: [],
upcomingCharges: running ? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1) : [],
}
: null,
openOffer: open ? offerView(open) : null,
regularPriceCents: config.stripe.priceAmountCents,
openOffers: {
hosting: hostingOpen ? offerView(hostingOpen) : null,
wartung: wartungOpen ? offerView(wartungOpen) : null,
},
products: {
hosting: { regularPriceCents: getProduct('hosting').amountCents },
wartung: { regularPriceCents: getProduct('wartung').amountCents },
},
maxMonths: config.offers.maxMonths,
defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays),
}
@@ -223,11 +242,15 @@ async function attachCouponToSubscription(subscriptionId, couponId, metadata) {
// ---------------------------------------------------------------------------
export function validateOfferInput(body) {
const productKey = body?.productKey
if (!PRODUCT_KEYS.includes(productKey)) {
throw httpError(400, 'Bitte ein Produkt wählen (Hosting-Abo oder Wartungs-Abo).')
}
const months = Number(body?.months)
if (!Number.isInteger(months) || months < 1 || months > config.offers.maxMonths) {
throw httpError(400, `Bitte 1 bis ${config.offers.maxMonths} Angebotsmonate wählen.`)
}
const regularPriceCents = config.stripe.priceAmountCents
const regularPriceCents = getProduct(productKey).amountCents
const monthlyPriceCents = Number(body?.monthlyPriceCents)
if (!Number.isInteger(monthlyPriceCents) || monthlyPriceCents < 0 || monthlyPriceCents >= regularPriceCents) {
throw httpError(400, `Der Angebotspreis muss zwischen 0,00 € und ${euro(regularPriceCents - 1)} liegen.`)
@@ -240,6 +263,7 @@ export function validateOfferInput(body) {
throw httpError(400, 'Bitte ein Gültigkeitsdatum ab heute und höchstens ein Jahr im Voraus wählen.')
}
return {
productKey,
months,
monthlyPriceCents,
regularPriceCents,
@@ -259,6 +283,7 @@ async function mailOffer(doc, customer, nextChargeAt = null) {
const running = doc.scope === 'laufend'
const values = {
name: customer.name || '',
produkt: productLabel(productKeyOf(doc)),
angebot_text: offerText(doc),
ersparnis: euro(savingsOf(doc)),
hinweis: doc.note || '',
@@ -293,11 +318,12 @@ export async function createOffer(body, admin) {
const input = validateOfferInput(body)
const customer = await getDocument(config.collections.customers, String(body?.customerId || '')).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
if (await getOpenOffer(customer.$id)) {
throw httpError(409, 'Für diesen Kunden gibt es schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
if (await getOpenOffer(customer.$id, input.productKey)) {
throw httpError(409, 'Für dieses Produkt gibt es für diesen Kunden schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
}
const sub = await getCurrentSubscription(customer.$id)
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
// Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status) && productKeyOf(sub) === input.productKey)
const id = randomUUID()
const { notifyCustomer, ...fields } = input
@@ -428,6 +454,20 @@ export async function activateOfferFromCheckout(session) {
})
return null
}
// Angebot und Bestellung fuer unterschiedliche Produkte (F32/F33): nichts anwenden, T03 mit Grund
const metaProductKey = session.metadata?.productKey
if (metaProductKey && productKeyOf(doc) !== metaProductKey) {
await sendAdminNotice({
template: 'T03',
values: {
angebot_nr: shortRef(offerId),
stripe_abo_id: subscriptionId,
angebot_text: offerText(doc),
angebot_status: `Produkt passt nicht: Angebot für ${productLabel(productKeyOf(doc))}, bestellt wurde ${productLabel(metaProductKey)}`,
},
})
return null
}
if (session.metadata?.offerDeferred === '1') {
// Testkonto: Im Checkout lief der Testrabatt, das Angebot kommt jetzt zusaetzlich ans Abo
await attachCouponToSubscription(subscriptionId, doc.stripeCouponId)
+690
View File
@@ -0,0 +1,690 @@
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;
// Fix 1: auch die Zustimmungsdaten des Hosting-Abschlusses)
export const RESTORABLE_METADATA = Object.freeze([
'productKey', 'termStartAt', 'switchedAt', 'switchedFrom',
'termsVersion', 'termsAcceptedAt', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent',
])
// Stripe haelt eine Aenderung hoechstens 23 Stunden zurueck; danach gleicht das Lesen des Abos ab (Fix 1)
const PENDING_MAX_MS = 23 * 3600 * 1000
const AMOUNT_CHANGED_MESSAGE = 'Der Betrag hat sich geändert – bitte Seite neu laden.'
/**
* 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,
storageUnits: items.storageUnits,
// 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). Ein
* offener Wechsel, dessen Zahlungsfrist abgelaufen ist, wird vorher mit Stripe abgeglichen (Fix 1).
*/
async function switchContext(customerId, current = null) {
let doc = current || (await requireSubscription(customerId))
const customer = await customerOf(customerId)
let events = await listContractEvents(customerId)
const open = events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === doc.$id)
if (open && (await reconcilePendingSwitch(open))) {
doc = await requireSubscription(customerId)
events = await 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, nach Rabatt: 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)
return previewFor(ctx, Math.floor(Date.now() / 1000))
}
// Gutschrift einer anteiligen Zeile nach Rabatt (Fix 1): ein Rabatt auf das Hosting-Abo verkleinert auch die
// Gutschrift; discount_amounts sind bei Gutschriftzeilen negativ, deshalb mit Betraegen gerechnet
const creditOfLine = (line) => {
const discount = (line.discount_amounts || []).reduce((sum, d) => sum + Math.abs(Number(d.amount) || 0), 0)
return Math.max(0, Math.abs(Number(line.amount) || 0) - discount)
}
async function previewFor(ctx, prorationDate) {
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 creditCents = (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 + creditOfLine(line), 0)
const start = new Date(prorationDate * 1000)
return {
prorationDate,
amountDueCents: Number(preview?.amount_due) || 0,
creditCents,
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(monthlyCentsOf(facts)),
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(monthlyCentsOf(facts)),
},
blocks,
})
}
const factsOf = (event) => ({
switchedAt: event.details?.switchedAt || event.consentAt,
amountDueCents: event.details?.amountDueCents,
monthlyCents: event.details?.monthlyCents,
})
// Monatsgebuehr fuer {{monatspreis}}; Ereignisse von vor Task 12 kennen nur den Katalogpreis des Wartungs-Abos
const monthlyCentsOf = (facts) => facts.monthlyCents ?? config.products.wartung.amountCents
// 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). Mit
* options.expectedAmountCents (Route: angezeigter Betrag) rechnet der Server die Vorschau nach; weicht der Betrag
* ab, 409 ohne Aenderung bei Stripe.
* 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, { expectedAmountCents } = {}) {
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, expectedAmountCents)
} finally {
switchesInFlight.delete(doc.$id)
}
}
async function performSwitch(customerId, current, consent, prorationDate, expectedAmountCents) {
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.')
}
// Fix 1 (8): bestellt wird genau der Betrag, den die Uebersicht gezeigt hat (neue Vorschau, dasselbe proration_date)
if (expectedAmountCents !== undefined) {
const { amountDueCents } = await previewFor(ctx, prorationDate)
if (amountDueCents !== expectedAmountCents) throw httpError(409, AMOUNT_CHANGED_MESSAGE)
}
const sepa = ctx.paymentMethod === 'sepa_debit'
const switchedAt = new Date().toISOString()
// Spec 3.5: Monatsgebuehr des Wartungs-Abos mit gebuchtem Zusatzspeicher ({{monatspreis}} in K12 und T12)
const monthlyCents = config.products.wartung.amountCents + ctx.storageUnits * config.mailStorage.amountCents
// 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,
monthlyCents,
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,
// Fix 1 (2): Ablauf der zurueckgehaltenen Aenderung, fuer den Abgleich ohne Webhook
pendingExpiresAt: pending && updated.pending_update.expires_at ? new Date(updated.pending_update.expires_at * 1000).toISOString() : null,
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 ? null : String(stripeSub.metadata[key]).slice(0, 300)])
),
},
})
const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' }
const facts = { switchedAt, amountDueCents, monthlyCents }
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, zusatzspeicher: ctx.storageUnits > 0 })
// 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(eventId, contact, doneBy = 'stripe') {
return withEventLock(eventId, async () => {
// Frisch lesen: ein anderer Webhook (oder der Abgleich) kann den Wechsel gerade abgeschlossen haben
const event = await getContractEvent(eventId)
if (event.status !== 'offen') return { skipped: `Wechsel ist schon ${event.status}` }
const now = new Date().toISOString()
const wasEffective = Boolean(event.details?.effectiveAt)
await updateContractEvent(eventId, {
status: 'erledigt',
doneAt: now,
doneBy,
details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now },
})
if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true })
return { completed: true, eventId }
})
}
// Fix 1 (4): ein Wechsel-Ereignis wird im Prozess nur von einem Ablauf zugleich geaendert (invoice.paid und
// pending_update_applied, Wiederholungen, Abgleich); ein zweiter gleichzeitiger Ablauf tut nichts
const eventsInProgress = new Set()
async function withEventLock(eventId, fn) {
if (eventsInProgress.has(eventId)) return { skipped: 'wird gerade bearbeitet' }
eventsInProgress.add(eventId)
try {
return await fn()
} finally {
eventsInProgress.delete(eventId)
}
}
/**
* 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' } = {}) {
return withEventLock(eventId, () => failSwitch(eventId, grund, doneBy))
}
async function failSwitch(eventId, grund, doneBy) {
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' }
return completeSwitch(event.$id, await contactOf(sub.customerId, sub.email))
}
/** 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)
return completeSwitch(event.$id, await contactOf(doc.customerId, doc.email))
}
/**
* invoice.payment_failed einer Wechselrechnung bei SEPA-Lastschrift: T12 mit den Handgriffen fuers Team. Nur fuer
* den echten Wechsel (Fix 1): offenes Ereignis `wechsel` des Abos mit Zahlart sepa_debit, schon wirksam und fuer
* genau diese Rechnung (details.invoiceId; fehlt sie, wird sie jetzt gespeichert). details.lastschriftFailedAt
* verhindert ein zweites T12 bei Wiederholungen oder erneuten Einzugsversuchen. Das Ereignis bleibt offen, bis das
* Team den Wechsel mit dem Skript als gescheitert abschliesst. Ohne Ereignis (Nachweis fehlte beim Klick) gilt
* ersatzweise der Stand bei Stripe: switchedAt in den letzten 30 Tagen, keine zurueckgehaltene Aenderung - dann T12
* mit dem Hinweis nachweis_fehlt. Bei Karte haelt Stripe die Aenderung zurueck, 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 meta = stripeSub.metadata || {}
const customerId = meta.webklarCustomerId || null
const switches = customerId
? (await listContractEvents(customerId)).filter((e) => e.type === 'wechsel' && e.subscriptionId === subscriptionId)
: []
const open = switches.find((e) => e.status === 'offen')
const contact = await contactOf(customerId, meta.email || invoice.customer_email)
if (open) {
return withEventLock(open.$id, async () => {
const event = await getContractEvent(open.$id)
const d = event.details || {}
if (event.status !== 'offen' || d.paymentMethod !== 'sepa_debit' || !d.effectiveAt) return { skipped: 'kein wirksamer SEPA-Wechsel' }
if (d.invoiceId && d.invoiceId !== invoice.id) return { skipped: 'nicht die Wechselrechnung' }
if (d.lastschriftFailedAt) return { skipped: 'schon gemeldet' }
await updateContractEvent(event.$id, { details: { lastschriftFailedAt: new Date().toISOString(), invoiceId: invoice.id } })
await sendSwitchNotice(contact, subscriptionId, { switchedAt: d.switchedAt, amountDueCents: invoice.amount_due, monthlyCents: d.monthlyCents }, { lastschrift_gescheitert: true })
return { notified: true, eventId: event.$id }
})
}
// Rueckfall ohne Ereignis: nur, wenn kein Ereignis zu diesem Wechsel existiert (ein erledigter bleibt unberuehrt)
const switchedAt = Date.parse(meta.switchedAt || '')
if (!switchedAt || Date.now() - switchedAt > SEPA_FAILURE_WINDOW_MS || stripeSub.pending_update) {
return { skipped: 'kein wirksamer Wechsel in den letzten 30 Tagen' }
}
if (switches.some((e) => e.details?.switchedAt === meta.switchedAt)) return { skipped: 'Wechsel schon abgeschlossen' }
await sendSwitchNotice(
contact,
subscriptionId,
{
switchedAt: new Date(switchedAt).toISOString(),
amountDueCents: invoice.amount_due,
monthlyCents: config.products.wartung.amountCents + describeItems(stripeSub.items?.data).storageUnits * config.mailStorage.amountCents,
},
{ lastschrift_gescheitert: true, nachweis_fehlt: true }
)
return { notified: true, eventId: null }
}
/**
* Abgleich ohne Webhook (Fix 1): ein offener Wechsel, der noch nicht wirksam ist (Karte, Zahlung stand aus), und
* dessen zurueckgehaltene Aenderung laengst verfallen sein muesste (details.pendingExpiresAt, sonst 23 Stunden nach
* dem Wechsel), wird mit einem Abruf bei Stripe geklaert: keine zurueckgehaltene Aenderung mehr und Preis noch
* Hosting -> gescheitert (K14, wie pending_update_expired); Preis Wartung -> erledigt (T12, wie
* pending_update_applied). Aufgerufen beim Lesen des Abos (GET /api/abo) und vor Vorschau und Wechsel. Liefert
* 'gescheitert' | 'erledigt' | null; Fehler landen nur im Log.
*/
export async function reconcilePendingSwitch(event, now = Date.now()) {
try {
if (event?.type !== 'wechsel' || event.status !== 'offen' || event.details?.effectiveAt) return null
const switchedAt = Date.parse(event.details?.switchedAt || event.consentAt || '')
const expiresAt = Date.parse(event.details?.pendingExpiresAt || '') || (switchedAt ? switchedAt + PENDING_MAX_MS : NaN)
if (!expiresAt || now < expiresAt) return null
const stripeSub = await getStripe().subscriptions.retrieve(event.subscriptionId)
if (stripeSub.pending_update) return null
const productKey = describeItems(stripeSub.items?.data).productKey
if (productKey === 'hosting') {
const result = await markSwitchFailed(event.$id, SWITCH_FAILED_REASONS.abgelaufen, { doneBy: 'abgleich' })
return result.failed ? 'gescheitert' : null
}
if (productKey === 'wartung') {
const sub = (await syncSubscription(event.subscriptionId)) || null
const result = await completeSwitch(event.$id, await contactOf(event.customerId, sub?.email), 'abgleich')
return result.completed ? 'erledigt' : null
}
return null
} catch (err) {
console.error(`[wechsel] Abgleich von Wechsel ${event?.$id} mit Stripe fehlgeschlagen:`, err.message)
return null
}
}
+79 -4
View File
@@ -1,3 +1,5 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../config.js'
/**
@@ -23,6 +25,67 @@ export function getProduct(key) {
return product
}
/** Die archivierte Fassung 3 (public/vertragsunterlagen/v3): gilt fuer jede Zustimmung ohne Fassung "-v4". */
export const TERMS_VERSION_V3 = '2026-09-25-v3'
/** Ist die Fassung die aktuelle Version 4 (Hosting- und Wartungs-Abo)? Aeltere und fehlende Fassungen: nein. */
export function isTermsV4(termsVersion) {
return typeof termsVersion === 'string' && termsVersion.endsWith('-v4')
}
/**
* Vertragsunterlagen zur zugestimmten Fassung: Version 4 (Suffix "-v4") zeigt die aktuellen
* Bedingungen samt PDF, jede aeltere Version (auch unbekannt/keine) das Archiv der Version 3.
* version: die Fassung, die eine Mail nennt - die gespeicherte, ohne gespeicherte die archivierte Fassung 3,
* damit Versionsangabe und PDF-Anhang immer zusammenpassen (Task 12 Fix 1).
* endParagraph: der Paragraph zum Vertragsende (Daten, AuthCode, Loeschung) in dieser Fassung, fuer die
* Mails K02/K03/T05/T07 (F08) - in Fassung 4 ist § 9 der Wechsel zwischen den Abos, § 11 das Vertragsende.
*/
export function termsDocFor(termsVersion) {
if (isTermsV4(termsVersion)) {
return { version: termsVersion, url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' }
}
return {
version: typeof termsVersion === 'string' && termsVersion ? termsVersion : TERMS_VERSION_V3,
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
endParagraph: '§ 9',
}
}
/** Katalogname ohne "WEBklar " (z. B. "Hosting-Abo") fuer {{produkt}} in den Mails (F33); ohne Schluessel "Abo". */
export function productLabel(key) {
return PRODUCT_KEYS.includes(key) ? config.products[key].name.replace(/^WEBklar\s+/, '') : 'Abo'
}
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
/**
* Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat (§ 312f BGB):
* { filename, path } mit dem absoluten Pfad unter public/. Ein Speicher-Plus haengt die Fassung aus der
* Zustimmung an (consent.termsVersion), nicht die des Abos (F22): Bestandskunden mit Fassung 3 bestellen
* den Zusatzspeicher nach Fassung 4.
*/
export function contractAttachmentFor(termsVersion) {
const { pdfFile } = termsDocFor(termsVersion)
return { filename: pdfFile, path: path.join(PUBLIC_DIR, pdfFile) }
}
/** Ansicht eines Produkts fuers Kundenportal (Vergleich, Bestellseite): Preis und Leistungen. */
export function productView(key) {
const product = getProduct(key)
return {
key: product.key,
name: product.name,
amountCents: product.amountCents,
minTermMonths: product.minTermMonths,
minutesPerMonth: product.minutesPerMonth,
// Kopie, kein Verweis auf den Katalog: aufrufender Code darf das zurueckgegebene Array
// veraendern, ohne den geteilten Katalogeintrag fuer alle anderen zu verfaelschen (B10)
features: product.features.map((row) => [...row]),
}
}
/** lookup_key eines Stripe-Preises -> 'wartung' | 'hosting' | 'mailStorage' | null */
export function productKeyForLookupKey(lookupKey) {
if (!lookupKey) return null
@@ -52,12 +115,27 @@ export function describeItems(items) {
const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null
// Regulaere Abo-Zeile (Monatsposition): keine anteilige Verrechnung, keine einzelne Rechnungsposition
const isRegularSubscriptionLine = (line) =>
line?.parent?.type === 'subscription_item_details' && line.parent.subscription_item_details?.proration === false
/** Preis-IDs aller Rechnungszeilen, jede nur einmal. */
export function priceIdsOfInvoice(invoice) {
const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean)
return [...new Set(ids)]
}
/**
* Regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Abrechnungsmonats) oder null.
* Anteilige Zeilen (Plus im laufenden Monat) zaehlen nicht. priceKeys wie bei creditMinutesForInvoice.
*/
export function regularStorageLine(invoice, priceKeys) {
return (
(invoice?.lines?.data || []).find((line) => priceKeys.get(priceIdOfLine(line)) === 'mailStorage' && isRegularSubscriptionLine(line)) ||
null
)
}
/**
* Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten
* regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige
@@ -74,10 +152,7 @@ export function creditMinutesForInvoice(invoice, priceKeys) {
const key = legacy && known == null ? 'wartung' : known
if (!PRODUCT_KEYS.includes(key)) continue
const minutes = config.products[key].minutesPerMonth
const regular =
line.parent?.type === 'subscription_item_details' &&
line.parent.subscription_item_details?.proration === false
if (minutes > 0 && regular && line.amount > 0) return minutes
if (minutes > 0 && isRegularSubscriptionLine(line) && line.amount > 0) return minutes
}
return 0
}
+233
View File
@@ -0,0 +1,233 @@
import { rm } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../config.js'
import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
import { getStripe } from './stripeBilling.js'
import { filesOf, STATUS } from './takeoverCredits.js'
/**
* Loeschfristen (Datenschutzerklaerung Abschnitt 7 und 9, Rechtsrunde Entscheidung 11):
* - Abo-Daten: "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet."
* Ein Kunde ist faellig, wenn alle seine Abos beendet sind und seit dem Ende des Kalenderjahres seines
* letzten Abo-Endes drei Jahre vergangen sind (Ende 15.03.2022 -> faellig am 01.01.2026). Geloescht werden
* die Dokumente in subscriptions und contractEvents sowie die Zustimmungsangaben in den Stripe-Metadaten
* (F41b). Rechnungen bleiben (Abschnitt 9).
* - Uebernahme-Antraege: Nachweise abgelehnter Antraege loescht schon die taegliche Bereinigung
* (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die
* Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des
* Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht.
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
* Liste abgeschnitten (>= LIST_LIMIT), bricht der Lauf ab, bevor etwas geplant oder geloescht wird.
*/
const SUBSCRIPTIONS = () => config.collections.subscriptions
const EVENTS = () => config.collections.contractEvents
const TAKEOVER = () => config.collections.takeoverCredits
export const RETENTION_YEARS = 3
export const RECEIPT_YEARS = 8
const LIST_LIMIT = 5000
/** Alle Zustimmungs-/Nachweisangaben, die das Portal in die Stripe-Metadaten des Abos schreibt. */
export const CONSENT_METADATA_KEYS = [
'termsVersion',
'termsAcceptedAt',
'earlyStartConsentAt',
'startChoice',
'consentIp',
'consentUserAgent',
'consentTextVersion',
'switchedAt',
'switchedFrom',
]
const berlinYear = (dateLike) =>
Number(new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric' }).format(new Date(dateLike)))
const validDate = (value) => (value && !Number.isNaN(new Date(value).getTime()) ? value : null)
/** Ende eines Abos (ISO) oder null, wenn es nicht beendet ist. */
function endOf(doc) {
// Stripe hat das Abo nie aktiviert und verfallen lassen: zaehlt als beendet
if (doc.status === 'incomplete_expired' && !validDate(doc.endedAt)) {
return validDate(doc.canceledAt) || validDate(doc.$updatedAt) || validDate(doc.startedAt)
}
if (!validDate(doc.endedAt) && doc.status !== 'canceled') return null
return validDate(doc.endedAt) || validDate(doc.canceledAt) || validDate(doc.currentPeriodEnd)
}
/**
* @param {Date} [now]
* @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[],
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
*/
export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } = {}) {
const currentYear = berlinYear(now)
const list = async (collection, queries = []) => {
const docs = await listDocuments(collection, [...queries, Query.limit(limit)])
if (docs.length >= limit) throw new Error(`Liste abgeschnitten, Paginierung nötig (${collection}, ${docs.length} Einträge)`)
return docs
}
const subs = await list(SUBSCRIPTIONS())
const byCustomer = new Map()
for (const doc of subs) {
if (!doc.customerId) continue
if (!byCustomer.has(doc.customerId)) byCustomer.set(doc.customerId, [])
byCustomer.get(doc.customerId).push(doc)
}
const customers = []
const pending = []
const skipped = []
for (const [customerId, docs] of byCustomer) {
const incomplete = docs.filter((d) => d.status === 'incomplete')
const others = docs.filter((d) => d.status !== 'incomplete')
if (incomplete.length && others.every((d) => endOf(d))) {
pending.push({
customerId,
subscriptionIds: incomplete.map((d) => d.$id),
reason: 'Abo im Status incomplete hält Löschung auf (Stripe klären: wird es aktiv oder verfällt es?)',
})
}
const ends = docs.map(endOf)
// ein laufendes Abo (oder ein beendetes ohne erkennbares Datum) haelt alle Daten des Kunden
if (ends.some((end) => !end)) continue
const lastEndAt = ends.reduce((a, b) => (new Date(a) > new Date(b) ? a : b))
if (currentYear < berlinYear(lastEndAt) + RETENTION_YEARS + 1) continue
const events = await list(EVENTS(), [Query.equal('customerId', customerId)])
customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) })
}
const takeover = []
const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)])
for (const doc of rejected) {
if (!filesOf(doc).length || !validDate(doc.decidedAt) || new Date(doc.decidedAt).getTime() > rejectedCutoff) continue
takeover.push({
id: doc.$id,
action: 'melden',
reason: `abgelehnt am ${doc.decidedAt.slice(0, 10)}, Nachweise noch vorhanden (die tägliche Bereinigung löscht sie nach sechs Monaten; Logs und Upload-Ordner prüfen)`,
})
}
const approved = await list(TAKEOVER(), [Query.equal('status', STATUS.approved)])
for (const doc of approved) {
const decided = validDate(doc.decidedAt)
if (!decided) {
skipped.push({ id: doc.$id, reason: 'genehmigt, aber ohne Entscheidungsdatum (decidedAt); Frist nicht berechenbar, von Hand prüfen' })
continue
}
if (currentYear < berlinYear(decided) + RECEIPT_YEARS + 1) continue
takeover.push({
id: doc.$id,
action: 'loeschen',
reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`,
})
}
return { customers, takeover, pending, skipped }
}
const isGone = (err) => err?.status === 404
async function deleteQuietly(collection, id) {
try {
await deleteDocument(collection, id)
return true
} catch (err) {
if (isGone(err)) return false
throw err
}
}
/**
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number },
* manual: { customerId: string, subscriptionId: string, reason: string }[],
* failed: { what: string, id: string, reason: string }[] }>}
*/
export async function applyDeletions(plan) {
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0 }
const manual = []
const failed = []
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
const attempt = async (what, id, fn) => {
try {
return await fn()
} catch (err) {
failed.push({ what, id, reason: err.message })
return false
}
}
for (const customer of plan.customers || []) {
for (const id of customer.subscriptionIds) {
try {
await getStripe().subscriptions.update(id, { metadata: clear })
deleted.stripeCleared += 1
} catch (err) {
manual.push({
customerId: customer.customerId,
subscriptionId: id,
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
})
}
}
for (const id of customer.eventIds) {
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
}
for (const id of customer.subscriptionIds) {
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
}
}
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
const removed = await attempt('takeoverCredits', item.id, async () => {
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
return true
})
if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind
if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1
}
return { deleted, manual, failed }
}
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })
/** Bericht als Text (Konsole und T14); result nur nach dem Loeschen. */
export function formatReport(plan, result = null) {
const lines = []
if (!plan.customers.length) lines.push('Kunden: nichts fällig.')
for (const c of plan.customers) {
lines.push(
`Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)`
)
}
const del = plan.takeover.filter((t) => t.action === 'loeschen')
const report = plan.takeover.filter((t) => t.action === 'melden')
if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`))
if (report.length) lines.push('', 'Zur Kenntnis (nicht von diesem Lauf gelöscht):', ...report.map((t) => ` ${t.id}: ${t.reason}`))
if (plan.pending?.length) {
lines.push('', 'Hält Löschung auf:', ...plan.pending.map((p) => ` Kunde ${p.customerId}, Abo ${p.subscriptionIds.join(', ')}: ${p.reason}`))
}
if (plan.skipped?.length) {
lines.push('', 'Übersprungen (von Hand prüfen):', ...plan.skipped.map((t) => ` ${t.id}: ${t.reason}`))
}
if (result) {
const d = result.deleted
lines.push(
'',
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`
)
if (result.failed?.length) {
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
}
if (result.manual.length) {
lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`))
}
}
return lines.join('\n')
}
+173 -60
View File
@@ -8,7 +8,7 @@ import {
updateDocument,
} from './appwriteAdmin.js'
import { lateStartAt } from './deadlines.js'
import { creditMinutesForInvoice, describeItems, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
/**
* Wartungs-Abo ueber Stripe Billing.
@@ -50,41 +50,64 @@ export function isStripeConfigured() {
return Boolean(config.stripe.secretKey)
}
let cachedPriceId = null
/** Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. */
export async function ensureAboPrice() {
if (cachedPriceId) return cachedPriceId
// Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice)
const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher' }
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */
function catalogFor(key) {
return key === 'mailStorage' ? config.mailStorage : getProduct(key)
}
const priceIdCache = new Map()
/**
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
* 'mailStorage'. Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
*/
export async function ensurePrice(key) {
if (priceIdCache.has(key)) return priceIdCache.get(key)
const stripe = getStripe()
const catalog = catalogFor(key)
const { data } = await stripe.prices.list({
lookup_keys: [config.stripe.priceLookupKey],
lookup_keys: [catalog.lookupKey],
active: true,
limit: 1,
})
if (data[0]) {
cachedPriceId = data[0].id
return cachedPriceId
priceIdCache.set(key, data[0].id)
return data[0].id
}
const product = await stripe.products.create({
name: config.stripe.productName,
description: config.stripe.productDescription,
statement_descriptor: config.stripe.statementDescriptor,
images: [config.stripe.productImage],
metadata: { webklar: 'wartungs-abo' },
name: catalog.name,
...(catalog.description ? { description: catalog.description } : {}),
...(catalog.statementDescriptor ? { statement_descriptor: catalog.statementDescriptor } : {}),
// Produktbild: eigenes Katalogbild, sonst das generische Logo (Fix round 1, Important 2 - war
// beim Verallgemeinern von ensureAboPrice zu ensurePrice verloren gegangen)
images: [catalog.image || config.stripe.productImage],
metadata: { webklar: PRODUCT_METADATA[key] },
})
const price = await stripe.prices.create({
product: product.id,
currency: 'eur',
unit_amount: config.stripe.priceAmountCents,
unit_amount: catalog.amountCents,
recurring: { interval: 'month' },
tax_behavior: 'inclusive',
lookup_key: config.stripe.priceLookupKey,
nickname: 'Wartungs-Abo monatlich',
lookup_key: catalog.lookupKey,
nickname: catalog.priceNickname,
})
cachedPriceId = price.id
return cachedPriceId
priceIdCache.set(key, price.id)
return price.id
}
// Beide Abos zusammen im Kundenportal, nicht mehr nur die Wartung (Hosting-Abo dazu)
export const PORTAL_HEADLINE = 'WEBklar – Abo verwalten'
let cachedPortalConfigId = null
/** Nur fuer Tests: Cache zuruecksetzen, damit beide Zweige (Update noetig / schon richtig) einzeln pruefbar sind. */
export function resetPortalConfigCacheForTests() {
cachedPortalConfigId = null
}
/** Kundenportal-Konfiguration (Zahlungsmittel, Rechnungen, Kuendigung zum Periodenende). */
export async function ensurePortalConfiguration() {
if (cachedPortalConfigId) return cachedPortalConfigId
@@ -92,12 +115,30 @@ export async function ensurePortalConfiguration() {
const { data } = await stripe.billingPortal.configurations.list({ active: true, limit: 20 })
const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo')
if (own) {
// Aeltere Konfiguration (frueher nur Wartung) traegt noch die alte Ueberschrift -> anpassen.
// Fix round 1, Minor 6: Cache erst nach Erfolg setzen, damit ein Fehlschlag beim naechsten
// Aufruf (z. B. Klick auf "Abo verwalten") erneut versucht wird statt fuer immer stehen zu
// bleiben; ein Fehlschlag bricht den Aufruf selbst nicht ab (die Portal-Sitzung soll trotzdem
// zustande kommen), nur eine Warnung im Log.
if (own.business_profile?.headline !== PORTAL_HEADLINE) {
try {
await stripe.billingPortal.configurations.update(own.id, {
business_profile: {
headline: PORTAL_HEADLINE,
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
},
})
} catch (err) {
console.warn(`[stripe] Kundenportal-Konfiguration ${own.id}: Überschrift konnte nicht aktualisiert werden - ${err.message}`)
return own.id
}
}
cachedPortalConfigId = own.id
return own.id
}
const created = await stripe.billingPortal.configurations.create({
business_profile: {
headline: 'Webklar – Wartungs-Abo verwalten',
headline: PORTAL_HEADLINE,
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
},
default_return_url: `${config.publicBaseUrl}/dashboard.html`,
@@ -197,6 +238,9 @@ export async function syncSubscription(subscriptionId) {
stripeSubscriptionId: sub.id,
status: sub.status,
productKey: items.productKey || 'wartung',
// Plan 2: Wechseltag (Beginn der neuen Mindestlaufzeit) und bezahlter Speicherstand
termStartAt: termStartOf(sub).toISOString(),
storageUnits: items.storageUnits,
planKey: items.lookupKey || config.products.wartung.lookupKey,
priceAmount: items.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents,
startedAt: toIso(sub.start_date),
@@ -219,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()
@@ -265,11 +309,25 @@ export function minTermMonthsFor(termsVersion) {
return ['2026-09-25', '2026-09-25-v2'].includes(termsVersion) ? 0 : config.abo.minTermMonths
}
export function serviceStartOf(sub) {
/**
* Beginn der aktuellen Mindestlaufzeit: der Wechseltag aus den Metadaten (nach einem Wechsel ins
* Wartungs-Abo), sonst der Leistungsbeginn (Ende der Probezeit bzw. Abo-Start). Ersetzt
* serviceStartOf in cancelSubscriptionAt.
*/
export function termStartOf(sub) {
const termStartAt = sub.metadata?.termStartAt
if (termStartAt) {
const date = new Date(termStartAt)
if (!Number.isNaN(date.getTime())) return date
// Von Hand verstellte/beschaedigte Metadaten duerfen syncSubscription (toISOString) und
// cancelSubscriptionAt nicht mit einer Exception abschiessen - Warnung, Fallback wie ohne Wechsel
console.warn(`[stripe] Abo ${sub.id || '?'}: ungültiges termStartAt in den Metadaten ("${termStartAt}") - falle auf Leistungsbeginn zurück`)
}
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
}
function subscriptionIdOfInvoice(invoice) {
/** Stripe-Abo-ID (oder -Objekt) einer Rechnung: Webhook-Nutzlast (alt/neu) oder frisch geladene Rechnung. */
export function subscriptionIdOfInvoice(invoice) {
return (
invoice.subscription ||
invoice.parent?.subscription_details?.subscription ||
@@ -299,8 +357,33 @@ export async function resolvePriceKeys(priceIds) {
return keys
}
/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
export async function grantCreditForInvoice(invoice) {
/**
* Alle Zeilen einer Rechnung. invoices.retrieve bettet hoechstens 10 Zeilen ein (lines.has_more); anteilige
* Zeilen stehen vor den Abo-Zeilen, mehrere Speicheraenderungen in einem Monat schieben also die Wartungs-
* oder Speicher-Zeile hinaus. Bei has_more kommen alle Seiten ueber invoices.listLineItems (F18). Eine
* Vorschau-Rechnung hat keine ID und laesst sich nicht nachladen: dort zaehlen die eingebetteten Zeilen.
*/
export async function invoiceLines(invoice) {
const embedded = invoice?.lines?.data || []
if (!invoice?.lines?.has_more || !invoice.id) return embedded
return getStripe().invoices.listLineItems(invoice.id, { limit: 100 }).autoPagingToArray({ limit: 1000 })
}
/**
* Rechnung frisch laden (in der API-Version des SDK, nicht der des Webhook-Endpunkts), mit allen Zeilen.
* Der Webhook invoice.paid laedt sie genau einmal und reicht sie an Stundengutschrift und Speicher-Buchung weiter.
*/
export async function loadInvoice(invoiceId) {
const fresh = await getStripe().invoices.retrieve(invoiceId)
const data = await invoiceLines(fresh)
return { ...fresh, lines: { ...(fresh.lines || {}), data, has_more: false } }
}
/**
* Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). invoice ist die
* Webhook-Nutzlast (wird frisch geladen) oder, mit fresh: true, eine schon per loadInvoice geladene Rechnung.
*/
export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
const ref = subscriptionIdOfInvoice(invoice)
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
@@ -308,28 +391,28 @@ export async function grantCreditForInvoice(invoice) {
// Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot
// oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €),
// anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde.
const fresh = await getStripe().invoices.retrieve(invoice.id)
const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))
const loaded = fresh ? invoice : await loadInvoice(invoice.id)
const minutes = creditMinutesForInvoice(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded)))
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
const sub = await syncSubscription(subscriptionId)
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000)
const grantedAt = new Date((loaded.status_transitions?.paid_at || loaded.created) * 1000)
try {
await createDocument(
config.collections.hourCredits,
{
customerId: sub.customerId,
stripeSubscriptionId: subscriptionId,
stripeInvoiceId: invoice.id,
stripeInvoiceId: loaded.id,
minutes,
grantedAt: grantedAt.toISOString(),
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
source: 'stripe_invoice',
note: fresh.number ? `Rechnung ${fresh.number}` : '',
note: loaded.number ? `Rechnung ${loaded.number}` : '',
},
invoice.id
loaded.id
)
return { granted: true, customerId: sub.customerId, subscription: sub }
} catch (err) {
@@ -371,50 +454,88 @@ async function getOrCreateStripeCustomer(customer) {
return created.id
}
/** Rabatt fuer Testkonten: 49,50 € auf jede Abrechnung -> es werden nur 0,50 € abgebucht. */
async function ensureTestCoupon() {
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1)
/**
* Rabatt fuer Testkonten holen oder (einmalig) anlegen. productKey: 'wartung' | 'hosting'; die ID
* kommt aus dem Katalog (products.<key>.testCouponId), wie amountCents und der lookup_key.
* Fix round 1, Important 1: Stripe begrenzt den Coupon-Namen auf 40 Zeichen - der volle Produktname
* ("Testzugang WEBklar (0,50 €) – WEBklar Hosting-Abo" = 49) hätte in echtem Stripe 400 zurückgegeben.
*/
export async function ensureTestCoupon(productKey) {
const catalog = getProduct(productKey) // 'mailStorage' oder unbekannt -> 400 (kein Testrabatt dafuer)
const id = catalog.testCouponId
if (!id) {
const err = new Error(`Kein Testrabatt fuer ${productKey}`)
err.status = 400
throw err
}
const stripe = getStripe()
const existing = await stripe.coupons.retrieve(config.abo.testCouponId).catch(() => null)
const existing = await stripe.coupons.retrieve(id).catch(() => null)
if (existing) return existing.id
const created = await stripe.coupons.create({
id: config.abo.testCouponId,
name: 'Testzugang WEBklar (0,50 €)',
amount_off: config.stripe.priceAmountCents - 50,
id,
name: `Testzugang ${capitalize(productKey)} (0,50 €)`,
amount_off: catalog.amountCents - 50,
currency: 'eur',
duration: 'forever',
metadata: { webklar: 'wartungs-abo-test' },
metadata: { webklar: `${PRODUCT_METADATA[productKey]}-test` },
})
return created.id
}
// Preis wie in den Bedingungen: "15,00 €" (normales Leerzeichen, kein geschuetztes wie bei toLocaleString)
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
/**
* Stripe Checkout fuer das Abo. Der Kunde hat seine Bestellung vorher im Portal
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
* Hinweis ueber dem Bezahlknopf im Checkout (custom_text): Vertragsschluss, Leistungsbeginn, Preis
* und Mindestlaufzeit des bestellten Produkts; bei einem Angebot der vereinbarte Preis.
*/
function checkoutMessage(product, lateStart, offer) {
const intro =
`Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.` +
(lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : '')
const price = offer
? `Preis wie vereinbart: ${offer.text}.`
: `${lateStart ? 'Danach ' : ''}${fmtEuro(product.amountCents)} monatlich,`
return `${intro} ${price} Mindestlaufzeit ${product.minTermMonths} Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.`
}
/**
* Stripe Checkout fuer ein Abo (productKey 'wartung' | 'hosting'). Der Kunde hat seine Bestellung
* vorher im Portal abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
* Position, Beschreibung, Hinweistext und Testrabatt richten sich nach dem Produkt.
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist' (Probezeit ohne Leistung
* und ohne Abbuchung bis consent.serviceStartAt, dem Tag nach dem Ende der Widerrufsfrist, deadlines.js).
* offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten
* bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred).
*/
export async function createAboCheckout(customer, consent, offer = null) {
export async function createAboCheckout(customer, consent, productKey, offer = null) {
const product = getProduct(productKey) // unbekanntes Produkt -> 400
// Hoechstens ein laufendes Abo je Kunde (Spec 7); der Wechsel zwischen den Abos laeuft ueber planSwitch
const current = await getCurrentSubscription(customer.$id)
if (current && ACTIVE_STATUSES.has(current.status)) {
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
const err = new Error('Sie haben bereits ein laufendes Abo abgeschlossen.')
err.status = 409
throw err
}
const stripe = getStripe()
const [priceId, stripeCustomerId] = await Promise.all([
ensureAboPrice(),
ensurePrice(productKey),
getOrCreateStripeCustomer(customer),
])
const lateStart = consent.startChoice === 'nach_frist'
const serviceStartAt = lateStart ? consent.serviceStartAt || lateStartAt() : null
const isTestAccount = config.abo.testEmails.includes(String(customer.email || '').toLowerCase())
const testCoupon = isTestAccount ? await ensureTestCoupon() : null
// Stripe-Metadaten: max. 500 Zeichen je Wert
const testCoupon = isTestAccount ? await ensureTestCoupon(productKey) : null
const offerMeta = offer
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
: {}
// Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Zuordnung, Produkt und Nachweis der Zustimmung
const meta = {
webklarCustomerId: customer.$id,
email: customer.email || '',
productKey,
termsVersion: config.abo.termsVersion,
consentTextVersion: config.abo.termsVersion,
termsAcceptedAt: consent.termsAcceptedAt,
@@ -422,13 +543,10 @@ export async function createAboCheckout(customer, consent, offer = null) {
earlyStartConsentAt: consent.earlyStartConsentAt || '',
consentIp: String(consent.consentIp || '').slice(0, 64),
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
...(offer
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
: {}),
...offerMeta,
}
// Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon
const coupon = testCoupon || offer?.stripeCouponId || null
const offerPrice = offer ? ` Preis wie vereinbart: ${offer.text}.` : ''
const params = {
mode: 'subscription',
customer: stripeCustomerId,
@@ -440,20 +558,15 @@ export async function createAboCheckout(customer, consent, offer = null) {
customer_update: { address: 'auto', name: 'auto' },
subscription_data: {
metadata: meta,
description: config.stripe.productName,
description: product.name,
...(serviceStartAt ? { trial_end: Math.floor(serviceStartAt.getTime() / 1000) } : {}),
},
metadata: meta,
// An der Checkout-Sitzung nur, was der Webhook braucht (Zuordnung, Produkt, Angebot). Keine
// Zustimmungsdaten: abgeschlossene Sitzungen lassen sich spaeter nicht mehr bereinigen
// (Loeschfristen, Datenschutzerklaerung Abschnitt 7) - die stehen nur am Abo.
metadata: { webklarCustomerId: customer.$id, productKey, ...offerMeta },
...(coupon ? { discounts: [{ coupon }] } : {}),
custom_text: {
submit: {
message: offer
? `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.${lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : ''}${offerPrice} Mindestlaufzeit 12 Monate, danach monatlich kündbar.`
: lateStart
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
},
},
custom_text: { submit: { message: checkoutMessage(product, lateStart, offer) } },
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
}
@@ -504,7 +617,7 @@ export async function cancelSubscriptionAt(subscriptionId, requestedDate = null,
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId)
const { effectiveAt, minTermEnd } = computeCancellationDate({
serviceStart: serviceStartOf(sub),
serviceStart: termStartOf(sub),
receivedAt,
requestedDate,
kind,
+19 -10
View File
@@ -6,6 +6,7 @@ import { domainToASCII, domainToUnicode } from 'node:url'
import { config } from '../config.js'
import { createDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getProduct, PRODUCT_KEYS } from './products.js'
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
/**
@@ -34,7 +35,7 @@ export const STATUS = {
export const SERVICE_LABELS = {
domain: 'Domain',
hosting: 'Hosting / Website-Betrieb',
email: 'E-Mail-Postfach',
email: 'E-Mail-Postfächer',
sonstiges: 'Sonstiges',
}
@@ -45,7 +46,7 @@ export const REQUIRED_CHECKS = [
{ key: 'recipient', label: 'Rechnung ist auf den Kunden ausgestellt (Name bzw. Firma stimmt)' },
{ key: 'service', label: 'Leistung und Domain auf der Rechnung stimmen mit dem Antrag überein' },
{ key: 'period', label: 'Betrag und bezahlter Zeitraum stimmen mit der Rechnung überein' },
{ key: 'replaced', label: 'Die Leistung wird durch das Wartungs-Abo ersetzt (Umzug zu WEBklar)' },
{ key: 'replaced', label: 'Die Leistung wird durch das Abo ersetzt (Umzug zu WEBklar)' },
]
const DAY_MS = 24 * 60 * 60 * 1000
@@ -348,6 +349,8 @@ function withHistory(doc, entry) {
const serviceStartOf = (sub) => sub?.trialEnd || sub?.startedAt || null
const isActiveSub = (sub) => Boolean(sub && ACTIVE_STATUSES.has(sub.status))
// Produkt des Abos: Altbestand ohne productKey und unbekannte Schluessel gelten als Wartungs-Abo
const productKeyOf = (sub) => (PRODUCT_KEYS.includes(sub?.productKey) ? sub.productKey : 'wartung')
export async function getTakeoverRequest(id) {
return getDocument(COLLECTION(), id)
@@ -387,7 +390,7 @@ export function customerView(doc) {
export async function createTakeoverRequest({ customer, input, uploads, consentIp }) {
const sub = await getCurrentSubscription(customer.$id)
if (!isActiveSub(sub)) {
throw httpError(409, 'Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft.')
throw httpError(409, 'Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft.')
}
const own = await listTakeoverRequests({ customerId: customer.$id })
const open = own.filter((r) => r.status === STATUS.open || r.status === STATUS.question)
@@ -618,14 +621,14 @@ export async function runAutoChecks(doc, { allRequests, subscription, calc }) {
}
if (!subscription) {
checks.push({ key: 'abo', status: 'warn', label: 'Kunde hat kein Wartungs-Abo – eine Gutschrift kann nicht verrechnet werden' })
checks.push({ key: 'abo', status: 'warn', label: 'Kunde hat kein Abo – eine Gutschrift kann nicht verrechnet werden' })
} else if (!isActiveSub(subscription)) {
checks.push({ key: 'abo', status: 'warn', label: `Wartungs-Abo ist beendet (Status ${subscription.status}) – keine Verrechnung möglich` })
checks.push({ key: 'abo', status: 'warn', label: `Abo ist beendet (Status ${subscription.status}) – keine Verrechnung möglich` })
} else {
checks.push({
key: 'abo',
status: subscription.cancelAtPeriodEnd ? 'info' : 'ok',
label: `Wartungs-Abo läuft, Leistungsbeginn ${fmtDate(serviceStartOf(subscription))}${subscription.cancelAtPeriodEnd ? `, gekündigt zum ${fmtDate(subscription.cancelAt || subscription.currentPeriodEnd)}` : ''}`,
label: `Abo läuft, Leistungsbeginn ${fmtDate(serviceStartOf(subscription))}${subscription.cancelAtPeriodEnd ? `, gekündigt zum ${fmtDate(subscription.cancelAt || subscription.currentPeriodEnd)}` : ''}`,
})
}
@@ -637,12 +640,14 @@ export async function runAutoChecks(doc, { allRequests, subscription, calc }) {
)
}
// Plausibilitaet gegen die Monatsgebuehr des Abos des Kunden (productKeyOf: ohne bzw. mit unbekanntem Schluessel = Wartungs-Abo)
const monthly = doc.invoiceAmountCents / Math.max(1, doc.billingMonths)
if (monthly > config.stripe.priceAmountCents) {
const aboCents = getProduct(productKeyOf(subscription)).amountCents
if (monthly > aboCents) {
checks.push({
key: 'amount',
status: 'info',
label: `Beim bisherigen Anbieter ${fmtEuro(monthly)} pro Monat – mehr als das Abo. Enthält die Rechnung weitere Leistungen?`,
label: `Beim bisherigen Anbieter ${fmtEuro(monthly)} pro Monat – mehr als die Monatsgebühr des Abos (${fmtEuro(aboCents)}). Enthält die Rechnung weitere Leistungen?`,
})
}
@@ -732,6 +737,10 @@ export async function getAdminDetail(id) {
? {
status: subscription.status,
active: isActiveSub(subscription),
productKey: productKeyOf(subscription),
productName: getProduct(productKeyOf(subscription)).name,
// Zusatzspeicher (Pakete zu 5 GB) fuer die Zeile "Abo" im Admin (Task 13)
storageUnits: Number(subscription.storageUnits) || 0,
startedAt: subscription.startedAt,
serviceStart: serviceStartOf(subscription),
currentPeriodEnd: subscription.currentPeriodEnd,
@@ -768,7 +777,7 @@ export async function applyTakeoverCredit(doc) {
if (!isStripeConfigured()) return { applied: false, doc, reason: 'Stripe ist nicht eingerichtet.' }
const sub = await getCurrentSubscription(doc.customerId)
if (!isActiveSub(sub) || !sub.stripeCustomerId) {
return { applied: false, doc, reason: 'Der Kunde hat kein laufendes Wartungs-Abo.' }
return { applied: false, doc, reason: 'Der Kunde hat kein laufendes Abo.' }
}
try {
const txn =
@@ -873,7 +882,7 @@ export async function decideTakeoverRequest(id, body, admin) {
const verified = readInvoiceFigures(body, doc)
const sub = await getCurrentSubscription(doc.customerId)
if (!isActiveSub(sub)) {
throw httpError(409, 'Der Kunde hat kein laufendes Wartungs-Abo – die Gutschrift kann nicht verrechnet werden.')
throw httpError(409, 'Der Kunde hat kein laufendes Abo – die Gutschrift kann nicht verrechnet werden.')
}
const corrections = [
+549
View File
@@ -0,0 +1,549 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
aboOffers: schemaKeys('aboOffers'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
// Testkonto (Testrabatt im Checkout): Kunde c4 der Fixtures
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, viewAsCookie, seedFixtures, subscriptionDoc, stripeSubscription } =
await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { offerText } = await import('../server/services/offers.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
// F25: Mails landen in einer Senke statt bei SMTP, damit K01 (Anhang je Fassung) und T02 pruefbar sind (Task 12)
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
const DAY_MS = 86400000
const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString()
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
const orderBody = (productKey, extra = {}) => ({
productKey,
acceptTerms: true,
startChoice: 'sofort',
termsVersion: config.abo.termsVersion,
offerId: '',
...extra,
})
/** Bestellung abschicken; dazu der Checkout-Aufruf beim Stripe-Nachbau (nur Aufrufe ab jetzt, sonst null). */
async function checkout(body, { cookie = customerCookie('c3') } = {}) {
const before = st.calls.length
const res = await post('/api/abo/checkout', body, { cookie })
const call = st.calls.slice(before).find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') || null
return { res, call }
}
const message = (call) => call.body['custom_text[submit][message]']
const priceIdFor = (lookupKey) => [...st.prices.values()].find((p) => p.lookup_key === lookupKey)?.id
// Hinweistext ueber dem Bezahlknopf, wortgleich (Wartung wie vor Plan 2, plus § 19 UStG)
const INTRO_SOFORT =
'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande.'
const INTRO_FRIST =
'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.'
const TAIL = 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.'
const OFFER_O1 = {
$id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000,
validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '',
}
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) })
assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]])
assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2)
assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null)
assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html')
// F13: PDF der zugestimmten Fassung; Katalogdaten fuer Speicher-Widget und Bestellseiten
assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Abo-Vertragsunterlagen.pdf')
assert.equal(r.subscription.termsVersion, config.abo.termsVersion)
assert.deepEqual(r.mailStorage, { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 })
// Angebote je Produkt (Hosting bis Aufgabe 11 immer null); das alte Feld offer entfaellt (F15)
assert.deepEqual(r.offers, { hosting: null, wartung: null })
assert.equal('offer' in r, false)
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: null })
assert.equal(r.subscription.canChangeStorage, true)
assert.equal(r.subscription.switchPending, null)
assert.deepEqual(r.subscription.withdrawals, [])
assert.equal(r.subscription.withdrawalOpen, false)
assert.equal(r.subscription.withdrawalUntil, null)
assert.equal(r.available, true)
assert.equal(r.testAccount, false)
assert.equal(r.termsVersion, config.abo.termsVersion)
assert.ok(r.lateStartAt)
})
test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, termsVersion: '2026-09-25-v3' }))
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger)
assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
assert.equal(r.subscription.productKey, 'wartung')
// Wechseln kann nur ein Hosting-Abo; Zusatzspeicher gibt es in beiden Abos
assert.equal(r.subscription.canSwitch, false)
assert.equal(r.subscription.canChangeStorage, true)
})
test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: undefined, termsVersion: '2026-09-25-v3' }))
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.productKey, 'wartung')
assert.ok(r.ledger)
assert.equal(r.subscription.canSwitch, false)
})
test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
assert.equal((await rawGet('/api/abo')).status, 401)
assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401)
await assert.rejects(get('/api/abo'), (e) => e.status === 401)
})
test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
const { res, call } = await checkout(orderBody('hosting'))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.match(res.body.url, /^https:\/\//)
assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo')
assert.match(message(call), /15,00 € monatlich/)
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
assert.equal(message(call), `${INTRO_SOFORT} 15,00 € monatlich, ${TAIL}`)
assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_1500'))
assert.equal(call.body['discounts[0][coupon]'], undefined)
assert.equal(call.body['subscription_data[trial_end]'], undefined)
// Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt
assert.equal(call.body['subscription_data[metadata][termsAcceptedAt]'] !== undefined, true)
assert.ok(call.body['subscription_data[metadata][consentIp]'])
assert.equal(call.body['subscription_data[metadata][termsVersion]'], config.abo.termsVersion)
for (const key of ['termsVersion', 'consentTextVersion', 'termsAcceptedAt', 'startChoice', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent', 'email']) {
assert.equal(call.body[`metadata[${key}]`], undefined, `metadata[${key}] darf nicht an der Sitzung stehen`)
}
assert.equal(call.body['metadata[webklarCustomerId]'], 'c3')
assert.equal(call.body['metadata[productKey]'], 'hosting')
})
test('Checkout Hosting nach der Widerrufsfrist: Probezeit bis zum Leistungsbeginn, Hinweistext', async () => {
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
const { res, call } = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt }))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(call.body['subscription_data[trial_end]'], String(unix(lateStartAt)))
assert.equal(message(call), `${INTRO_FRIST} Danach 15,00 € monatlich, ${TAIL}`)
// Veralteter Leistungsbeginn (Datumswechsel seit dem Laden der Seite) -> 409, kein Checkout
const stale = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt: '2026-01-02T05:00:00.000Z' }))
assert.equal(stale.res.status, 409)
assert.equal(stale.call, null)
})
test('Checkout Wartung: 50,00 €, Beschreibung, Hinweistexte wortgleich', async () => {
const sofort = await checkout(orderBody('wartung'))
assert.equal(sofort.res.status, 200, JSON.stringify(sofort.res.body))
assert.equal(sofort.call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo')
assert.equal(sofort.call.body['subscription_data[metadata][productKey]'], 'wartung')
assert.equal(sofort.call.body['line_items[0][price]'], priceIdFor('webklar_wartung_monatlich_5000'))
assert.equal(message(sofort.call), `${INTRO_SOFORT} 50,00 € monatlich, ${TAIL}`)
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
const frist = await checkout(orderBody('wartung', { startChoice: 'nach_frist', lateStartAt }))
assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body))
assert.equal(message(frist.call), `${INTRO_FRIST} Danach 50,00 € monatlich, ${TAIL}`)
assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt)))
})
test('Checkout Testkonto: Testrabatt des bestellten Produkts', async () => {
const r = await get('/api/abo', { cookie: customerCookie('c4') })
assert.equal(r.testAccount, true)
const hosting = await checkout(orderBody('hosting'), { cookie: customerCookie('c4') })
assert.equal(hosting.res.status, 200, JSON.stringify(hosting.res.body))
assert.equal(hosting.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050-HOSTING')
const wartung = await checkout(orderBody('wartung'), { cookie: customerCookie('c4') })
assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body))
assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050')
})
test('Checkout: unbekanntes Produkt → 400', async () => {
const gold = await checkout(orderBody('gold'))
assert.equal(gold.res.status, 400)
assert.equal(gold.call, null)
assert.equal((await checkout({ ...orderBody('hosting'), productKey: undefined })).res.status, 400)
})
test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => {
aw.seed('aboOffers', OFFER_O1)
try {
const r = await get('/api/abo', { cookie: customerCookie('c3') })
assert.equal(r.offers.hosting, null)
assert.equal(r.offers.wartung.id, 'o1')
const hosting = await checkout(orderBody('hosting', { offerId: 'o1' }))
assert.equal(hosting.res.status, 409)
assert.match(hosting.res.body.error, /anderes Abo/)
assert.equal(hosting.call, null)
const wartung = await checkout(orderBody('wartung', { offerId: 'o1' }))
assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body))
assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1')
assert.equal(wartung.call.body['subscription_data[metadata][offerId]'], 'o1')
assert.equal(message(wartung.call), `${INTRO_SOFORT} Preis wie vereinbart: ${offerText(OFFER_O1)}. ${TAIL}`)
// Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout)
assert.equal(wartung.call.body['metadata[offerId]'], 'o1')
} finally {
await deleteDocument(config.collections.aboOffers, 'o1')
}
})
test('Checkout mit laufendem Abo → 409', async () => {
aw.seed('subscriptions', hostingDoc)
const { res, call } = await checkout(orderBody('wartung'), { cookie: customerCookie('c1') })
assert.equal(res.status, 409)
assert.match(res.body.error, /bereits/)
assert.equal(call, null)
})
test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') })
assert.equal(res.status, 403)
assert.equal(call, null)
})
test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => {
aw.seed('subscriptions', { ...hostingDoc, cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' })
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false)
assert.equal(r.subscription.cancelAt, '2027-10-05T10:00:00.000Z')
})
test('Kein Wechsel und kein Speicher-Plus bei trialing, past_due oder Kündigung nur per cancelAt', async () => {
for (const patch of [
{ status: 'trialing', trialEnd: '2026-11-01T05:00:00.000Z', startChoice: 'nach_frist' },
{ status: 'past_due' },
{ cancelAtPeriodEnd: false, cancelAt: '2027-10-05T10:00:00.000Z' },
]) {
aw.seed('subscriptions', { ...hostingDoc, ...patch })
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false, JSON.stringify(patch))
assert.equal(r.subscription.canChangeStorage, false, JSON.stringify(patch))
assert.equal(r.subscription.active, true, JSON.stringify(patch))
}
aw.seed('subscriptions', hostingDoc)
})
test('Widerrufsfristen aus den Ereignissen', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
const consentAt = daysAgo(2)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt }, status: 'offen', dueAt: consentAt,
})
try {
const r = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(r.subscription.withdrawals.map((w) => w.type), ['speicher'])
assert.equal(r.subscription.withdrawalOpen, true)
assert.equal(r.subscription.withdrawalUntil, withdrawalDeadline(consentAt).endsAt.toISOString())
assert.equal(r.subscription.withdrawals[0].until, r.subscription.withdrawalUntil)
assert.ok(r.subscription.withdrawals[0].eventId)
// Offenes Plus: konsoleH noch nicht umgestellt
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null })
} finally {
await clearEvents('c1')
}
})
test('Geplante Verringerung: ab Periodenende, ohne Widerrufsfrist', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd,
})
try {
const r = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: hostingDoc.currentPeriodEnd } })
assert.deepEqual(r.subscription.withdrawals, [])
assert.equal(r.subscription.withdrawalOpen, false)
assert.equal(r.subscription.canChangeStorage, true)
} finally {
await clearEvents('c1')
}
})
// F12: solange ein Wechsel auf die Zahlung wartet, gibt es weder Wechsel noch Speicheraenderung,
// und das Portal kann den Kunden zur Zahlungsseite zurueckfuehren
test('Wechsel wartet auf die Zahlung: canSwitch und canChangeStorage false, switchPending mit Zahlungslink', async () => {
aw.seed('subscriptions', hostingDoc)
const ev = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' }, consent, status: 'offen',
})
try {
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false)
assert.equal(r.subscription.canChangeStorage, false)
assert.deepEqual(r.subscription.switchPending, { since: consent.consentAt, paymentUrl: 'https://pay.example/inv' })
assert.equal(ev.status, 'offen')
} finally {
await clearEvents('c1')
}
})
// F14: Abos von vor Plan 2 haben kein Ereignis abschluss -> Frist ab startedAt wie bisher
test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async () => {
const startedAt = daysAgo(2)
aw.seed('subscriptions', { ...hostingDoc, startedAt, termStartAt: startedAt })
const r = await get('/api/abo', { cookie: customerCookie() })
const until = withdrawalDeadline(startedAt).endsAt.toISOString()
assert.deepEqual(r.subscription.withdrawals, [{ type: 'abschluss', eventId: null, until }])
assert.equal(r.subscription.withdrawalOpen, true)
assert.equal(r.subscription.withdrawalUntil, until)
// Mit Ereignis zaehlt allein das Ereignis (hier: Vertragsschluss vor 40 Tagen, Frist vorbei)
await recordContractEvent({
id: eventIdFor('abschluss', 'sub_h'), customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) },
})
try {
const again = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(again.subscription.withdrawals, [])
assert.equal(again.subscription.withdrawalOpen, false)
} finally {
await clearEvents('c1')
}
})
test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', async () => {
aw.seed('subscriptions', { ...hostingDoc, termStartAt: '2026-10-05T10:00:00.000Z' })
assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-10-05T10:00:00.000Z')
aw.seed('subscriptions', { ...hostingDoc, termStartAt: null, trialEnd: '2026-11-01T05:00:00.000Z', status: 'trialing' })
assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-11-01T05:00:00.000Z')
aw.seed('subscriptions', hostingDoc)
})
// ---------------------------------------------------------------------------
// Webhook checkout.session.completed (zuletzt: gibt c3 voruebergehend ein Abo)
// ---------------------------------------------------------------------------
// Abo-IDs 28 Zeichen wie bei Stripe (sub_ + 24): 'abschluss-' + ID waeren 38 Zeichen, Appwrite erlaubt 36
const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
const checkoutCompleted = (subscriptionId, metadata = {}) => ({
id: `evt_${subscriptionId}`,
object: 'event',
type: 'checkout.session.completed',
created: Math.floor(Date.now() / 1000),
data: {
object: {
id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3',
customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' },
metadata: { webklarCustomerId: 'c3', productKey: 'hosting', ...metadata },
},
},
})
async function cleanupWebhook(subscriptionId, offerId = null) {
await clearEvents('c3')
await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {})
if (offerId) await deleteDocument(config.collections.aboOffers, offerId).catch(() => {})
}
// F04/F05/F25: der Abschluss wird als Ereignis nachgewiesen, die Frist zaehlt ab dem Vertragsschluss bei
// Stripe (startedAt), eine Webhook-Wiederholung legt kein zweites Ereignis an
test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => {
assert.equal(SUB_NEW.length, 28)
const stripeSub = stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'hosting' })
// Zustimmung im Portal eine Minute vor dem Abschluss bei Stripe
const termsAcceptedAt = new Date((stripeSub.start_date - 60) * 1000).toISOString()
stripeSub.metadata.termsAcceptedAt = termsAcceptedAt
st.subscriptions.set(SUB_NEW, stripeSub)
const event = checkoutCompleted(SUB_NEW)
try {
const first = await postWebhook(event)
assert.equal(first.status, 200, JSON.stringify(first.body))
const events = await listContractEvents('c3')
assert.equal(events.length, 1)
const [e] = events
assert.equal(e.$id, eventIdFor('abschluss', SUB_NEW))
assert.equal(e.$id.length, 36)
assert.equal(e.type, 'abschluss')
assert.equal(e.subscriptionId, SUB_NEW)
assert.equal(e.productKey, 'hosting')
assert.equal(e.status, 'erledigt')
assert.equal(e.details.contractAt, new Date(stripeSub.start_date * 1000).toISOString())
assert.equal(e.termsVersion, config.abo.termsVersion)
assert.equal(e.consentAt, termsAcceptedAt)
assert.equal(e.consentIp, '127.0.0.1')
assert.equal(e.consentUserAgent, 'test')
assert.equal(aw.docs('subscriptions').get(SUB_NEW).productKey, 'hosting')
const again = await postWebhook(event)
assert.equal(again.status, 200)
assert.equal((await listContractEvents('c3')).length, 1)
// Falsche Signatur: 400, nichts verarbeitet
assert.equal((await postWebhook(event, { secret: 'whsec_falsch' })).status, 400)
} finally {
await cleanupWebhook(SUB_NEW)
}
})
// Task 12: K01 je Produkt (Katalogname, Preis, Liste „Enthalten“) mit dem PDF der Fassung, der der Kunde zugestimmt
// hat (§ 312f BGB, contractAttachmentFor); T02 nennt das Produkt und die Aufgabe fuer UptimeRobot
test('Webhook checkout.session.completed: K01 je Produkt mit PDF der zugestimmten Fassung, T02 mit UptimeRobot', async () => {
const subH = 'sub_1SD6y2SxAbCdEfGhIjKlMnOs'
st.subscriptions.set(subH, stripeSubscription({ id: subH, customerId: 'c3', productKey: 'hosting' }))
try {
mails.sent.length = 0
const res = await postWebhook(checkoutCompleted(subH))
assert.equal(res.status, 200, JSON.stringify(res.body))
const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung'))
assert.ok(k01, 'K01 verschickt')
assert.equal(k01.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung')
assert.deepEqual(k01.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']])
assert.ok(k01.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt')
for (const text of ['15,00 € / Monat', 'gemäß § 19 UStG ohne Umsatzsteuer', '1 E-Mail-Postfach', `(Version ${config.abo.termsVersion})`]) {
assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
}
assert.ok(!k01.text.includes('Stunde Arbeitszeit') && !k01.text.includes('Stundenkontingent'), k01.text)
assert.ok(aw.docs('subscriptions').get(subH).confirmationSentAt)
const t02 = mails.sent.find((m) => m.subject.startsWith('[Abo] Neues'))
assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo: '), t02.subject)
assert.ok(t02.text.includes('Website als Monitor in UptimeRobot anlegen'), t02.text)
} finally {
await cleanupWebhook(subH)
}
// Wartungs-Abo nach Fassung 3 (Altbestand): das Archiv-PDF der Fassung 3 und die Zeile „Enthalten“ der Fassung 3
// (1 E-Mail-Postfach, 1 Stunde Arbeitszeit pro Monat; Block wartung_v3, Fix 1)
const subW = 'sub_1SD6z3TxAbCdEfGhIjKlMnOt'
st.subscriptions.set(subW, stripeSubscription({ id: subW, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion: '2026-09-25-v3' } }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(checkoutCompleted(subW, { productKey: 'wartung' }))).status, 200)
const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung'))
assert.equal(k01.subject, 'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung')
assert.deepEqual(k01.attachments.map((a) => a.filename), ['WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'])
for (const text of ['50,00 € / Monat', '• 1 E-Mail-Postfach', '• 1 Stunde Arbeitszeit pro Monat', 'Ihr aktuelles Stundenkontingent', '(Version 2026-09-25-v3) einschließlich des Auftragsverarbeitungsvertrags']) {
assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
}
assert.ok(!k01.text.includes('beliebig viele') && !k01.text.includes('je bezahltem Monat'), k01.text)
} finally {
await cleanupWebhook(subW)
}
// Gegenprobe: Wartungs-Abo nach Fassung 4 - aktuelles PDF, beliebig viele Postfaecher, Stunde je bezahltem Monat
const subW4 = 'sub_1SD6z4UxAbCdEfGhIjKlMnOu'
st.subscriptions.set(subW4, stripeSubscription({ id: subW4, customerId: 'c3', productKey: 'wartung' }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(checkoutCompleted(subW4, { productKey: 'wartung' }))).status, 200)
const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung'))
assert.equal(k01.subject, 'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung')
assert.deepEqual(k01.attachments.map((a) => a.filename), ['WEBklar-Abo-Vertragsunterlagen.pdf'])
for (const text of ['50,00 € / Monat', '• beliebig viele Postfächer', '1 Stunde Arbeitszeit je bezahltem Monat', `(Version ${config.abo.termsVersion}) einschließlich des Auftragsverarbeitungsvertrags`]) {
assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
}
assert.ok(!k01.text.includes('1 E-Mail-Postfach') && !k01.text.includes('pro Monat'), k01.text)
} finally {
await cleanupWebhook(subW4)
}
// Fix 1 (3): ohne gespeicherte Fassung kommen Versionsangabe und PDF aus derselben Quelle - beide Fassung 3
const subW0 = 'sub_1SD6z5VxAbCdEfGhIjKlMnOv'
st.subscriptions.set(subW0, stripeSubscription({ id: subW0, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion: null } }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(checkoutCompleted(subW0, { productKey: 'wartung' }))).status, 200)
const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung'))
assert.ok(k01, 'K01 verschickt')
assert.deepEqual(k01.attachments.map((a) => a.filename), ['WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'])
assert.ok(k01.text.includes('(Version 2026-09-25-v3)'), k01.text)
assert.ok(!k01.text.includes(config.abo.termsVersion), k01.text)
assert.ok(k01.text.includes('• 1 E-Mail-Postfach'), k01.text)
} finally {
await cleanupWebhook(subW0)
}
})
// Fix 1 (5): T07 nennt die nicht genutzte Arbeitszeit nur beim Wartungs-Abo
test('Webhook customer.subscription.deleted: T07 mit Arbeitszeit-Hinweis nur bei Wartung', async () => {
const deleted = (id) => ({ id: `evt_del_${id}`, object: 'event', type: 'customer.subscription.deleted', created: Math.floor(Date.now() / 1000), data: { object: { id, object: 'subscription', status: 'canceled' } } })
const subW = 'sub_1SD6z6WxAbCdEfGhIjKlMnOw'
const subH = 'sub_1SD6z7XxAbCdEfGhIjKlMnOx'
st.subscriptions.set(subW, stripeSubscription({ id: subW, customerId: 'c3', productKey: 'wartung', status: 'canceled' }))
st.subscriptions.set(subH, stripeSubscription({ id: subH, customerId: 'c3', productKey: 'hosting', status: 'canceled' }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(deleted(subW))).status, 200)
const t07w = mails.sent.find((m) => m.subject.startsWith('[Abo] Abo beendet'))
assert.ok(t07w, 'T07 verschickt')
assert.ok(t07w.text.includes('Abo: Wartungs-Abo') && t07w.text.includes('Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit dem Vertragsende.'), t07w.text)
mails.sent.length = 0
assert.equal((await postWebhook(deleted(subH))).status, 200)
const t07h = mails.sent.find((m) => m.subject.startsWith('[Abo] Abo beendet'))
assert.ok(t07h.text.includes('Abo: Hosting-Abo') && t07h.text.includes('Gebuchter Zusatzspeicher endet mit dem Abo.'), t07h.text)
assert.ok(!t07h.text.includes('Arbeitszeit') && !t07h.text.includes('Stundenkontingent'), t07h.text)
} finally {
await cleanupWebhook(subW)
await cleanupWebhook(subH)
}
})
// Fix 1, Important: ein dauerhafter Fehler beim Nachweis (4xx, z. B. Collection oder Attribut fehlt) darf
// Angebot, T02 und K01 nicht blockieren; ein voruebergehender (5xx, Netz) gibt 500, Stripe wiederholt
test('Webhook: Nachweis dauerhaft nicht speicherbar (400) → 200, Angebot trotzdem aktiviert', async () => {
const subId = 'sub_1SD6w0QxAbCdEfGhIjKlMnOq'
st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o2' } }))
aw.seed('aboOffers', { ...OFFER_O1, $id: 'o2', stripeCouponId: 'WEBKLAR-ANGEBOT-O2' })
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 400 })
try {
const res = await postWebhook(checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o2' }))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal((await listContractEvents('c3')).length, 0)
assert.equal(aw.docs('aboOffers').get('o2').status, 'aktiv')
assert.equal(aw.docs('aboOffers').get('o2').stripeSubscriptionId, subId)
assert.equal(aw.docs('subscriptions').get(subId).productKey, 'wartung')
} finally {
await cleanupWebhook(subId, 'o2')
}
})
test('Webhook: Nachweis voruebergehend nicht speicherbar (500) → 500, Wiederholung holt alles nach', async () => {
const subId = 'sub_1SD6x1RxAbCdEfGhIjKlMnOr'
st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o3' } }))
aw.seed('aboOffers', { ...OFFER_O1, $id: 'o3', stripeCouponId: 'WEBKLAR-ANGEBOT-O3' })
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
const event = checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o3' })
try {
const failed = await postWebhook(event)
assert.equal(failed.status, 500)
assert.equal((await listContractEvents('c3')).length, 0)
assert.equal(aw.docs('aboOffers').get('o3').status, 'offen')
// Stripe wiederholt: Nachweis, Angebot und Hinweise kommen nach
const retry = await postWebhook(event)
assert.equal(retry.status, 200, JSON.stringify(retry.body))
const events = await listContractEvents('c3')
assert.equal(events.length, 1)
assert.equal(events[0].$id, eventIdFor('abschluss', subId))
assert.equal(aw.docs('aboOffers').get('o3').status, 'aktiv')
} finally {
await cleanupWebhook(subId, 'o3')
}
})
+215
View File
@@ -0,0 +1,215 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
const aw = await startAppwriteMock({
schema: { contractEvents: schemaKeys('contractEvents'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const {
recordContractEvent,
listContractEvents,
openWithdrawals,
listOpenStorageTasks,
completeContractEvent,
updateContractEvent,
eventIdFor,
getContractEvent,
} = await import('../server/services/contractEvents.js')
test('Ereignis speichern und lesen (details als JSON)', async () => {
await recordContractEvent({
customerId: 'c1',
subscriptionId: 'sub_1',
type: 'speicher',
productKey: 'hosting',
details: { fromUnits: 0, toUnits: 2 },
consent: {
termsVersion: '2026-10-05-v4',
consentAt: '2026-10-05T10:00:00.000Z',
consentIp: '1.2.3.4',
consentUserAgent: 'UA',
},
status: 'offen',
dueAt: '2026-10-05T10:00:00.000Z',
})
const [e] = await listContractEvents('c1')
assert.deepEqual(e.details, { fromUnits: 0, toUnits: 2 })
// Zweites offenes Speicher-Ereignis mit frueherem dueAt, absichtlich NACH dem ersten angelegt
// (umgekehrte Reihenfolge): listOpenStorageTasks() muss trotzdem nach dueAt aufsteigend sortieren,
// nicht nach der Anlage-Reihenfolge.
await recordContractEvent({
customerId: 'c5',
subscriptionId: 'sub_5',
type: 'speicher',
productKey: 'hosting',
details: { fromUnits: 0, toUnits: 1 },
consent: {
termsVersion: '2026-10-05-v4',
consentAt: '2026-10-02T10:00:00.000Z',
consentIp: '1.2.3.4',
consentUserAgent: 'UA',
},
status: 'offen',
dueAt: '2026-10-03T10:00:00.000Z',
})
const tasks = await listOpenStorageTasks()
assert.equal(tasks.length, 2)
// Wichtig: details kommt geparst zurueck, wie bei listContractEvents (nicht als JSON-String)
assert.equal(tasks[0].details.toUnits, 1)
// Minor: faelligstes (frueheres dueAt) zuerst, unabhaengig von der Anlage-Reihenfolge
assert.deepEqual(tasks.map((t) => t.dueAt), ['2026-10-03T10:00:00.000Z', '2026-10-05T10:00:00.000Z'])
})
test('Widerrufsfristen je Bestellung', () => {
const ev = (type, consentAt, details = {}, status = 'erledigt') => ({ $id: type, type, consentAt, details, status })
const now = new Date('2026-10-10T12:00:00Z')
const open = openWithdrawals(
[
ev('abschluss', '2026-09-01T10:00:00Z'),
ev('wechsel', '2026-10-05T10:00:00Z'),
ev('speicher', '2026-10-06T10:00:00Z', { fromUnits: 2, toUnits: 1 }),
ev('speicher', '2026-10-07T10:00:00Z', { fromUnits: 0, toUnits: 1 }),
],
now
)
assert.deepEqual(open.map((o) => o.type), ['wechsel', 'speicher'])
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'widerrufen')], now).length, 0)
// F02: ein Wechsel ohne Zahlung (gescheitert) hat wie ein widerrufener kein Widerrufsrecht mehr
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'gescheitert')], now).length, 0)
})
// F04: die Frist zaehlt ab dem Vertragsschluss bei Stripe (details.contractAt), nicht ab dem fruehereren
// Portal-Klick (consentAt). Waere hier consentAt massgeblich, waere die Frist laengst abgelaufen.
test('Widerrufsfrist zaehlt ab dem Vertragsschluss (details.contractAt), nicht ab dem Portal-Klick', () => {
const now = new Date('2026-10-10T12:00:00Z')
const ev = {
$id: 'e1',
type: 'abschluss',
status: 'erledigt',
consentAt: '2026-08-01T10:00:00Z',
details: { contractAt: '2026-10-08T10:00:00Z' },
}
assert.deepEqual(openWithdrawals([ev], now).map((o) => o.eventId), ['e1'])
})
test('Aufgabe erledigen', async () => {
const [t] = await listOpenStorageTasks()
const done = await completeContractEvent(t.$id, 'kenso@webklar.com')
assert.equal(done.status, 'erledigt')
assert.equal(done.doneBy, 'kenso@webklar.com')
})
// F01: Status setzen und details zusammenfuehren (nicht ersetzen) in einem Aufruf
test('Ereignis aktualisieren: Status setzen, details zusammenfuehren', async () => {
const created = await recordContractEvent({
customerId: 'c2',
subscriptionId: 'sub_2',
type: 'wechsel',
productKey: 'hosting',
details: { von: 'wartung', zu: 'hosting' },
status: 'offen',
})
const updated = await updateContractEvent(created.$id, {
status: 'gescheitert',
details: { grund: 'Zahlung fehlgeschlagen' },
})
assert.equal(updated.status, 'gescheitert')
assert.deepEqual(JSON.parse(updated.details), { von: 'wartung', zu: 'hosting', grund: 'Zahlung fehlgeschlagen' })
})
// Task 9: ein einzelnes Ereignis lesen (markSwitchFailed, Skript fuers Team), details geparst wie in der Liste
test('Einzelnes Ereignis lesen: details geparst, unbekannte id 404', async () => {
const created = await recordContractEvent({
customerId: 'c5',
subscriptionId: 'sub_5',
type: 'wechsel',
productKey: 'wartung',
details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' },
status: 'offen',
})
const event = await getContractEvent(created.$id)
assert.equal(event.$id, created.$id)
assert.equal(event.status, 'offen')
assert.deepEqual(event.details, { from: 'hosting', paymentUrl: 'https://pay.example/inv' })
await assert.rejects(getContractEvent('gibt-es-nicht'), (e) => e.status === 404)
})
// F05: dieselbe id zweimal speichern (z. B. Webhook-Wiederholung) ist kein Fehler, sondern liefert
// das schon vorhandene Dokument zurueck, ohne ein zweites anzulegen
test('Ereignis mit vorhandener id (409): liefert das schon gespeicherte Dokument', async () => {
const first = await recordContractEvent({
customerId: 'c3',
subscriptionId: 'sub_3',
type: 'abschluss',
productKey: 'hosting',
details: { contractAt: '2026-10-01T09:00:00.000Z' },
id: 'abschluss-sub_3',
})
const again = await recordContractEvent({
customerId: 'c3',
subscriptionId: 'sub_3',
type: 'abschluss',
productKey: 'hosting',
details: { contractAt: '2026-10-01T09:00:00.000Z' },
id: 'abschluss-sub_3',
})
assert.equal(again.$id, 'abschluss-sub_3')
assert.equal(again.$id, first.$id)
assert.equal((await listContractEvents('c3')).length, 1)
})
// Rechtsrunde 28.09.2026: eine Speicher-Verringerung hat kein Pflicht-Haekchen und damit keine
// Zustimmungsdaten - recordContractEvent muss mit consent: null funktionieren und darf dann keine
// consentIp/consentUserAgent speichern
test('Speicher-Verringerung ohne Zustimmung (consent: null) speichert keine Zustimmungsdaten', async () => {
const doc = await recordContractEvent({
customerId: 'c4',
subscriptionId: 'sub_4',
type: 'speicher',
productKey: 'hosting',
details: { fromUnits: 2, toUnits: 1 },
consent: null,
})
assert.equal(doc.consentIp ?? null, null)
assert.equal(doc.consentUserAgent ?? null, null)
assert.equal(doc.termsVersion ?? null, null)
})
// F05 (Fix 1): feste Ereignis-IDs muessen Appwrites Regeln fuer Dokument-IDs einhalten (hoechstens
// 36 Zeichen) - 'abschluss-' + eine echte Stripe-Abo-ID (sub_ + 24) waeren 38. Deshalb
// '<typ>-' + sha256 des Schluessels, auf 36 Zeichen gekuerzt; gleicher Schluessel -> gleiche ID.
test('eventIdFor: 36 Zeichen nach Appwrite-Regeln, deterministisch', () => {
const subId = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
const id = eventIdFor('abschluss', subId)
assert.equal(id.length, 36)
assert.match(id, /^abschluss-[0-9a-f]{26}$/)
assert.equal(id, eventIdFor('abschluss', subId))
assert.notEqual(id, eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOq'))
// Laengerer Typ -> kuerzerer Hash, immer 36 Zeichen
assert.equal(eventIdFor('kuendigung', subId).length, 36)
assert.match(eventIdFor('kuendigung', subId), /^kuendigung-[0-9a-f]{25}$/)
assert.throws(() => eventIdFor('abschluss', ''), /Schl/)
assert.throws(() => eventIdFor('Abschluss!', subId), /Typ/)
})
test('recordContractEvent lehnt IDs ab, die Appwrite nicht annimmt', async () => {
const base = { customerId: 'c6', subscriptionId: 'sub_6', type: 'abschluss', productKey: 'hosting' }
await assert.rejects(
recordContractEvent({ ...base, id: 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' }),
(e) => e.status === 400 && /36/.test(e.message)
)
await assert.rejects(recordContractEvent({ ...base, id: '-abschluss' }), (e) => e.status === 400)
await assert.rejects(recordContractEvent({ ...base, id: 'abschluss/1' }), (e) => e.status === 400)
assert.equal((await listContractEvents('c6')).length, 0)
// Gueltige feste ID (aus eventIdFor) wird gespeichert
const ok = await recordContractEvent({ ...base, id: eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOp') })
assert.equal(ok.$id.length, 36)
})
+47 -1
View File
@@ -11,7 +11,7 @@ const aw = await startAppwriteMock({
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { grantCreditForInvoice } = await import('../server/services/stripeBilling.js')
const { grantCreditForInvoice, loadInvoice, invoiceLines } = await import('../server/services/stripeBilling.js')
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true })
st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000))
@@ -116,3 +116,49 @@ test('Altbestand: Abo ohne bekannten Preis bekommt weiter seine Stunde, auch bei
assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60)
assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung')
})
// F18 / Hinweis aus dem Review von Plan 1: invoices.retrieve bettet hoechstens 10 Zeilen ein. Anteilige
// Zeilen stehen vor den Abo-Zeilen; mehrere Speicheraenderungen in einem Monat schieben die Wartungs-Zeile
// hinaus. Der Nachbau bettet wie Stripe 10 Zeilen ein (has_more), der Rest kommt ueber /lines.
test('Rechnung mit 11 Zeilen: die Wartungs-Zeile als elfte wird nachgeladen → 60 Minuten', async () => {
st.prices.set('price_s', price('price_s', 'webklar_mailspeicher_5gb_monatlich_100', 100))
const proration = (amount) => ({
amount,
pricing: { type: 'price_details', price_details: { price: 'price_s' } },
parent: { type: 'subscription_item_details', subscription_item_details: { proration: true } },
})
const prorations = Array.from({ length: 10 }, (_, i) => proration(10 + i))
st.invoices.set('in_11', inv('in_11', 'sub_w', [...prorations, regular('price_w', 5000)]))
const before = st.calls.length
const r = await grantCreditForInvoice(payload('in_11', 'sub_w', 5000))
assert.equal(r.granted, true)
assert.equal(aw.docs('hourCredits').get('in_11').minutes, 60)
const calls = st.calls.slice(before)
assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11').length, 1)
assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11/lines'), 'weitere Zeilen ueber /lines geladen')
})
test('invoiceLines: eingebettete Zeilen ohne has_more, sonst alle Seiten; loadInvoice liefert alle Zeilen', async () => {
const embedded = { id: 'in_egal', lines: { data: [{ amount: 1 }, { amount: 2 }], has_more: false } }
const before = st.calls.length
assert.deepEqual(await invoiceLines(embedded), [{ amount: 1 }, { amount: 2 }])
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf ohne has_more')
const loaded = await loadInvoice('in_11')
assert.equal(loaded.lines.data.length, 11)
assert.equal(loaded.lines.has_more, false)
assert.equal(loaded.lines.data[10].amount, 5000)
// Vorschau-Rechnungen haben keine ID: has_more laesst sich dann nicht nachladen, die eingebetteten zaehlen
assert.deepEqual(await invoiceLines({ lines: { data: [{ amount: 3 }], has_more: true } }), [{ amount: 3 }])
})
// F18: der Webhook laedt die Rechnung einmal (loadInvoice) und reicht sie als fresh weiter - kein zweiter Abruf
test('Schon frisch geladene Rechnung (fresh: true) wird nicht noch einmal geladen', async () => {
st.invoices.set('in_7', inv('in_7', 'sub_w', [regular('price_w', 5000)]))
const fresh = await loadInvoice('in_7')
const before = st.calls.length
const r = await grantCreditForInvoice(fresh, { fresh: true })
assert.equal(r.granted, true)
assert.equal(aw.docs('hourCredits').get('in_7').minutes, 60)
assert.equal(aw.docs('hourCredits').get('in_7').note, 'Rechnung WK-in_7')
assert.deepEqual(st.calls.slice(before).filter((c) => c.path.startsWith('/v1/invoices')), [])
})
+427
View File
@@ -0,0 +1,427 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
const { config } = await import('../server/config.js')
// = `git show 18c3cdf:public/abo-bedingungen.html | sha256sum` (Fassung 3, Stand 25.09.2026).
// Fest eingetragen, weil die Tests auch ohne git laufen muessen (node:20-alpine).
const V3_SHA256 = '1c9e4b002dcd5d7896bbda24c573e56544b7123e3a88d154e5db77bd2855bc47'
// = sha256 des Blocks ab `const TAKEOVER_CONDITIONS` bis vor das schliessende ] in public/app.js, je conditionsVersion
// (Nachweis der Zustimmung). Aendert sich der Text der Bedingungen, muss config.takeover.conditionsVersion hochgezogen
// und der neue Hash hier eingetragen werden; die Fehlermeldung des Tests nennt ihn.
const TAKEOVER_CONDITIONS_SHA256 = { '2026-09-30': '2b7a745cb599c0ab28f20615a745bd86da6faa8d120391ca56beac35a0473e62' }
const file = (rel) => new URL(`../${rel}`, import.meta.url)
const read = (rel) => readFileSync(file(rel), 'utf8')
const sha256 = (rel) => createHash('sha256').update(readFileSync(file(rel))).digest('hex')
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim()
// HTML-Ausschnitt von einem Anker bis zum naechsten, danach ohne Tags. Die Inhaltsuebersicht wiederholt
// die Ueberschriften, deshalb wird nicht nach dem ersten Text-Treffer, sondern nach id="..." geschnitten.
const section = (html, fromId, toId) => {
const a = html.indexOf(`id="${fromId}"`)
assert.ok(a >= 0, `Anker #${fromId} fehlt`)
const b = html.indexOf(`id="${toId}"`, a + 1)
assert.ok(b > a, `Anker #${toId} fehlt nach #${fromId}`)
return strip(html.slice(a, b))
}
const between = (s, a, b) => {
const i = s.indexOf(a)
assert.ok(i >= 0, `Text fehlt: ${a}`)
const j = s.indexOf(b, i)
assert.ok(j > i, `Text fehlt: ${b}`)
return s.slice(i, j)
}
const V3 = 'public/vertragsunterlagen/v3/abo-bedingungen.html'
const V4 = 'public/abo-bedingungen.html'
test('Fassung 3 unverändert archiviert (HTML und PDF)', () => {
assert.equal(sha256(V3), V3_SHA256)
// Das bisherige PDF bleibt die Fassung 3 und liegt identisch im Archiv
assert.equal(
sha256('public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'),
sha256('public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
)
assert.ok(read(V3).includes('Version 3 · Stand 25.09.2026'))
})
test('Widerrufsbelehrung und Muster-Widerrufsformular wortgleich mit Fassung 3', () => {
const v3 = read(V3), v4 = read(V4)
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
const muster3 = muster(section(v3, 'widerruf', 'formular'))
assert.ok(muster3.length > 1500)
assert.equal(muster(section(v4, 'widerruf', 'formular')), muster3)
// Muster-Widerrufsformular (Anlage 2 zu Art. 246a EGBGB) ebenfalls unveraendert
const formular = (s) => between(s, 'Muster-Widerrufsformular', '(*) Unzutreffendes streichen.')
assert.equal(formular(section(v4, 'formular', 'widerruf-hinweise')), formular(section(v3, 'formular', 'avv')))
})
test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
const v4html = read(V4), v4 = strip(v4html)
for (const s of ['15,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher',
'12 Monate', 'statische', 'Hetzner', '§ 19 UStG', 'Leistungsbeschreibung A', 'Leistungsbeschreibung B',
'„Zahlungspflichtig bestellen“', '„Verträge hier kündigen“', '„Nur den E-Mail-Zusatzspeicher“', '„Speichern“',
'Vertragsbedingungen für die WEBklar-Abos']) assert.ok(v4.includes(s), s)
assert.ok(!v4.includes('Version 3'))
const hosting = section(v4html, 'hosting', 'wartung')
assert.ok(hosting.includes('1 E-Mail-Postfach'))
assert.ok(hosting.includes('statische'))
const wartung = section(v4html, 'wartung', 'widerruf')
assert.ok(wartung.includes('beliebig viele Postfächer'))
assert.ok(wartung.includes('zusammen 5 GB'))
assert.ok(!wartung.includes('1 E-Mail-Postfach'))
// Hinweise zum Widerruf bei Wechsel und Zusatzspeicher stehen ausserhalb der Musterbelehrung
const hinweise = section(v4html, 'widerruf-hinweise', 'avv')
assert.ok(hinweise.includes('Wechsel'))
assert.ok(hinweise.includes('Zusatzspeicher'))
assert.ok(hinweise.includes('Wertersatz'))
})
test('Anker der Fassung 4', () => {
const v4 = read(V4)
for (const id of ['widerruf', 'hosting', 'wartung', 'zusatzspeicher', 'wechsel', 'avv', 'widerruf-hinweise', 'formular', 'tom', 'sub']) {
assert.equal(v4.split(`id="${id}"`).length, 2, `Anker #${id} genau einmal`)
}
// Reihenfolge: allgemeiner Teil, Leistungsbeschreibung A, B, Widerruf, Hinweise, Anlagen 1-3
const order = ['id="wechsel"', 'id="zusatzspeicher"', 'id="hosting"', 'id="wartung"', 'id="widerruf"', 'id="formular"',
'id="widerruf-hinweise"', 'id="avv"', 'id="tom"', 'id="sub"'].map((m) => v4.indexOf(m))
assert.deepEqual([...order].sort((a, b) => a - b), order)
})
test('Version und Stand passen zu termsVersion', () => {
const m = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion ${config.abo.termsVersion}`)
const [, y, mo, d] = m
assert.ok(strip(read(V4)).includes(`Version 4 · Stand ${d}.${mo}.${y}`))
assert.ok(read(V4).includes(`content: "Version 4 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
})
test('PDF der Fassung 4 verlinkt, erzeugt und im Build-Skript', () => {
const v4 = read(V4)
assert.ok(v4.includes('href="/WEBklar-Abo-Vertragsunterlagen.pdf"'))
assert.ok(!v4.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'))
const pdf = readFileSync(file('public/WEBklar-Abo-Vertragsunterlagen.pdf'))
assert.ok(pdf.length > 50_000 && pdf.subarray(0, 5).toString() === '%PDF-')
const build = read('scripts/build-legal-pdfs.sh')
assert.ok(build.includes('pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf'))
const erzeugt = build.split('\n').filter((l) => l.startsWith('pdf '))
assert.ok(!erzeugt.some((l) => l.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')), 'Fassung 3 wird nicht neu erzeugt')
})
// --- Korrekturen aus der Prüfung (Review 1) ---
const v4Section = (fromId, toId) => section(read(V4), fromId, toId)
test('Widerrufsfrist beginnt mit dem Vertragsschluss, nicht mit der Bestellung', () => {
const hinweise = v4Section('widerruf-hinweise', 'avv')
assert.ok(hinweise.includes('mit dem Vertragsschluss (§ 2 Abs. 3 bzw. Abs. 5)'))
assert.ok(!hinweise.includes('mit der Bestellung'))
// § 2 Abs. 3 = Vertragsschluss beim Abo, Abs. 5 = Vertragsschluss bei Wechsel und Zusatzspeicher
const html = read(V4)
const p2 = html.slice(html.indexOf('id="p2"'), html.indexOf('id="p3"'))
// Absaetze auf oberster Ebene stehen mit genau vier Leerzeichen am Zeilenanfang (die Liste a-e in Abs. 2 mit acht)
const absaetze = [...p2.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
assert.equal(absaetze.length, 6)
assert.ok(absaetze[2].startsWith('Der Vertrag kommt zustande, sobald der Kunde den Vorgang bei Stripe abgeschlossen hat'))
assert.ok(absaetze[4].includes('Mit dem Klick auf „Zahlungspflichtig bestellen“ kommt der Vertrag zustande'))
assert.ok(hinweise.includes('gehören zu diesen Bedingungen, nicht zur gesetzlichen Widerrufsbelehrung'))
assert.ok(hinweise.includes('Mehrbetrag ist, was der Kunde seit dem Wechsel mehr gezahlt hat'))
assert.ok(hinweise.includes('Wertersatz verlangen wir dafür nicht (Nr. 4)'))
})
// Absaetze eines Paragraphen auf oberster Ebene: genau vier Leerzeichen vor <li> (verschachtelte Listen haben acht)
const absaetzeVon = (fromId, toId) => {
const html = read(V4)
const slice = html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`))
return [...slice.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
}
// Eine Zeile der Übersicht "Das Wichtigste auf einen Blick"
const uebersichtZeile = (name) => strip(between(read(V4), `<tr><td>${name}</td><td>`, '</td></tr>'))
test('Wechsel, Plus und Minus im Portal nur ohne Kündigung und ohne offene Zahlung', () => {
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher'), speicher = absaetzeVon('zusatzspeicher', 'p11')
assert.equal(wechsel.length, 5)
assert.equal(speicher.length, 6)
// Jeder Absatz einzeln, damit ein fehlendes "nicht gekündigt" in Abs. 1 ODER Abs. 5 auffällt
assert.ok(wechsel[0].includes('nicht gekündigt'), '§ 9 Abs. 1')
assert.ok(speicher[0].includes('nicht gekündigt'), '§ 10 Abs. 1 (Bestellen und Ändern im Portal)')
assert.ok(speicher[4].includes('nicht gekündigt'), '§ 10 Abs. 5 (Verringern mit „Speichern“)')
assert.ok(speicher[4].includes('„Nur den E-Mail-Zusatzspeicher“'))
assert.ok(speicher[4].includes('kann der Kunde die Zahl der Pakete nur noch über „Verträge hier kündigen“ oder in Textform verringern'))
assert.ok(!speicher[4].includes('auch eine Verringerung'))
// Übersicht: dieselben Bedingungen wie § 9 Abs. 1 und § 10 Abs. 1, kein unbedingtes "jederzeit"
for (const name of ['E-Mail-Zusatzspeicher', 'Wechsel']) {
const zeile = uebersichtZeile(name)
for (const s of ['nach Leistungsbeginn', 'nicht gekündigt', 'keine Zahlung offen']) assert.ok(zeile.includes(s), `${name}: ${s}`)
assert.ok(!/jederzeit im Kundenportal|Jederzeit zum Ende/.test(zeile), name)
}
})
test('Bestellübersichten nennen Laufzeit und Kündigungsmöglichkeit (§ 312j Abs. 2 BGB)', () => {
assert.ok(v4Section('p2', 'p3').includes('sowie Laufzeit und Kündigungsmöglichkeit'))
assert.ok(v4Section('wechsel', 'zusatzspeicher').includes('die Kündigungsmöglichkeit nach Ablauf der neuen Mindestlaufzeit'))
assert.ok(v4Section('zusatzspeicher', 'p11').includes('zum Ende jedes Abrechnungsmonats kündbar'))
})
test('Faire Nutzung der Postfächer, Hetzner benannt, Formulare, AVV-Zusatzleistungen', () => {
const leistungen = v4Section('p3', 'p4')
assert.ok(leistungen.includes('auch unentgeltlich'))
assert.ok(leistungen.includes('Versandgrenzen von Hetzner'))
assert.ok(leistungen.includes('(im Folgenden „Hetzner“)'))
assert.ok(!/\bder Anbieter\b/.test(leistungen))
assert.ok(v4Section('p6', 'p7').includes('für ihn tätige Personen'))
const hosting = v4Section('hosting', 'wartung')
assert.ok(hosting.includes('sind im Hosting-Abo nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an'))
assert.ok(!hosting.includes('Formulardienst'))
const avv = v4Section('avv', 'tom')
assert.ok(avv.includes('sowie für gesondert vereinbarte Zusatzleistungen zum Abo, etwa eine Formularlösung,'))
assert.ok(avv.includes('sowie gesondert vereinbarter Zusatzleistungen'))
assert.ok(avv.includes('zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll'))
assert.ok(strip(read(V4)).includes('über „Abo verwalten & Rechnungen“'))
})
// Text je <li> und <p>, jeweils dem innersten offenen Element zugeordnet. Text hinter einer verschachtelten
// Liste zaehlt so zum aeusseren <li> (eine Regex bis zum ersten </li> wuerde ihn uebersehen).
const textJeElement = (html) => {
const out = [], stack = []
for (const [, close, tag, text] of html.matchAll(/<(\/?)([a-zA-Z0-9]+)[^>]*>|([^<]+)/g)) {
if (text !== undefined) { if (stack.length) stack[stack.length - 1].text += text; continue }
const t = tag.toLowerCase()
if (t !== 'li' && t !== 'p') continue
if (!close) stack.push({ tag: t, text: '' })
else { const el = stack.pop(); assert.equal(el?.tag, t, `Tag-Verschachtelung bei </${t}>`); out.push(strip(el.text)) }
}
assert.equal(stack.length, 0, 'offene <li>/<p>')
return out
}
test('Jeder Preis in Absätzen und Listenpunkten mit Hinweis auf § 19 UStG', () => {
// Die Übersichtstabelle ist ausgenommen (<td>); ihre Zeile "Preise" trägt den Hinweis
assert.ok(uebersichtZeile('Preise').includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'))
const mitPreis = textJeElement(read(V4)).filter((t) => /\d+(,\d\d)? €/.test(t))
assert.ok(mitPreis.length >= 7, `Preisstellen: ${mitPreis.length}`)
// Ein Wortlaut je Stelle: Fliesstext in Klammern, Kopfzeilen von A und B mit Punkt, § 7 Abs. 1 mit vollem Satz
const fliesstext = '(Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'
const kopfzeile = '· Endpreis, gemäß § 19 UStG ohne Umsatzsteuer'
const p7 = 'Alle Preise sind Endpreise. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.'
for (const t of mitPreis) {
assert.ok(t.includes(fliesstext) || t.includes(kopfzeile) || t.includes(p7), `ohne einheitlichen § 19-Hinweis: ${t.slice(0, 90)}`)
}
assert.ok(!strip(read(V4)).includes('(gemäß § 19 UStG ohne Umsatzsteuer)'), 'alter Wortlaut ohne „Endpreis“')
})
test('Alle internen Links und Querverweise treffen', () => {
const html = read(V4)
const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
for (const [, ref] of html.matchAll(/href="#([^"]+)"/g)) assert.equal(ids.filter((i) => i === ref).length, 1, `#${ref}`)
// Absätze je Paragraph: <li> der ol.abs auf oberster Ebene; bei § 3 die Überschriften "(M)"
const absaetze = {}
for (const [, n, body] of html.matchAll(/<h2 id="[^"]+">§ (\d+) [^<]*<\/h2>([\s\S]*?)(?=<h2 |<p class="anlage-kopf)/g)) {
const h3 = [...body.matchAll(/<h3>\((\d+)\)/g)].map((x) => Number(x[1]))
if (h3.length) { absaetze[n] = Math.max(...h3); continue }
const start = body.indexOf('<ol class="abs">')
let depth = 0, count = 0
if (start >= 0) for (const [, close, tag] of body.slice(start).matchAll(/<(\/?)(ol|ul|li)\b[^>]*>/g)) {
if (tag === 'li') { if (!close && depth === 1) count++; continue }
depth += close ? -1 : 1
if (depth === 0) break
}
absaetze[n] = count
}
assert.equal(Object.keys(absaetze).length, 16)
const text = strip(html)
const refs = [...text.matchAll(/§ (\d+) Abs\. (\d+)/g)]
assert.ok(refs.length >= 15, `Querverweise: ${refs.length}`)
for (const [ref, n, a] of refs) assert.ok(Number(a) <= (absaetze[n] ?? 0), `${ref}: § ${n} hat ${absaetze[n] ?? 0} Absätze`)
// Leistungsbeschreibung A/B Nr. N: nummerierte <h3> der jeweiligen Beschreibung
const nummern = (fromId, toId) => [...html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`)).matchAll(/<h3>(\d+)\. /g)].map((x) => Number(x[1]))
const maxA = Math.max(...nummern('hosting', 'wartung')), maxB = Math.max(...nummern('wartung', 'widerruf'))
for (const [ref, ab, nr] of text.matchAll(/Leistungsbeschreibung ([AB]) Nr\. (\d+)/g)) assert.ok(Number(nr) <= (ab === 'A' ? maxA : maxB), ref)
for (const [ref, nr] of v4Section('hosting', 'wartung').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxA, `A: ${ref}`)
for (const [ref, nr] of v4Section('wartung', 'widerruf').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxB, `B: ${ref}`)
})
// --- Follow-up vor der Rechtsrunde (Task 1, Fix 2) ---
test('Follow-up: Webmail, Überschrift der Hinweise, § 11 Abs. 3, § 4 Preiswortlaut', () => {
const html = read(V4), text = strip(html)
// Webmail als Zugang zu den Postfaechern, einmal genannt (§ 3 Abs. 4); Übersicht und A/B nennen keine Zugangswege
const p3 = html.slice(html.indexOf('id="p3"'), html.indexOf('id="p4"'))
const abs4 = strip(between(p3, '<h3>(4) E-Mail-Postfächer</h3>', '<h3>(5)'))
assert.ok(abs4.includes('Webmail'))
assert.equal(text.split('Webmail').length - 1, 1, 'Webmail genau einmal')
// Die Hinweise betreffen auch den Widerruf des Abos selbst: kurze Überschrift, Anker bleibt
assert.ok(html.includes('<h2 id="widerruf-hinweise">Hinweise zum Widerruf</h2>'))
assert.ok(html.includes('<a href="#widerruf-hinweise">Hinweise zum Widerruf</a>'))
assert.ok(!text.includes('Hinweise zum Widerruf bei Wechsel und Zusatzspeicher'))
// § 11 Abs. 3: nur die fuer den Kunden gespeicherten Daten werden geloescht; Vertrags- und Abrechnungsdaten
// bleiben nach der Datenschutzerklaerung (Nachweis drei Jahre, Rechnungen nach § 147 AO)
const p11 = absaetzeVon('p11', 'p12')
assert.equal(p11.length, 3)
assert.ok(p11[2].includes('löschen wir die Daten, die wir für den Kunden gespeichert haben (Website bzw. Anwendungsdaten und alle Postfächer)'))
assert.ok(p11[2].includes('Vertrags- und Abrechnungsdaten bewahren wir auf, wie es die Datenschutzerklärung beschreibt (project.webklar.com/datenschutz.html)'))
assert.ok(p11[2].includes('Sicherungskopien werden spätestens mit Ablauf ihrer Aufbewahrungszeit (§ 3 Abs. 5) überschrieben'))
assert.ok(!p11[2].includes('soweit keine gesetzliche Aufbewahrungspflicht besteht'))
// § 4 Abs. 6 und 7: derselbe Wortlaut wie an den anderen Fliesstext-Preisen
const p4 = absaetzeVon('p4', 'p5')
assert.equal(p4.length, 7)
assert.ok(p4[5].includes('100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'), '§ 4 Abs. 6')
assert.ok(p4[6].includes('100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'), '§ 4 Abs. 7')
})
// --- Datenschutzerklärung (Plan 2, Task 2) ---
const DS = 'public/datenschutz.html'
// Ein Satz ab seinem Anfang bis zum nächsten Satzende (in den geprüften Sätzen kommt kein "Art. 6" o. ä. vor)
const sentence = (s, start) => between(s, start, '. ') + '.'
test('Datenschutz: Abos, produktneutral, Stand wie termsVersion', () => {
const ds = strip(read(DS))
for (const s of ['Abos und Zahlung über Stripe', 'UptimeRobot s. r. o.', 'Obchodná 507/2', '811 06 Bratislava',
'Standardvertragsklauseln', 'im Rahmen unserer Abos']) assert.ok(ds.includes(s), s)
// Produktneutral; keine Zusage einer Ereignisansicht (Plan 2 baut keine); UptimeRobot ist kein Unterauftragsverarbeiter
for (const s of ['insbesondere das Wartungs-Abo', 'Schließen Sie das Wartungs-Abo', 'im Rahmen des Wartungs-Abos',
'für das Wartungs-Abo über Stripe', 'Sie sehen diese Ereignisse', 'Unterauftragsverarbeiter']) assert.ok(!ds.includes(s), `noch da: ${s}`)
// Stand = Datum aus termsVersion (JJJJ-MM-TT-v4). Die Kopplung gilt bis zum Livegang: Task 20 trägt in beiden das
// Freigabedatum ein. Danach darf sie gelockert werden, sobald die Erklärung unabhängig von den Bedingungen geändert wird.
const m = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion nicht im Format JJJJ-MM-TT-v4: ${config.abo.termsVersion}`)
const [, y, mo, d] = m
const stand = /Stand (\d{2}\.\d{2}\.\d{4})/.exec(ds)
assert.ok(stand, 'Stand fehlt')
assert.equal(stand[1], `${d}.${mo}.${y}`)
assert.ok(read('scripts/build-legal-pdfs.sh').includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
})
test('Datenschutz Abschnitt 3 und 12: UptimeRobot, Drittland USA, SCC im AVV mit Kopie', () => {
const ds = strip(read(DS))
// Nur die UptimeRobot-Absätze (nach dem Hetzner-Absatz), damit "Auftragsverarbeitung" wirklich UptimeRobot meint
const robot = between(ds, 'Ob unsere Websites', '4. Cookies')
for (const s of ['UptimeRobot s. r. o., Obchodná 507/2, 811 06 Bratislava (Slowakei)', 'Adressen (URLs)', 'Daten der Besucher',
'Auftragsverarbeitung', 'außerhalb der EU', 'insbesondere in den USA', 'Standardvertragsklauseln', 'Art. 46 Abs. 2 lit. c DSGVO',
'uptimerobot.com/dpa', 'Kopie', 'lit. b', 'lit. f', 'entfernen wir']) assert.ok(robot.includes(s), `UptimeRobot: ${s}`)
const empfaenger = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
assert.ok(empfaenger.includes('UptimeRobot s. r. o.'))
assert.ok(empfaenger.includes('(insbesondere USA)'))
})
test('Datenschutz Abschnitt 7: Nachweisdaten je Bestellung, Vertragsereignisse, Kündigungs- und Widerrufsseite', () => {
const abos = between(strip(read(DS)), '7. Abos und Zahlung über Stripe', '8. E-Mail')
// Nachweisdaten (mit IP und Browser) für Abschluss, Wechsel und Zusatzspeicher-Bestellung, im Portal und bei Stripe
const nachweis = between(abos, 'Um Ihre Bestellungen nachweisen', 'Außerdem')
for (const s of ['Version der Vertragsbedingungen', 'gegebenenfalls Ihre Angaben zum Leistungsbeginn', 'IP-Adresse', 'Browserkennung',
'bei Stripe', 'Abschluss eines Abos', 'Wechsel', 'Zusatzspeicher']) assert.ok(nachweis.includes(s), `Nachweis: ${s}`)
// Ereignis-Satz nennt Kündigung und Widerruf als Ereignisse (eine "Kündigungsbestätigung" genügt nicht)
const ereignisse = sentence(abos, 'Außerdem halten wir')
for (const re of [/[ :,]Abschluss[ ,.]/, /[ :,]Wechsel[ ,.]/, /[ :,]Zusatzspeicher[ ,.]/, /[ :,]Verringerungen[ ,.]/,
/[ :,]Kündigung[ ,.]/, /[ :,]Widerruf[ ,.]/]) assert.match(ereignisse, re)
// Verringerung, Kündigung, Widerruf: nur Zeitpunkt und Status (Task 8 speichert beim Minus keine IP/Browserkennung)
assert.ok(abos.includes('nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung'))
assert.ok(abos.includes('Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“.'))
assert.ok(abos.includes('Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen'))
assert.ok(sentence(abos, 'Diese Angaben löschen wir').includes('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet'))
// Öffentliche Kündigungs- und Widerrufsseite (§§ 312k, 356a BGB): Daten, Zweck, Rechtsgrundlage, IP nur zur Begrenzung
const formulare = between(abos, 'kuendigen.html', 'Abschnitt 9')
for (const s of ['widerrufen.html', 'ohne Anmeldung', 'Namen', 'E-Mail-Adresse', 'Kundennummer', 'Eingang', 'Bestätigung', 'Team',
'Art. 6 Abs. 1 lit. c DSGVO', '§§ 312k, 356a BGB', 'lit. b', 'IP-Adresse', 'Arbeitsspeicher', 'zehn',
'drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet', 'Zahl der Pakete']) assert.ok(formulare.includes(s), `Formulare: ${s}`)
})
// --- Rechtsrunde 28.09.2026 (Spec § 14: Wechsel erst mit Zahlung, Zusatzspeicher teilweise kündbar, Aufbewahrung) ---
test('Rechtsrunde: Wechsel steht unter der Bedingung der Zahlung (§ 9 Abs. 3, § 2 Abs. 5, § 7 Abs. 4, Übersicht)', () => {
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher')
assert.equal(wechsel.length, 5)
for (const s of ['Der Wechsel steht unter der Bedingung, dass die Wechselrechnung bezahlt wird.',
'Bei Zahlung per Kreditkarte wird er erst mit der erfolgreichen Zahlung wirksam.',
'Gelingt die Zahlung nicht, etwa weil der Kunde eine von seiner Bank verlangte Freigabe nicht bestätigt, wird er nicht wirksam; spätestens 23 Stunden nach der Bestellung verfällt die Wechselrechnung.',
'Bei SEPA-Lastschrift beginnt das Wartungs-Abo schon mit der Bestellung; wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt.',
'läuft das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter; bei SEPA-Lastschrift bleibt der Abbuchungstag der Wechseltag; wir informieren den Kunden per E-Mail.',
'Das Wartungs-Abo beginnt, sobald der Wechsel wirksam ist',
// Leistung erst ab dem wirksamen Wechsel; Abbuchungstag und Mindestlaufzeit ab dem Bestelltag
'Ab Beginn des Wartungs-Abos gilt Leistungsbeschreibung B. Wechseltag ist der Tag der Bestellung; er ist der neue Abbuchungstag (§ 7 Abs. 4), und mit ihm beginnt eine neue Mindestlaufzeit von zwölf Monaten (§ 8 Abs. 1).',
'ab Beginn des Wartungs-Abos kann der Kunde weitere Postfächer beauftragen; wir richten sie ein, sobald die Wechselrechnung bezahlt ist.']) assert.ok(wechsel[2].includes(s), `§ 9 Abs. 3: ${s}`)
const p9 = v4Section('wechsel', 'zusatzspeicher')
for (const s of ['beginnt sofort mit dem Wechsel', 'Ab dem Wechseltag gilt Leistungsbeschreibung B', 'kommt er nicht zustande']) assert.ok(!p9.includes(s), `noch da: ${s}`)
// Wertersatz beim Widerruf des Wechsels erst ab Beginn des Wartungs-Abos, nicht ab der Bestellung
const hinweise = v4Section('widerruf-hinweise', 'avv')
assert.ok(hinweise.includes('für die Tage vom Beginn des Wartungs-Abos bis zum Widerruf'))
assert.ok(!hinweise.includes('vom Wechsel bis zum Widerruf'))
assert.ok(wechsel[1].includes('Wird der Wechsel wirksam, endet das Hosting-Abo im gegenseitigen Einvernehmen.'))
assert.ok(absaetzeVon('p2', 'p3')[4].includes('beim Wechsel steht er unter der Bedingung des § 9 Abs. 3 (Zahlung der Wechselrechnung)'))
assert.ok(absaetzeVon('p7', 'p8')[3].includes('Wird die Wechselrechnung nicht bezahlt, gilt § 9 Abs. 3.'))
assert.ok(absaetzeVon('p4', 'p5')[0].includes('mit der Zahlung der Wechselrechnung (§ 9 Abs. 3)'))
assert.ok(uebersichtZeile('Wechsel').includes('unter der Bedingung, dass die Wechselrechnung bezahlt wird'))
})
test('Rechtsrunde: Zusatzspeicher ganz oder teilweise kündbar, auch über „Verträge hier kündigen“ (§ 8 Abs. 7 und 8, § 10 Abs. 5, Übersicht)', () => {
const p8 = absaetzeVon('p8', 'wechsel')
assert.equal(p8.length, 8)
assert.ok(p8[6].includes('ganz oder teilweise (mit einer geringeren Zahl von Paketen) kündigen'))
assert.ok(p8[7].includes('ganz oder mit der Zahl der Pakete, die er kündigt'))
assert.ok(p8[7].includes('auch, wenn das Abo schon gekündigt ist'))
assert.ok(p8[7].includes('Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir'), 'passt auch zur teilweisen Kündigung des Zusatzspeichers')
const abs5 = absaetzeVon('zusatzspeicher', 'p11')[4]
assert.ok(abs5.includes('Den Zusatzspeicher ganz oder teilweise kündigen kann er jederzeit über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“ und der Zahl der Pakete oder in Textform (§ 8 Abs. 8)'))
assert.ok(abs5.includes('nach einer Kündigung des Abos kann der Kunde die Zahl der Pakete nur noch über „Verträge hier kündigen“ oder in Textform verringern'))
assert.ok(uebersichtZeile('Kündigen').includes('ganz oder mit der Zahl der Pakete'))
assert.ok(uebersichtZeile('E-Mail-Zusatzspeicher').includes('ganz oder teilweise kündbar'))
})
test('Rechtsrunde Datenschutz: Aufbewahrung nach dem Ende des letzten Abos, Zahl der Pakete, kurzer UptimeRobot-Eintrag', () => {
const html = read(DS), ds = strip(html)
assert.equal(ds.split('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet').length - 1, 2, 'Abschnitt 7 und Kündigungs-/Widerrufsseite')
for (const s of ['drei Jahre nach Vertragsende', 'nach dem Ende Ihres letzten Abos']) assert.ok(!ds.includes(s), `noch da: ${s}`)
// Abschnitt 6 (Kundenkonto) verweist für Abos und Bestellungen auf die Fristen in Abschnitt 7
assert.ok(between(ds, '6. Kundenportal', '7. Abos').includes('Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7.'))
// Übernahmegutschrift und Abschnitt 9 behalten ihre eigenen Fristen
assert.ok(ds.includes('sechs Monate nach der Entscheidung'))
assert.ok(ds.includes('acht Jahre auf (§ 147 AO, § 14b UStG), Handels- und Geschäftsbriefe sechs Jahre'))
assert.ok(between(ds, 'kuendigen.html', 'Abschnitt 9').includes('beim E-Mail-Zusatzspeicher die Zahl der Pakete, die Sie kündigen'))
// Abschnitt 12: der UptimeRobot-Eintrag etwa so lang wie seine Nachbarn, mit Drittland und Rolle
const empfaenger = [...html.slice(html.indexOf('12. Empfänger'), html.indexOf('13. Ihre Rechte')).matchAll(/<li>([\s\S]*?)<\/li>/g)].map((m) => strip(m[1]))
const robot = empfaenger.find((e) => e.startsWith('UptimeRobot'))
assert.ok(robot.includes('Daten auch außerhalb der EU (insbesondere USA)') && robot.includes('Auftragsverarbeiter') && robot.includes('Abschnitt 3'), robot)
assert.ok(robot.length <= 135, `UptimeRobot-Eintrag zu lang: ${robot.length} Zeichen`)
})
// --- Übernahmegutschrift, Prüftexte, Leitfaden (Plan 2, Task 3) ---
test('Übernahmegutschrift und Leitfaden: produktneutral', () => {
const app = read('public/app.js')
const cond = between(app, 'const TAKEOVER_CONDITIONS', ']')
assert.ok(!cond.includes('Wartungs-Abo') && cond.includes('Ihr Abo'), 'Bedingungen nennen „Ihr Abo“ statt des Wartungs-Abos')
assert.notEqual(config.takeover.conditionsVersion, '2026-09-25', 'conditionsVersion hochgezogen')
const tc = read('server/services/takeoverCredits.js')
assert.ok(!/Wartungs-Abo (läuft|ist beendet)|kein (laufendes )?Wartungs-Abo/.test(tc), 'Prüf-Labels und Fehlermeldungen sagen „Abo“')
const lf = read('docs/leitfaden-empfehlungsgeber.html')
assert.ok(lf.includes('Hosting-Abo') && lf.includes('beliebig viele Postfächer'), 'Leitfaden nennt beide Abos')
// Festlegungen zur Umsetzung: Postfächer im Plural (Wartungs-Abo: beliebig viele), Entwurfsdatum als Version,
// Karte im Portal und Checkliste ohne Produktnamen (Preisvergleich je Produkt: test/takeoverCredits.test.js)
assert.ok(cond.includes('E-Mail-Postfächer'))
assert.match(config.takeover.conditionsVersion, /^\d{4}-\d{2}-\d{2}$/)
const condHash = createHash('sha256').update(cond).digest('hex')
assert.equal(condHash, TAKEOVER_CONDITIONS_SHA256[config.takeover.conditionsVersion],
`Bedingungen der Übernahmegutschrift geändert: conditionsVersion hochziehen und Hash eintragen (${condHash})`)
assert.ok(app.includes('sobald Ihr Abo läuft') && !app.includes('sobald Ihr Wartungs-Abo läuft'))
assert.ok(!tc.includes('durch das Wartungs-Abo ersetzt'))
// Leitfaden: beide Preise mit § 19 UStG, Zusatzspeicher, Wechsel, Gutschrift für beide Abos, Bestellweg „Verträge“
const lfText = strip(lf)
for (const s of ['zusammen 5 GB', 'Zusatzspeicher', 'Wechsel', 'Übernahmegutschrift', 'beiden Abos', '„Verträge“']) assert.ok(lfText.includes(s), s)
for (const preis of ['15,00 €', '50,00 €', '1,00 €']) {
const i = lfText.indexOf(preis)
assert.ok(i >= 0, preis)
assert.ok(lfText.slice(i, i + 80).includes('§ 19 UStG'), `${preis} ohne § 19 UStG`)
}
assert.ok(!lfText.includes('1 E-Mail-Postfach und 1 Stunde'), 'Wartungs-Abo nicht mehr mit einem Postfach')
assert.ok(!lfText.includes('Startseite'), 'kein veralteter Bestellweg')
})
test('§ 13 Abs. 3: Datenverlust-Klausel mit Ausnahme für Absatz 1 (Vorsatz, grobe Fahrlässigkeit)', () => {
const html = readFileSync(new URL('../public/abo-bedingungen.html', import.meta.url), 'utf8')
assert.ok(html.includes('Außer in den Fällen von Absatz 1 haften wir für den Verlust von Daten'))
})
File diff suppressed because it is too large. Load diff
+367 -7
View File
@@ -1,18 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { test } from 'node:test'
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
import { contractAttachmentFor, termsDocFor } from '../server/services/products.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
'T14',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
test('alle 22 Vorlagen sind da', () => {
test('alle Vorlagen sind da', () => {
assert.deepEqual(templateIds(), EXPECTED)
})
@@ -22,7 +31,8 @@ for (const id of EXPECTED) {
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
@@ -59,7 +69,7 @@ test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden <br>', () => {
})
test('optionale Blöcke: nur eingeschaltete erscheinen', () => {
const values = { name: 'Max', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' }
const values = { name: 'Max', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' }
const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false })
assert.doesNotMatch(early.html, /Ihr Angebot:/)
assert.match(early.html, /Sie haben ausdrücklich verlangt/)
@@ -94,11 +104,13 @@ test('Anrede ohne Namen, fehlende und unbekannte Werte werden gemeldet', () => {
})
test('Textfassung K01: alle Vertragsangaben lesbar', () => {
// F34: Produkt und Preis kommen aus dem Katalog, der Produktblock (hier wartung) bringt die Liste „Enthalten“
const mail = renderMail(
'K01',
{ name: 'Erika Musterfrau', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' },
{ sofortbeginn: true }
{ name: 'Erika Musterfrau', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' },
{ sofortbeginn: true, wartung: true }
)
assert.deepEqual(mail.problems, [])
const t = mail.text
for (const line of [
'Guten Tag Erika Musterfrau,',
@@ -110,7 +122,7 @@ test('Textfassung K01: alle Vertragsangaben lesbar', () => {
'• 1 Domain',
'Zum Kundenportal: https://project.webklar.com/dashboard.html',
'📎 Widerrufsbelehrung mit Muster-Widerrufsformular',
'Ihre Zustimmung zu den Vertragsbedingungen (Version 2026-09-25-v3) haben wir am 01.10.2026, 10:07 Uhr erhalten.',
'Ihre Zustimmung zu den Vertragsbedingungen (Version 2026-09-25-v3) einschließlich des Auftragsverarbeitungsvertrags haben wir am 01.10.2026, 10:07 Uhr erhalten.',
'Vertrag widerrufen (https://project.webklar.com/widerrufen.html)',
'Vertrag kündigen (https://project.webklar.com/kuendigen.html)',
'Telefon 0176 23726355 · support@webklar.com',
@@ -129,3 +141,351 @@ test('htmlToText: Links, Buttons und Leerzeilen', () => {
test('Vorlagen-Ordner enthält nur Vorlagen', () => {
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), [])
})
test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', () => {
const tpl = loadTemplate('K09')
const mail = renderMail('K09', sampleValues(tpl), allBlocks(tpl, true))
assert.match(mail.text, /mit den nächsten Monatsgebühren Ihres Abos\. Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben\./)
assert.match(mail.text, /Eine Auszahlung ist nicht möglich\. Wechseln Sie in ein anderes Abo, verrechnen wir die Gutschrift weiter; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest\./)
assert.doesNotMatch(mail.html, /Wartungs-Abo|Vertragsende/)
})
// Task 7 (Fix 1): konnte das Vertragsereignis "abschluss" dauerhaft nicht gespeichert werden (4xx bei
// Appwrite), erfaehrt das Team das im T02-Hinweis statt in einem stillen Webhook-Fehler
test('T02: Block nachweis_fehlt weist auf den fehlenden Nachweis hin', () => {
const tpl = loadTemplate('T02')
const values = sampleValues(tpl)
const withBlock = renderMail('T02', values, { nachweis_fehlt: true })
assert.deepEqual(withBlock.problems, [])
assert.match(withBlock.text, /Nachweis nicht gespeichert/)
assert.match(withBlock.text, /contractEvents/)
assert.doesNotMatch(renderMail('T02', values, {}).text, /Nachweis nicht gespeichert/)
})
// Task 9 (Rechtsrunde 28.09.2026): Wechsel ins Wartungs-Abo wird erst mit der Zahlung wirksam
test('K12, K14, T12: Betreff, Platzhalter und Blöcke des Wechsels', () => {
assert.equal(loadTemplate('K12').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung')
assert.deepEqual(loadTemplate('K12').placeholders, ['name', 'wechseltag', 'betrag_heute', 'monatspreis', 'mindestlaufzeit_bis', 'bedingungen_version', 'zustimmung_zeitpunkt'])
assert.equal(loadTemplate('K14').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen')
assert.deepEqual(loadTemplate('K14').placeholders, ['name', 'grund'])
assert.equal(loadTemplate('T12').subject, '[Abo] Wechsel ins Wartungs-Abo: {{email}}')
// F37: feste Platzhalter fuer T12
assert.deepEqual(loadTemplate('T12').placeholders, ['email', 'name', 'stripe_abo_id', 'wechseltag', 'betrag_heute', 'monatspreis'])
for (const block of ['gescheitert', 'lastschrift_gescheitert']) assert.ok(loadTemplate('T12').blocks.includes(block), block)
})
test('K12: Block zahlung_offen wörtlich, Block lastschrift mit dem Satz aus § 9 Abs. 3', () => {
const values = sampleValues(loadTemplate('K12'))
const offen = renderMail('K12', values, { zahlung_offen: true })
assert.ok(offen.text.includes('Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.'))
assert.doesNotMatch(offen.text, /SEPA-Lastschrift/)
const sepa = renderMail('K12', values, { lastschrift: true })
assert.match(sepa.text, /Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt/)
assert.match(sepa.text, /Ihr Abbuchungstag bleibt der Wechseltag/)
assert.doesNotMatch(sepa.text, /sobald die Zahlung bestätigt ist/)
const plain = renderMail('K12', values, {})
assert.doesNotMatch(plain.text, /sobald die Zahlung bestätigt ist|Wird die Lastschrift nicht eingelöst/)
// Vertragsbestaetigung: Preis mit § 19 UStG, Mindestlaufzeit, Unterlagen, Zustimmung zum Sofortbeginn
for (const text of ['gemäß § 19 UStG ohne Umsatzsteuer', 'Mindestlaufzeit 12 Monate bis Wert-mindestlaufzeit_bis.', 'Widerrufsbelehrung mit Muster-Widerrufsformular', 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen']) {
assert.ok(plain.text.includes(text), text)
}
})
test('K14: Hosting-Abo läuft wie vor dem Wechsel weiter; SEPA: Abbuchungstag bleibt', () => {
const values = { name: 'Kunde', grund: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.' }
const card = renderMail('K14', values, {})
assert.deepEqual(card.problems, [])
assert.ok(card.text.includes('Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter.'))
assert.ok(card.text.includes(values.grund))
assert.doesNotMatch(card.text, /Abbuchungstag/)
assert.match(renderMail('K14', values, { lastschrift: true }).text, /Ihr Abbuchungstag bleibt der Tag des Wechsels\./)
})
// Nach der Pruefung der Rechtsrunden-Texte: Stripe kann die Frist auch kuerzer setzen
test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => {
for (const id of ['K12', 'K14', 'T12']) {
const tpl = loadTemplate(id)
assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id)
}
})
// Task 8 (Fix 1): K13 (Kunde) und T13 (Team) fuer den Zusatzspeicher, Aufbau wie K12/T12; Endfassung der Texte in Aufgabe 12
test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => {
assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung')
assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab'])
// Fix 2 (3): plus = Einrichtung mit anteiliger Berechnung, plus_bezahlt = Plus bis zur schon bezahlten Menge,
// bestellung = Unterlagen und Widerruf (bei beiden), minus = Verringerung zum Periodenende
assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'plus_bezahlt', 'bestellung', 'minus'])
assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}')
for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) {
assert.ok(loadTemplate('T13').placeholders.includes(p), p)
}
assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise'])
const values = sampleValues(loadTemplate('K13'))
const plus = renderMail('K13', values, { plus: true, bestellung: true })
assert.deepEqual(plus.problems, [])
assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/)
assert.match(plus.text, /wird eingerichtet/)
assert.match(plus.text, /14 Tagen widerrufen/)
assert.match(plus.text, /Vertragsunterlagen im Anhang/)
assert.doesNotMatch(plus.text, /ohne anteilige Erstattung|keine zusätzlichen Kosten/)
const paid = renderMail('K13', values, { plus_bezahlt: true, bestellung: true })
assert.ok(paid.text.includes('Die Pakete sind für den laufenden Monat schon bezahlt; es entstehen keine zusätzlichen Kosten. Ab dem nächsten Abrechnungsmonat gelten Wert-pakete Pakete.'))
assert.match(paid.text, /14 Tagen widerrufen/)
assert.match(paid.text, /Vertragsunterlagen im Anhang/)
assert.doesNotMatch(paid.text, /wird eingerichtet|anteilig/)
const minus = renderMail('K13', values, { minus: true })
assert.match(minus.text, /ohne anteilige Erstattung/)
assert.match(minus.text, /keine neuen E-Mails/)
assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang|schon bezahlt/)
const t = loadTemplate('T13')
const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true })
assert.deepEqual(teilweise.problems, [])
assert.match(teilweise.text, /Schritt 2 fehlgeschlagen/)
assert.match(teilweise.text, /von Hand auf 5 setzen/)
assert.match(renderMail('T13', sampleValues(t), { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/)
assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/)
})
// Task 10: Kuendigung und Widerruf mit Auswahl - K03/T08 nennen den Vertrag und die gekuendigten Pakete, K04/T09 die
// Bestellung; die Handgriffe je Bestellung und "wir pruefen" ohne offene Frist. Endfassung der Texte in Aufgabe 12
test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Auswahl', () => {
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen'])
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' }
const speicher = renderMail('K03', k03, { zusatzspeicher: true })
assert.deepEqual(speicher.problems, [])
assert.match(speicher.text, /Vertrag: E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo/)
assert.match(speicher.text, /Ihr Zusatzspeicher verringert sich zum\n31\.10\.2026/)
assert.match(speicher.text, /um 2 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/)
assert.doesNotMatch(speicher.text, /Wartungs-Abo\n|Stundenkontingent|Stundenguthaben|Wir prüfen Ihre Kündigung/)
assert.match(renderMail('K03', k03, { pruefen: true }).text, /bestätigen Ihnen den Zeitpunkt, zu dem sie wirkt, gesondert per E-Mail/)
// Task 12 (Fix 1, 6): das Stundenkontingent gibt es nur im Wartungs-Abo (Block wartung innerhalb von abo); es ist
// kein Geldguthaben (v4 § 4 Abs. 1), darum „nicht genutzte Arbeitszeit“ statt „Stundenguthaben“
assert.match(renderMail('K03', k03, { abo: true, wartung: true }).text, /Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende\./)
assert.doesNotMatch(renderMail('K03', k03, { abo: true }).text, /Stundenkontingent|Stundenguthaben/)
assert.doesNotMatch(renderMail('K03', k03, { abo: true, wartung: true }).text, /Stundenguthaben/)
assert.match(renderMail('K03', k03, { abo: true }).text, /Domain-AuthCode/)
const k04 = { ...sampleValues(loadTemplate('K04')), bestellung: 'Wechsel ins Wartungs-Abo' }
assert.match(renderMail('K04', k04, { wechsel: true }).text, /Widerruf der Bestellung „Wechsel ins Wartungs-Abo“/)
// Task 12: Wortlaut der Hinweise zum Widerruf (Fassung 4)
assert.match(renderMail('K04', k04, { wechsel: true }).text, /zu den bisherigen Bedingungen weiter/)
assert.match(renderMail('K04', k04, { zusatzspeicher: true }).text, /Menge vor der Bestellung/)
assert.match(renderMail('K04', k04, { abschluss: true }).text, /keine weiteren Abbuchungen/)
assert.match(renderMail('K04', k04, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/)
assert.doesNotMatch(renderMail('K04', k04, {}).text, /Wie es weitergeht|WEBklar Wartungs-Abo“/)
const t08 = sampleValues(loadTemplate('T08'))
assert.match(renderMail('T08', t08, { wechsel_ausstehend: true }).text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
assert.match(renderMail('T08', t08, { speicher_fehlgeschlagen: true }).text, /NICHT automatisch verringert/)
assert.match(renderMail('T08', t08, { kein_speicher: true }).text, /kein Zusatzspeicher gebucht/)
assert.match(renderMail('T08', t08, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
const t09 = { ...sampleValues(loadTemplate('T09')), ereignisse: 'speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete' }
const speicherT09 = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true })
assert.match(speicherT09.text, /Menge zurück/)
assert.match(speicherT09.text, /speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete/)
assert.doesNotMatch(speicherT09.text, /Abo in Stripe sofort beenden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text, /Abo in Stripe sofort beenden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, keine_frist: true }).text, /keine offene Frist gefunden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text, /Preis zurück auf das Hosting-Abo/)
// Fix 1: Paketzahl in jedem Speicherpfad (K03/T08), Pruefung der Kuendigungsart, T09-Hinweise
assert.match(renderMail('K03', { ...k03, pakete_angabe: 'alle gebuchten Pakete' }, { speicher_angaben: true, pruefen: true }).text, /Pakete: alle gebuchten Pakete/)
assert.match(renderMail('K03', { ...k03, kuendigungsart: 'außerordentliche Kündigung' }, { speicher_kind_pruefung: true }).text, /Ihre außerordentliche Kündigung wird geprüft/)
const t08a = { ...t08, pakete_angabe: '2 Paket(e)', kuendigungsart: 'Außerordentliche Kündigung' }
assert.match(renderMail('T08', t08a, { speicher_angaben: true, wechsel_ausstehend: true }).text, /Pakete: 2 Paket\(e\)/)
assert.match(renderMail('T08', t08a, { wechsel_ausstehend: true }).text, /gekündigt: 2 Paket\(e\)/)
assert.match(renderMail('T08', t08a, { speicher_kind_pruefung: true }).text, /Außerordentliche Kündigung des Zusatzspeichers/)
const t09b = { ...t09, fristende: 'keine Frist gefunden', pruefhinweis: 'Ereignisse nicht lesbar – bitte von Hand prüfen' }
assert.match(renderMail('T09', t09b, { abo_gefunden: true }).text, /Widerrufsfrist bis: keine Frist gefunden/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, nicht_pruefbar: true }).text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, wechsel: true, wechsel_offen: true }).text, /Wechselrechnung noch offen – stornieren/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, abschluss_verspaetet: true }).text, /als ordentliche Kündigung behandeln/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, bereits_widerrufen: true }).text, /schon widerrufen/)
})
// ---------------------------------------------------------------------------
// Task 12: Mails je Produkt (Hosting-Abo, Wartungs-Abo), Vertragsunterlagen der zugestimmten Fassung,
// Paragraph zum Vertragsende je Fassung, Folgen des Widerrufs wie in den Hinweisen der Fassung 4, UptimeRobot
// ---------------------------------------------------------------------------
test('Neue Vorlagen vorhanden', () => {
for (const id of ['K12', 'K13', 'K14', 'T12', 'T13']) assert.ok(templateIds().includes(id), id)
})
test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => {
const base = { name: 'K', produkt: 'Hosting-Abo', preis: '15,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026',
mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' }
const h = renderMail('K01', base, { hosting: true, sofortbeginn: true })
assert.equal(h.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung')
assert.ok(h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit'), h.text)
assert.ok(h.text.includes('15,00 € / Monat') && h.text.includes('gemäß § 19 UStG ohne Umsatzsteuer'))
assert.ok(h.text.includes('Ihr Hosting-Abo ist bestätigt.') && h.text.includes('Abschluss Ihres Hosting-Abos'))
assert.doesNotMatch(h.text, /Stundenkontingent/)
assert.deepEqual(h.problems, [])
const w = renderMail('K01', { ...base, produkt: 'Wartungs-Abo', preis: '50,00 € / Monat' }, { wartung: true, sofortbeginn: true })
assert.ok(w.text.includes('beliebig viele Postfächer'), w.text)
assert.ok(w.text.includes('1 Stunde Arbeitszeit je bezahltem Monat') && w.text.includes('Stundenkontingent'))
assert.doesNotMatch(w.text, /1 E-Mail-Postfach\n/)
assert.deepEqual(w.problems, [])
})
test('T02 und T07 erinnern an UptimeRobot', () => {
// F38: echte Platzhalter, keine Unstimmigkeiten
const t02 = renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', appwrite_id: 'c1', stripe_abo_id: 'sub_1', angebot_text: '' }, {})
assert.deepEqual(t02.problems, [])
assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo') && t02.text.includes('Website als Monitor in UptimeRobot anlegen'), t02.text)
assert.equal(loadTemplate('T02').file, 'T02 Neues Abo.html')
assert.match(renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', appwrite_id: 'c1', stripe_abo_id: 'sub_1', angebot_text: '' }, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
const t07 = renderMail('T07', { produkt: 'Hosting-Abo', email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled', vertragsende_paragraph: '§ 11' }, {})
assert.deepEqual(t07.problems, [])
assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text)
})
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14 und T12', () => {
for (const id of templateIds()) if (!['K12', 'K14', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
})
test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => {
// contractAttachmentFor(termsVersion) liefert { filename, path } und wird von sendContractConfirmation benutzt
assert.equal(contractAttachmentFor('2026-09-25-v3').filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
assert.equal(contractAttachmentFor('2026-10-05-v4').filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
assert.ok(existsSync(contractAttachmentFor('2026-10-05-v4').path))
assert.ok(existsSync(contractAttachmentFor('2026-09-25-v3').path))
})
// Rechtsrunde: In Fassung 4 ist § 9 der Wechsel, das Vertragsende steht in § 11 (F08)
test('K02, K03, T05, T07: Paragraph zum Vertragsende aus der Fassung des Kunden', () => {
assert.equal(termsDocFor('2026-09-25-v3').endParagraph, '§ 9')
assert.equal(termsDocFor(null).endParagraph, '§ 9')
assert.equal(termsDocFor('2026-10-05-v4').endParagraph, '§ 11')
for (const id of ['K02', 'K03', 'T05', 'T07']) {
const tpl = loadTemplate(id)
assert.ok(tpl.placeholders.includes('vertragsende_paragraph'), id)
const v4 = renderMail(id, { ...sampleValues(tpl), vertragsende_paragraph: '§ 11' }, allBlocks(tpl, true))
assert.deepEqual(v4.problems, [], id)
assert.doesNotMatch(v4.text, /§ 9 der Vertragsbedingungen/, id)
assert.match(v4.text, /§ 11 der Vertragsbedingungen/, id)
assert.match(renderMail(id, { ...sampleValues(tpl), vertragsende_paragraph: '§ 9' }, allBlocks(tpl, true)).text, /§ 9 der Vertragsbedingungen/, id)
}
})
test('K02–K06, T02, T05–T09: Produkt aus dem Katalog statt fest Wartungs-Abo', () => {
for (const id of ['K02', 'K03', 'K04', 'K05', 'K06', 'T02', 'T05', 'T06', 'T07', 'T08', 'T09']) {
assert.ok(loadTemplate(id).placeholders.includes('produkt'), id)
}
const values = { name: 'K', produkt: 'Hosting-Abo', eingang_zeitpunkt: '01.10.2026, 10:00', vertragsende: '01.10.2027', vertragsende_paragraph: '§ 11' }
const k02 = renderMail('K02', values, {})
assert.deepEqual(k02.problems, [])
assert.equal(k02.subject, 'Bestätigung Ihrer Kündigung – WEBklar Hosting-Abo')
assert.match(k02.text, /Kündigung des WEBklar Hosting-Abos vom 01\.10\.2026, 10:00 Uhr/)
assert.doesNotMatch(k02.text, /Stundenkontingent|Abo verwalten|Wartungs-Abo/)
const k02w = renderMail('K02', { ...values, produkt: 'Wartungs-Abo' }, { wartung: true })
assert.match(k02w.text, /Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende\./)
assert.equal(renderMail('K03', { ...sampleValues(loadTemplate('K03')), produkt: 'Wartungs-Abo' }, {}).subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo')
assert.equal(renderMail('K04', { ...sampleValues(loadTemplate('K04')), produkt: 'Hosting-Abo' }, {}).subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Hosting-Abo')
for (const id of ['T05', 'T06', 'T07', 'T08', 'T09']) {
const tpl = loadTemplate(id)
assert.match(renderMail(id, { ...sampleValues(tpl), produkt: 'Hosting-Abo' }, allBlocks(tpl, true)).text, /^Abo: Hosting-Abo$/m, id)
}
})
// F36: die Folgen des Widerrufs je Bestellung wie in den Hinweisen zum Widerruf der Fassung 4
test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeicher, wir prüfen)', () => {
const values = { name: 'K', produkt: 'Hosting-Abo', bestellung: 'Abschluss Ihres Abos', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' }
const abschluss = renderMail('K04', values, { abschluss: true })
assert.deepEqual(abschluss.problems, [])
assert.match(abschluss.text, /keine weiteren Abbuchungen/)
assert.match(abschluss.text, /Wertersatz/)
assert.match(abschluss.text, /Zusatzspeicher[^.]*erstatten wir vollständig; Wertersatz verlangen wir dafür nicht/)
const wechsel = renderMail('K04', { ...values, bestellung: 'Wechsel ins Wartungs-Abo' }, { wechsel: true }).text
assert.match(wechsel, /Hosting-Abo läuft zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit/)
assert.match(wechsel, /Mehrbetrag/)
assert.match(wechsel, /abzüglich des Wertersatzes/)
assert.match(wechsel, /§ 357a Abs\. 2 BGB/)
const speicher = renderMail('K04', { ...values, bestellung: 'E-Mail-Zusatzspeicher' }, { zusatzspeicher: true }).text
assert.match(speicher, /Menge vor der Bestellung/)
assert.match(speicher, /erstatten wir vollständig/)
assert.match(speicher, /Wertersatz verlangen wir nicht/)
assert.doesNotMatch(speicher, /abzüglich/)
assert.match(renderMail('K04', values, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/)
})
test('K12: Monatspreis mit gebuchtem Zusatzspeicher (Block), Widerruf des Wechsels wie in Fassung 4', () => {
const values = sampleValues(loadTemplate('K12'))
const mit = renderMail('K12', values, { zusatzspeicher: true })
assert.deepEqual(mit.problems, [])
assert.match(mit.text, /einschließlich Ihres gebuchten E-Mail-Zusatzspeichers/)
const ohne = renderMail('K12', values, {})
assert.doesNotMatch(ohne.text, /einschließlich Ihres gebuchten/)
assert.match(ohne.text, /Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter/)
assert.match(ohne.text, /Mehrbetrag/)
// K13: Widerruf des Zusatzspeichers ohne Wertersatz
const k13 = renderMail('K13', sampleValues(loadTemplate('K13')), { plus: true, bestellung: true })
assert.match(k13.text, /erstatten alles, was Sie dafür gezahlt haben\. Wertersatz verlangen wir nicht\./)
})
// T09: die Handgriffe je Bestellung passen zu den Hinweisen zum Widerruf (Zusatzspeicher ohne Wertersatz)
test('T09: Handgriffe je Bestellung ohne Wertersatz beim Zusatzspeicher', () => {
const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Hosting-Abo' }
const speicher = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true }).text
assert.match(speicher, /vollständig erstatten/)
assert.doesNotMatch(speicher, /Wertersatz für die Tage abziehen/)
const wechsel = renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text
assert.match(wechsel, /Mehrbetrag/)
assert.match(wechsel, /Wertersatz/)
const abschluss = renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text
assert.match(abschluss, /Zusatzspeicher[^.]*vollständig/)
})
// Task 12, Fix round 1: K01 je Fassung (v3-Zeile „1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat“ im Block wartung_v3),
// Zustimmung einschließlich AVV; K12 „Heute fällig“ nennt den Zusatzspeicher nur im Block; T07 nennt die Arbeitszeit nur bei Wartung
test('K01: Block wartung_v3 mit der Zeile der Fassung 3, wartung mit der Fassung 4, Zustimmung einschließlich AVV', () => {
const base = { name: 'K', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026',
mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' }
assert.ok(loadTemplate('K01').blocks.includes('wartung_v3'), 'Block wartung_v3 deklariert')
const v3 = renderMail('K01', base, { wartung_v3: true, sofortbeginn: true })
assert.deepEqual(v3.problems, [])
assert.match(v3.text, /• 1 E-Mail-Postfach/)
assert.match(v3.text, /• 1 Stunde Arbeitszeit pro Monat/)
assert.match(v3.text, /Dort sehen Sie jederzeit Ihr aktuelles Stundenkontingent\./)
assert.doesNotMatch(v3.text, /beliebig viele|je bezahltem Monat|Zusatzspeicher/)
assert.match(v3.text, /Ihre Zustimmung zu den Vertragsbedingungen \(Version 2026-09-25-v3\) einschließlich des Auftragsverarbeitungsvertrags haben wir am 01\.10\.2026, 10:07 Uhr erhalten\./)
const v4 = renderMail('K01', { ...base, bedingungen_version: '2026-10-05-v4' }, { wartung: true, sofortbeginn: true })
assert.match(v4.text, /• beliebig viele Postfächer/)
assert.match(v4.text, /1 Stunde Arbeitszeit je bezahltem Monat/)
assert.doesNotMatch(v4.text, /1 E-Mail-Postfach|pro Monat/)
const h = renderMail('K01', { ...base, produkt: 'Hosting-Abo', preis: '15,00 € / Monat' }, { hosting: true, sofortbeginn: true })
assert.doesNotMatch(h.text, /Stundenkontingent|Stunde Arbeitszeit/)
})
test('K12: „Heute fällig“ nennt den Zusatzspeicher nur im Block zusatzspeicher', () => {
const values = sampleValues(loadTemplate('K12'))
assert.match(renderMail('K12', values, { zusatzspeicher: true }).text, /Monatsgebühr des Wartungs-Abos mit gebuchtem Zusatzspeicher, abzüglich der Gutschrift/)
const ohne = renderMail('K12', values, {}).text
assert.match(ohne, /Monatsgebühr des Wartungs-Abos, abzüglich der Gutschrift/)
assert.doesNotMatch(ohne, /mit gebuchtem Zusatzspeicher/)
})
test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
const values = { produkt: 'Wartungs-Abo', email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled', vertragsende_paragraph: '§ 11' }
assert.ok(loadTemplate('T07').blocks.includes('wartung'), 'Block wartung deklariert')
const w = renderMail('T07', values, { wartung: true })
assert.deepEqual(w.problems, [])
assert.match(w.text, /Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit dem Vertragsende\. Gebuchter Zusatzspeicher endet mit dem Abo\./)
const h = renderMail('T07', { ...values, produkt: 'Hosting-Abo' }, {})
assert.deepEqual(h.problems, [])
assert.match(h.text, /Gebuchter Zusatzspeicher endet mit dem Abo\./)
assert.doesNotMatch(h.text, /Arbeitszeit|Stundenkontingent|Restguthaben/)
})
+201
View File
@@ -0,0 +1,201 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
aboOffers: schemaKeys('aboOffers'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, get } = await import('./support/testApp.js')
const { hostingDoc, customerCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { validateOfferInput, getOpenOffer, createOffer, offerContext, activateOfferFromCheckout } =
await import('../server/services/offers.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
const ADMIN = { name: 'Admin', email: 'admin@example.invalid' }
// ---------------------------------------------------------------------------
// Step 1 (Brief): validateOfferInput + getOpenOffer
// ---------------------------------------------------------------------------
test('Angebot Hosting: Normalpreis 15 €, Preis darunter', () => {
assert.equal(validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1000 }).regularPriceCents, 1500)
assert.throws(() => validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1500 }), (e) => e.status === 400)
assert.throws(() => validateOfferInput({ months: 2, monthlyPriceCents: 1000 }), (e) => e.status === 400)
})
test('Offenes Angebot nur fürs passende Produkt; alte Angebote gelten als Wartung', async () => {
aw.seed('aboOffers', { $id: 'o_alt', customerId: 'c1', status: 'offen', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 2500 })
aw.seed('aboOffers', { $id: 'o_h', customerId: 'c1', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 1000 })
try {
assert.equal((await getOpenOffer('c1', 'hosting')).$id, 'o_h')
assert.equal((await getOpenOffer('c1', 'wartung')).$id, 'o_alt')
} finally {
await deleteDocument(config.collections.aboOffers, 'o_alt')
await deleteDocument(config.collections.aboOffers, 'o_h')
}
})
// ---------------------------------------------------------------------------
// createOffer: getrennte offene Angebote je Produkt (F32)
// ---------------------------------------------------------------------------
test('createOffer: offene Angebote fuer Hosting und Wartung koennen nebeneinander bestehen, Konflikt nur im selben Produkt', async () => {
// c3 hat kein Abo (Fixtures)
const hosting = await createOffer({ customerId: 'c3', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: false }, ADMIN)
try {
assert.equal(hosting.doc.productKey, 'hosting')
assert.equal(hosting.doc.regularPriceCents, 1500)
assert.equal(hosting.doc.status, 'offen')
await assert.rejects(
createOffer({ customerId: 'c3', productKey: 'hosting', months: 1, monthlyPriceCents: 500, notifyCustomer: false }, ADMIN),
(e) => e.status === 409
)
const wartung = await createOffer({ customerId: 'c3', productKey: 'wartung', months: 1, monthlyPriceCents: 2000, notifyCustomer: false }, ADMIN)
try {
assert.equal(wartung.doc.productKey, 'wartung')
assert.equal((await getOpenOffer('c3', 'hosting')).$id, hosting.doc.$id)
assert.equal((await getOpenOffer('c3', 'wartung')).$id, wartung.doc.$id)
} finally {
await deleteDocument(config.collections.aboOffers, wartung.doc.$id)
}
} finally {
await deleteDocument(config.collections.aboOffers, hosting.doc.$id)
}
})
test('createOffer: nur bei passendem Produkt wird das Angebot sofort aufs laufende Abo angewendet', async () => {
// c1 hat ein laufendes Hosting-Abo (sub_h)
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' }))
const matching = await createOffer({ customerId: 'c1', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: false }, ADMIN)
try {
assert.equal(matching.doc.scope, 'laufend')
assert.equal(matching.doc.status, 'aktiv')
assert.equal(matching.doc.stripeSubscriptionId, 'sub_h')
} finally {
await deleteDocument(config.collections.aboOffers, matching.doc.$id)
}
const other = await createOffer({ customerId: 'c1', productKey: 'wartung', months: 2, monthlyPriceCents: 2000, notifyCustomer: false }, ADMIN)
try {
// c1 hat kein Wartungs-Abo: bleibt offen, wird nicht angewendet
assert.equal(other.doc.scope, 'neu')
assert.equal(other.doc.status, 'offen')
} finally {
await deleteDocument(config.collections.aboOffers, other.doc.$id)
}
})
// ---------------------------------------------------------------------------
// offerContext: Produkte mit Normalpreisen, productKey am Abo, offene Angebote je Produkt
// ---------------------------------------------------------------------------
test('offerContext: products mit Normalpreisen, subscription.productKey, offene Angebote je Produkt', async () => {
const withoutSub = await offerContext('c3')
assert.deepEqual(withoutSub.products, { hosting: { regularPriceCents: 1500 }, wartung: { regularPriceCents: 5000 } })
assert.equal(withoutSub.subscription, null)
assert.deepEqual(withoutSub.openOffers, { hosting: null, wartung: null })
const withSub = await offerContext('c1')
assert.equal(withSub.subscription.productKey, 'hosting')
assert.equal(withSub.subscription.active, true)
aw.seed('aboOffers', { $id: 'ctx_h', customerId: 'c1', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 1, monthlyPriceCents: 500 })
aw.seed('aboOffers', { $id: 'ctx_w', customerId: 'c1', status: 'offen', productKey: 'wartung', validUntil: '2099-01-01T00:00:00.000Z', months: 1, monthlyPriceCents: 1000 })
try {
const ctx = await offerContext('c1')
assert.equal(ctx.openOffers.hosting.id, 'ctx_h')
assert.equal(ctx.openOffers.wartung.id, 'ctx_w')
} finally {
await deleteDocument(config.collections.aboOffers, 'ctx_h')
await deleteDocument(config.collections.aboOffers, 'ctx_w')
}
})
// ---------------------------------------------------------------------------
// activateOfferFromCheckout: Produkt passt nicht -> nichts angewendet, T03 mit Grund (F33)
// ---------------------------------------------------------------------------
test('activateOfferFromCheckout: Produkt weicht ab -> nichts angewendet, T03 mit Grund in angebot_status', async () => {
mails.sent.length = 0
const offer = await createOffer({ customerId: 'c3', productKey: 'wartung', months: 2, monthlyPriceCents: 2000, notifyCustomer: false }, ADMIN)
try {
const session = {
subscription: 'sub_mismatch',
metadata: { offerId: offer.doc.$id, productKey: 'hosting' },
}
const result = await activateOfferFromCheckout(session)
assert.equal(result, null)
const fresh = (await import('../server/services/appwriteAdmin.js')).getDocument
const doc = await fresh(config.collections.aboOffers, offer.doc.$id)
assert.equal(doc.status, 'offen')
const t03 = mails.sent.find((m) => m.subject.includes('nicht mehr offen'))
assert.ok(t03, 'T03 wurde nicht verschickt')
assert.match(t03.html, /Wartungs-Abo/)
assert.match(t03.html, /Hosting-Abo/)
} finally {
await deleteDocument(config.collections.aboOffers, offer.doc.$id)
}
})
// ---------------------------------------------------------------------------
// K05/K06 nennen das Produkt ({{produkt}}, F33)
// ---------------------------------------------------------------------------
test('K05 nennt das bestellte Produkt', async () => {
mails.sent.length = 0
const offer = await createOffer(
{ customerId: 'c3', productKey: 'hosting', months: 2, monthlyPriceCents: 1000, notifyCustomer: true },
ADMIN
)
try {
const k05 = mails.sent.find((m) => m.to?.[0]?.address === 'k3@example.invalid')
assert.ok(k05, 'K05 wurde nicht verschickt')
assert.match(k05.html, /Hosting-Abo/)
assert.doesNotMatch(k05.html, /Wartungs-Abo/)
} finally {
await deleteDocument(config.collections.aboOffers, offer.doc.$id)
}
})
// ---------------------------------------------------------------------------
// GET /api/abo: offers.hosting und offers.wartung unabhaengig voneinander
// ---------------------------------------------------------------------------
test('GET /api/abo: offers.hosting und offers.wartung getrennt', async () => {
aw.seed('aboOffers', { $id: 'g_h', customerId: 'c3', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 1, monthlyPriceCents: 500 })
try {
const r = await get('/api/abo', { cookie: customerCookie('c3') })
assert.equal(r.offers.hosting.id, 'g_h')
assert.equal(r.offers.wartung, null)
} finally {
await deleteDocument(config.collections.aboOffers, 'g_h')
}
})
File diff suppressed because it is too large. Load diff
+114 -2
View File
@@ -1,9 +1,20 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
import path from 'node:path'
const { config } = await import('../server/config.js')
const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice } =
await import('../server/services/products.js')
const {
getProduct,
productKeyForLookupKey,
describeItems,
priceIdsOfInvoice,
creditMinutesForInvoice,
termsDocFor,
productView,
contractAttachmentFor,
regularStorageLine,
} = await import('../server/services/products.js')
const line = (price, amount, proration = false) => ({
amount,
@@ -101,3 +112,104 @@ test('priceIdsOfInvoice: eindeutige Preis-IDs, ohne Lücken', () => {
const inv = { lines: { data: [line('price_w', 5000), line('price_w', -100, true), { amount: 5 }] } }
assert.deepEqual(priceIdsOfInvoice(inv), ['price_w'])
})
// Fix round 1 / B9: feste Form statt Einzelfelder
test('Vertragsunterlagen je Version', () => {
// F08 (Task 12): endParagraph = Paragraph zum Vertragsende der Fassung (v4: § 9 ist der Wechsel, § 11 das Vertragsende);
// Fix 1 (3): version = die Fassung, die K01 nennt - ohne Angabe die archivierte Fassung 3, passend zum PDF
assert.deepEqual(termsDocFor('2026-10-05-v4'), { version: '2026-10-05-v4', url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' })
assert.deepEqual(termsDocFor('2026-09-25-v3'), {
version: '2026-09-25-v3',
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
endParagraph: '§ 9',
})
for (const none of [null, undefined, '']) {
assert.deepEqual(termsDocFor(none), {
version: '2026-09-25-v3',
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
endParagraph: '§ 9',
}, String(none))
}
assert.equal(termsDocFor('2026-09-25-v2').version, '2026-09-25-v2', 'aeltere Fassung bleibt benannt')
})
test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => {
const w = productView('wartung').features.flat().join(' ')
assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach'))
assert.ok(productView('hosting').features.flat().join(' ').includes('1 E-Mail-Postfach'))
})
// Fix round 1, Important 3: Hosting darf keine Website-Pflege suggerieren (row 1 war von Wartung
// kopiert) und muss "in 15-Minuten-Schritten" nennen.
test('Produktansicht Hosting: Betriebssystem/Server-Software statt Website-Pflege', () => {
const h = productView('hosting').features.flat().join(' ')
assert.ok(h.includes('Betriebssystem und Server-Software'))
assert.ok(!h.includes('Updates, Sicherheit'))
assert.ok(h.includes('15-Minuten-Schritten'))
})
// Fix round 1, B9: volle Form statt Einzelfelder, Kopie statt Referenz auf den Katalog (B10)
test('productView: vollständige Form, Kopie der features', () => {
const v = productView('hosting')
assert.deepEqual(Object.keys(v).sort(), ['amountCents', 'features', 'key', 'minTermMonths', 'minutesPerMonth', 'name'].sort())
assert.equal(v.key, 'hosting')
assert.equal(v.name, 'WEBklar Hosting-Abo')
assert.equal(v.amountCents, 1500)
assert.equal(v.minTermMonths, 12)
assert.equal(v.minutesPerMonth, 0)
assert.ok(Array.isArray(v.features) && v.features.length > 0)
v.features.push(['x', 'y'])
assert.notEqual(productView('hosting').features.length, v.features.length)
})
// F07: die Wartungs-Beschreibung darf die alte 1-Postfach-Regel nicht mehr enthalten (Version 4:
// beliebig viele E-Mail-Postfächer, zusammen 5 GB). Der Stripe-Produkttext selbst wird erst beim
// Livegang ueber scripts/stripe-products.mjs gesetzt. B9: auch das § 19-Satzende pruefen.
test('Wartung-Beschreibung ohne 1-Postfach-Regel (F07)', () => {
const d = getProduct('wartung').description
assert.ok(!d.includes('1 E-Mail-Postfach'))
assert.ok(d.includes('beliebig viele E-Mail-Postfächer'))
assert.ok(d.endsWith('Gemäß § 19 UStG ohne Umsatzsteuer.'))
})
// Fix round 1, item 11: Domain-Zeile nennt die Endungen wie spec 3.1 / v4 § 3 Abs. 3
test('Produktansicht: Domain-Zeile nennt die Endungen', () => {
for (const key of ['wartung', 'hosting']) {
const flat = productView(key).features.flat().join(' ')
assert.ok(flat.includes('.de') && flat.includes('.com') && flat.includes('.eu') && flat.includes('.net') && flat.includes('.org'))
}
})
// F22 / Task 12: Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat
test('contractAttachmentFor: Datei je Fassung mit absolutem Pfad unter public/', () => {
const v3 = contractAttachmentFor('2026-09-25-v3')
const v4 = contractAttachmentFor('2026-10-05-v4')
assert.equal(v3.filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
assert.equal(v4.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
assert.ok(path.isAbsolute(v4.path) && v4.path.endsWith(path.join('public', v4.filename)))
assert.ok(existsSync(v4.path), v4.path)
assert.ok(existsSync(v3.path), v3.path)
assert.deepEqual(contractAttachmentFor(null), v3)
assert.deepEqual(Object.keys(v4).sort(), ['filename', 'path'])
})
// Task 8: die regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Monats), nie eine anteilige
test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein anderes Produkt', () => {
const inv = (...lines) => ({ lines: { data: lines } })
const storage = { ...line('price_s', 300), quantity: 3 }
assert.equal(regularStorageLine(inv(line('price_s', 90, true), line('price_w', 5000), storage), keys), storage)
assert.equal(regularStorageLine(inv(line('price_s', 90, true)), keys), null)
assert.equal(regularStorageLine(inv(line('price_w', 5000), line('price_h', 1500)), keys), null)
assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null)
assert.equal(regularStorageLine({}, keys), null)
})
// Task 8 (Fix 1, Testluecke 9): eine Rechnung nur mit Zusatzspeicher bringt keine Stunde, auch nicht als
// "Altbestand" (der Speicherpreis ist bekannt, nur kein Abo-Produkt)
test('Nur Zusatzspeicher auf der Rechnung: keine Stunde', () => {
const inv = (...lines) => ({ lines: { data: lines } })
assert.equal(creditMinutesForInvoice(inv({ ...line('price_s', 300), quantity: 3 }), keys), 0)
assert.equal(creditMinutesForInvoice(inv(line('price_s', 100), line('price_s', 33, true)), keys), 0)
})
+667
View File
@@ -0,0 +1,667 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Oeffentliche Kuendigungs- und Widerrufsseite (Task 10): Auswahl des Vertrags (Abo oder nur der
// E-Mail-Zusatzspeicher, ganz oder mit Paketzahl) bzw. der Bestellung (Abschluss, Wechsel, Zusatzspeicher).
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState, purgeRateLimit } = await import('../server/routes/abo.js')
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs (ensurePrice findet sie, stripeSubscription() haengt
// dieselben IDs an die Abo-Posten)
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
const DAY_MS = 86400000
const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString()
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd)
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
/** Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen (units Pakete); leert Ereignisse und Senke. */
async function resetSubH({ units = 0, patch = {}, stripe = {} } = {}) {
await clearEvents('c1')
mails.sent.length = 0
aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, ...patch })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units, ...stripe }))
}
// Jeder Test faengt ohne Ratenbegrenzung an (10 Anfragen je Stunde und IP, F30)
beforeEach(() => resetPublicRateLimit())
const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h')
const storageQuantity = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)?.quantity ?? 0
const bySubject = (start) => mails.sent.find((m) => m.subject.startsWith(start)) || null
const k03 = () => bySubject('Eingangsbestätigung Ihrer Kündigung')
const k04 = () => bySubject('Eingangsbestätigung Ihres Widerrufs')
const k13 = () => bySubject('Ihr E-Mail-Zusatzspeicher')
const t08 = () => bySubject('[Abo] Kündigung eingegangen')
const t09 = () => bySubject('[Abo] WIDERRUF eingegangen')
const t13 = () => bySubject('[Speicher]')
const events = async (type) => (await listContractEvents('c1')).filter((e) => e.type === type)
const cancel = (body) => post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', ...body })
const withdraw = (body) => post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', ...body })
// ---------------------------------------------------------------------------
// Kuendigung: nur der Zusatzspeicher (Rechtsrunde 28.09.2026, Entscheidung 5)
// ---------------------------------------------------------------------------
test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async () => {
await resetSubH({ units: 3 })
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(st.calls.findLast((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h').body['items[0][quantity]'], '0')
assert.equal(subscriptionUpdates(before)[0].body.proration_behavior, 'none')
assert.equal(storageQuantity(), 0)
const all = await listContractEvents('c1')
assert.equal(all[0].type, 'kuendigung')
// F31: das Ereignis traegt Vertrag, Wirksamkeit und die Pakete; ohne IP oder Browser (Datenschutz Abschnitt 7)
assert.deepEqual(all[0].details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 3, fromUnits: 3, toUnits: 0 })
assert.equal(all[0].status, 'erledigt')
assert.equal(all[0].subscriptionId, 'sub_h')
assert.equal(all[0].consentIp ?? null, null)
assert.equal(all[0].$id.length, 36)
// Die Verringerung selbst ist das Ereignis speicher ueber den Kuendigungsweg (Task 8)
const [speicher] = await events('speicher')
assert.equal(speicher.details.viaCancellation, true)
assert.equal(speicher.details.toUnits, 0)
// K03 nennt Vertrag, Pakete und den Zeitpunkt; K13 entfaellt (F31), T08 und T13 gehen ans Team
assert.ok(k03(), 'K03')
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
assert.ok(k03().text.includes('um 3 Paket(e) gekündigt; danach bleiben 0 Paket(e)'), k03().text)
assert.ok(k03().text.includes(PERIOD_END_LABEL), PERIOD_END_LABEL)
assert.doesNotMatch(k03().text, /Wartungs-Abo\n/, 'kein fester Vertragsname')
assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular')
assert.match(t08().text, /um 3 Paket\(e\) verringert/)
assert.match(t08().text, /E-Mail-Zusatzspeicher/)
assert.ok(t13(), 'T13 (Speicher-Aufgabe)')
// Die Abo-Kuendigung selbst bleibt aus: kein cancel_at, kein cancelRequestedAt
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
assert.equal(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, undefined)
})
// Task 12: Betreff und Text nennen das Abo des Kunden (Katalogname ohne „WEBklar “, F33); das Vertragsende steht
// in der Fassung 4 in § 11, nicht in § 9 (F08); das Stundenkontingent gibt es nur im Wartungs-Abo (kein Geldguthaben, Fix 1)
test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4', async () => {
await resetSubH()
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.equal(k03().subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Hosting-Abo')
assert.ok(k03().text.includes('§ 11 der Vertragsbedingungen'), k03().text)
assert.ok(!k03().text.includes('§ 9 der Vertragsbedingungen') && !k03().text.includes('Stundenkontingent') && !k03().text.includes('Stundenguthaben'))
assert.ok(t08().text.includes('Abo: Hosting-Abo'), t08().text)
await resetSubH()
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Hosting-Abo')
assert.ok(t09().text.includes('Abo: Hosting-Abo'), t09().text)
// Wartungs-Abo nach Fassung 3 (Altbestand): § 9, nicht genutzte Arbeitszeit aus dem Stundenkontingent
await resetSubH({ patch: { productKey: 'wartung', termsVersion: '2026-09-25-v3' }, stripe: { productKey: 'wartung', metadata: { termsVersion: '2026-09-25-v3' } } })
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.equal(k03().subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo')
assert.ok(k03().text.includes('§ 9 der Vertragsbedingungen') && k03().text.includes('Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende.'), k03().text)
// Unbekannte E-Mail: kein Abo, neutrale Anrede „Abo“, Paragraph der aktuellen Fassung (F08)
await resetSubH()
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
const k03u = mails.sent.find((m) => m.subject.startsWith('Eingangsbestätigung Ihrer Kündigung'))
assert.equal(k03u.subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Abo')
assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text)
})
test('2 von 3 Paketen ergibt Menge 1', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 2, fromUnits: 3, toUnits: 1 })
assert.ok(k03().text.includes('um 2 Paket(e) gekündigt; danach bleiben 1 Paket(e)'), k03().text)
assert.doesNotMatch(k03().text, /um 3 Paket/)
})
test('Ohne packages ergibt Menge 0; mehr Pakete als gebucht heißt alle', async () => {
await resetSubH({ units: 3 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '' })).status, 200)
assert.equal(storageQuantity(), 0)
await resetSubH({ units: 2 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: 10 })).status, 200)
assert.equal(storageQuantity(), 0)
const [ev] = await events('kuendigung')
assert.equal(ev.details.packages, 2)
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
})
test('Gekündigtes Abo: die Menge wird trotzdem reduziert', async () => {
await resetSubH({
units: 2,
patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd },
stripe: { cancel_at_period_end: true },
})
const res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.equal(ev.details.toUnits, 1)
assert.match(k03().text, /um 1 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/)
})
test('packages: 0 oder 11 ergibt 400, ohne Stripe-Aufruf und ohne Mail', async () => {
await resetSubH({ units: 3 })
const before = st.calls.length
for (const packages of [0, 11, '0', '11', '1,5', 'x']) {
const res = await cancel({ contract: 'zusatzspeicher', packages })
assert.equal(res.status, 400, String(packages))
assert.match(res.body.error, /1 bis 10/)
}
assert.equal(st.calls.length, before)
assert.deepEqual(mails.sent, [])
assert.deepEqual(await listContractEvents('c1'), [])
// Beim Abo zaehlt das Feld nicht (im Formular ausgeblendet)
assert.equal((await cancel({ contract: 'abo', packages: '0' })).status, 200)
})
test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Änderung', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200)
assert.equal(st.calls.filter((c) => c.method === 'POST').length, st.calls.slice(0, before).filter((c) => c.method === 'POST').length)
// K03 sagt "wir pruefen", T08 sagt dem Team, dass kein Speicher gebucht ist; Ereignis als Aufgabe (offen)
assert.match(k03().text, /prüfen/)
assert.doesNotMatch(k03().text, /endet zum|verringert/)
assert.match(t08().text, /kein Zusatzspeicher gebucht/i)
// § 312k Abs. 4 BGB: die Bestaetigung nennt den Inhalt der Erklaerung, also auch die Paketzahl
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 0, toUnits: null })
assert.equal(t13(), null)
mails.sent.length = 0
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
assert.match(k03().text, /Pakete: 2 Paket\(e\)/)
assert.match(t08().text, /Pakete: 2 Paket\(e\)/)
})
test('Unbekannte E-Mail: 200, keine Änderung', async () => {
await resetSubH({ units: 1 })
const before = st.calls.length
const res = await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'abo' })
assert.equal(res.status, 200)
assert.equal(st.calls.length, before)
assert.match(k03().text, /ordnen Ihre Kündigung/)
assert.match(t08().text, /KEIN aktives Abo/)
assert.deepEqual(await listContractEvents('c1'), [])
// Auch beim Zusatzspeicher
mails.sent.length = 0
assert.equal((await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'zusatzspeicher' })).status, 200)
assert.equal(st.calls.length, before)
assert.match(k03().text, /ordnen Ihre Kündigung/)
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
})
test('Kündigung des Abos: wie bisher bei Stripe, dazu das Ereignis kuendigung mit dem Abo-Namen', async () => {
await resetSubH({ units: 2 })
const before = st.calls.length
const res = await cancel({ contract: 'abo', customerNumber: 'KD-1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body['metadata[cancelSource]'], 'kuendigungsbutton')
assert.ok(upd[0].body.cancel_at_period_end === 'true' || upd[0].body.cancel_at, 'Kuendigung bei Stripe')
assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt)
// Der Speicher bleibt (er endet mit dem Abo)
assert.equal(storageQuantity(), 2)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'erledigt')
assert.equal(ev.details.contract, 'abo')
assert.equal(ev.details.kind, 'ordentlich')
assert.ok(ev.details.effectiveAt)
assert.equal(ev.productKey, 'hosting')
assert.deepEqual(await events('speicher'), [])
// Die Bestaetigung nennt das tatsaechliche Abo (Spec 4.5)
assert.match(k03().text, /Vertrag: WEBklar Hosting-Abo/)
assert.match(k03().text, /Ihr Abo endet zum/)
assert.match(t08().text, /Vertrag: WEBklar Hosting-Abo/)
assert.match(t08().text, /Automatisch gekündigt/)
// Standard ohne contract: das Abo
await resetSubH({ units: 0 })
assert.equal((await cancel({})).status, 200)
assert.equal((await events('kuendigung'))[0].details.contract, 'abo')
})
test('Außerordentliche Kündigung des Abos: keine Stripe-Änderung, Ereignis offen fürs Team', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
assert.equal((await cancel({ kind: 'ausserordentlich', reason: 'Grund' })).status, 200)
assert.deepEqual(subscriptionUpdates(before), [])
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'abo', kind: 'ausserordentlich', effectiveAt: null })
assert.match(k03().text, /Wir prüfen Ihre Kündigung/)
})
// Controller: waehrend ein Kartenwechsel aussteht (offenes Ereignis wechsel, Stripe pending_update) darf die
// Verringerung das Stripe-Abo nicht anfassen - Stripe wuerde die Wechselrechnung stornieren
test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wir prüfen“, T08 mit Hinweis', async () => {
const switchedAt = new Date().toISOString()
const openSwitch = () =>
recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent, status: 'offen',
})
// Ausloeser 1: nur das offene Ereignis wechsel (Stripe traegt noch kein pending_update, z. B. Webhook-Rueckstand)
await resetSubH({ units: 2 })
await openSwitch()
let before = st.calls.length
let res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
assert.equal(storageQuantity(), 2)
assert.deepEqual(await events('speicher'), [])
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: 1, fromUnits: 2, toUnits: null })
assert.match(k03().text, /prüfen/)
assert.match(k03().text, /Pakete: 1 Paket\(e\)/)
assert.match(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
assert.match(t08().text, /Pakete: 1 Paket\(e\)/)
assert.equal((await events('wechsel'))[0].status, 'offen')
// Ausloeser 2: nur pending_update am Stripe-Abo (Ereignis nicht gespeichert, nachweis_fehlt beim Wechsel)
await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } })
before = st.calls.length
res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
assert.equal(storageQuantity(), 2)
assert.match(t08().text, /Wechsel ausstehend/)
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
// Beide Ausloeser weg: Verringerung laeuft
await resetSubH({ units: 2 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '1' })).status, 200)
assert.equal(storageQuantity(), 1)
})
// F20: wirft changeStorage trotzdem (z. B. Stripe lehnt ab), bleibt die Seite bei 200 - K03 "wir pruefen", T08 Hinweis
test('changeStorage scheitert: 200, K03 „wir prüfen“, T08 nennt den Fehler', async () => {
await resetSubH({ units: 2 })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau lehnt ab' })
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 2)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 2, toUnits: null })
assert.match(k03().text, /prüfen/)
assert.match(t08().text, /nicht automatisch verringert/i)
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
assert.match(t08().text, /Stripe/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.equal((await events('speicher'))[0].status, 'gescheitert')
})
// Sind die Ereignisse nicht lesbar (Wechsel-Pruefung unmoeglich), bleibt Stripe unangetastet: "wir pruefen" + T08
test('Ereignisse nicht lesbar: Speicher nicht angefasst, K03 „wir prüfen“, T08 nennt den Fehler', async () => {
await resetSubH({ units: 2 })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [])
assert.equal(storageQuantity(), 2)
assert.match(k03().text, /prüfen/)
assert.match(t08().text, /NICHT automatisch verringert/)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
})
// ---------------------------------------------------------------------------
// Widerruf: Bestellung waehlen (Abschluss, Wechsel, Zusatzspeicher)
// ---------------------------------------------------------------------------
test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Preiswechsel', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(3)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
const before = st.calls.length
const res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const evs = await listContractEvents('c1')
const wechsel = evs.find((e) => e.type === 'wechsel')
assert.equal(wechsel.status, 'widerrufen')
assert.equal(st.calls.slice(before).filter((c) => c.method === 'POST' && /subscriptions/.test(c.path)).length, 0)
const widerruf = evs.find((e) => e.type === 'widerruf')
assert.equal(widerruf.details.order, 'wechsel')
assert.equal(widerruf.details.eventId, wechsel.$id)
assert.deepEqual(widerruf.details.eventIds, [wechsel.$id])
assert.equal(widerruf.status, 'offen')
assert.equal(widerruf.$id.length, 36)
assert.equal(widerruf.consentIp ?? null, null)
// Das Abo wird nicht gekuendigt; withdrawalRequestedAt bleibt der Abo-Kuendigung vorbehalten
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined)
// K04 nennt die Bestellung, T09 die Handgriffe und die Frist
assert.match(k04().text, /Wechsel ins Wartungs-Abo/)
assert.match(k04().text, /zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben/)
assert.doesNotMatch(k04().text, /keine weiteren Abbuchungen/)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /Preis .*zurück/)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(consentAt).endsAt)))
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
})
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(30)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
const before = st.calls.length
const res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200)
const evs = await listContractEvents('c1')
assert.notEqual(evs.find((e) => e.type === 'wechsel').status, 'widerrufen')
assert.equal(evs.find((e) => e.type === 'widerruf').details.eventId, null)
assert.deepEqual(evs.find((e) => e.type === 'widerruf').details.eventIds, [])
assert.equal(subscriptionUpdates(before).length, 0)
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /keine offene Frist gefunden/)
assert.match(t09().text, /NACH Fristende/)
})
// F03: mehrere Speicher-Bestellungen (Plus) in der Frist -> alle widerrufen; ein Minus hat keine Frist
test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerrufen, T09 listet sie', async () => {
await resetSubH({ units: 3 })
const plus1 = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { kind: 'einrichten', fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt: daysAgo(5) }, status: 'erledigt',
})
const minus = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { kind: 'periodenende', fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd,
})
const plus2 = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { kind: 'einrichten', fromUnits: 1, toUnits: 3 }, consent: { ...consent, consentAt: daysAgo(1) }, status: 'offen', dueAt: daysAgo(1),
})
const before = st.calls.length
const res = await withdraw({ order: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const evs = await listContractEvents('c1')
const statusOf = (id) => evs.find((e) => e.$id === id).status
assert.equal(statusOf(plus1.$id), 'widerrufen')
assert.equal(statusOf(plus2.$id), 'widerrufen')
assert.equal(statusOf(minus.$id), 'offen')
const widerruf = evs.find((e) => e.type === 'widerruf')
assert.equal(widerruf.details.order, 'zusatzspeicher')
assert.deepEqual([...widerruf.details.eventIds].sort(), [plus1.$id, plus2.$id].sort())
assert.ok([plus1.$id, plus2.$id].includes(widerruf.details.eventId))
assert.equal(subscriptionUpdates(before).length, 0)
assert.match(k04().text, /E-Mail-Zusatzspeicher/)
assert.match(k04().text, /Menge/)
assert.match(t09().text, /0 → 2/)
assert.match(t09().text, /1 → 3/)
assert.match(t09().text, /Menge .*zurück/)
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
const before = st.calls.length
const res = await withdraw({ order: 'abschluss', customerNumber: 'KD-1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body.cancel_at_period_end, 'true')
assert.equal(upd[0].body['metadata[cancelSource]'], 'widerruf')
assert.ok(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt)
const [widerruf] = await events('widerruf')
assert.deepEqual(widerruf.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: true })
assert.match(k04().text, /Abschluss Ihres Abos/)
assert.match(k04().text, /keine weiteren Abbuchungen/)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /auf Kündigung gesetzt/)
assert.match(t09().text, /Abo in Stripe sofort beenden/)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(startedAt).endsAt)))
// Standard ohne order: der Abschluss; mit Ereignis abschluss zaehlt allein das Ereignis (hier 40 Tage alt: Frist vorbei)
await resetSubH({ units: 0 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
details: { contractAt: hostingDoc.startedAt }, consent: { ...consent, consentAt: hostingDoc.startedAt },
})
const late = st.calls.length
assert.equal((await withdraw({})).status, 200)
assert.deepEqual(subscriptionUpdates(late), [])
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
const [w2] = await events('widerruf')
assert.deepEqual(w2.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: false })
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /keine offene Frist gefunden/)
})
test('Widerruf mit unbekannter E-Mail oder ungültiger Auswahl', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
const res = await post('/api/abo/public/withdraw', { name: 'X', email: 'niemand@example.invalid', order: 'zusatzspeicher' })
assert.equal(res.status, 200)
assert.equal(st.calls.length, before)
assert.match(t09().text, /KEIN aktives Abo/)
assert.deepEqual(await listContractEvents('c1'), [])
// Unbekannte Auswahl faellt auf den Standard zurueck; ohne Name oder E-Mail 400
assert.equal((await withdraw({ order: 'irgendwas' })).status, 200)
assert.equal((await events('widerruf'))[0].details.order, 'abschluss')
assert.equal((await post('/api/abo/public/withdraw', { name: '', email: 'k@example.invalid' })).status, 400)
assert.equal((await post('/api/abo/public/cancel', { name: 'K', email: 'kein-mail' })).status, 400)
})
// F30: Ratenbegrenzung je IP (10 je Stunde), fuer Tests zuruecksetzbar; alte Eintraege verschwinden nach einer
// Stunde, damit IP-Adressen nicht laenger als noetig im Speicher bleiben (Datenschutz Abschnitt 7)
test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde werden entfernt', async () => {
await resetSubH({ units: 0 })
for (let i = 0; i < 10; i += 1) assert.equal((await cancel({})).status, 200, `Anfrage ${i + 1}`)
const limited = await cancel({})
assert.equal(limited.status, 429)
assert.equal((await withdraw({})).status, 429, 'gemeinsames Limit beider Seiten')
assert.equal(publicRateLimitState().ips, 1)
resetPublicRateLimit()
assert.equal(publicRateLimitState().ips, 0)
assert.equal((await cancel({})).status, 200)
assert.equal(publicRateLimitState().ips, 1)
// Eintraege aelter als eine Stunde werden aus der Map entfernt (Zeit im Test vorgegeben), juengere bleiben
purgeRateLimit(Date.now() + 3600 * 1000 - 5000)
assert.equal(publicRateLimitState().ips, 1)
purgeRateLimit(Date.now() + 3600 * 1000 + 1)
assert.equal(publicRateLimitState().ips, 0)
})
// Fix 1 (6): eine ausserordentliche Kuendigung des Speichers wird trotzdem zum Periodenende verringert; die Pruefung
// der Art steht in K03 und T08
test('Zusatzspeicher außerordentlich gekündigt: trotzdem zum Periodenende verringert, Prüfung angekündigt', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2', kind: 'ausserordentlich', reason: 'Postfach dauernd voll' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.equal(ev.details.toUnits, 1)
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
assert.match(k03().text, /außerordentliche Kündigung wird geprüft/)
assert.match(t08().text, /Außerordentliche Kündigung des Zusatzspeichers/)
assert.match(t08().text, /Grund: Postfach dauernd voll/)
// ordentlich: kein Pruefungshinweis
await resetSubH({ units: 3 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
assert.doesNotMatch(k03().text, /wird geprüft/)
assert.doesNotMatch(t08().text, /des Zusatzspeichers: Grund prüfen/)
})
// Fix 1 (4): Ereignisse nicht lesbar - Abschluss faellt auf startedAt zurueck (F14), sonst Hinweis "von Hand pruefen"
test('Widerruf bei nicht lesbaren Ereignissen: Hinweis fürs Team, Abschluss zählt weiter ab startedAt', async () => {
await resetSubH({ units: 0 })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
let before = st.calls.length
let res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [])
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art/)
assert.equal((await events('widerruf')).length, 1, 'Ereignis widerruf trotzdem gespeichert')
// Abschluss in der Frist: der Rueckfall auf startedAt gilt auch, wenn die Ereignisse nicht lesbar sind
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
before = st.calls.length
res = await withdraw({ order: 'abschluss' })
assert.equal(res.status, 200)
assert.equal(subscriptionUpdates(before).length, 1)
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, true)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /Ereignisse nicht lesbar/)
// Abo-Beginn fehlt (kein Ereignis abschluss, kein startedAt): eigener Hinweis, nichts geaendert
await resetSubH({ units: 0, patch: { startedAt: null, termStartAt: null } })
before = st.calls.length
res = await withdraw({ order: 'abschluss' })
assert.equal(res.status, 200)
assert.deepEqual(subscriptionUpdates(before), [])
assert.match(t09().text, /Abo-Beginn fehlt/)
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art|NACH Fristende/)
})
// Fix 1 (5): "NACH Fristende" nur nach dem Fristende; ein zweiter Widerruf derselben Bestellung heisst "bereits widerrufen"
test('Wiederholter Widerruf: „bereits widerrufen“ statt „NACH Fristende“', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(3)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.match(t09().text, /RECHTZEITIG/)
mails.sent.length = 0
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.match(t09().text, /bereits widerrufen/)
assert.doesNotMatch(t09().text, /NACH Fristende|RECHTZEITIG/)
assert.match(k04().text, /prüfen/)
const widerrufe = await events('widerruf')
assert.equal(widerrufe.length, 2)
assert.equal(widerrufe[0].details.eventId, null)
})
// Fix 1 (7): Widerruf eines noch nicht wirksamen Kartenwechsels bzw. eines verspaeteten Abschluss-Widerrufs
test('T09: offene Wechselrechnung stornieren; verspäteter Abschluss-Widerruf ggf. als Kündigung', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(1)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent: { ...consent, consentAt }, status: 'offen',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
assert.match(t09().text, /Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird/)
// Wirksamer (bezahlter) Wechsel: kein Storno-Hinweis
await resetSubH({ units: 0 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.doesNotMatch(t09().text, /Wechselrechnung noch offen/)
// Abschluss nach der Frist (Fixture: Abo seit 40 Tagen): Hinweis auf die ordentliche Kuendigung
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
assert.match(t09().text, /NACH Fristende/)
assert.match(t09().text, /ggf\. als ordentliche Kündigung behandeln/)
// Zusatzspeicher ohne jede Bestellung: keine Frist gefunden statt "–, 24 Uhr"
mails.sent.length = 0
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
assert.match(t09().text, /Widerrufsfrist bis: keine Frist gefunden/)
assert.doesNotMatch(t09().text, /–, 24 Uhr|als ordentliche Kündigung/)
assert.match(t09().text, /keine Bestellung dieser Art/)
})
// ---------------------------------------------------------------------------
// Formulare: Auswahl vorhanden und wortgleich (Spec 4.5); die Bedienung prueft Task 10 Schritt 4 mit Playwright
// ---------------------------------------------------------------------------
const html = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8')
test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext', () => {
const src = html('kuendigen.html')
assert.match(src, /name="contract" value="abo"[^>]*> Mein Abo \(Hosting- oder Wartungs-Abo, mit Zusatzspeicher\)/)
assert.match(src, /name="contract" value="zusatzspeicher"[^>]*> Nur den E-Mail-Zusatzspeicher/)
assert.match(src, /<input id="packages" name="packages" type="number"[^>]*min="1"[^>]*max="10"/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail\./)
assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/)
})
test('widerrufen.html: Radioknöpfe order', () => {
const src = html('widerrufen.html')
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
})
test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeicher', () => {
const src = html('legal-form.js')
assert.doesNotMatch(src, /\['Vertrag', 'WEBklar Wartungs-Abo'\]/)
assert.match(src, /packages/)
})
+266
View File
@@ -0,0 +1,266 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten und temporaerer Upload-Ordner (F41d), BEVOR config.js geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
takeoverCredits: schemaKeys('takeoverCredits'),
},
})
const st = await startFakeStripe()
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
process.env.TAKEOVER_UPLOAD_DIR = uploadDir
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
const { run } = await import('../scripts/loeschfristen.mjs')
const { stripeSubscription } = await import('./support/fixtures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
rmSync(uploadDir, { recursive: true, force: true })
return Promise.all([aw.close(), st.close()])
})
// 01.01.2026, 12:00 Berlin
const JAN_2026 = new Date('2026-01-01T12:00:00+01:00')
const DEC_2025 = new Date('2025-12-31T12:00:00+01:00')
const sub = (id, customerId, fields) => aw.seed('subscriptions', { $id: id, stripeSubscriptionId: id, customerId, ...fields })
const event = (id, customerId, subscriptionId) =>
aw.seed('contractEvents', { $id: id, customerId, subscriptionId, type: 'abschluss', status: 'erledigt', consentIp: '10.0.0.1', consentUserAgent: 'x' })
const stripeSub = (id, customerId, extra = {}) => st.subscriptions.set(id, stripeSubscription({ id, customerId, status: 'canceled', ...extra }))
const dueIds = (plan) => plan.customers.map((c) => c.customerId).sort()
test('Abo beendet am 15.03.2022: fällig am 01.01.2026, nicht am 31.12.2025', async () => {
sub('r1_sub', 'r1', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
event('r1_ev', 'r1', 'r1_sub')
assert.ok(!dueIds(await findDueDeletions(DEC_2025)).includes('r1'))
const plan = await findDueDeletions(JAN_2026)
const entry = plan.customers.find((c) => c.customerId === 'r1')
assert.deepEqual(entry, { customerId: 'r1', lastEndAt: '2022-03-15T10:00:00.000Z', subscriptionIds: ['r1_sub'], eventIds: ['r1_ev'] })
})
test('Ende zählt in deutscher Zeit: 31.12.2021 23:30 UTC ist schon 2022', async () => {
sub('r1b_sub', 'r1b', { status: 'canceled', endedAt: '2021-12-31T23:30:00.000Z' })
assert.ok(!dueIds(await findDueDeletions(DEC_2025)).includes('r1b'))
assert.ok(dueIds(await findDueDeletions(JAN_2026)).includes('r1b'))
})
test('Abo beendet vor zwei Jahren: nicht fällig', async () => {
sub('r2_sub', 'r2', { status: 'canceled', endedAt: new Date(JAN_2026.getTime() - 2 * 365 * 86400000).toISOString() })
assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('r2'))
})
test('altes beendetes Abo und ein laufendes Abo desselben Kunden: nicht fällig', async () => {
sub('r3_old', 'r3', { status: 'canceled', endedAt: '2019-05-01T00:00:00.000Z' })
sub('r3_new', 'r3', { status: 'active', endedAt: null })
assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('r3'))
})
test('das letzte Ende zählt; Ende = endedAt, sonst canceledAt, sonst currentPeriodEnd bei canceled', async () => {
sub('r4_a', 'r4', { status: 'canceled', endedAt: '2018-01-01T00:00:00.000Z' })
sub('r4_b', 'r4', { status: 'canceled', endedAt: null, canceledAt: '2022-06-01T00:00:00.000Z', currentPeriodEnd: '2010-01-01T00:00:00.000Z' })
sub('r5_a', 'r5', { status: 'canceled', endedAt: null, canceledAt: null, currentPeriodEnd: '2022-02-01T00:00:00.000Z' })
sub('r6_a', 'r6', { status: 'past_due', endedAt: null, canceledAt: null, currentPeriodEnd: '2010-02-01T00:00:00.000Z' })
const plan = await findDueDeletions(JAN_2026)
const r4 = plan.customers.find((c) => c.customerId === 'r4')
assert.equal(r4.lastEndAt, '2022-06-01T00:00:00.000Z')
assert.deepEqual(r4.subscriptionIds.sort(), ['r4_a', 'r4_b'])
assert.ok(dueIds(plan).includes('r5'))
assert.ok(!dueIds(plan).includes('r6'), 'nicht beendet (past_due) zählt nicht')
})
test('applyDeletions: löscht Dokumente, leert alle Zustimmungs-Metadaten bei Stripe, lässt Rechnungen und andere Kunden', async () => {
sub('a1_sub', 'a1', { status: 'canceled', endedAt: '2021-01-10T00:00:00.000Z' })
event('a1_ev', 'a1', 'a1_sub')
event('a1_ev2', 'a1', 'a1_sub')
sub('a2_sub', 'a2', { status: 'active' })
event('a2_ev', 'a2', 'a2_sub')
stripeSub('a1_sub', 'a1', {
metadata: { switchedAt: '2021-01-01', switchedFrom: 'hosting' },
})
st.invoices.set('in_a1', { id: 'in_a1', object: 'invoice', subscription: 'a1_sub' })
const callsBefore = st.calls.length
const plan = await findDueDeletions(JAN_2026)
assert.ok(plan.customers.some((c) => c.customerId === 'a1'))
const result = await applyDeletions({ customers: plan.customers.filter((c) => c.customerId === 'a1'), takeover: [] })
assert.deepEqual(result.manual, [])
assert.equal(result.deleted.subscriptions, 1)
assert.equal(result.deleted.contractEvents, 2)
assert.ok(!aw.docs('subscriptions').has('a1_sub'))
assert.ok(!aw.docs('contractEvents').has('a1_ev') && !aw.docs('contractEvents').has('a1_ev2'))
assert.ok(aw.docs('subscriptions').has('a2_sub') && aw.docs('contractEvents').has('a2_ev'))
const update = st.calls.slice(callsBefore).find((c) => c.method === 'POST' && c.path === '/v1/subscriptions/a1_sub')
assert.ok(update, 'Stripe-Abo wurde geändert')
for (const key of CONSENT_METADATA_KEYS) assert.equal(update.body[`metadata[${key}]`], '', `${key} geleert`)
assert.deepEqual(CONSENT_METADATA_KEYS.slice().sort(), [
'consentIp', 'consentTextVersion', 'consentUserAgent', 'earlyStartConsentAt', 'startChoice', 'switchedAt', 'switchedFrom', 'termsAcceptedAt', 'termsVersion',
])
const meta = st.subscriptions.get('a1_sub').metadata
for (const key of CONSENT_METADATA_KEYS) assert.ok(!(key in meta), `${key} weg`)
assert.equal(meta.webklarCustomerId, 'a1', 'übrige Angaben (Zuordnung) bleiben')
assert.ok(st.invoices.has('in_a1'), 'Rechnungen bleiben')
})
test('abgelehnte Stripe-Änderung landet in manual, die Appwrite-Dokumente werden trotzdem gelöscht', async () => {
sub('m1_sub', 'm1', { status: 'canceled', endedAt: '2020-02-02T00:00:00.000Z' })
event('m1_ev', 'm1', 'm1_sub')
stripeSub('m1_sub', 'm1')
st.failNext({ method: 'POST', path: '/v1/subscriptions/m1_sub', status: 400, message: 'A canceled subscription can only update its cancellation_details.' })
const result = await applyDeletions({
customers: [{ customerId: 'm1', lastEndAt: '2020-02-02T00:00:00.000Z', subscriptionIds: ['m1_sub'], eventIds: ['m1_ev'] }],
takeover: [],
})
assert.equal(result.manual.length, 1)
assert.equal(result.manual[0].subscriptionId, 'm1_sub')
assert.equal(result.manual[0].customerId, 'm1')
assert.match(result.manual[0].reason, /canceled subscription/)
assert.ok(!aw.docs('subscriptions').has('m1_sub') && !aw.docs('contractEvents').has('m1_ev'))
assert.ok('consentIp' in st.subscriptions.get('m1_sub').metadata, 'bei Stripe unverändert, Hinweis im Bericht')
})
test('Übernahme-Anträge: abgelehnte nur melden, genehmigte erst nach 8 Jahren ab Jahresende löschen', async () => {
const withFiles = (id) => {
mkdirSync(path.join(uploadDir, id), { recursive: true })
writeFileSync(path.join(uploadDir, id, 'nachweis.pdf'), 'x')
return JSON.stringify([{ id: 'f1', name: 'nachweis.pdf' }])
}
const tc = (id, fields) => aw.seed('takeoverCredits', { $id: id, customerId: 't1', ...fields })
// abgelehnt vor >6 Monaten, Nachweise noch da (tägliche Bereinigung hat sie nicht erwischt): nur melden
tc('tk_rej', { status: 'abgelehnt', decidedAt: '2025-05-01T00:00:00.000Z', files: withFiles('tk_rej') })
// abgelehnt, Nachweise schon gelöscht: nichts zu melden
tc('tk_rej_clean', { status: 'abgelehnt', decidedAt: '2025-05-01T00:00:00.000Z', files: '[]' })
// genehmigt 2017: Frist endet mit Ablauf 2025 (8 Jahre ab Jahresende 2017), am 01.01.2026 fällig
tc('tk_old', { status: 'genehmigt', decidedAt: '2017-11-20T00:00:00.000Z', files: withFiles('tk_old') })
// genehmigt 2018: erst am 01.01.2027 fällig
tc('tk_2018', { status: 'genehmigt', decidedAt: '2018-03-01T00:00:00.000Z', files: withFiles('tk_2018') })
tc('tk_open', { status: 'offen', files: withFiles('tk_open') })
const plan = await findDueDeletions(JAN_2026)
const byId = Object.fromEntries(plan.takeover.map((t) => [t.id, t]))
assert.deepEqual(Object.keys(byId).sort(), ['tk_old', 'tk_rej'])
assert.equal(byId.tk_rej.action, 'melden')
assert.match(byId.tk_rej.reason, /abgelehnt/)
assert.equal(byId.tk_old.action, 'loeschen')
assert.match(byId.tk_old.reason, /acht Jahre|8 Jahre/)
const result = await applyDeletions({ customers: [], takeover: plan.takeover })
assert.equal(result.deleted.takeover, 1)
assert.ok(aw.docs('takeoverCredits').has('tk_rej') && existsSync(path.join(uploadDir, 'tk_rej')), 'abgelehnter Antrag bleibt der täglichen Bereinigung überlassen')
assert.ok(!aw.docs('takeoverCredits').has('tk_old') && !existsSync(path.join(uploadDir, 'tk_old')))
assert.ok(aw.docs('takeoverCredits').has('tk_2018') && existsSync(path.join(uploadDir, 'tk_2018')))
// 2027: auch 2018 ist fällig
const later = await findDueDeletions(new Date('2027-01-02T12:00:00+01:00'))
assert.ok(later.takeover.some((t) => t.id === 'tk_2018' && t.action === 'loeschen'))
})
test('Skript: Probelauf ändert nichts und schickt nichts', async () => {
sub('s1_sub', 's1', { status: 'canceled', endedAt: '2021-03-03T00:00:00.000Z' })
event('s1_ev', 's1', 's1_sub')
stripeSub('s1_sub', 's1')
const callsBefore = st.calls.length
const sentBefore = mails.sent.length
const lines = []
const result = await run({ mode: '', now: JAN_2026, log: (l) => lines.push(l) })
assert.equal(result.applied, false)
assert.ok(aw.docs('subscriptions').has('s1_sub') && aw.docs('contractEvents').has('s1_ev'))
assert.equal(st.calls.length, callsBefore)
assert.equal(mails.sent.length, sentBefore)
const text = lines.join('\n')
assert.match(text, /Probelauf/)
assert.match(text, /s1/)
})
test('Skript: LOESCHFRISTEN=mail schickt den Bericht als T14 ans Team, löscht nichts', async () => {
const sentBefore = mails.sent.length
const result = await run({ mode: 'mail', now: JAN_2026, log: () => {} })
assert.equal(result.applied, false)
assert.equal(mails.sent.length, sentBefore + 1)
const mail = mails.sent.at(-1)
assert.match(mail.subject, /^\[Datenschutz\] Löschfristen fällig/)
assert.match(mail.text, /s1/)
assert.match(mail.text, /Probelauf|Vorschau/)
assert.ok(aw.docs('subscriptions').has('s1_sub'))
})
test('Skript: LOESCHFRISTEN=apply löscht', async () => {
const result = await run({ mode: 'apply', now: JAN_2026, log: () => {} })
assert.equal(result.applied, true)
assert.ok(!aw.docs('subscriptions').has('s1_sub') && !aw.docs('contractEvents').has('s1_ev'))
assert.ok(aw.docs('subscriptions').has('a2_sub'), 'laufendes Abo bleibt')
})
test('Skript: unbekannter Modus wird abgelehnt', async () => {
await assert.rejects(run({ mode: 'loeschen', now: JAN_2026, log: () => {} }), /LOESCHFRISTEN/)
})
test('abgeschnittene Liste: Abbruch vor jeder Planung', async () => {
sub('l1_a', 'l1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' })
sub('l1_b', 'l1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' })
await assert.rejects(findDueDeletions(JAN_2026, { limit: 2 }), /Liste abgeschnitten, Paginierung nötig/)
// mit ausreichendem Limit läuft es
assert.ok(Array.isArray((await findDueDeletions(JAN_2026)).customers))
})
test('applyDeletions: Fehler bei einem Dokument stoppen den Rest nicht und landen in failed', async () => {
sub('f1_sub', 'f1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' })
sub('f2_sub', 'f2', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' })
event('f1_ev', 'f1', 'f1_sub')
event('f1_ev2', 'f1', 'f1_sub')
stripeSub('f1_sub', 'f1')
stripeSub('f2_sub', 'f2')
aw.failNext({ collection: 'contractEvents', method: 'DELETE', status: 500, message: 'kaputt' })
const plan = {
customers: [
{ customerId: 'f1', lastEndAt: '2019-01-01T00:00:00.000Z', subscriptionIds: ['f1_sub'], eventIds: ['f1_ev', 'f1_ev2'] },
{ customerId: 'f2', lastEndAt: '2019-01-01T00:00:00.000Z', subscriptionIds: ['f2_sub'], eventIds: [] },
],
takeover: [],
}
const result = await applyDeletions(plan)
assert.equal(result.failed.length, 1)
assert.equal(result.failed[0].id, 'f1_ev')
assert.match(result.failed[0].reason, /kaputt/)
assert.equal(result.deleted.contractEvents, 1)
assert.ok(!aw.docs('subscriptions').has('f2_sub'), 'zweiter Kunde wurde trotzdem bearbeitet')
assert.match(formatReport(plan, result), /Fehlgeschlagen[\s\S]*f1_ev/)
})
test('incomplete_expired zählt als beendet, incomplete hält auf und steht im Bericht', async () => {
sub('i1_sub', 'i1', { status: 'incomplete_expired', endedAt: null, canceledAt: null, startedAt: '2020-01-01T00:00:00.000Z', $updatedAt: '2021-05-05T00:00:00.000Z' })
sub('i2_sub', 'i2', { status: 'incomplete_expired', endedAt: null, canceledAt: '2020-02-02T00:00:00.000Z' })
sub('i3_old', 'i3', { status: 'canceled', endedAt: '2018-01-01T00:00:00.000Z' })
sub('i3_inc', 'i3', { status: 'incomplete', endedAt: null })
const plan = await findDueDeletions(JAN_2026)
assert.ok(dueIds(plan).includes('i1'))
assert.ok(dueIds(plan).includes('i2'))
assert.equal(plan.customers.find((c) => c.customerId === 'i2').lastEndAt, '2020-02-02T00:00:00.000Z')
assert.ok(!dueIds(plan).includes('i3'))
assert.ok(plan.pending.some((p) => p.customerId === 'i3' && p.subscriptionIds.includes('i3_inc')))
assert.match(formatReport(plan), /Hält Löschung auf:[\s\S]*i3_inc/)
})
test('aktives Abo mit cancelAtPeriodEnd und altem canceledAt zählt nicht als beendet', async () => {
sub('c1_sub', 'c1', { status: 'active', cancelAtPeriodEnd: true, canceledAt: '2010-01-01T00:00:00.000Z', endedAt: null, currentPeriodEnd: '2010-02-01T00:00:00.000Z' })
assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('c1'))
})
test('genehmigter Antrag ohne decidedAt: übersprungen und gemeldet, kein Rückgriff auf appliedAt', async () => {
aw.seed('takeoverCredits', { $id: 'nd_tk', customerId: 'nd', status: 'genehmigt', appliedAt: '2010-01-01T00:00:00.000Z', files: '[]' })
const plan = await findDueDeletions(JAN_2026)
assert.ok(!plan.takeover.some((t) => t.id === 'nd_tk'))
assert.ok(plan.skipped.some((t) => t.id === 'nd_tk'))
assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/)
})
+271
View File
@@ -0,0 +1,271 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
/**
* Task 13: Admin-Reiter "E-Mail-Speicher" (GET/POST /api/portal-admin/storage-tasks), Schnittstelle fuers
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden des Reiters.
*/
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet, sessionCookie } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } =
await import('./support/fixtures.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const { default: adminCustomersRoutes } = await import('../server/routes/admin/customers.js')
const { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/portal-admin': portalAdminRoutes, '/api/admin/customers': adminCustomersRoutes })
// F25: Mails (K14 beim Wechsel-Abgleich) landen in der Senke statt bei SMTP
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
const HOUR = 3600000
const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString()
const ADMIN = { cookie: adminCookie() }
const STAFF_JWT = { headers: { Authorization: 'Bearer jwt-admin' } }
async function clearEvents() {
for (const customerId of ['c1', 'c2', 'c3']) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
}
const storageEvent = ({ id, customerId, subscriptionId, kind, fromUnits, toUnits, dueAt, status = 'offen', extra = {} }) =>
recordContractEvent({
id,
customerId,
subscriptionId,
type: 'speicher',
productKey: 'hosting',
details: { kind, fromUnits, toUnits, effectiveAt: dueAt, totalGb: 5 + 5 * toUnits, monthlyCents: 100 * toUnits, ...extra },
consent: toUnits > fromUnits ? consent : null,
status,
dueAt,
})
/**
* Ausgangslage jedes Tests: zwei aktive Abos (sub_h von c1 ohne Pakete, sub_h2 von c2 mit 2 Paketen ->
* 5 + 15 = 20 GB vergeben), dazu eine Einrichtung fuer c2 (faellig seit einer Stunde, 0 -> 2) und eine
* Verringerung fuer c1 zum Periodenende (3 -> 1).
*/
beforeEach(async () => {
await clearEvents()
mails.sent.length = 0
seedFixtures(aw)
// sub_h wurde von 3 auf 1 Paket verringert; konsoleH haelt bis zum Periodenende noch 3 (offene Aufgabe unten)
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' }))
st.subscriptions.set('sub_h2', stripeSubscription({ id: 'sub_h2', customerId: 'c2', productKey: 'hosting', storageUnits: 2 }))
await storageEvent({ id: 'speicher-einrichten-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 0, toUnits: 2, dueAt: iso(-HOUR) })
await storageEvent({ id: 'speicher-periodenende-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'periodenende', fromUnits: 3, toUnits: 1, dueAt: hostingDoc.currentPeriodEnd })
})
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
// sub_h: 1 Paket, aber 3→1 erst zum Periodenende → konsoleH haelt 5 + 15 = 20 GB; sub_h2: 5 + 10 = 15 GB
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 35)
assert.equal(r.packageGb, 50)
assert.equal(r.warnGb, 40)
assert.equal(r.packageName, 'Webhosting M')
assert.equal(r.tasks.length, 2)
// faelligste zuerst: die Einrichtung (seit einer Stunde faellig) vor der Verringerung zum Periodenende
assert.equal(r.tasks[0].toUnits, 2)
assert.equal(r.tasks[0].fromUnits, 0)
assert.equal(r.tasks[0].kind, 'einrichten')
assert.equal(r.tasks[0].customerId, 'c2')
assert.equal(r.tasks[0].customerName, 'Kunde Zwei')
assert.equal(r.tasks[0].email, 'k2@example.invalid')
assert.equal(r.tasks[0].targetGb, 15)
assert.equal(r.tasks[0].productKey, 'hosting')
assert.ok(r.tasks[0].dueAt && r.tasks[0].createdAt)
assert.equal(r.tasks[1].kind, 'periodenende')
assert.equal(r.tasks[1].toUnits, 1)
assert.equal(r.tasks[1].targetGb, 10)
assert.equal(r.tasks[1].dueAt, hostingDoc.currentPeriodEnd)
assert.equal(r.tasks[1].customerName, 'Kunde Eins')
assert.deepEqual(r.failed, [])
})
test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...hostingDoc, customerId: 'c3', status: 'canceled', storageUnits: 10 }))
aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
try {
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 45)
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_alt')
await deleteDocument(config.collections.subscriptions, 'sub_pd')
}
})
test('Admin: belegter Speicher zaehlt die Verringerung erst nach Erledigt', async () => {
const before = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(before.allocatedGb, 35, 'offene Aufgabe 3→1: konsoleH haelt noch 20 GB fuer sub_h')
const task = before.tasks.find((t) => t.kind === 'periodenende')
await post(`/api/portal-admin/storage-tasks/${task.id}/done`, {}, ADMIN)
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(after.allocatedGb, 25, 'nach Erledigt: 5 + 5 = 10 GB fuer sub_h, dazu 15 GB fuer sub_h2')
})
test('Admin: Aufgabe nennt Gesamtspeicher des Kunden und Kontakt-Domain', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(tasks[0].targetGb, 15)
assert.equal(tasks[0].contactDomain, 'example.invalid')
assert.equal(tasks[1].targetGb, 10)
})
test('Admin: Erledigt ohne E-Mail des Mitarbeiters wird abgelehnt (400)', async () => {
const noEmail = { cookie: sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso' }) }
const r = await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, noEmail)
assert.equal(r.status, 400)
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
})
test('Admin: Erledigt auf ein gescheitertes Ereignis → 409', async () => {
await storageEvent({ id: 'speicher-gescheitert-x', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
const r = await post('/api/portal-admin/storage-tasks/speicher-gescheitert-x/done', {}, ADMIN)
assert.equal(r.status, 409)
assert.equal((await getContractEvent('speicher-gescheitert-x')).status, 'gescheitert')
})
test('Admin: gescheiterte Aenderungen nur der letzten 30 Tage', async () => {
await storageEvent({ id: 'speicher-gescheitert-neu', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert' })
await storageEvent({ id: 'speicher-gescheitert-alt', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 5, dueAt: iso(-2000), status: 'gescheitert' })
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-alt')), $createdAt: iso(-31 * 24 * HOUR) })
aw.seed('contractEvents', { ...(await getContractEvent('speicher-gescheitert-neu')), $createdAt: iso(-29 * 24 * HOUR) })
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.deepEqual(r.failed.map((f) => f.id), ['speicher-gescheitert-neu'])
})
test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(status, 200)
assert.equal(task.status, 'erledigt')
assert.equal(task.doneBy, 'kenso@webklar.com')
assert.ok(task.doneAt)
assert.equal(task.id, tasks[0].id)
const stored = await getContractEvent(tasks[0].id)
assert.equal(stored.status, 'erledigt')
assert.equal(stored.doneBy, 'kenso@webklar.com')
// Die Liste zeigt sie nicht mehr
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.deepEqual(after.tasks.map((t) => t.id), [tasks[1].id])
})
test('Admin: Erledigt nur fuer offene Speicher-Aufgaben (404 unbekannt, 409 schon erledigt)', async () => {
assert.equal((await post('/api/portal-admin/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const again = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(again.status, 409)
// Ein Wechsel-Ereignis ist keine Speicher-Aufgabe
await recordContractEvent({ id: 'wechsel-offen-x', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: {}, consent, status: 'offen' })
assert.equal((await post('/api/portal-admin/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404)
})
test('Admin: teilweise (Stripe-Schritt 2 gescheitert) und gescheiterte Aenderungen sind sichtbar', async () => {
await storageEvent({ id: 'speicher-teilweise-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 2, toUnits: 5, dueAt: iso(-1000), extra: { partial: true, error: 'Stripe: card_declined' } })
await storageEvent({ id: 'speicher-gescheitert-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert', extra: { error: 'Stripe: resource_missing' } })
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const partial = r.tasks.find((t) => t.id === 'speicher-teilweise-c2')
assert.equal(partial.partial, true)
assert.equal(partial.error, 'Stripe: card_declined')
assert.equal(r.tasks.some((t) => t.id === 'speicher-gescheitert-c1'), false, 'gescheitert ist keine offene Aufgabe')
assert.equal(r.failed.length, 1)
assert.equal(r.failed[0].id, 'speicher-gescheitert-c1')
assert.equal(r.failed[0].status, 'gescheitert')
assert.equal(r.failed[0].error, 'Stripe: resource_missing')
assert.equal(r.failed[0].customerName, 'Kunde Eins')
assert.equal(r.failed[0].toUnits, 4)
})
test('Admin: Wechsel-Abgleich beim Laden des Reiters (verlorener Stripe-Webhook)', async () => {
// Offener Kartenwechsel, dessen zurueckgehaltene Aenderung laengst verfallen ist; Stripe hat sie verworfen
await recordContractEvent({
id: 'wechsel-abgelaufen-c1',
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: { switchedAt: iso(-30 * HOUR), pendingExpiresAt: iso(-60000), from: 'hosting', to: 'wartung' },
consent,
status: 'offen',
})
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.tasks.length, 2)
const ev = await getContractEvent('wechsel-abgelaufen-c1')
assert.equal(ev.status, 'gescheitert')
assert.equal(ev.doneBy, 'abgleich')
})
test('Admin: Wechsel-Abgleich laesst einen noch laufenden Wechsel in Ruhe', async () => {
await recordContractEvent({
id: 'wechsel-laufend-c1',
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: { switchedAt: iso(-HOUR), pendingExpiresAt: iso(22 * HOUR) },
consent,
status: 'offen',
})
const before = st.calls.length
await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal((await getContractEvent('wechsel-laufend-c1')).status, 'offen')
assert.equal(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel')
})
test('Ohne Admin-Session 401', async () => {
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/portal-admin/storage-tasks')).status, 401)
assert.equal((await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
})
test('Ticketsystem-Schnittstelle: Produkt und aktiv', async () => {
const r = await get('/api/admin/customers/c1/abo', STAFF_JWT)
assert.deepEqual(r, { productKey: 'hosting', active: true })
assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403)
assert.equal((await rawGet('/api/admin/customers/c1/abo')).status, 401)
assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-fremd' } })).status, 401)
})
test('Ticketsystem-Schnittstelle: Wartungs-Abo, gekuendigtes Abo, kein Abo', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))
assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'wartung', active: true })
// Altbestand ohne productKey gilt als Wartungs-Abo
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, productKey: null }))
assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'wartung', active: true })
// Beendetes Abo: Produkt bekannt, aber nicht aktiv
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, status: 'canceled' }))
assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'hosting', active: false })
// Kein Abo
assert.deepEqual(await get('/api/admin/customers/c3/abo', STAFF_JWT), { productKey: null, active: false })
})
+171 -1
View File
@@ -9,7 +9,17 @@ const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscr
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { syncSubscription } = await import('../server/services/stripeBilling.js')
const { config } = await import('../server/config.js')
const {
syncSubscription,
ensurePrice,
ensureTestCoupon,
ensurePortalConfiguration,
termStartOf,
getStripe,
PORTAL_HEADLINE,
resetPortalConfigCacheForTests,
} = await import('../server/services/stripeBilling.js')
const sub = (id, items, extra = {}) => ({
id,
@@ -53,6 +63,30 @@ test('Spiegel: Wartungs-Abo wie bisher', async () => {
st.subscriptions.set('sub_w', sub('sub_w', [item('w', 'webklar_wartung_monatlich_5000', 5000)]))
const doc = await syncSubscription('sub_w')
assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 5000])
// Fix round 1, Important 4: Bestandskunde ohne Wechsel-Metadaten -> termStartAt vom Leistungsbeginn (start_date)
assert.equal(doc.termStartAt, new Date(1790000000 * 1000).toISOString())
})
// Fix round 1, Important 4: mit Probezeit (trial_end) und ohne Wechsel-Metadaten gewinnt trial_end
test('Spiegel: termStartAt ohne Wechsel-Metadaten faellt auf trial_end zurueck (Probezeit)', async () => {
st.subscriptions.set('sub_trial', sub('sub_trial', [item('wt', 'webklar_wartung_monatlich_5000', 5000)], { trial_end: 1791234567 }))
const doc = await syncSubscription('sub_trial')
assert.equal(doc.termStartAt, new Date(1791234567 * 1000).toISOString())
})
// Fix round 1, Minor 5: eine von Hand verstellte/ungueltige Metadaten-Angabe darf nicht crashen
// (toISOString auf Invalid Date wuerde werfen) - Warnung statt Absturz, Fallback wie ohne Metadaten
test('termStartOf: ungültiges Datum in den Metadaten -> Warnung, Fallback auf trial_end/start_date', () => {
const original = console.warn
const warnings = []
console.warn = (...args) => warnings.push(args.join(' '))
try {
const result = termStartOf({ id: 'sub_bad', metadata: { termStartAt: 'nicht-valide' }, trial_end: 1791234567, start_date: 1790000000 })
assert.equal(result.toISOString(), new Date(1791234567 * 1000).toISOString())
assert.ok(warnings.some((w) => w.includes('termStartAt')))
} finally {
console.warn = original
}
})
test('Spiegel: unbekannter Preis gilt als Wartungs-Abo, Betrag vom ersten Posten', async () => {
@@ -60,3 +94,139 @@ test('Spiegel: unbekannter Preis gilt als Wartungs-Abo, Betrag vom ersten Posten
const doc = await syncSubscription('sub_x')
assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 4900])
})
// Stripe-Nachbau braucht dazu: POST /v1/products, POST /v1/prices, GET /v1/coupons/:id, POST /v1/coupons,
// GET/POST /v1/billing_portal/configurations und POST /v1/billing_portal/configurations/:id (F09)
// Fix round 1, B9: before-Snapshot statt ueber die ganze Datei zaehlen; Preis-/Produktfelder und Bild geprueft
test('ensurePrice legt Hosting einmal an: Preis- und Produktfelder, Bild', async () => {
const before = st.calls.length
const id1 = await ensurePrice('hosting'); const id2 = await ensurePrice('hosting')
assert.equal(id1, id2)
const calls = st.calls.slice(before)
const createdPrices = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(createdPrices.length, 1)
assert.equal(createdPrices[0].body['lookup_key'], 'webklar_hosting_monatlich_1500')
assert.equal(createdPrices[0].body['unit_amount'], '1500')
assert.equal(createdPrices[0].body['tax_behavior'], 'inclusive')
assert.equal(createdPrices[0].body['recurring[interval]'], 'month')
const createdProducts = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products')
assert.equal(createdProducts.length, 1)
assert.equal(createdProducts[0].body['metadata[webklar]'], 'hosting-abo')
// Important 2: Produktbild wiederhergestellt (7799d7c:71 hatte es, ging beim Verallgemeinern verloren)
assert.equal(createdProducts[0].body['images[0]'], config.stripe.productImage)
})
// Important 4: ein bereits vorhandener aktiver Preis wird gefunden, nichts wird bei Stripe angelegt
test('ensurePrice: vorhandener aktiver Preis wird gefunden, nichts wird angelegt', async () => {
st.prices.set('price_seeded_wartung', {
id: 'price_seeded_wartung',
object: 'price',
active: true,
lookup_key: 'webklar_wartung_monatlich_5000',
unit_amount: 5000,
recurring: { interval: 'month' },
tax_behavior: 'inclusive',
})
const before = st.calls.length
const id = await ensurePrice('wartung')
assert.equal(id, 'price_seeded_wartung')
const writes = st.calls.slice(before).filter((c) => c.method === 'POST' && (c.path === '/v1/products' || c.path === '/v1/prices'))
assert.deepEqual(writes, [])
})
// Important 1 (Stripe-Nachbau-Haertung): Stripe begrenzt Coupon-Namen auf 40 Zeichen, IDs und aktive
// lookup_keys sind eindeutig - der Nachbau soll das wie die echte API mit 400 quittieren.
test('Stripe-Nachbau: Grenzen bei Preisen und Coupons (400)', async () => {
const stripe = getStripe()
st.prices.set('price_dup_lookup', {
id: 'price_dup_lookup', object: 'price', active: true, lookup_key: 'lookup_fuer_diesen_test', unit_amount: 100,
})
await assert.rejects(
stripe.prices.create({ product: 'prod_x', currency: 'eur', unit_amount: 100, lookup_key: 'lookup_fuer_diesen_test' }),
(err) => err.statusCode === 400
)
st.coupons.set('coupon_dup', { id: 'coupon_dup', object: 'coupon', name: 'x' })
await assert.rejects(
stripe.coupons.create({ id: 'coupon_dup', name: 'y', amount_off: 100, currency: 'eur', duration: 'once' }),
(err) => err.statusCode === 400
)
await assert.rejects(
stripe.coupons.create({ name: 'x'.repeat(41), amount_off: 100, currency: 'eur', duration: 'once' }),
(err) => err.statusCode === 400
)
})
// Important 1: Namen ≤ 40 Zeichen (Stripe-Limit; die alten Namen mit dem vollen Produktnamen waren 49/50)
test('Testrabatt Hosting: 14,50 € Nachlass, Name ≤ 40 Zeichen', async () => {
const before = st.calls.length
assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING')
const call = st.calls.slice(before).find((c) => c.path === '/v1/coupons' && c.method === 'POST')
assert.equal(call.body['amount_off'], '1450')
assert.equal(call.body['name'], 'Testzugang Hosting (0,50 €)')
assert.ok(call.body['name'].length <= 40)
})
test('Testrabatt Wartung: 49,50 € Nachlass, Name ≤ 40 Zeichen', async () => {
const before = st.calls.length
assert.equal(await ensureTestCoupon('wartung'), 'WEBKLAR-TEST-050')
const call = st.calls.slice(before).find((c) => c.path === '/v1/coupons' && c.method === 'POST')
assert.equal(call.body['amount_off'], '4950')
assert.equal(call.body['name'], 'Testzugang Wartung (0,50 €)')
assert.ok(call.body['name'].length <= 40)
})
// F09: eine bestehende eigene Konfiguration mit der alten Wartungs-Abo-Überschrift wird angepasst,
// nicht doppelt angelegt. Fix round 1, Minor 6: eigener Cache-Reset + eigene Map, damit dieser Test
// unabhaengig vom No-op-Test (gleiche Funktion, anderer Zweig) lauffaehig bleibt - ensurePortalConfiguration
// cached die Konfigurations-ID sonst ueber den ganzen Prozess hinweg.
test('Kundenportal: alte Überschrift → configurations.update', async () => {
resetPortalConfigCacheForTests()
st.billingPortalConfigurations.clear()
st.billingPortalConfigurations.set('bpc_old', {
id: 'bpc_old',
object: 'billing_portal.configuration',
active: true,
business_profile: { headline: 'Webklar – Wartungs-Abo verwalten' },
metadata: { webklar: 'wartungs-abo' },
})
const id = await ensurePortalConfiguration()
assert.equal(id, 'bpc_old')
const updateCall = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/billing_portal/configurations/bpc_old')
assert.ok(updateCall)
assert.equal(updateCall.body['business_profile[headline]'], 'WEBklar – Abo verwalten')
})
// Minor 6: Ueberschrift schon richtig -> kein ueberfluessiger update-Aufruf
test('Kundenportal: Überschrift schon richtig → kein Update-Aufruf', async () => {
resetPortalConfigCacheForTests()
st.billingPortalConfigurations.clear()
st.billingPortalConfigurations.set('bpc_ok', {
id: 'bpc_ok',
object: 'billing_portal.configuration',
active: true,
business_profile: { headline: PORTAL_HEADLINE },
metadata: { webklar: 'wartungs-abo' },
})
const before = st.calls.length
const id = await ensurePortalConfiguration()
assert.equal(id, 'bpc_ok')
const updateCalls = st.calls.slice(before).filter((c) => c.method === 'POST' && c.path.startsWith('/v1/billing_portal/configurations/'))
assert.deepEqual(updateCalls, [])
})
test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; storageUnits', async () => {
st.subscriptions.set(
'sub_s',
sub('sub_s', [item('w2', 'webklar_wartung_monatlich_5000', 5000), item('s2', 'webklar_mailspeicher_5gb_monatlich_100', 100, 3)], {
metadata: {
webklarCustomerId: 'c1',
email: 'k@example.invalid',
termsVersion: '2026-09-25-v3',
termStartAt: '2026-10-05T10:00:00.000Z',
},
})
)
const doc = await syncSubscription('sub_s')
assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z')
assert.equal(doc.storageUnits, 3)
})
+244
View File
@@ -29,6 +29,33 @@ test('Appwrite-Nachbau: Schema wie Appwrite', async () => {
await assert.rejects(createDocument('subscriptions', { unbekannt: 1 }, 'sub_b'), (e) => e.status === 400)
})
// Appwrite 1.8: Dokument-IDs hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich,
// nicht mit einem Sonderzeichen beginnend - sonst 400 (Task 7, Fix 1)
test('Appwrite-Nachbau: Dokument-ID nach Appwrite-Regeln', async () => {
const tooLong = 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' // 38 Zeichen
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, tooLong), (e) => e.status === 400 && /documentId/.test(e.message))
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, '-abc'), (e) => e.status === 400)
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, 'abc/def'), (e) => e.status === 400)
const ok = await createDocument('subscriptions', { customerId: 'c1' }, `abschluss-${'f'.repeat(26)}`)
assert.equal(ok.$id.length, 36)
})
test('Appwrite-Nachbau: failNext liefert einmal den gewuenschten Fehler', async () => {
aw.failNext({ collection: 'subscriptions', method: 'POST', status: 500 })
await assert.rejects(createDocument('subscriptions', { customerId: 'c9' }, 'sub_fail'), (e) => e.status === 500)
assert.equal((await createDocument('subscriptions', { customerId: 'c9' }, 'sub_fail')).$id, 'sub_fail')
// Nur die passende Anfrage scheitert; andere Collections/Methoden laufen durch
aw.failNext({ collection: 'hourCredits', method: 'POST', status: 404 })
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
await assert.rejects(createDocument('hourCredits', { customerId: 'c9' }, 'hc_1'), (e) => e.status === 404)
// after: so viele passende Anfragen gehen vorher noch durch (Task 8: erst die zweite PATCH scheitert).
// adminFetch bildet jeden 5xx-Status auf status 500 ab.
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 503, after: 1 })
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
await assert.rejects(getDocument('subscriptions', 'sub_fail'), (e) => e.status === 500 && /503/.test(e.message))
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
})
test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => {
st.subscriptions.set('sub_1', { id: 'sub_1', object: 'subscription', items: { object: 'list', data: [] } })
assert.equal((await getStripe().subscriptions.retrieve('sub_1')).id, 'sub_1')
@@ -43,3 +70,220 @@ test('useTestEnv lehnt fremde Hosts ab', () => {
assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url }))
assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1')
})
// F17 (Task 8): POST /v1/subscriptions/:id mit Positionen (anlegen per Preis, aendern per ID), Metadaten
// (leerer Wert loescht wie bei Stripe) und einmaligem Zusatz nextSubscriptionUpdate fuer die Antwort
test('Stripe-Nachbau: Abo-Update mit Positionen, Metadaten, einmaliger Zusatz', async () => {
const stripe = getStripe()
st.prices.set('price_a', { id: 'price_a', object: 'price', active: true, lookup_key: 'lookup_a', unit_amount: 100 })
st.subscriptions.set('sub_u', {
id: 'sub_u',
object: 'subscription',
status: 'active',
metadata: { keep: '1', drop: 'x' },
items: { object: 'list', data: [{ id: 'si_1', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
const added = await stripe.subscriptions.update('sub_u', {
items: [{ price: 'price_a', quantity: 2 }],
metadata: { drop: '', neu: 'y' },
proration_behavior: 'none',
})
assert.equal(added.items.data.length, 2)
const item = added.items.data[1]
assert.equal(item.quantity, 2)
assert.equal(item.price.id, 'price_a')
assert.equal(item.price.lookup_key, 'lookup_a')
assert.match(item.id, /^si_/)
assert.deepEqual(added.metadata, { keep: '1', neu: 'y' })
assert.equal(added.status, 'active')
assert.equal(st.calls.at(-1).body['items[0][price]'], 'price_a')
const changed = await stripe.subscriptions.update('sub_u', { items: [{ id: item.id, quantity: 0 }] })
assert.equal(changed.items.data[1].quantity, 0)
assert.equal((await stripe.subscriptions.retrieve('sub_u')).items.data[1].quantity, 0, 'gespeichert')
// Wie Stripe: derselbe Preis nicht zweimal, unbekannter Preis oder Posten 400/404, unbekanntes Abo 404
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_a', quantity: 1 }] }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ id: 'si_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_gibt_es_nicht', { metadata: { a: 'b' } }), (e) => e.statusCode === 404)
// Kuendigungsfelder wie cancelAtPeriodEnd/cancelSubscriptionAt sie setzen
const cancelled = await stripe.subscriptions.update('sub_u', { cancel_at_period_end: true, metadata: { cancelSource: 'test' } })
assert.equal(cancelled.cancel_at_period_end, true)
assert.equal(cancelled.metadata.cancelSource, 'test')
const dated = await stripe.subscriptions.update('sub_u', { cancel_at: 1800000000, proration_behavior: 'none' })
assert.equal(dated.cancel_at, 1800000000)
// nextSubscriptionUpdate: einmalig in die naechste Antwort gemischt, nicht gespeichert
st.nextSubscriptionUpdate = { latest_invoice: { status: 'paid', hosted_invoice_url: 'https://pay.example/inv' } }
const once = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
assert.equal(once.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
assert.equal(once.metadata.a, 'b')
assert.equal((await stripe.subscriptions.retrieve('sub_u')).latest_invoice, undefined)
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).latest_invoice, undefined)
assert.equal(st.nextSubscriptionUpdate, null)
})
// Task 9: mit pending_update im einmaligen Zusatz verhaelt sich der Nachbau wie Stripe bei einer ausstehenden
// Aktualisierung (Zahlung nicht erfolgt): das Abo bleibt unveraendert, die Aenderung wartet, bis ein Test sie
// mit applyPendingUpdate uebernimmt (= Rechnung bezahlt)
test('Stripe-Nachbau: ausstehende Aktualisierung bleibt zurückgehalten, bis sie übernommen wird', async () => {
const stripe = getStripe()
st.prices.set('price_b', { id: 'price_b', object: 'price', active: true, lookup_key: 'lookup_b', unit_amount: 500 })
st.subscriptions.set('sub_p', {
id: 'sub_p',
object: 'subscription',
status: 'active',
metadata: { productKey: 'hosting' },
items: { object: 'list', data: [{ id: 'si_p', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
st.nextSubscriptionUpdate = { pending_update: { expires_at: 1 }, latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } }
const pending = await stripe.subscriptions.update('sub_p', {
items: [{ id: 'si_p', price: 'price_b' }],
metadata: { productKey: 'wartung' },
payment_behavior: 'pending_if_incomplete',
})
assert.deepEqual(pending.pending_update, { expires_at: 1 })
assert.equal(pending.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
// Wie Stripe: Antwort und gespeichertes Abo zeigen den alten Stand
assert.equal(pending.metadata.productKey, 'hosting')
assert.equal(pending.items.data[0].price.id, 'price_0')
// Wie Stripe (Fix 1): das Abo traegt pending_update, bis die Aenderung uebernommen oder verworfen wird
const stored = await stripe.subscriptions.retrieve('sub_p')
assert.equal(stored.metadata.productKey, 'hosting')
assert.deepEqual(stored.pending_update, { expires_at: 1 })
// Bezahlt: die zurueckgehaltene Aenderung wird uebernommen
const applied = st.applyPendingUpdate('sub_p')
assert.equal(applied.metadata.productKey, 'wartung')
const after = await stripe.subscriptions.retrieve('sub_p')
assert.equal(after.items.data[0].price.id, 'price_b')
assert.equal(after.pending_update, null)
assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/)
// Verfallen: Stripe verwirft die Aenderung, das Abo bleibt beim alten Stand
st.nextSubscriptionUpdate = { pending_update: { expires_at: 2 } }
await stripe.subscriptions.update('sub_p', { metadata: { productKey: 'neu' }, payment_behavior: 'pending_if_incomplete' })
st.expirePendingUpdate('sub_p')
const expired = await stripe.subscriptions.retrieve('sub_p')
assert.equal(expired.metadata.productKey, 'wartung')
assert.equal(expired.pending_update, null)
assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/)
})
// Wie Stripe (docs.stripe.com/billing/subscriptions/pending-updates, Stand 28.09.2026): mit
// pending_if_incomplete nur die unterstuetzten Parameter; Metadaten hoechstens 40 Zeichen je Schluessel und
// 500 je Wert
test('Stripe-Nachbau: pending_if_incomplete nur mit unterstützten Parametern, Metadaten mit Stripe-Grenzen', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_q', {
id: 'sub_q',
object: 'subscription',
status: 'active',
metadata: {},
items: { object: 'list', data: [{ id: 'si_q', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', description: 'nicht erlaubt' }),
(e) => e.statusCode === 400 && /description/.test(e.message)
)
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', items: [{ id: 'si_q', metadata: { x: '1' } }] }),
(e) => e.statusCode === 400 && /metadata/.test(e.message)
)
const ok = await stripe.subscriptions.update('sub_q', {
payment_behavior: 'pending_if_incomplete',
items: [{ id: 'si_q', quantity: 2 }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: 1790000000,
metadata: { a: 'b' },
discounts: [{ coupon: 'X' }],
expand: ['latest_invoice'],
})
assert.equal(ok.items.data[0].quantity, 2)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(501) } }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { ['k'.repeat(41)]: '1' } }), (e) => e.statusCode === 400)
assert.equal((await stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(500) } })).metadata.a.length, 500)
})
// F25: Mails lassen sich in Tests pruefen (nodemailer jsonTransport statt SMTP); null schaltet zurueck
test('Mail-Senke: Kunden- und Team-Mails landen in der Senke, null schaltet zurück', async () => {
const { mailSink } = await import('./support/mailSink.js')
const { sendAdminNotice, sendCustomerMail, setMailTransportForTests } = await import('../server/services/mailer.js')
const sink = mailSink()
setMailTransportForTests(sink)
try {
await sendCustomerMail('k@example.invalid', {
template: 'K10',
values: { name: 'K', antrag_datum: '01.10.2026', antrag_nr: 'A1', begruendung: 'Nachweis fehlt' },
attachments: [{ filename: 'a.pdf', content: Buffer.from('PDF'), contentType: 'application/pdf' }],
})
assert.deepEqual(await sendAdminNotice({ template: 'T06', values: { email: 'k@example.invalid', produkt: 'Hosting-Abo', stripe_abo_id: 'sub_1' } }), { sent: true })
assert.equal(sink.sent.length, 2)
const [customerMail, notice] = sink.sent
assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid'])
assert.equal(customerMail.from.name, 'WEBklar')
assert.match(customerMail.text, /Nachweis fehlt/)
assert.deepEqual(customerMail.attachments.map((a) => a.filename), ['a.pdf'])
assert.equal(notice.from.name, 'WEBklar Kundenportal')
assert.match(notice.subject, /^\[Abo\]/)
} finally {
setMailTransportForTests(null)
}
await assert.rejects(sendCustomerMail('k@example.invalid', { template: 'K10', values: {} }), /SMTP nicht konfiguriert/)
})
// F17: GET /v1/invoices/:id bettet wie Stripe hoechstens 10 Zeilen ein, GET /v1/invoices/:id/lines blaettert
test('Stripe-Nachbau: Rechnung bettet höchstens 10 Zeilen ein, /lines blättert', async () => {
const stripe = getStripe()
st.invoices.set('in_p', { id: 'in_p', object: 'invoice', lines: { object: 'list', data: Array.from({ length: 12 }, (_, i) => ({ amount: i })) } })
const invoice = await stripe.invoices.retrieve('in_p')
assert.equal(invoice.lines.data.length, 10)
assert.equal(invoice.lines.has_more, true)
assert.ok(invoice.lines.data.every((l) => l.id && l.object === 'line_item'))
const page = await stripe.invoices.listLineItems('in_p', { limit: 5 })
assert.equal(page.data.length, 5)
assert.equal(page.has_more, true)
const all = await stripe.invoices.listLineItems('in_p', { limit: 5 }).autoPagingToArray({ limit: 100 })
assert.deepEqual(all.map((l) => l.amount), [...Array(12).keys()])
const small = await stripe.invoices.retrieve('in_p') // unveraendert seedbar: die Quelle behaelt alle Zeilen
assert.equal(small.lines.data.length, 10)
st.invoices.set('in_q', { id: 'in_q', object: 'invoice', lines: { object: 'list', data: [{ amount: 1 }] } })
assert.equal((await stripe.invoices.retrieve('in_q')).lines.has_more, false)
await assert.rejects(stripe.invoices.listLineItems('in_nix'), (e) => e.statusCode === 404)
})
// F17: POST /v1/invoices/create_preview liefert die vorab hinterlegte Vorschau (Objekt oder Funktion des Aufrufs)
test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => {
const stripe = getStripe()
const params = { subscription: 'sub_u', subscription_details: { items: [{ id: 'si_1', quantity: 2 }], proration_behavior: 'create_prorations', proration_date: 1 } }
const empty = await stripe.invoices.createPreview(params)
assert.deepEqual(empty.lines.data, [])
st.invoicePreview = { object: 'invoice', lines: { object: 'list', data: [{ amount: 42 }], has_more: false } }
assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 42)
assert.equal(st.calls.at(-1).body['subscription_details[items][0][quantity]'], '2')
assert.equal(st.calls.at(-1).body['subscription_details[proration_behavior]'], 'create_prorations')
st.invoicePreview = (body) => ({ object: 'invoice', lines: { data: [{ amount: Number(body.subscription_details.items[0].quantity) * 100 }] } })
assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 200)
await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404)
st.invoicePreview = null
})
// F41d / Task 8 (Fix 1): Fehler im Stripe-Nachbau einplanen - einmalig, nur fuer den passenden Aufruf
// (method, path als String oder RegExp); after passende Aufrufe gehen vorher durch. Der gescheiterte Aufruf
// steht trotzdem in calls; 4xx wiederholt stripe-node nicht (5xx schon, maxNetworkRetries).
test('Stripe-Nachbau: failNext lässt den nächsten passenden Aufruf scheitern', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_f', { id: 'sub_f', object: 'subscription', status: 'active', metadata: {}, items: { object: 'list', data: [] } })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_f', status: 400, message: 'Nachbau: abgelehnt' })
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } }), (e) => e.statusCode === 400 && /abgelehnt/.test(e.message))
assert.equal(st.calls.at(-1).body['metadata[a]'], 'b', 'gescheiterter Aufruf steht in calls')
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, undefined, 'gescheiterter Aufruf aendert nichts')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } })).metadata.a, 'b', 'einmalig')
// Nur der passende Pfad (RegExp) und erst nach `after` passenden Aufrufen
st.failNext({ method: 'POST', path: /\/v1\/subscriptions\/sub_f$/, status: 402, after: 1 })
assert.equal((await stripe.subscriptions.retrieve('sub_f')).id, 'sub_f')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'c' } })).metadata.a, 'c')
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402)
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c')
})
+45 -1
View File
@@ -3,11 +3,27 @@ import http from 'node:http'
/**
* Appwrite-Nachbau für Tests: die REST-Pfade, die server/services/appwriteAdmin.js benutzt, im
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
* Appwrite ("Invalid document structure: Unknown attribute").
* Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite
* (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am
* Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt");
* after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert).
*/
export async function startAppwriteMock({ schema = {} } = {}) {
const collections = new Map()
let sequence = 0
let pendingFailure = null
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
const invalidDocumentId = [
400,
{
message: 'Invalid `documentId` param: UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char',
code: 400,
type: 'general_argument_invalid',
},
]
const docs = (collection) => {
if (!collections.has(collection)) collections.set(collection, new Map())
@@ -77,15 +93,39 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/)
const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
if (userPath && req.method === 'GET') return [200, { $id: userPath[1], passwordUpdate: '' }]
// GET /v1/account (requireAppwriteStaff, Ticketsystem-JWT): jwt-admin = Mitarbeiter mit Label admin,
// jwt-kunde = angemeldet ohne Label, alles andere 401 wie Appwrite bei einem ungueltigen JWT
if (url.pathname === '/v1/account' && req.method === 'GET') {
const jwt = req.headers['x-appwrite-jwt']
if (jwt === 'jwt-admin') return [200, { $id: 'u_admin', email: 'kenso@webklar.com', labels: ['admin'] }]
if (jwt === 'jwt-kunde') return [200, { $id: 'u_admin', email: 'kenso@webklar.com', labels: [] }]
return [401, { message: 'User (role: guests) missing scope (account)', code: 401, type: 'general_unauthorized_scope' }]
}
if (!docPath) return [404, { message: 'Route not found', code: 404 }]
const [, collection, id] = docPath
const store = docs(collection)
const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }]
// Eingeplanter Fehler (failNext): genau eine passende Anfrage scheitert
if (
pendingFailure &&
(!pendingFailure.collection || pendingFailure.collection === collection) &&
(!pendingFailure.method || pendingFailure.method === req.method)
) {
if (pendingFailure.after > 0) {
pendingFailure.after -= 1
} else {
const { status, message } = pendingFailure
pendingFailure = null
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
}
}
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
if (!id && req.method === 'POST') {
const { documentId, data = {} } = await readBody(req)
if (!validDocumentId(documentId)) return invalidDocumentId
if (store.has(documentId)) {
return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }]
}
@@ -128,6 +168,10 @@ export async function startAppwriteMock({ schema = {} } = {}) {
url: `http://127.0.0.1:${server.address().port}/v1`,
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
seed,
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { collection, method, status, message, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+357 -3
View File
@@ -4,16 +4,67 @@ import http from 'node:http'
* Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE)
* an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als
* { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]').
*
* Abo-Änderungen (F17): POST /v1/subscriptions/:id mischt items[i][id|price|quantity|deleted] und
* metadata (leerer Wert löscht) in das gespeicherte Abo; st.nextSubscriptionUpdate wird einmalig nur
* in die nächste Antwort gemischt (z. B. latest_invoice). Enthält er pending_update, bleibt das Abo wie bei
* Stripe unverändert und trägt pending_update (ausstehende Aktualisierung, Task 9), bis st.applyPendingUpdate(id)
* die Änderung übernimmt oder st.expirePendingUpdate(id) sie verwirft. Wie Stripe: mit
* payment_behavior=pending_if_incomplete nur die dafür erlaubten Parameter, Metadaten-Schlüssel höchstens 40
* und Werte höchstens 500 Zeichen. Rechnungen: GET /v1/invoices/:id bettet wie Stripe höchstens 10 Zeilen ein,
* GET /v1/invoices/:id/lines blättert (limit, starting_after);
* POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys).
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
// pending-updates, "Unterstützte Attribute", geprüft am 28.09.2026); items[i][id] verweist auf die Position
const PENDING_UPDATE_PARAMS = new Set([
'expand', 'payment_behavior', 'proration_behavior', 'proration_date', 'billing_cycle_anchor', 'items', 'trial_end',
'trial_from_plan', 'metadata', 'discounts', 'cancel_at_period_end', 'coupon', 'promotion_code', 'add_invoice_items',
])
const PENDING_UPDATE_ITEM_PARAMS = new Set(['id', 'price', 'quantity', 'discounts'])
export async function startFakeStripe() {
const subscriptions = new Map()
const prices = new Map()
const invoices = new Map()
const products = new Map()
const coupons = new Map()
const billingPortalConfigurations = new Map()
const customers = new Map()
const checkoutSessions = new Map()
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
const pendingUpdates = new Map()
const calls = []
let seq = 0
let nextSubscriptionUpdate = null
let invoicePreview = null
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
let pendingFailure = null
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
const plannedFailure = (method, pathname) => {
if (!pendingFailure) return null
if (pendingFailure.method && pendingFailure.method !== method) return null
if (pendingFailure.path && !matchesPath(pendingFailure.path, pathname)) return null
if (pendingFailure.after > 0) {
pendingFailure.after -= 1
return null
}
const { status, message } = pendingFailure
pendingFailure = null
return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }]
}
const flat = (params) => Object.fromEntries(params.entries())
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
// Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen
const invoiceLinesOf = (invoiceId) =>
(invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line }))
const EMBEDDED_LINES = 10
const readBody = (req) =>
new Promise((resolve) => {
@@ -22,11 +73,100 @@ export async function startFakeStripe() {
req.on('end', () => resolve(new URLSearchParams(raw)))
})
const route = (method, path, query) => {
// Stripe serialisiert verschachtelte Parameter (z. B. recurring[interval]=month) als flache
// Formularschluessel. Fuer die Routen selbst wieder zu einem verschachtelten Objekt zusammenbauen.
const unflatten = (params) => {
const root = {}
for (const [rawKey, value] of params.entries()) {
const path = rawKey.match(/[^[\]]+/g) || [rawKey]
let node = root
path.forEach((key, i) => {
if (i === path.length - 1) {
node[key] = value
} else {
if (!(key in node)) node[key] = /^\d+$/.test(path[i + 1]) ? [] : {}
node = node[key]
}
})
}
return root
}
const route = (method, path, query, body) => {
let m
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
}
if (method === 'POST' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
const existing = subscriptions.get(m[1])
if (!existing) return noSuch('subscription', m[1])
const data = unflatten(body)
// Wie Stripe: mit pending_if_incomplete nur die Parameter, die ausstehende Aktualisierungen kennen
if (data.payment_behavior === 'pending_if_incomplete') {
const param =
Object.keys(data).find((key) => !PENDING_UPDATE_PARAMS.has(key)) ||
(data.items || []).flatMap((entry, i) => Object.keys(entry).filter((key) => !PENDING_UPDATE_ITEM_PARAMS.has(key)).map((key) => `items[${i}][${key}]`))[0]
if (param) return invalid(param, `Received unsupported parameter ${param} with payment_behavior=pending_if_incomplete.`)
}
// Wie Stripe: Metadaten-Schluessel hoechstens 40, Werte hoechstens 500 Zeichen
for (const [key, value] of Object.entries(data.metadata || {})) {
if (key.length > 40) return invalid(`metadata[${key}]`, 'Metadata keys can be at most 40 characters long.')
if (String(value).length > 500) return invalid(`metadata[${key}]`, 'Metadata values can be at most 500 characters long.')
}
const items = (existing.items?.data || []).map((item) => ({ ...item }))
for (const [i, entry] of (data.items || []).entries()) {
if (entry.id) {
// Vorhandene Position: Menge/Preis aendern oder entfernen
const index = items.findIndex((item) => item.id === entry.id)
if (index < 0) return invalid(`items[${i}][id]`, `No such subscription_item: '${entry.id}'`)
if (entry.deleted === 'true') {
items.splice(index, 1)
continue
}
if (entry.price) {
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
items[index].price = prices.get(entry.price)
}
if (entry.quantity !== undefined) items[index].quantity = Number(entry.quantity)
} else if (entry.price) {
// Neue Position per Preis; wie Stripe: derselbe Preis hoechstens einmal je Abo
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
if (items.some((item) => item.price?.id === entry.price)) {
return invalid(`items[${i}][price]`, 'You cannot combine multiple subscription items with the same price.')
}
items.push({
id: nextId('si'),
object: 'subscription_item',
quantity: entry.quantity !== undefined ? Number(entry.quantity) : 1,
price: prices.get(entry.price),
})
} else {
return invalid(`items[${i}]`, 'Each subscription item must have an id or a price.')
}
}
// Metadaten wie bei Stripe zusammenfuehren: leerer Wert loescht den Schluessel
const metadata = { ...(existing.metadata || {}) }
for (const [key, value] of Object.entries(data.metadata || {})) {
if (value === '') delete metadata[key]
else metadata[key] = value
}
const updated = { ...existing, items: { object: 'list', ...(existing.items || {}), data: items }, metadata }
if (data.cancel_at_period_end !== undefined) updated.cancel_at_period_end = data.cancel_at_period_end === 'true'
if (data.cancel_at !== undefined) updated.cancel_at = data.cancel_at === '' ? null : Number(data.cancel_at)
// Einmaliger Zusatz nur fuer diese Antwort (pending_update, latest_invoice, ...), nicht gespeichert
const extra = nextSubscriptionUpdate
nextSubscriptionUpdate = null
if (extra?.pending_update) {
// Zahlung nicht erfolgt (Task 9): Stripe haelt die Aenderung in pending_update zurueck, das Abo bleibt
// unveraendert und traegt pending_update, bis die Rechnung bezahlt ist (applyPendingUpdate) oder die
// Aenderung verfaellt (expirePendingUpdate)
pendingUpdates.set(m[1], updated)
subscriptions.set(m[1], { ...existing, pending_update: extra.pending_update })
return [200, { ...existing, ...extra }]
}
subscriptions.set(m[1], updated)
return [200, extra ? { ...updated, ...extra } : updated]
}
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
}
@@ -38,8 +178,176 @@ export async function startFakeStripe() {
)
return [200, listOf(found)]
}
if (method === 'POST' && path === '/v1/prices') {
const data = unflatten(body)
// Wie echtes Stripe: ein lookup_key darf nur an einem aktiven Preis gleichzeitig haengen
// (Fix round 1, Important 1 - haertet den Nachbau, ensurePrice fragt vorher ohnehin per GET)
if (data.lookup_key) {
const clash = [...prices.values()].find((p) => p.lookup_key === data.lookup_key && p.active !== false)
if (clash) {
return [400, { error: { type: 'invalid_request_error', param: 'lookup_key', message: `An active price already exists for lookup_key: '${data.lookup_key}'.` } }]
}
}
const obj = {
id: nextId('price'),
object: 'price',
active: true,
product: data.product,
currency: data.currency || 'eur',
unit_amount: data.unit_amount != null ? Number(data.unit_amount) : null,
recurring: data.recurring ? { interval: data.recurring.interval } : null,
tax_behavior: data.tax_behavior || null,
lookup_key: data.lookup_key || null,
nickname: data.nickname || null,
}
prices.set(obj.id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1])
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
// Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more)
const lines = invoiceLinesOf(m[1])
return [
200,
{
...invoices.get(m[1]),
lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` },
},
]
}
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/lines$/))) {
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
const lines = invoiceLinesOf(m[1])
const limit = Math.min(Number(query.get('limit')) || 10, 100)
const after = query.get('starting_after')
const afterIndex = after ? lines.findIndex((line) => line.id === after) : -1
if (after && afterIndex < 0) return invalid('starting_after', `No such line item: '${after}'`)
const start = afterIndex + 1
return [200, { object: 'list', data: lines.slice(start, start + limit), has_more: start + limit < lines.length, url: `/v1/invoices/${m[1]}/lines` }]
}
if (method === 'POST' && path === '/v1/invoices/create_preview') {
const data = unflatten(body)
if (data.subscription && !subscriptions.has(data.subscription)) return noSuch('subscription', data.subscription)
const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview
return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }]
}
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
return products.has(m[1]) ? [200, products.get(m[1])] : noSuch('product', m[1])
}
if (method === 'POST' && path === '/v1/products') {
const data = unflatten(body)
const obj = {
id: nextId('prod'),
object: 'product',
active: true,
name: data.name || '',
description: data.description ?? null,
statement_descriptor: data.statement_descriptor ?? null,
images: data.images || [],
metadata: data.metadata || {},
}
products.set(obj.id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/coupons\/([^/]+)$/))) {
return coupons.has(m[1]) ? [200, coupons.get(m[1])] : noSuch('coupon', m[1])
}
if (method === 'POST' && path === '/v1/coupons') {
const data = unflatten(body)
const id = data.id || nextId('coupon')
// Wie echtes Stripe: Name auf 40 Zeichen begrenzt, IDs eindeutig (Fix round 1, Important 1)
if (data.name && data.name.length > 40) {
return [400, { error: { type: 'invalid_request_error', param: 'name', message: 'Name must be no more than 40 characters.' } }]
}
if (coupons.has(id)) {
return [400, { error: { type: 'invalid_request_error', message: `Coupon with ID '${id}' already exists.` } }]
}
const obj = {
id,
object: 'coupon',
valid: true,
name: data.name || null,
amount_off: data.amount_off != null ? Number(data.amount_off) : null,
currency: data.currency || null,
duration: data.duration || 'once',
metadata: data.metadata || {},
}
coupons.set(id, obj)
return [200, obj]
}
// Checkout (Neuabschluss): Kunde suchen/anlegen, Checkout-Sitzung anlegen (F10)
if (method === 'GET' && path === '/v1/customers/search') {
// Suchsyntax wie in getOrCreateStripeCustomer: metadata['webklarCustomerId']:'c1'
const m2 = (query.get('query') || '').match(/metadata\['(\w+)'\]:'([^']*)'/)
const found = [...customers.values()].filter((c) => !m2 || c.metadata?.[m2[1]] === m2[2])
return [200, { object: 'search_result', data: found, has_more: false, url: '/v1/customers/search' }]
}
if (method === 'POST' && path === '/v1/customers') {
const data = unflatten(body)
const obj = {
id: nextId('cus'),
object: 'customer',
email: data.email ?? null,
name: data.name ?? null,
address: data.address ?? null,
invoice_settings: data.invoice_settings || {},
metadata: data.metadata || {},
}
customers.set(obj.id, obj)
return [200, obj]
}
if (method === 'POST' && path === '/v1/checkout/sessions') {
const data = unflatten(body)
const id = nextId('cs')
const obj = {
id,
object: 'checkout.session',
url: `https://checkout.stripe.test/c/pay/${id}`,
mode: data.mode || 'payment',
customer: data.customer ?? null,
client_reference_id: data.client_reference_id ?? null,
subscription: null,
metadata: data.metadata || {},
subscription_data: data.subscription_data || {},
}
checkoutSessions.set(id, obj)
return [200, obj]
}
if (method === 'GET' && path === '/v1/billing_portal/configurations') {
const active = query.get('active')
const found = [...billingPortalConfigurations.values()].filter(
(c) => active === null || String(c.active !== false) === active
)
return [200, listOf(found)]
}
if (method === 'POST' && path === '/v1/billing_portal/configurations') {
const data = unflatten(body)
const obj = {
id: nextId('bpc'),
object: 'billing_portal.configuration',
active: true,
business_profile: data.business_profile || {},
default_return_url: data.default_return_url || null,
features: data.features || {},
metadata: data.metadata || {},
}
billingPortalConfigurations.set(obj.id, obj)
return [200, obj]
}
if (method === 'POST' && (m = path.match(/^\/v1\/billing_portal\/configurations\/([^/]+)$/))) {
const existing = billingPortalConfigurations.get(m[1])
if (!existing) return noSuch('configuration', m[1])
const data = unflatten(body)
const updated = {
...existing,
...data,
business_profile: { ...existing.business_profile, ...(data.business_profile || {}) },
features: { ...existing.features, ...(data.features || {}) },
metadata: { ...existing.metadata, ...(data.metadata || {}) },
}
billingPortalConfigurations.set(m[1], updated)
return [200, updated]
}
return [404, { error: { type: 'invalid_request_error', message: `No such route: ${method} ${path}` } }]
}
@@ -48,7 +356,7 @@ export async function startFakeStripe() {
const url = new URL(req.url, 'http://127.0.0.1')
const body = await readBody(req)
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) })
const [status, payload] = route(req.method, url.pathname, url.searchParams)
const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
@@ -60,6 +368,52 @@ export async function startFakeStripe() {
subscriptions,
prices,
invoices,
products,
coupons,
billingPortalConfigurations,
customers,
checkoutSessions,
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
* applyPendingUpdate(id) die Aenderung uebernimmt oder expirePendingUpdate(id) sie verwirft.
*/
get nextSubscriptionUpdate() {
return nextSubscriptionUpdate
},
set nextSubscriptionUpdate(value) {
nextSubscriptionUpdate = value
},
/** Zurueckgehaltene Aenderung uebernehmen (= Wechselrechnung bezahlt, Stripe wendet pending_update an). */
applyPendingUpdate(id) {
const updated = pendingUpdates.get(id)
if (!updated) throw new Error(`Stripe-Nachbau: keine ausstehende Aktualisierung fuer ${id}`)
pendingUpdates.delete(id)
const applied = { ...updated, pending_update: null }
subscriptions.set(id, applied)
return applied
},
/** Zurueckgehaltene Aenderung verfaellt (Stripe storniert die Rechnung und verwirft pending_update). */
expirePendingUpdate(id) {
if (!pendingUpdates.has(id)) throw new Error(`Stripe-Nachbau: keine ausstehende Aktualisierung fuer ${id}`)
pendingUpdates.delete(id)
subscriptions.set(id, { ...subscriptions.get(id), pending_update: null })
},
/** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */
get invoicePreview() {
return invoicePreview
},
set invoicePreview(value) {
invoicePreview = value
},
/**
* Naechster passender Aufruf (method, path als String oder RegExp) scheitert einmalig mit status; after
* passende Aufrufe gehen vorher durch. Der Aufruf steht trotzdem in calls. 4xx wiederholt stripe-node
* nicht, 5xx schon (maxNetworkRetries) - fuer "Stripe lehnt ab" also 4xx einplanen.
*/
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { method, path, status, message, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+176
View File
@@ -0,0 +1,176 @@
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() vor config.js gelaufen ist
import { sessionCookie } from './testApp.js'
import { config } from '../../server/config.js'
/**
* Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c4, Abo-Dokumente, Zustimmung,
* Session-Cookies. seedFixtures(aw) legt Kunden und Abos in den Appwrite-Nachbau; c3 hat kein Abo
* (Neuabschluss-Tests), c4 ist ein Testkonto (ABO_TEST_EMAILS=test@example.invalid im Test setzen).
* Erst nach useTestEnv() dynamisch importieren (siehe testApp.js).
*/
const DAY_MS = 86400000
// Sekundengenau wie Stripe (unix-Zeitstempel): der Spiegel schreibt toIso(unix) und trifft so die
// Fixture-Werte wortgleich (z. B. storagePaidPeriodEnd = currentPeriodEnd nach einem Speicher-Plus)
const daysFromNow = (days) => new Date(Math.floor((Date.now() + days * DAY_MS) / 1000) * 1000).toISOString()
export const customers = {
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
c2: { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true },
c3: { $id: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid', name: 'Kunde Drei', status: 'active', portalAccessEnabled: true },
c4: { $id: 'c4', appwriteUserId: 'u4', email: 'test@example.invalid', name: 'Testkonto', status: 'active', portalAccessEnabled: true },
}
/** Abo-Dokument mit id als Dokument-ID und Stripe-Abo-ID (in Produktion immer gleich). */
export const subscriptionDoc = (id, overrides = {}) => ({ ...overrides, $id: id, stripeSubscriptionId: id })
// Abo seit 40 Tagen (Widerrufsfrist des Abschlusses vorbei), naechste Abbuchung in 20 Tagen
const startedAt = daysFromNow(-40)
/** Hosting-Abo sub_h von c1: aktiv, Fassung 4, ohne Zusatzspeicher. */
export const hostingDoc = subscriptionDoc('sub_h', {
customerId: 'c1',
email: 'k@example.invalid',
stripeCustomerId: 'cus_c1',
status: 'active',
productKey: 'hosting',
planKey: config.products.hosting.lookupKey,
priceAmount: config.products.hosting.amountCents,
startedAt,
termStartAt: startedAt,
currentPeriodEnd: daysFromNow(20),
trialEnd: null,
cancelAtPeriodEnd: false,
cancelAt: null,
canceledAt: null,
endedAt: null,
termsVersion: config.abo.termsVersion,
termsAcceptedAt: startedAt,
earlyStartConsentAt: startedAt,
startChoice: 'sofort',
consentIp: '127.0.0.1',
consentUserAgent: 'test',
consentTextVersion: config.abo.termsVersion,
storageUnits: 0,
storagePaidUnits: 0,
storagePaidPeriodEnd: null,
})
/** Wartungs-Abo sub_w von c1 (im Test meist per subscriptionDoc('sub_h', { ...wartungDoc }) eingespielt). */
export const wartungDoc = subscriptionDoc('sub_w', {
...hostingDoc,
productKey: 'wartung',
planKey: config.products.wartung.lookupKey,
priceAmount: config.products.wartung.amountCents,
})
/** Zweites Hosting-Abo sub_h2 von c2. */
export const hosting2Doc = subscriptionDoc('sub_h2', {
...hostingDoc,
customerId: 'c2',
email: 'k2@example.invalid',
stripeCustomerId: 'cus_c2',
})
const consentAt = new Date().toISOString()
/** Zustimmung im Portal (Bestellung, Wechsel, Speicher-Plus); earlyStartConsentAt fuer den Wechsel (F16). */
export const consent = {
termsVersion: config.abo.termsVersion,
consentAt,
consentIp: '127.0.0.1',
consentUserAgent: 'test',
earlyStartConsentAt: consentAt,
}
/** Kunden-Session; Standard c1. */
export const customerCookie = (customerId = 'c1') =>
sessionCookie({ customerId, appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}` })
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
export const adminCookie = () =>
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
/** Admin im Modus "Als Kunde ansehen" (portalAdmin.js /view-as): Bestellen und Aendern sind gesperrt. */
export const viewAsCookie = (customerId = 'c1') =>
sessionCookie({
role: 'admin',
viewAs: true,
isAdmin: true,
appwriteUserId: 'u_admin',
adminName: 'Kenso',
adminEmail: 'kenso@webklar.com',
customerId,
name: customers[customerId]?.name || '',
email: customers[customerId]?.email || '',
})
/** Kunden c1–c4 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */
export function seedFixtures(aw) {
for (const customer of Object.values(customers)) aw.seed('customers', customer)
aw.seed('subscriptions', hostingDoc)
aw.seed('subscriptions', hosting2Doc)
}
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
/**
* Stripe-Abo in der Form der SDK-API-Version für den Stripe-Nachbau: Abo-Posten je Produkt,
* dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. Die Standard-ID ist 28 Zeichen lang wie
* bei Stripe (sub_ + 24); fuer den Spiegel von sub_h explizit id: 'sub_h' uebergeben. metadata
* ergaenzt/ueberschreibt die Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...).
*/
export function stripeSubscription({
id = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp',
customerId = 'c1',
productKey = 'hosting',
storageUnits = 0,
metadata = {},
...rest
} = {}) {
const product = config.products[productKey]
const items = [
{
id: `si_${id}_abo`,
object: 'subscription_item',
quantity: 1,
price: { id: `price_${productKey}`, object: 'price', lookup_key: product.lookupKey, unit_amount: product.amountCents },
},
]
if (storageUnits > 0) {
items.push({
id: `si_${id}_speicher`,
object: 'subscription_item',
quantity: storageUnits,
price: { id: 'price_mailStorage', object: 'price', lookup_key: config.mailStorage.lookupKey, unit_amount: config.mailStorage.amountCents },
})
}
return {
id,
object: 'subscription',
status: 'active',
customer: `cus_${customerId}`,
start_date: unix(hostingDoc.startedAt),
current_period_end: unix(hostingDoc.currentPeriodEnd),
trial_end: null,
cancel_at_period_end: false,
cancel_at: null,
canceled_at: null,
ended_at: null,
metadata: {
webklarCustomerId: customerId,
email: customers[customerId]?.email || '',
productKey,
termsVersion: config.abo.termsVersion,
consentTextVersion: config.abo.termsVersion,
termsAcceptedAt: consentAt,
startChoice: 'sofort',
earlyStartConsentAt: consentAt,
consentIp: '127.0.0.1',
consentUserAgent: 'test',
...metadata,
},
items: { object: 'list', data: items },
...rest,
}
}
+21
View File
@@ -0,0 +1,21 @@
import nodemailer from 'nodemailer'
/**
* Mail-Senke für Tests (F25): nodemailer-jsonTransport statt SMTP. Nach
* setMailTransportForTests(mailSink()) (server/services/mailer.js)
* landet jede Mail in sent, in der Reihenfolge des Versands, als das Objekt, das nodemailer daraus
* erzeugt: from { address, name }, to/bcc [{ address, name }], subject, text, html, attachments
* (Inhalt base64). Es verlässt nichts den Prozess.
*/
export function mailSink() {
const json = nodemailer.createTransport({ jsonTransport: true })
const sent = []
return {
sent,
async sendMail(mail) {
const info = await json.sendMail(mail)
sent.push(JSON.parse(info.message))
return info
},
}
}
+120
View File
@@ -0,0 +1,120 @@
import http from 'node:http'
import express from 'express'
import signature from 'cookie-signature'
import Stripe from 'stripe'
import { config } from '../../server/config.js'
import { sessionMiddleware } from '../../server/middleware/session.js'
/**
* Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit
* SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der
* Test übergibt:
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes })
*
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
* Nachbauten, weigert sich dieses Modul beim Laden. Die Anfrage-Helfer sprechen die zuletzt
* gestartete App an.
*/
function assertTestEnv() {
for (const [name, value] of [
['APPWRITE_ENDPOINT', config.appwrite.endpoint],
['STRIPE_API_BASE', config.stripe.apiBase],
]) {
let host = null
try {
host = new URL(value || '').hostname
} catch {
host = null
}
if (host !== '127.0.0.1') {
throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() muss vor dem ersten Import von config.js laufen`)
}
}
}
assertTestEnv()
/**
* Signierter Session-Cookie wie setPortalSession (cookie-parser: Praefix "s:" + cookie-signature).
* Kunde: { customerId, appwriteUserId }. Admin: { role: 'admin', appwriteUserId, name, email }.
*/
export function sessionCookie(session) {
const value = `s:${signature.sign(JSON.stringify(session), config.sessionSecret)}`
return `${config.cookieName}=${encodeURIComponent(value)}`
}
let baseUrl = null
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, close }. */
export async function startTestApp(routers = {}) {
const app = express()
app.set('trust proxy', 1)
app.use(sessionMiddleware())
app.use(
express.json({
limit: '2mb',
verify: (req, _res, buf) => {
req.rawBody = buf
},
})
)
for (const [mountPath, router] of Object.entries(routers)) app.use(mountPath, router)
app.use((err, _req, res, _next) => {
if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' })
})
const server = http.createServer(app)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
baseUrl = `http://127.0.0.1:${server.address().port}`
return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) }
}
async function request(method, path, { body, cookie, headers = {} } = {}) {
if (!baseUrl) throw new Error('testApp: startTestApp() zuerst aufrufen')
const res = await fetch(baseUrl + path, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
...headers,
},
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
redirect: 'manual',
})
const text = await res.text()
let parsed = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = text
}
return { status: res.status, body: parsed, headers: res.headers }
}
/** GET -> { status, body } */
export const rawGet = (path, opts = {}) => request('GET', path, opts)
/** GET -> JSON-Body; ab Status 400 fliegt ein Fehler mit err.status und err.body. */
export async function get(path, opts = {}) {
const { status, body } = await rawGet(path, opts)
if (status >= 400) {
const err = new Error(`GET ${path} → ${status}: ${body?.error || JSON.stringify(body)}`)
err.status = status
err.body = body
throw err
}
return body
}
/** POST mit JSON-Body -> { status, body } */
export const post = (path, body, opts = {}) => request('POST', path, { ...opts, body })
// Signiert wie Stripe (whsec_test aus useTestEnv); der Schluessel wird nie fuer Netzaufrufe benutzt
const webhookSigner = new Stripe('sk_test_fake')
/** Stripe-Ereignis signiert an den Webhook schicken (F25) -> { status, body }. */
export function postWebhook(event, { path = '/api/abo/webhook', secret = config.stripe.webhookSecret || 'whsec_test' } = {}) {
const payload = JSON.stringify(event)
const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret })
return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })
}
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
// runAutoChecks arbeitet ohne Domain komplett offline (kein DNS-Abgleich, kein Appwrite, kein Stripe):
// Die Pruefung vergleicht den Monatsbetrag beim bisherigen Anbieter mit der Monatsgebuehr des Abos des Kunden.
const { runAutoChecks } = await import('../server/services/takeoverCredits.js')
// 240 € fuer 12 Monate = 20,00 € pro Monat: mehr als das Hosting-Abo (15,00 €), weniger als das Wartungs-Abo (50,00 €)
const doc = { $id: 'req1', $createdAt: '2026-10-01T10:00:00.000Z', domain: '', invoiceAmountCents: 24000, billingMonths: 12, files: '[]' }
const sub = (productKey) => ({
status: 'active',
startedAt: '2026-10-01T00:00:00.000Z',
currentPeriodEnd: '2026-11-01T00:00:00.000Z',
...(productKey === undefined ? {} : { productKey }),
})
const amountCheck = async (subscription) =>
(await runAutoChecks(doc, { allRequests: [], subscription, calc: null })).find((c) => c.key === 'amount')
test('Plausibilität: 20 € pro Monat ist mehr als das Hosting-Abo (15,00 €)', async () => {
const hosting = await amountCheck(sub('hosting'))
assert.ok(hosting, 'Hinweis „mehr als die Monatsgebühr“ fehlt beim Hosting-Abo')
assert.equal(hosting.status, 'info')
assert.ok(hosting.label.includes('15,00'), hosting.label)
assert.ok(hosting.label.includes('20,00'), hosting.label)
})
test('Plausibilität: 20 € pro Monat ist nicht mehr als das Wartungs-Abo (50,00 €)', async () => {
assert.equal(await amountCheck(sub('wartung')), undefined)
})
test('Abos ohne oder mit unbekanntem productKey gelten als Wartungs-Abo, ohne Abo ebenso', async () => {
assert.equal(await amountCheck(sub(undefined)), undefined, 'Altbestand ohne productKey')
assert.equal(await amountCheck(sub('gibtsnicht')), undefined, 'unbekannter productKey darf die Prüfung nicht abbrechen')
const ohneAbo = await runAutoChecks(doc, { allRequests: [], subscription: null, calc: null })
assert.equal(ohneAbo.find((c) => c.key === 'amount'), undefined)
assert.equal(ohneAbo.find((c) => c.key === 'abo')?.status, 'warn')
})