Wartungs-Abo v3: Mindestlaufzeit 12 Monate

- Bedingungen § 8: 12 Monate ab Leistungsbeginn, Kuendigung zum Ende bis
  1 Monat vorher, danach monatlich; Sonderkuendigung bei Geschaeftsaufgabe
  (1 Monat Frist), einvernehmliche Beendigung jederzeit; gilt beidseitig
- Kuendigungslogik computeCancellationDate (Mindestlaufzeit, verpasste
  Frist, Wunschtermin, Geschaeftsaufgabe, Monatsende) + cancelAt gespiegelt
- Stripe-Kundenportal: Kuendigung aus (Stripe kennt keine Mindestlaufzeit)
- Bestellseite, Abo-Karte, Vertragsbestaetigung, Checkout-Text, PDF,
  Leitfaden angepasst; termsVersion 2026-09-25-v3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 17:59:49 +00:00
1 parent 9e21aa0690
commit 9b563482ff
11 files changed
+112 -56

No files matched your search

Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -45,7 +45,7 @@
<div class="box"> <div class="box">
<strong>Das Wartungs-Abo auf einen Blick</strong><br> <strong>Das Wartungs-Abo auf einen Blick</strong><br>
50 € pro Monat, ohne Mindestlaufzeit, monatlich kündbar. Enthalten sind Betrieb und Pflege der Website, 50 € pro Monat, Mindestlaufzeit 12 Monate, danach monatlich kündbar. Enthalten sind Betrieb und Pflege der Website,
1 Domain, 1 E-Mail-Postfach und 1 Stunde Arbeitszeit pro Monat (sammelt sich an, jede Stunde 12 Monate gültig). 1 Domain, 1 E-Mail-Postfach und 1 Stunde Arbeitszeit pro Monat (sammelt sich an, jede Stunde 12 Monate gültig).
Alle Details stehen in den Abo-Bedingungen: project.webklar.com/abo-bedingungen.html Alle Details stehen in den Abo-Bedingungen: project.webklar.com/abo-bedingungen.html
</div> </div>
Binary file not shown.
+10 -8
View File
@@ -36,7 +36,7 @@
@media print { @media print {
@page { size: A4; margin: 18mm 17mm 18mm; @page { size: A4; margin: 18mm 17mm 18mm;
@top-left { content: "WEBklar Wartungs-Abo · Vertragsunterlagen"; font: 8pt sans-serif; color: #888; } @top-left { content: "WEBklar Wartungs-Abo · Vertragsunterlagen"; font: 8pt sans-serif; color: #888; }
@top-right { content: "Version 2 · Stand 25.09.2026"; font: 8pt sans-serif; color: #888; } @top-right { content: "Version 3 · Stand 25.09.2026"; font: 8pt sans-serif; color: #888; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } } @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
body { background: #fff; font-size: 10pt; line-height: 1.45; } body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; } main { max-width: none; padding: 0; }
@@ -58,7 +58,7 @@
<div class="brand">WEBKLAR</div> <div class="brand">WEBKLAR</div>
<h1>Wartungs-Abo</h1> <h1>Wartungs-Abo</h1>
<p class="meta">Vertragsbedingungen mit Leistungsbeschreibung, Widerrufsbelehrung und Auftragsverarbeitungsvertrag · Version 2 · Stand 25.09.2026</p> <p class="meta">Vertragsbedingungen mit Leistungsbeschreibung, Widerrufsbelehrung und Auftragsverarbeitungsvertrag · Version 3 · Stand 25.09.2026</p>
<p class="screen-only meta"><a href="/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf">Als PDF herunterladen und ausdrucken</a></p> <p class="screen-only meta"><a href="/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf">Als PDF herunterladen und ausdrucken</a></p>
<div class="box"> <div class="box">
@@ -69,8 +69,8 @@
<tr><td>Enthalten</td><td>Betrieb und Pflege Ihrer Website bzw. Web-Anwendung · 1 Domain · 1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat</td></tr> <tr><td>Enthalten</td><td>Betrieb und Pflege Ihrer Website bzw. Web-Anwendung · 1 Domain · 1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat</td></tr>
<tr><td>Stundenkontingent</td><td>1 Stunde je bezahltem Monat; nicht genutzte Zeit bleibt 12 Monate verfügbar. Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde.</td></tr> <tr><td>Stundenkontingent</td><td>1 Stunde je bezahltem Monat; nicht genutzte Zeit bleibt 12 Monate verfügbar. Mehraufwand nur nach Ihrer Freigabe, 100 € pro Stunde.</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr> <tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Laufzeit</td><td>unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des laufenden Abrechnungsmonats kündbar</td></tr> <tr><td>Laufzeit</td><td>Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</td></tr>
<tr><td>Kündigen</td><td>project.webklar.com/kuendigen.html (ohne Anmeldung), im Kundenportal oder per E-Mail</td></tr> <tr><td>Kündigen</td><td>project.webklar.com/kuendigen.html (ohne Anmeldung) oder per E-Mail</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen: project.webklar.com/widerrufen.html (siehe Widerrufsbelehrung)</td></tr> <tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen: project.webklar.com/widerrufen.html (siehe Widerrufsbelehrung)</td></tr>
</table> </table>
</div> </div>
@@ -210,11 +210,13 @@
<h2 id="p8">§ 8 Laufzeit und Kündigung</h2> <h2 id="p8">§ 8 Laufzeit und Kündigung</h2>
<ol class="abs"> <ol class="abs">
<li>Der Vertrag läuft auf unbestimmte Zeit. Eine Mindestlaufzeit gibt es nicht.</li> <li>Der Vertrag hat eine Mindestlaufzeit von zwölf Monaten ab Leistungsbeginn, bei sofortigem Beginn also ab Vertragsschluss, sonst ab Ablauf der Widerrufsfrist.</li>
<li>Der Kunde kann jederzeit zum Ende des laufenden Abrechnungsmonats kündigen. Bis dahin bleiben alle Leistungen verfügbar. Die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li> <li>Beide Seiten können zum Ende der Mindestlaufzeit mit einer Frist von einem Monat kündigen. Wird nicht fristgerecht gekündigt, läuft der Vertrag auf unbestimmte Zeit weiter.</li>
<li>Wir können mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen.</li> <li>Nach Ablauf der Mindestlaufzeit kann der Kunde jederzeit zum Ende des laufenden Abrechnungsmonats kündigen. Wir können dann mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen.</li>
<li>Der Kunde kann über die Schaltfläche „Verträge hier kündigen“ unter project.webklar.com/kuendigen.html kündigen (ohne Anmeldung), im Kundenportal unter „Abo verwalten“ oder in Textform, etwa per E-Mail.</li> <li>Gibt der Kunde den Geschäftsbetrieb, für den die Website bestimmt ist, dauerhaft auf, kann er auch während der Mindestlaufzeit mit einer Frist von einem Monat zum Ende eines Abrechnungsmonats kündigen. Auf Verlangen weist er die Geschäftsaufgabe nach, etwa durch die Gewerbeabmeldung.</li>
<li>Eine vorzeitige Beendigung im gegenseitigen Einvernehmen ist jederzeit möglich.</li>
<li>Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt unberührt.</li> <li>Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt unberührt.</li>
<li>Der Kunde kann über die Schaltfläche „Verträge hier kündigen“ unter project.webklar.com/kuendigen.html kündigen (ohne Anmeldung) oder in Textform, etwa per E-Mail. Den Zeitpunkt, zu dem der Vertrag endet, bestätigen wir unverzüglich per E-Mail. Bis dahin bleiben alle Leistungen verfügbar. Die Gebühr für den laufenden Monat wird nicht anteilig erstattet.</li>
</ol> </ol>
<h2 id="p9">§ 9 Vertragsende</h2> <h2 id="p9">§ 9 Vertragsende</h2>
+6 -5
View File
@@ -613,13 +613,13 @@ function renderAboOffer(card, data, { ended } = {}) {
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p> <p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h2 class="abo-title">Wartungs-Abo</h2> <h2 class="abo-title">Wartungs-Abo</h2>
</div> </div>
<div class="abo-price"><strong>50 €</strong><span>pro Monat · monatlich kündbar</span></div> <div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>
</div> </div>
${aboFeaturesHtml()} ${aboFeaturesHtml()}
<div class="abo-actions"> <div class="abo-actions">
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button> <button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
<span class="abo-note">${available <span class="abo-note">${available
? 'Keine Mindestlaufzeit. Zahlung per Kreditkarte oder SEPA-Lastschrift. Gemäß § 19 UStG ohne Umsatzsteuer.' ? 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Zahlung per Kreditkarte oder SEPA-Lastschrift. Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</span> : 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</span>
</div> </div>
<div class="abo-order hidden" id="abo-order"> <div class="abo-order hidden" id="abo-order">
@@ -642,7 +642,7 @@ function renderAboOffer(card, data, { ended } = {}) {
<tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb &amp; Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr> <tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb &amp; Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr>
<tr><td>Preis</td><td><strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten</td></tr> <tr><td>Preis</td><td><strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten</td></tr>
<tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr> <tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr>
<tr><td>Laufzeit</td><td>unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr> <tr><td>Laufzeit</td><td><strong>Mindestlaufzeit 12 Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr> <tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com</td></tr> <tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com</td></tr>
</table> </table>
@@ -700,7 +700,7 @@ function renderAboActive(card, data) {
if (sub.serviceStartsAt) { if (sub.serviceStartsAt) {
statusLine = `<span class="pill pill-muted">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>` statusLine = `<span class="pill pill-muted">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>`
} else if (sub.cancelAtPeriodEnd) { } else if (sub.cancelAtPeriodEnd) {
statusLine = `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>` statusLine = `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}</span>`
} else if (sub.paymentProblem) { } else if (sub.paymentProblem) {
statusLine = '<span class="pill pill-red">Zahlung offen</span>' statusLine = '<span class="pill pill-red">Zahlung offen</span>'
} else { } else {
@@ -731,11 +731,12 @@ function renderAboActive(card, data) {
${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''} ${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
</div> </div>
<p class="abo-note">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. <p class="abo-note">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
Aufträge schicken Sie uns einfach über den Chat.</p> Aufträge schicken Sie uns einfach über den Chat.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}</p>
<div class="abo-actions" style="margin-top:14px"> <div class="abo-actions" style="margin-top:14px">
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button> <button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button>
${sub.withdrawalOpen ? `<a class="abo-withdraw" href="/widerrufen.html">Vertrag widerrufen</a><span class="abo-note">möglich bis ${fmtDateShort(sub.withdrawalUntil)}</span>` : ''} ${sub.withdrawalOpen ? `<a class="abo-withdraw" href="/widerrufen.html">Vertrag widerrufen</a><span class="abo-note">möglich bis ${fmtDateShort(sub.withdrawalUntil)}</span>` : ''}
<a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a> <a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a>
${sub.cancelAtPeriodEnd ? '' : '<a class="abo-note" href="/kuendigen.html">Kündigen</a>'}
</div> </div>
${usageRows || creditRows ? ` ${usageRows || creditRows ? `
<details class="abo-history"> <details class="abo-history">
+1
View File
@@ -16,6 +16,7 @@
<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>
<label class="radio"><input type="radio" name="kind" value="ausserordentlich" data-text="Außerordentliche Kündigung"> Außerordentliche Kündigung aus wichtigem Grund</label> <label class="radio"><input type="radio" name="kind" value="ausserordentlich" data-text="Außerordentliche Kündigung"> Außerordentliche Kündigung aus wichtigem Grund</label>
<label class="radio"><input type="radio" name="kind" value="geschaeftsaufgabe" data-text="Sonderkündigung wegen Geschäftsaufgabe"> Sonderkündigung wegen Geschäftsaufgabe</label>
</fieldset> </fieldset>
<div id="reason-wrap" class="hidden"> <div id="reason-wrap" class="hidden">
<label for="reason" data-label-for="reason" data-label="Kündigungsgrund">Kündigungsgrund</label> <label for="reason" data-label-for="reason" data-label="Kündigungsgrund">Kündigungsgrund</label>
+2
View File
@@ -109,6 +109,8 @@ await ensureCollection(
{ type: 'string', key: 'consentIp', size: 64 }, { type: 'string', key: 'consentIp', size: 64 },
{ type: 'string', key: 'consentUserAgent', size: 300 }, { type: 'string', key: 'consentUserAgent', size: 300 },
{ type: 'string', key: 'consentTextVersion', size: 32 }, { type: 'string', key: 'consentTextVersion', size: 32 },
// v3: Beendigungszeitpunkt (Mindestlaufzeit / Wunschtermin)
{ type: 'datetime', key: 'cancelAt' },
], ],
[ [
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] }, { key: 'idx_customer', type: 'key', attributes: ['customerId'] },
+4 -2
View File
@@ -61,7 +61,7 @@ export const config = {
priceAmountCents: 5000, priceAmountCents: 5000,
productName: 'WEBklar Wartungs-Abo', productName: 'WEBklar Wartungs-Abo',
productDescription: productDescription:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Keine Mindestlaufzeit, monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.', 'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG', statementDescriptor: 'WEBKLAR WARTUNG',
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png', productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung // Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
@@ -72,7 +72,9 @@ export const config = {
abo: { abo: {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert) // Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-25-v2', termsVersion: '2026-09-25-v3',
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
minTermMonths: 12,
minutesPerMonth: 60, minutesPerMonth: 60,
creditValidityMonths: 12, creditValidityMonths: 12,
usageRoundingMinutes: 15, usageRoundingMinutes: 15,
+29 -9
View File
@@ -34,6 +34,21 @@ function addDays(iso, days) {
return d return d
} }
function addMonthsIso(iso, months) {
const d = new Date(iso)
const day = d.getUTCDate()
d.setUTCDate(1)
d.setUTCMonth(d.getUTCMonth() + months)
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
d.setUTCDate(Math.min(day, last))
return d.toISOString()
}
function minTermEndOf(sub) {
const start = sub?.trialEnd || sub?.startedAt
return start ? addMonthsIso(start, config.abo.minTermMonths) : null
}
function publicSubscription(sub) { function publicSubscription(sub) {
if (!sub) return null if (!sub) return null
const active = ACTIVE_STATUSES.has(sub.status) const active = ACTIVE_STATUSES.has(sub.status)
@@ -45,6 +60,8 @@ function publicSubscription(sub) {
startedAt: sub.startedAt, startedAt: sub.startedAt,
currentPeriodEnd: sub.currentPeriodEnd, currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd), cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
cancelAt: sub.cancelAt || (sub.cancelAtPeriodEnd ? sub.currentPeriodEnd : null),
minTermEnd: minTermEndOf(sub),
endedAt: sub.endedAt, endedAt: sub.endedAt,
priceAmount: sub.priceAmount, priceAmount: sub.priceAmount,
startChoice: sub.startChoice || null, startChoice: sub.startChoice || null,
@@ -162,7 +179,8 @@ async function sendContractConfirmation(subDoc) {
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)', ' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`, ` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`, ` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar', ` Laufzeit: Mindestlaufzeit 12 Monate bis ${fmtDate(minTermEndOf(subDoc))}; Kündigung zu diesem Termin bis einen Monat vorher,`,
' danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar',
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,', ' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)', ' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
'', '',
@@ -194,13 +212,13 @@ async function sendContractConfirmation(subDoc) {
/** Kuendigung ueber "Abo verwalten" (Stripe-Kundenportal): Bestaetigung in Textform + Hinweis an uns. */ /** Kuendigung ueber "Abo verwalten" (Stripe-Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
async function sendPortalCancellationConfirmation(sub, createdUnix) { async function sendPortalCancellationConfirmation(sub, createdUnix) {
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000) const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
const endsAt = sub.currentPeriodEnd const endsAt = sub.cancelAt || sub.currentPeriodEnd
await sendCustomerMail(sub.email, 'Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo', [ await sendCustomerMail(sub.email, 'Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo', [
'Guten Tag,', 'Guten Tag,',
'', '',
`wir bestätigen Ihre Kündigung des WEBklar Wartungs-Abos, eingegangen am ${fmtDateTime(receivedAt)} über „Abo verwalten“ im Kundenportal.`, `wir bestätigen Ihre Kündigung des WEBklar Wartungs-Abos, eingegangen am ${fmtDateTime(receivedAt)} über „Abo verwalten“ im Kundenportal.`,
'', '',
`Ihr Wartungs-Abo endet zum ${fmtDate(endsAt)} (Ende des Abrechnungsmonats). Bis dahin bleiben alle Leistungen verfügbar,`, `Ihr Wartungs-Abo endet zum ${fmtDate(endsAt)}. Bis dahin bleiben alle Leistungen verfügbar,`,
'danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.', 'danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.',
'Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.', 'Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.',
'', '',
@@ -330,7 +348,7 @@ function validDeclaration(body) {
name, name,
email, email,
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50), customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : '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) : '',
} }
@@ -355,9 +373,9 @@ router.post('/public/cancel', async (req, res) => {
let effectiveAt = null let effectiveAt = null
try { try {
sub = await findActiveSubscriptionByEmail(d.email) sub = await findActiveSubscriptionByEmail(d.email)
if (sub && d.kind === 'ordentlich') { 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') const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton', d.kind, receivedAt)
sub = result.subscription sub = result.subscription
effectiveAt = result.effectiveAt effectiveAt = result.effectiveAt
} }
@@ -370,8 +388,9 @@ router.post('/public/cancel', async (req, res) => {
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message) console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
} }
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
const effectiveText = effectiveAt const effectiveText = effectiveAt
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)} (Ende des Abrechnungsmonats).` ? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)}.${d.kind === 'geschaeftsaufgabe' ? ' Bitte senden Sie uns innerhalb von zwei Wochen einen Nachweis der Geschäftsaufgabe (z. B. Gewerbeabmeldung) an kenso.gri@gmail.com.' : ''}`
: d.kind === 'ausserordentlich' : d.kind === 'ausserordentlich'
? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.' ? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.'
: 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.' : 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
@@ -379,7 +398,7 @@ router.post('/public/cancel', async (req, res) => {
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [ await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
`Guten Tag ${d.name},`, `Guten Tag ${d.name},`,
'', '',
`wir haben Ihre ${d.kind === 'ausserordentlich' ? 'außerordentliche' : 'ordentliche'} Kündigung am ${fmtDateTime(receivedAt)} erhalten.`, `wir haben Ihre ${kindLabel} am ${fmtDateTime(receivedAt)} erhalten.`,
'', '',
` Name: ${d.name}`, ` Name: ${d.name}`,
` E-Mail: ${d.email}`, ` E-Mail: ${d.email}`,
@@ -398,7 +417,7 @@ router.post('/public/cancel', async (req, res) => {
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [ await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`, `${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`, `Art: ${kindLabel}, gewünschter Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
d.reason ? `Grund: ${d.reason}` : '', d.reason ? `Grund: ${d.reason}` : '',
effectiveAt effectiveAt
? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).` ? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
@@ -406,6 +425,7 @@ router.post('/public/cancel', async (req, res) => {
? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!` ? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!', : 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '', d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
d.kind === 'geschaeftsaufgabe' ? 'Sonderkündigung Geschäftsaufgabe -> Nachweis (z. B. Gewerbeabmeldung) anfordern/prüfen; ohne Nachweis Kündigungstermin in Stripe auf Ende der Mindestlaufzeit setzen.' : '',
]) ])
return res.json({ ok: true, receivedAt: receivedAt.toISOString() }) return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
+59 -31
View File
@@ -93,14 +93,8 @@ export async function ensurePortalConfiguration() {
invoice_history: { enabled: true }, invoice_history: { enabled: true },
payment_method_update: { enabled: true }, payment_method_update: { enabled: true },
customer_update: { enabled: true, allowed_updates: ['address', 'name', 'email'] }, customer_update: { enabled: true, allowed_updates: ['address', 'name', 'email'] },
subscription_cancel: { // Kuendigung nur ueber die eigene Kuendigungsseite: Stripe kennt die Mindestlaufzeit nicht
enabled: true, subscription_cancel: { enabled: false },
mode: 'at_period_end',
cancellation_reason: {
enabled: true,
options: ['too_expensive', 'unused', 'switched_service', 'other'],
},
},
}, },
metadata: { webklar: 'wartungs-abo' }, metadata: { webklar: 'wartungs-abo' },
}) })
@@ -170,6 +164,7 @@ export async function syncSubscription(subscriptionId) {
startedAt: toIso(sub.start_date), startedAt: toIso(sub.start_date),
currentPeriodEnd: toIso(periodEndOf(sub)), currentPeriodEnd: toIso(periodEndOf(sub)),
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at), cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at),
cancelAt: sub.cancel_at ? toIso(sub.cancel_at) : sub.cancel_at_period_end ? toIso(periodEndOf(sub)) : null,
canceledAt: toIso(sub.canceled_at), canceledAt: toIso(sub.canceled_at),
endedAt: toIso(sub.ended_at), endedAt: toIso(sub.ended_at),
termsVersion: meta.termsVersion || null, termsVersion: meta.termsVersion || null,
@@ -185,11 +180,50 @@ export async function syncSubscription(subscriptionId) {
} }
function addMonths(date, months) { function addMonths(date, months) {
// Monatsende abfangen (31.01. + 1 Monat = 28./29.02., wie bei Stripe)
const d = new Date(date) const d = new Date(date)
const day = d.getUTCDate()
d.setUTCDate(1)
d.setUTCMonth(d.getUTCMonth() + months) d.setUTCMonth(d.getUTCMonth() + months)
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
d.setUTCDate(Math.min(day, last))
return d return d
} }
/**
* Beendigungszeitpunkt nach § 8 der Bedingungen (Version 3):
* - Leistungsbeginn = Ende der Probezeit (Beginn nach Widerrufsfrist) bzw. Abo-Start
* - Mindestlaufzeit 12 Abrechnungsmonate; Kuendigung bis 1 Monat vor deren Ende wirkt zum Ende,
* spaeter eingehende Kuendigungen zum Ende des darauffolgenden Abrechnungsmonats
* - nach der Mindestlaufzeit: zum Ende des laufenden Abrechnungsmonats
* - Geschaeftsaufgabe: 1 Monat Frist zum Ende eines Abrechnungsmonats
* - Wunschtermin: fruehestens zum berechneten Zeitpunkt, sonst zum Ende des Abrechnungsmonats,
* in dem der Wunschtermin liegt
*/
export function computeCancellationDate({ serviceStart, receivedAt = new Date(), requestedDate = null, kind = 'ordentlich' }) {
const start = new Date(serviceStart)
const boundary = (k) => addMonths(start, k)
const firstBoundaryFrom = (minK, notBefore) => {
let k = Math.max(1, minK)
while (boundary(k) < notBefore) k += 1
return k
}
const minTermK = config.abo.minTermMonths
let k
if (kind === 'geschaeftsaufgabe') {
k = firstBoundaryFrom(1, addMonths(receivedAt, 1))
} else {
const deadline = addMonths(boundary(minTermK), -1)
k = receivedAt <= deadline ? minTermK : firstBoundaryFrom(minTermK + 1, receivedAt)
}
if (requestedDate && requestedDate > boundary(k)) k = firstBoundaryFrom(k, requestedDate)
return { effectiveAt: boundary(k), minTermEnd: boundary(minTermK), serviceStart: start }
}
export function serviceStartOf(sub) {
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
}
function subscriptionIdOfInvoice(invoice) { function subscriptionIdOfInvoice(invoice) {
return ( return (
invoice.subscription || invoice.subscription ||
@@ -333,8 +367,8 @@ export async function createAboCheckout(customer, consent) {
custom_text: { custom_text: {
submit: { submit: {
message: lateStart message: lateStart
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, monatlich kündbar.' ? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, monatlich kündbar.', : 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
}, },
}, },
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`, success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
@@ -382,35 +416,29 @@ export async function cancelAtPeriodEnd(subscriptionId, source = 'system') {
return syncSubscription(subscriptionId) return syncSubscription(subscriptionId)
} }
/** /** Kuendigung nach § 8 (Mindestlaufzeit, Wunschtermin, Geschaeftsaufgabe). */
* Kuendigung zu einem Wunschtermin: wirksam zum Ende des Abrechnungsmonats, in dem export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, source = 'kuendigungsbutton', kind = 'ordentlich', receivedAt = new Date()) {
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
*/
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, source = 'kuendigungsbutton') {
const stripe = getStripe() const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId) const sub = await stripe.subscriptions.retrieve(subscriptionId)
const { effectiveAt, minTermEnd } = computeCancellationDate({
serviceStart: serviceStartOf(sub),
receivedAt,
requestedDate,
kind,
})
const periodEnd = periodEndOf(sub) const periodEnd = periodEndOf(sub)
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null const effectiveUnix = Math.floor(effectiveAt.getTime() / 1000)
if (!requested || !periodEnd || requested <= periodEnd) { if (periodEnd && Math.abs(effectiveUnix - periodEnd) < 3600) {
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, metadata: { cancelSource: source } })
} else {
await stripe.subscriptions.update(subscriptionId, { await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true, cancel_at: effectiveUnix,
proration_behavior: 'none',
metadata: { cancelSource: source }, metadata: { cancelSource: source },
}) })
const doc = await syncSubscription(subscriptionId)
return { subscription: doc, effectiveAt: toIso(periodEnd) }
} }
let boundary = new Date(periodEnd * 1000)
for (let i = 0; i < 36 && boundary.getTime() / 1000 < requested; i += 1) {
boundary = addMonths(new Date(periodEnd * 1000), i + 1)
}
await stripe.subscriptions.update(subscriptionId, {
cancel_at: Math.floor(boundary.getTime() / 1000),
proration_behavior: 'none',
metadata: { cancelSource: source },
})
const doc = await syncSubscription(subscriptionId) const doc = await syncSubscription(subscriptionId)
return { subscription: doc, effectiveAt: boundary.toISOString() } return { subscription: doc, effectiveAt: effectiveAt.toISOString(), minTermEnd: minTermEnd.toISOString() }
} }
export function constructWebhookEvent(rawBody, signature) { export function constructWebhookEvent(rawBody, signature) {