@@ -33,16 +34,28 @@ a{color:#111111;}
|
-| Ihr Wartungs-Abo ist bestätigt. |
-Guten Tag {{name}}, vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos. |
-WEBklar Wartungs-Abo 50,00 € / Monat gemäß § 19 UStG ohne Umsatzsteuer
+ | | Ihr {{produkt}} ist bestätigt. |
+Guten Tag {{name}}, vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres {{produkt}}s. |
+WEBklar {{produkt}} {{preis}} gemäß § 19 UStG ohne Umsatzsteuer
Ihr Angebot: {{angebot_text}} (wie vereinbart)
|
|
-| Vertragsschluss | {{vertragsschluss}} | | Leistungsbeginn | {{leistungsbeginn}} | | Laufzeit | Mindestlaufzeit 12 Monate bis {{mindestlaufzeit_bis}}. Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar. | | Enthalten | • Betrieb und Pflege Ihrer Website bzw. Web-Anwendung • 1 Domain • 1 E-Mail-Postfach • 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar) |
|
+| Vertragsschluss | {{vertragsschluss}} | | Leistungsbeginn | {{leistungsbeginn}} | | Laufzeit | Mindestlaufzeit 12 Monate bis {{mindestlaufzeit_bis}}. Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar. | | Enthalten |
+
+• Bereitstellung Ihrer statischen Website auf unserem Server in Deutschland (SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit, Updates von Betriebssystem und Server-Software) • 1 Domain • 1 E-Mail-Postfach (5 GB; mehr über den Zusatzspeicher) • Keine Pflege der Website; Änderungen nur nach Freigabe, 100 € pro Stunde in 15-Minuten-Schritten (gemäß § 19 UStG ohne Umsatzsteuer)
+
+
+• Betrieb und Pflege Ihrer Website bzw. Web-Anwendung • 1 Domain • beliebig viele Postfächer (zusammen 5 GB; mehr über den Zusatzspeicher) • 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)
+
+ |
|
|
+
| Dort sehen Sie jederzeit Ihr aktuelles Stundenkontingent. |
+
+
+| Dort sehen Sie jederzeit Ihre Verträge und Rechnungen. |
+
Ihre Vertragsunterlagen im Anhang (PDF) 📎 Vertragsbedingungen mit Leistungsbeschreibung 📎 Widerrufsbelehrung mit Muster-Widerrufsformular 📎 Auftragsverarbeitungsvertrag |
|
Ihre Zustimmung zu den Vertragsbedingungen (Version {{bedingungen_version}}) haben wir am {{zustimmung_zeitpunkt}} Uhr erhalten.
diff --git a/server/mail-templates/K02 Kuendigungsbestaetigung.html b/server/mail-templates/K02 Kuendigungsbestaetigung.html
index 0a3bbaa..763ddb2 100644
--- a/server/mail-templates/K02 Kuendigungsbestaetigung.html
+++ b/server/mail-templates/K02 Kuendigungsbestaetigung.html
@@ -4,16 +4,17 @@
-Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo
+Bestätigung Ihrer Kündigung – WEBklar {{produkt}}
-Ihr Wartungs-Abo endet zum {{vertragsende}}. ͏ ͏ ͏
+Ihr {{produkt}} endet zum {{vertragsende}}. ͏ ͏ ͏
@@ -34,10 +35,10 @@ a{color:#111111;}
|
| Ihre Kündigung ist bestätigt. |
-Guten Tag {{name}}, wir bestätigen Ihre Kündigung des WEBklar Wartungs-Abos vom {{eingang_zeitpunkt}} Uhr. |
+Guten Tag {{name}}, wir bestätigen Ihre Kündigung des WEBklar {{produkt}}s vom {{eingang_zeitpunkt}} Uhr. |
Ihr Abo endet zum {{vertragsende}} |
|
-| Bis dahin bleiben alle Leistungen verfügbar, danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende. |
-| Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit. |
+| Bis dahin bleiben alle Leistungen verfügbar, danach erfolgen keine weiteren Abbuchungen. Gebuchter E-Mail-Zusatzspeicher endet zusammen mit dem Abo. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende. |
+| Ihre Daten, den Domain-AuthCode und die Inhalte Ihrer Postfächer stellen wir Ihnen auf Wunsch gemäß {{vertragsende_paragraph}} der Vertragsbedingungen bereit. |
Sie haben das nicht veranlasst? |
|
Viele Grüße Ihr WEBklar-Team |
diff --git a/server/mail-templates/K03 Eingang Kuendigung.html b/server/mail-templates/K03 Eingang Kuendigung.html
index 8b5a8b5..f595400 100644
--- a/server/mail-templates/K03 Eingang Kuendigung.html
+++ b/server/mail-templates/K03 Eingang Kuendigung.html
@@ -4,16 +4,17 @@
-Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo
+Eingangsbestätigung Ihrer Kündigung – WEBklar {{produkt}}
-{{kunde_name}} hat das Wartungs-Abo abgeschlossen. ͏ ͏ ͏
+{{kunde_name}} hat das {{produkt}} abgeschlossen. ͏ ͏ ͏
@@ -33,8 +33,8 @@ a{color:#111111;}
|
-| Neues Wartungs-Abo abgeschlossen |
-| Kunde | {{kunde_name}} | | E-Mail | {{email}} | | Appwrite-Kunde | {{appwrite_id}} | | Stripe-Abo | {{stripe_abo_id}} |
+| Neues {{produkt}} abgeschlossen |
+| Kunde | {{kunde_name}} | | Abo | {{produkt}} | | E-Mail | {{email}} | | Appwrite-Kunde | {{appwrite_id}} | | Stripe-Abo | {{stripe_abo_id}} |
| Angebot | {{angebot_text}} |
@@ -48,6 +48,7 @@ a{color:#111111;}
Nachweis nicht gespeichert 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. |
|
+Zu tun Website als Monitor in UptimeRobot anlegen. |
|
| |
|
diff --git a/server/mail-templates/T05 Kuendigung Stripe.html b/server/mail-templates/T05 Kuendigung Stripe.html
index 34a40ef..5501aa1 100644
--- a/server/mail-templates/T05 Kuendigung Stripe.html
+++ b/server/mail-templates/T05 Kuendigung Stripe.html
@@ -7,8 +7,8 @@
Betreff: [Abo] Kündigung (Stripe): {{email}}
Absender: „WEBklar Kundenportal“
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).
-->
@@ -34,9 +34,9 @@ a{color:#111111;}
|
| Abo gekündigt |
-| Kunde | {{email}} | | Stripe-Abo | {{stripe_abo_id}} | | Vertragsende | {{vertragsende}} |
|
+| Kunde | {{email}} | | Abo | {{produkt}} | | Stripe-Abo | {{stripe_abo_id}} | | Vertragsende | {{vertragsende}} |
|
| Die Bestätigung an den Kunden ist raus. |
-Zu tun Hosting, Domain und Postfach gemäß § 9 der Vertragsbedingungen abwickeln (Export, AuthCode). |
|
+Zu tun Zum Vertragsende Website, Domain und Postfächer gemäß {{vertragsende_paragraph}} der Vertragsbedingungen abwickeln (Export, AuthCode); gebuchter Zusatzspeicher endet mit dem Abo. |
|
| |
diff --git a/server/mail-templates/T06 Kuendigung zurueckgenommen.html b/server/mail-templates/T06 Kuendigung zurueckgenommen.html
index 4c3ab69..18a23ad 100644
--- a/server/mail-templates/T06 Kuendigung zurueckgenommen.html
+++ b/server/mail-templates/T06 Kuendigung zurueckgenommen.html
@@ -7,8 +7,8 @@
Betreff: [Abo] Kündigung zurückgenommen: {{email}}
Absender: „WEBklar Kundenportal“
Empfänger: kenso@webklar.com, andrej@webklar.com
- Platzhalter: {{email}}, {{stripe_abo_id}}
-
+ Platzhalter: {{email}}, {{produkt}}, {{stripe_abo_id}}
+ produkt = Katalogname ohne „WEBklar “.
-->
@@ -34,7 +34,7 @@ a{color:#111111;}
|
| Kündigung zurückgenommen |
-| Kunde | {{email}} | | Stripe-Abo | {{stripe_abo_id}} |
|
+| Kunde | {{email}} | | Abo | {{produkt}} | | Stripe-Abo | {{stripe_abo_id}} |
|
| Das Abo läuft wieder normal weiter. Der Kunde bekommt dazu keine Mail. |
| |
diff --git a/server/mail-templates/T07 Abo beendet.html b/server/mail-templates/T07 Abo beendet.html
index 8540ab1..05720d4 100644
--- a/server/mail-templates/T07 Abo beendet.html
+++ b/server/mail-templates/T07 Abo beendet.html
@@ -7,8 +7,8 @@
Betreff: [Abo] Abo beendet: {{email}}
Absender: „WEBklar Kundenportal“
Empfänger: kenso@webklar.com, andrej@webklar.com
- Platzhalter: {{email}}, {{stripe_abo_id}}, {{status}}
-
+ Platzhalter: {{email}}, {{produkt}}, {{stripe_abo_id}}, {{status}}, {{vertragsende_paragraph}}
+ produkt = Katalogname ohne „WEBklar “; vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11).
-->
@@ -34,9 +34,9 @@ a{color:#111111;}
|
| Abo beendet |
-| Kunde | {{email}} | | Stripe-Abo | {{stripe_abo_id}} | | Status | {{status}} |
|
-| Ein Restguthaben verfällt. |
-Zu tun Hosting, Domain und Postfach gemäß § 9 der Vertragsbedingungen abwickeln (Export, AuthCode). |
|
+| Kunde | {{email}} | | Abo | {{produkt}} | | Stripe-Abo | {{stripe_abo_id}} | | Status | {{status}} |
|
+| Ein Restguthaben an Arbeitszeit verfällt mit dem Vertragsende; gebuchter Zusatzspeicher endet mit dem Abo. |
+Zu tun Website, Domain und Postfächer gemäß {{vertragsende_paragraph}} der Vertragsbedingungen abwickeln (Export bis 30 Tage nach Vertragsende, AuthCode). Monitor in UptimeRobot entfernen. |
|
| |
diff --git a/server/mail-templates/T08 Kuendigung eingegangen.html b/server/mail-templates/T08 Kuendigung eingegangen.html
index c04dfd6..f4533e9 100644
--- a/server/mail-templates/T08 Kuendigung eingegangen.html
+++ b/server/mail-templates/T08 Kuendigung eingegangen.html
@@ -7,7 +7,7 @@
Betreff: [Abo] Kündigung eingegangen: {{email}}
Absender: „WEBklar Kundenportal“
Empfänger: kenso@webklar.com, andrej@webklar.com
- Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{vertrag}}, {{kuendigungsart}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{stripe_abo_id}}, {{pakete}}, {{pakete_angabe}}, {{fehler}}
+ 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
-->
@@ -34,7 +34,7 @@ a{color:#111111;}
|
| Kündigung über die Kündigungsseite |
-| Name | {{name}} | | E-Mail | {{email}} |
+| Name | {{name}} | | E-Mail | {{email}} | | Abo | {{produkt}} |
| Kundennummer | {{kundennummer}} |
diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html
index 172022c..d6038bd 100644
--- a/server/mail-templates/T09 Widerruf eingegangen.html
+++ b/server/mail-templates/T09 Widerruf eingegangen.html
@@ -7,7 +7,7 @@
Betreff: [Abo] WIDERRUF eingegangen: {{email}}
Absender: „WEBklar Kundenportal“
Empfänger: kenso@webklar.com, andrej@webklar.com
- Platzhalter: {{email}}, {{name}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
+ 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
-->
@@ -34,7 +34,7 @@ a{color:#111111;}
|
| Widerruf eingegangen |
-| Name | {{name}} | | E-Mail | {{email}} | | Bestellung | {{bestellung}} |
+| Name | {{name}} | | E-Mail | {{email}} | | Bestellung | {{bestellung}} | | Abo | {{produkt}} |
| Kundennummer | {{kundennummer}} |
@@ -52,13 +52,13 @@ a{color:#111111;}
-Achtung Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und den Rest innerhalb von 14 Tagen erstatten (Stripe → Rückerstattung). |
|
+Achtung 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). |
|
-Zu tun Wechsel ins Wartungs-Abo widerrufen: in Stripe den Preis zurück auf das Hosting-Abo setzen und die Metadaten des Wechsels zurücknehmen (termStartAt, productKey, switchedAt – wie in scripts/wechsel-gescheitert.mjs), die Wechselrechnung erstatten (Wertersatz für die Tage im Wartungs-Abo abziehen) innerhalb von 14 Tagen. Widerrufen: {{ereignisse}} |
|
+Zu tun 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: {{ereignisse}} |
|
-Zu tun Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag erstatten (Wertersatz für die Tage abziehen) innerhalb von 14 Tagen. Widerrufen: {{ereignisse}} |
|
+Zu tun 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: {{ereignisse}} |
|
Achtung 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. |
|
diff --git a/server/mail-templates/T12 Wechsel.html b/server/mail-templates/T12 Wechsel.html
index e7b6db6..980fb37 100644
--- a/server/mail-templates/T12 Wechsel.html
+++ b/server/mail-templates/T12 Wechsel.html
@@ -9,7 +9,7 @@
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{stripe_abo_id}}, {{wechseltag}}, {{betrag_heute}}, {{monatspreis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): wirksam, lastschrift, gescheitert, lastschrift_gescheitert, nachweis_fehlt
- Stand Aufgabe 9: wirksam (Wechsel gilt), lastschrift (SEPA, noch nicht eingelöst), gescheitert (K14 verschickt), lastschrift_gescheitert (Handgriffe), nachweis_fehlt; Endfassung in Aufgabe 12.
+ 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).
-->
@@ -35,7 +35,7 @@ a{color:#111111;}
|
| Wechsel ins Wartungs-Abo |
-| Kunde | {{name}} | | E-Mail | {{email}} | | Stripe-Abo | {{stripe_abo_id}} | | Wechseltag | {{wechseltag}} | | Wechselrechnung | {{betrag_heute}} | | Monatspreis Wartung | {{monatspreis}} |
|
+| Kunde | {{name}} | | E-Mail | {{email}} | | Stripe-Abo | {{stripe_abo_id}} | | Wechseltag | {{wechseltag}} | | Wechselrechnung | {{betrag_heute}} | | Monatspreis ab Wechsel | {{monatspreis}} (Wartungs-Abo, ggf. mit gebuchtem Zusatzspeicher) |
|
Der Wechsel ist wirksam 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. Weitere Postfächer erst einrichten, wenn die Wechselrechnung bezahlt ist (Vertragsbedingungen § 9 Abs. 3). |
|
diff --git a/server/mail-templates/T13 Speicher-Aufgabe.html b/server/mail-templates/T13 Speicher-Aufgabe.html
index 5625d50..05bc856 100644
--- a/server/mail-templates/T13 Speicher-Aufgabe.html
+++ b/server/mail-templates/T13 Speicher-Aufgabe.html
@@ -9,7 +9,7 @@
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{aktion}}, {{email}}, {{name}}, {{stripe_abo_id}}, {{postfaecher_hinweis}}, {{neue_groesse_gb}}, {{pakete}}, {{faellig}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): einrichten, periodenende, teilweise, nachweis_fehlt
- Stand Aufgabe 8 (Fix 1): aktion ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“; einrichten (Plus über die bezahlte Menge, sofort), periodenende (Minus oder Plus bis zur bezahlten Menge, zum Periodenende), teilweise (Schritt 2 bei Stripe fehlgeschlagen), nachweis_fehlt; Endfassung in Aufgabe 12.
+ 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.
-->
diff --git a/server/routes/abo.js b/server/routes/abo.js
index e068e17..fb14445 100644
--- a/server/routes/abo.js
+++ b/server/routes/abo.js
@@ -1,6 +1,4 @@
import { readFile } from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
@@ -20,7 +18,7 @@ import {
reconcilePendingSwitch,
switchToWartung,
} from '../services/planSwitch.js'
-import { PRODUCT_KEYS, describeItems, productView, termsDocFor } from '../services/products.js'
+import { PRODUCT_KEYS, contractAttachmentFor, describeItems, productLabel, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
cancelAtPeriodEnd,
@@ -38,7 +36,6 @@ import {
} from '../services/stripeBilling.js'
const router = Router()
-const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
const fmtDate = (iso) =>
iso
@@ -68,6 +65,12 @@ function minTermEndOf(sub) {
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
const productKeyOf = (sub) => sub?.productKey || 'wartung'
+const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
+// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
+const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
+// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
+// gefundenes Abo die aktuelle Fassung (F08)
+const endParagraphOf = (sub) => termsDocFor(sub ? sub.termsVersion : config.abo.termsVersion).endParagraph
/**
* Abo fuers Portal. events: Vertragsereignisse des Kunden (listContractEvents); es zaehlen die
@@ -411,15 +414,26 @@ async function customerNameOf(sub, fallback = '') {
return customer?.name || fallback || ''
}
+/**
+ * K01 an den Kunden: Vertragsbestaetigung je Produkt (Katalogname, Preis, Liste "Enthalten" ueber die Bloecke
+ * hosting/wartung) mit den Vertragsunterlagen der Fassung, der der Kunde zugestimmt hat (§ 312f BGB,
+ * contractAttachmentFor: v3 Archiv-PDF, v4 aktuelles PDF). Ohne lesbare Datei geht die Mail ohne Anhang raus.
+ */
async function sendContractConfirmation(subDoc, fallbackName = '') {
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
- const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
- const pdf = await readFile(pdfPath).catch(() => null)
+ const attachment = contractAttachmentFor(subDoc.termsVersion)
+ const pdf = await readFile(attachment.path).catch((err) => {
+ console.error(`[abo] Vertragsunterlagen ${attachment.filename} nicht lesbar:`, err.message)
+ return null
+ })
const lateStart = subDoc.startChoice === 'nach_frist'
+ const productKey = productKeyOf(subDoc)
await sendCustomerMail(subDoc.email, {
template: 'K01',
values: {
name: await customerNameOf(subDoc, fallbackName),
+ produkt: productLabel(productKey),
+ preis: `${fmtEuro(config.products[productKey].amountCents)} / Monat`,
angebot_text: subDoc.offerText || '',
vertragsschluss: fmtDate(subDoc.startedAt),
leistungsbeginn: fmtDate(lateStart ? subDoc.trialEnd : subDoc.startedAt),
@@ -430,12 +444,12 @@ async function sendContractConfirmation(subDoc, fallbackName = '') {
},
blocks: {
angebot: Boolean(subDoc.offerText),
+ hosting: productKey === 'hosting',
+ wartung: productKey === 'wartung',
sofortbeginn: !lateStart,
beginn_nach_widerrufsfrist: lateStart,
},
- attachments: pdf
- ? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
- : [],
+ attachments: pdf ? [{ filename: attachment.filename, content: pdf, contentType: 'application/pdf' }] : [],
})
await updateDocument(config.collections.subscriptions, subDoc.$id, {
confirmationSentAt: new Date().toISOString(),
@@ -450,16 +464,19 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
template: 'K02',
values: {
name: await customerNameOf(sub),
+ produkt: productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
vertragsende: fmtDate(endsAt),
+ vertragsende_paragraph: endParagraphOf(sub),
},
+ blocks: { wartung: isWartung(sub) },
})
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
}).catch(() => {})
await sendAdminNotice({
template: 'T05',
- values: { email: sub.email, stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt) },
+ values: { email: sub.email, produkt: productLabelOf(sub), stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt), vertragsende_paragraph: endParagraphOf(sub) },
})
}
@@ -525,6 +542,7 @@ async function handleStripeEvent(event) {
await sendAdminNotice({
template: 'T02',
values: {
+ produkt: productLabel(sub ? productKeyOf(sub) : obj.metadata?.productKey),
email: sub?.email || obj.customer_details?.email || '',
kunde_name: obj.customer_details?.name || obj.customer_details?.email || '',
appwrite_id: sub?.customerId || obj.client_reference_id || '',
@@ -599,7 +617,7 @@ async function handleStripeEvent(event) {
)
}
if (reactivated && sub) {
- await sendAdminNotice({ template: 'T06', values: { email: sub.email, stripe_abo_id: obj.id } })
+ await sendAdminNotice({ template: 'T06', values: { email: sub.email, produkt: productLabelOf(sub), stripe_abo_id: obj.id } })
}
return
}
@@ -607,7 +625,7 @@ async function handleStripeEvent(event) {
const sub = await syncSubscription(obj.id)
await sendAdminNotice({
template: 'T07',
- values: { email: sub?.email || '', stripe_abo_id: obj.id, status: obj.status },
+ values: { email: sub?.email || '', produkt: productLabelOf(sub), stripe_abo_id: obj.id, status: obj.status, vertragsende_paragraph: endParagraphOf(sub) },
})
return
}
@@ -888,6 +906,7 @@ router.post('/public/cancel', async (req, res) => {
email: d.email,
kundennummer: d.customerNumber,
vertrag: contractLabel(d.contract, sub),
+ produkt: productLabelOf(sub),
// Eine Verringerung des Speichers wirkt immer zum Ende des Abrechnungsmonats
wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
grund: d.reason,
@@ -903,11 +922,13 @@ router.post('/public/cancel', async (req, res) => {
await sendCustomerMail(d.email, {
template: 'K03',
- values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) },
+ values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt), vertragsende_paragraph: endParagraphOf(sub) },
blocks: {
kundennummer: Boolean(d.customerNumber),
grund: Boolean(d.reason),
abo: !storage,
+ // Stundenguthaben nur im Wartungs-Abo (Block innerhalb von abo)
+ wartung: !storage && isWartung(sub),
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
@@ -1070,6 +1091,7 @@ router.post('/public/withdraw', async (req, res) => {
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }),
+ produkt: productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
@@ -1091,6 +1113,7 @@ router.post('/public/withdraw', async (req, res) => {
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub),
+ produkt: productLabelOf(sub),
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js
index 8088049..20064b5 100644
--- a/server/services/planSwitch.js
+++ b/server/services/planSwitch.js
@@ -197,6 +197,7 @@ async function stripeContext(doc, customer) {
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),
@@ -297,7 +298,7 @@ async function sendSwitchConfirmation(contact, consent, facts, blocks) {
name: contact.name,
wechseltag: fmtDate(facts.switchedAt),
betrag_heute: fmtEuro(facts.amountDueCents),
- monatspreis: fmtEuro(config.products.wartung.amountCents),
+ monatspreis: fmtEuro(monthlyCentsOf(facts)),
mindestlaufzeit_bis: fmtDate(addMonths(new Date(facts.switchedAt), 12)),
bedingungen_version: consent.termsVersion,
zustimmung_zeitpunkt: fmtDateTime(consent.consentAt),
@@ -317,13 +318,19 @@ async function sendSwitchNotice(contact, subscriptionId, facts, blocks) {
stripe_abo_id: subscriptionId,
wechseltag: fmtDate(facts.switchedAt),
betrag_heute: fmtEuro(facts.amountDueCents),
- monatspreis: fmtEuro(config.products.wartung.amountCents),
+ monatspreis: fmtEuro(monthlyCentsOf(facts)),
},
blocks,
})
}
-const factsOf = (event) => ({ switchedAt: event.details?.switchedAt || event.consentAt, amountDueCents: event.details?.amountDueCents })
+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()
@@ -370,6 +377,8 @@ async function performSwitch(customerId, current, consent, prorationDate, expect
}
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',
@@ -421,6 +430,7 @@ async function performSwitch(customerId, current, consent, prorationDate, expect
details: {
from: 'hosting',
amountDueCents,
+ monthlyCents,
prorationDate,
switchedAt,
paymentMethod: ctx.paymentMethod,
@@ -440,10 +450,10 @@ async function performSwitch(customerId, current, consent, prorationDate, expect
})
const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' }
- const facts = { switchedAt, amountDueCents }
+ 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 })
+ 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 })
@@ -623,7 +633,7 @@ export async function handleSwitchPaymentFailed(invoice) {
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 }, { lastschrift_gescheitert: true })
+ await sendSwitchNotice(contact, subscriptionId, { switchedAt: d.switchedAt, amountDueCents: invoice.amount_due, monthlyCents: d.monthlyCents }, { lastschrift_gescheitert: true })
return { notified: true, eventId: event.$id }
})
}
@@ -636,7 +646,11 @@ export async function handleSwitchPaymentFailed(invoice) {
await sendSwitchNotice(
contact,
subscriptionId,
- { switchedAt: new Date(switchedAt).toISOString(), amountDueCents: invoice.amount_due },
+ {
+ 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 }
diff --git a/server/services/products.js b/server/services/products.js
index ab50d00..6fe53c6 100644
--- a/server/services/products.js
+++ b/server/services/products.js
@@ -28,12 +28,19 @@ export function getProduct(key) {
/**
* 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.
+ * 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 (typeof termsVersion === 'string' && termsVersion.endsWith('-v4')) {
- return { url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf' }
+ return { url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' }
}
- return { url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' }
+ return { 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')
diff --git a/test/aboRoutes.test.js b/test/aboRoutes.test.js
index f6bb28c..6a164e6 100644
--- a/test/aboRoutes.test.js
+++ b/test/aboRoutes.test.js
@@ -27,9 +27,17 @@ const { recordContractEvent, listContractEvents, eventIdFor } = await import('..
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 })
-after(() => Promise.all([app.close(), aw.close(), st.close()]))
+// 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
@@ -399,6 +407,48 @@ test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss,
}
})
+// 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, Stunde und beliebig viele Postfaecher
+ 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', 'beliebig viele Postfächer', '1 Stunde Arbeitszeit je bezahltem Monat', '(Version 2026-09-25-v3)']) {
+ assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
+ }
+ } finally {
+ await cleanupWebhook(subW)
+ }
+})
+
// 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 () => {
diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js
index 4a32a66..e03f073 100644
--- a/test/mailTemplates.test.js
+++ b/test/mailTemplates.test.js
@@ -1,11 +1,12 @@
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);
-// die Endfassung der Texte folgt in Task 12
+// Endfassung der Texte in Task 12 (T14 „Löschfristen“ kommt mit Task 21 dazu)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
@@ -67,7 +68,7 @@ test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden ', () => {
})
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/)
@@ -102,11 +103,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,',
@@ -266,11 +269,15 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
assert.match(speicher.text, /um 2 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/)
assert.doesNotMatch(speicher.text, /Wartungs-Abo\n|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/)
- assert.match(renderMail('K03', k03, { abo: true }).text, /Stundenguthaben verfällt/)
+ // Task 12: das Stundenguthaben gibt es nur im Wartungs-Abo (Block wartung innerhalb von abo)
+ assert.match(renderMail('K03', k03, { abo: true, wartung: true }).text, /Stundenguthaben verfällt/)
+ assert.doesNotMatch(renderMail('K03', k03, { abo: 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“/)
- assert.match(renderMail('K04', k04, { wechsel: true }).text, /bisherigen Preis/)
+ // 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/)
@@ -304,3 +311,137 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
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/)
+})
diff --git a/test/planSwitch.test.js b/test/planSwitch.test.js
index 6478f01..4ef2bb2 100644
--- a/test/planSwitch.test.js
+++ b/test/planSwitch.test.js
@@ -301,6 +301,32 @@ test('Wechsel: Stripe-Aufruf mit Anker jetzt, Metadaten, Ereignis', async () =>
assert.deepEqual(logs.warn.filter((w) => /Vorlage (K12|T12)/.test(w)), [])
})
+// Task 12 (Spec 3.5): {{monatspreis}} in K12 und T12 ist die Monatsgebühr des Wartungs-Abos mit gebuchtem
+// Zusatzspeicher; K12 sagt es im Block zusatzspeicher. Auch nach einem gescheiterten Wechsel (T12 aus dem Ereignis)
+test('K12/T12: Monatspreis mit gebuchtem Zusatzspeicher, auch im Hinweis zum gescheiterten Wechsel', async () => {
+ await resetHosting({ stripe: { storageUnits: 2 } })
+ st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: 'in_speicher', status: 'open', amount_due: 4300, hosted_invoice_url: 'https://pay.example/inv2' }) }
+ await switchToWartung('c1', consent, nowUnix())
+ const [k12] = mailsWithSubject(K12_SUBJECT)
+ assert.ok(k12.text.includes('52,00 € / Monat'), k12.text)
+ assert.ok(k12.text.includes('einschließlich Ihres gebuchten E-Mail-Zusatzspeichers'), k12.text)
+ assert.ok(k12.text.includes('43,00 €'))
+ assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), [])
+ const [ev] = await listContractEvents('c1')
+ assert.equal(ev.details.monthlyCents, 5200)
+ await markSwitchFailed(ev.$id, SWITCH_FAILED_REASONS.abgelaufen)
+ const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
+ assert.ok(t12.text.includes('52,00 €'), t12.text)
+ assert.ok(t12.text.includes('43,00 €'))
+ // Ohne Zusatzspeicher: 50,00 € und kein Block
+ await resetHosting()
+ st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
+ await switchToWartung('c1', consent, nowUnix())
+ const [plain] = mailsWithSubject(K12_SUBJECT)
+ assert.ok(plain.text.includes('50,00 € / Monat'), plain.text)
+ assert.ok(!plain.text.includes('einschließlich Ihres gebuchten'))
+})
+
test('Offene Rechnung liefert paymentUrl', async () => {
await resetHosting({ id: 'sub_h2', customerId: 'c2', mirror: hosting2Doc })
// Karte, Zahlung nicht sofort erfolgreich (3-D Secure): Stripe haelt die Aenderung in pending_update zurueck
diff --git a/test/products.test.js b/test/products.test.js
index 5beece3..2c50a32 100644
--- a/test/products.test.js
+++ b/test/products.test.js
@@ -115,14 +115,17 @@ test('priceIdsOfInvoice: eindeutige Preis-IDs, ohne Lücken', () => {
// Fix round 1 / B9: feste Form statt Einzelfelder
test('Vertragsunterlagen je Version', () => {
- assert.deepEqual(termsDocFor('2026-10-05-v4'), { url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf' })
+ // F08 (Task 12): endParagraph = Paragraph zum Vertragsende der Fassung (v4: § 9 ist der Wechsel, § 11 das Vertragsende)
+ assert.deepEqual(termsDocFor('2026-10-05-v4'), { url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11' })
assert.deepEqual(termsDocFor('2026-09-25-v3'), {
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
+ endParagraph: '§ 9',
})
assert.deepEqual(termsDocFor(null), {
url: '/vertragsunterlagen/v3/abo-bedingungen.html',
pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf',
+ endParagraph: '§ 9',
})
})
diff --git a/test/publicForms.test.js b/test/publicForms.test.js
index d1b2f65..e53082b 100644
--- a/test/publicForms.test.js
+++ b/test/publicForms.test.js
@@ -118,6 +118,32 @@ test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async (
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 Stundenguthaben gibt es nur im Wartungs-Abo
+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('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, Stundenguthaben
+ 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('Stundenguthaben'), 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' })
@@ -362,7 +388,7 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre
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, /bisherigen Preis/)
+ 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/)
diff --git a/test/support.test.js b/test/support.test.js
index 9926012..5123168 100644
--- a/test/support.test.js
+++ b/test/support.test.js
@@ -218,7 +218,7 @@ test('Mail-Senke: Kunden- und Team-Mails landen in der Senke, null schaltet zur
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', stripe_abo_id: 'sub_1' } }), { sent: true })
+ 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'])
|
|
|
|
| | | | |
| | |