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