Wartungs-Abo v2: Rechtsrunde umgesetzt

- Bestellseite: Pflichtangaben direkt ueber dem Button, Zahlungsarten,
  Wahl des Leistungsbeginns (sofort / nach Widerrufsfrist via 15 Tage
  Stripe-Probezeit), keine Vorauswahl
- Zustimmungsnachweis: Zeitpunkt, Version, Wahl, IP, Browser in Stripe-
  Metadaten und Appwrite (neue Attribute, Setup-Skript ergaenzt)
- Checkout: Karte + SEPA (Fallback nur Karte), korrigierter Hinweistext
- Vertragsbestaetigung mit PDF der Vertragsunterlagen statt HTML
- Kuendigen/Widerrufen: Formular direkt (keine Zwischenseite), Wunsch-
  termin, Druck/PDF der Bestaetigung, Vorbefuellung bei Login
- Widerrufsbutton im Abo waehrend der Frist, 'beginnt am'-Status
- Pilotbetrieb per ABO_PILOT_EMAILS
- Vertragsbedingungen v2, Datenschutzerklaerung, Kalkulator ohne MwSt
  und mit 100/80 EUR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 14:10:21 +00:00
1 parent c0214725c0
commit 4ab154bfa2
18 files changed
+349 -158

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -327,7 +327,7 @@
<table> <table>
<tr><th style="width:28%">Bereich</th><th>Maßnahmen</th></tr> <tr><th style="width:28%">Bereich</th><th>Maßnahmen</th></tr>
<tr><td>Rechenzentrum und Zutritt</td><td>Betrieb im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert. Zutritt, Stromversorgung, Klimatisierung und Brandschutz regeln die Maßnahmen von Hetzner.</td></tr> <tr><td>Rechenzentrum und Zutritt</td><td>Betrieb im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert. Zutritt, Stromversorgung, Klimatisierung und Brandschutz regeln die Maßnahmen von Hetzner.</td></tr>
<tr><td>Zugang zu den Systemen</td><td>Serververwaltung nur per SSH mit Schlüssel, keine Anmeldung per Passwort. Firewall, die nur benötigte Dienste zulässt. Automatische Sperre bei wiederholten Fehlversuchen. Zwei-Faktor-Anmeldung für die Verwaltungskonten bei Hetzner, Stripe und dem E-Mail-Anbieter. Persönliche Konten für jede berechtigte Person.</td></tr> <tr><td>Zugang zu den Systemen</td><td>Serververwaltung nur über verschlüsselte SSH-Verbindung, Zugang nur für die Gesellschafter. Automatische Sperre bei wiederholten Fehlversuchen. Firewall des Rechenzentrumsbetreibers, die nur die benötigten Dienste zulässt. Zwei-Faktor-Anmeldung für die Verwaltungskonten bei Hetzner und Stripe.</td></tr>
<tr><td>Zugriff auf Daten</td><td>Zugriff nur für die Gesellschafter und beauftragte Mitarbeiter, soweit ihre Aufgabe es erfordert. Im Kundenportal sieht jeder Kunde nur seine eigenen Daten. Datenbanken sind nur für die jeweilige Anwendung erreichbar.</td></tr> <tr><td>Zugriff auf Daten</td><td>Zugriff nur für die Gesellschafter und beauftragte Mitarbeiter, soweit ihre Aufgabe es erfordert. Im Kundenportal sieht jeder Kunde nur seine eigenen Daten. Datenbanken sind nur für die jeweilige Anwendung erreichbar.</td></tr>
<tr><td>Übertragung</td><td>Verschlüsselte Übertragung (TLS) für alle Websites, das Kundenportal und die Verwaltung. E-Mail-Versand und -Abruf nur verschlüsselt.</td></tr> <tr><td>Übertragung</td><td>Verschlüsselte Übertragung (TLS) für alle Websites, das Kundenportal und die Verwaltung. E-Mail-Versand und -Abruf nur verschlüsselt.</td></tr>
<tr><td>Nachvollziehbarkeit</td><td>Änderungen an Websites laufen über eine Versionsverwaltung (Git) und sind nachvollziehbar. Aufträge und Weisungen werden im Ticketsystem bzw. im Chat des Kundenportals dokumentiert.</td></tr> <tr><td>Nachvollziehbarkeit</td><td>Änderungen an Websites laufen über eine Versionsverwaltung (Git) und sind nachvollziehbar. Aufträge und Weisungen werden im Ticketsystem bzw. im Chat des Kundenportals dokumentiert.</td></tr>
+61 -30
View File
@@ -579,10 +579,10 @@ async function pollChatBadge() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ABO_FEATURES = [ const ABO_FEATURES = [
['Betrieb &amp; Pflege Ihrer Website/Anwendung', 'Server, Updates, Sicherheit, Backups, Monitoring – damit sie online bleibt'], ['Betrieb &amp; Pflege Ihrer Website/Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'],
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'], ['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain'], ['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'sammelt sich an, jede Stunde ist 12 Monate gültig'], ['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
] ]
function fmtHours(min) { function fmtHours(min) {
@@ -598,6 +598,13 @@ function aboFeaturesHtml() {
).join('')}</ul>` ).join('')}</ul>`
} }
function lateStartDate() {
// Beginn nach der 14-tägigen Widerrufsfrist = 15 Tage nach heute
const d = new Date()
d.setDate(d.getDate() + 15)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function renderAboOffer(card, data, { ended } = {}) { function renderAboOffer(card, data, { ended } = {}) {
const available = data.available const available = data.available
card.innerHTML = ` card.innerHTML = `
@@ -612,25 +619,38 @@ function renderAboOffer(card, data, { ended } = {}) {
<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. Gemäß § 19 UStG ohne Umsatzsteuer.' ? 'Keine Mindestlaufzeit. 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">
<h3>Ihre Bestellung</h3> <h3>Ihre Bestellung</h3>
<table class="abo-summary">
<tr><td>Webklar Wartungs-Abo<br><span class="muted">Leistungen laut <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungsbeschreibung</a></span></td><td>50,00 € / Monat</td></tr>
<tr><td>Laufzeit</td><td>unbestimmt, monatlich kündbar</td></tr>
<tr><td>Zahlung</td><td>monatlich im Voraus (Karte, SEPA u. a. über Stripe)</td></tr>
<tr><td><strong>Gesamt heute</strong><br><span class="muted">Kein Umsatzsteuerausweis (§ 19 UStG)</span></td><td><strong>50,00 €</strong></td></tr>
</table>
<label class="abo-check"><input type="checkbox" id="abo-terms"> <label class="abo-check"><input type="checkbox" id="abo-terms">
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen inkl. Leistungsbeschreibung</a> und die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> gelesen und stimme ihnen zu.</span></label> <span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>
<label class="abo-check"><input type="checkbox" id="abo-early">
<span>Ich verlange ausdrücklich, dass Webklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf Wertersatz für die bis dahin erbrachten Leistungen schulde.</span></label> <fieldset class="abo-start-choice">
<legend>Wann sollen wir mit der Leistung beginnen?</legend>
<label class="abo-check"><input type="radio" name="abo-start-choice" value="sofort">
<span><strong>Sofort.</strong> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle.</span></label>
<label class="abo-check"><input type="radio" name="abo-start-choice" value="nach_frist">
<span><strong>Nach Ablauf der 14-tägigen Widerrufsfrist</strong> (am ${lateStartDate()}). Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.</span></label>
<p class="abo-note">Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.</p>
</fieldset>
<div class="abo-summary-box">
<table class="abo-summary">
<tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb &amp; Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr>
<tr><td>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>Laufzeit</td><td>unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com</td></tr>
</table>
<p class="abo-note">Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a> · <a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a></p>
</div>
<p class="error hidden" id="abo-error"></p> <p class="error hidden" id="abo-error"></p>
<div class="abo-actions"> <div class="abo-actions">
<button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button> <button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button>
<span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart.</span> <span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.</span>
</div> </div>
</div>` </div>`
@@ -647,9 +667,13 @@ function renderAboOffer(card, data, { ended } = {}) {
const errEl = card.querySelector('#abo-error') const errEl = card.querySelector('#abo-error')
hideError(errEl) hideError(errEl)
const acceptTerms = card.querySelector('#abo-terms').checked const acceptTerms = card.querySelector('#abo-terms').checked
const earlyStart = card.querySelector('#abo-early').checked const startChoice = card.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
if (!acceptTerms || !earlyStart) { if (!acceptTerms) {
showError(errEl, 'Bitte bestätigen Sie beide Punkte, um zu bestellen.') showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
return
}
if (!startChoice) {
showError(errEl, 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.')
return return
} }
btn.disabled = true btn.disabled = true
@@ -657,7 +681,7 @@ function renderAboOffer(card, data, { ended } = {}) {
try { try {
const { url } = await api('/api/abo/checkout', { const { url } = await api('/api/abo/checkout', {
method: 'POST', method: 'POST',
body: JSON.stringify({ acceptTerms, earlyStart, termsVersion: data.termsVersion }), body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion }),
}) })
window.location.href = url window.location.href = url
} catch (err) { } catch (err) {
@@ -671,45 +695,52 @@ function renderAboOffer(card, data, { ended } = {}) {
function renderAboActive(card, data) { function renderAboActive(card, data) {
const sub = data.subscription const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] } const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] }
const statusLine = sub.cancelAtPeriodEnd let statusLine
? `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>` if (sub.serviceStartsAt) {
: sub.paymentProblem statusLine = `<span class="pill pill-muted">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>`
? '<span class="pill pill-red">Zahlung offen</span>' } else if (sub.cancelAtPeriodEnd) {
: `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>` statusLine = `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>`
} else if (sub.paymentProblem) {
statusLine = '<span class="pill pill-red">Zahlung offen</span>'
} else {
statusLine = `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>`
}
const usageRows = (l.usage || []).map((u) => ` const usageRows = (l.usage || []).map((u) => `
<tr><td>${fmtDateShort(u.date)}</td> <tr><td>${fmtDateShort(u.date)}</td>
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td> <td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
<td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Guthaben hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('') <td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Kontingent hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
const creditRows = (l.credits || []).slice().reverse().map((c) => ` const creditRows = (l.credits || []).slice().reverse().map((c) => `
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatliche Gutschrift</td> <tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `gültig bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('') <td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
card.innerHTML = ` card.innerHTML = `
<div class="abo-head"> <div class="abo-head">
<div> <div>
<p class="abo-kicker">Ihr Wartungs-Abo</p> <p class="abo-kicker">Ihr Wartungs-Abo</p>
<h2 class="abo-title">Stundenkonto</h2> <h2 class="abo-title">Stundenkontingent</h2>
</div> </div>
<div>${statusLine}</div> <div>${statusLine}</div>
</div> </div>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''} ${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.</div>` : ''}
<div class="abo-balance"> <div class="abo-balance">
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbares Guthaben</span></div> <div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
<div class="abo-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div> <div class="abo-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
${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 Monat kommt 1 Stunde dazu. Abgerechnet 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.</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>` : ''}
<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>
</div> </div>
${usageRows || creditRows ? ` ${usageRows || creditRows ? `
<details class="abo-history"> <details class="abo-history">
<summary>Verlauf anzeigen</summary> <summary>Verlauf anzeigen</summary>
<table class="simple-table"> <table class="simple-table">
<thead><tr><th>Datum</th><th>Vorgang</th><th>Stunden</th></tr></thead> <thead><tr><th>Datum</th><th>Vorgang</th><th>Zeit</th></tr></thead>
<tbody>${usageRows}${creditRows}</tbody> <tbody>${usageRows}${creditRows}</tbody>
</table> </table>
</details>` : ''} </details>` : ''}
+15 -5
View File
@@ -146,8 +146,17 @@
.abo-history { margin-top: 14px; } .abo-history { margin-top: 14px; }
.abo-history summary { cursor: pointer; font-size: 14px; opacity: 0.85; } .abo-history summary { cursor: pointer; font-size: 14px; opacity: 0.85; }
.abo-history table { margin-top: 10px; } .abo-history table { margin-top: 10px; }
.portal-legal { margin-top: 40px; font-size: 13px; opacity: 0.7; display: flex; gap: 18px; flex-wrap: wrap; } .portal-legal { margin-top: 40px; font-size: 13px; display: flex; gap: 18px; flex-wrap: wrap; align-items: center; }
.portal-legal a { color: inherit; } .portal-legal a { color: rgba(255,255,255,0.7); }
.portal-legal a.legal-strong { color: #fff; font-weight: 700; text-decoration: underline; }
.abo-start-choice { border: none; padding: 0; margin: 14px 0 8px; }
.abo-start-choice legend { font-weight: 700; margin-bottom: 4px; }
.abo-summary-box { margin: 16px 0 10px; padding: 14px 16px; border-radius: 14px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); }
.abo-summary-box .abo-summary { margin-bottom: 6px; }
.abo-summary td:first-child { width: 26%; opacity: 0.75; }
.abo-summary td:last-child { text-align: left; }
.abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; }
.abo-withdraw:hover { background: #fff; color: #1c1c1c; }
</style> </style>
</head> </head>
<body class="portal-page dashboard-page"> <body class="portal-page dashboard-page">
@@ -213,9 +222,10 @@
</section> </section>
<footer class="portal-legal"> <footer class="portal-legal">
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen</a> <a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a>
<a href="/kuendigen.html">Verträge hier kündigen</a> <a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
<a href="/widerrufen.html">Vertrag widerrufen</a> <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a> <a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a>
</footer> </footer>
</main> </main>
+3 -2
View File
@@ -86,7 +86,8 @@
<p>Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p> <p>Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
<h2>8. E-Mail</h2> <h2>8. E-Mail</h2>
<p>Vertragsbestätigungen, Benachrichtigungen und unsere sonstige Korrespondenz versenden wir per E-Mail über <span class="offen">[E-MAIL-ANBIETER, festzulegen]</span>. Die Übertragung ist verschlüsselt. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, bei sonstiger Korrespondenz lit. f DSGVO.</p> <p>Vertragsbestätigungen, Benachrichtigungen und unsere sonstige Korrespondenz versenden und empfangen wir derzeit über den E-Mail-Dienst der Porkbun LLC, 11575 SW Pacific Hwy PMB 40649, Tigard, OR 97223, USA. Dabei werden Inhalte und Verbindungsdaten der E-Mails in den USA verarbeitet. Für Porkbun besteht kein Angemessenheitsbeschluss der EU-Kommission. Die Übermittlung erfolgt, soweit sie zur Durchführung eines Vertrags mit Ihnen oder zur Beantwortung Ihrer Anfrage erforderlich ist (Art. 49 Abs. 1 lit. b DSGVO). Wir stellen unseren E-Mail-Dienst in Kürze auf die Hetzner Online GmbH (Rechenzentrum Nürnberg, Deutschland) um.</p>
<p>Die Übertragung ist verschlüsselt. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, bei sonstiger Korrespondenz lit. f DSGVO. Die E-Mail-Postfächer, die wir Kunden im Rahmen des Wartungs-Abos bereitstellen, liegen bereits bei der Hetzner Online GmbH in Deutschland.</p>
<h2>9. Buchhaltung und Rechnungen</h2> <h2>9. Buchhaltung und Rechnungen</h2>
<p>Rechnungen erstellen wir mit einer Buchhaltungssoftware, die wir selbst auf unserem Server betreiben, bzw. für das Wartungs-Abo über Stripe. Soweit erforderlich, geben wir Buchhaltungsdaten an unsere Steuerberatung und an Finanzbehörden weiter. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO.</p> <p>Rechnungen erstellen wir mit einer Buchhaltungssoftware, die wir selbst auf unserem Server betreiben, bzw. für das Wartungs-Abo über Stripe. Soweit erforderlich, geben wir Buchhaltungsdaten an unsere Steuerberatung und an Finanzbehörden weiter. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO.</p>
@@ -102,7 +103,7 @@
<ul> <ul>
<li>Hetzner Online GmbH: Hosting und Datensicherung (Auftragsverarbeiter)</li> <li>Hetzner Online GmbH: Hosting und Datensicherung (Auftragsverarbeiter)</li>
<li>Stripe: Zahlungsabwicklung (Abschnitt 7)</li> <li>Stripe: Zahlungsabwicklung (Abschnitt 7)</li>
<li>unser E-Mail-Anbieter (Abschnitt 8)</li> <li>Porkbun LLC: unser E-Mail-Dienst bis zur Umstellung (Abschnitt 8)</li>
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li> <li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li> <li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
</ul> </ul>
+3 -3
View File
@@ -13,7 +13,7 @@
<a class="kal-brand" href="/admin.html" title="Zurück zum Admin-Panel">webklar</a> <a class="kal-brand" href="/admin.html" title="Zurück zum Admin-Panel">webklar</a>
<span class="kal-spacer"></span> <span class="kal-spacer"></span>
<span class="kal-stat"> <span class="kal-stat">
<span class="kal-stat-lbl">Netto</span> <span class="kal-stat-lbl">Projektpreis</span>
<span class="kal-stat-val big" id="hdr-net">0 €</span> <span class="kal-stat-val big" id="hdr-net">0 €</span>
</span> </span>
<span class="kal-stat"> <span class="kal-stat">
@@ -33,7 +33,7 @@
<div class="kicker" id="hero-kicker">Ihr Projekt</div> <div class="kicker" id="hero-kicker">Ihr Projekt</div>
<div class="hero-price-row"> <div class="hero-price-row">
<div class="hero-price" id="hero-net">0 €</div> <div class="hero-price" id="hero-net">0 €</div>
<div class="hero-price-sub" id="hero-sub">netto · 0 € brutto inkl. 19 % MwSt.</div> <div class="hero-price-sub" id="hero-sub">Endpreis · gemäß § 19 UStG ohne Umsatzsteuer</div>
</div> </div>
<div class="hero-tiles"> <div class="hero-tiles">
<div class="tile"> <div class="tile">
@@ -152,7 +152,7 @@
<footer> <footer>
webklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · 0170 4969375<br> webklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · 0170 4969375<br>
Alle Preise netto zzgl. gesetzlicher Umsatzsteuer. Stundenwerte sind Richtwerte. Alle Preise sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). Stundenwerte sind Richtwerte.
</footer> </footer>
</main> </main>
+8 -6
View File
@@ -8,7 +8,8 @@
localStorage des Browsers. */ localStorage des Browsers. */
/* --------------------------------------------------------------- Katalog --- */ /* --------------------------------------------------------------- Katalog --- */
const MWST = 19 // Kleinunternehmer (§ 19 UStG): alle Preise sind Endpreise, keine Umsatzsteuer
const USt_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung).'
const CATS = [ const CATS = [
{ {
@@ -118,7 +119,8 @@ const CATS = [
}, },
] ]
const STAFFEL = [{ max: 5, r: 150 }, { max: 15, r: 130 }, { max: 40, r: 115 }, { max: 1e9, r: 100 }] // Stundensatz: 100 € für kleine Aufträge, ab 10 Stunden (größeres Projekt) 80 €
const STAFFEL = [{ max: 9.99, r: 100 }, { max: 1e9, r: 80 }]
const PEOPLE = { const PEOPLE = {
justin: { justin: {
@@ -285,7 +287,7 @@ function totals() {
} }
return { return {
sel, hrsNum, rateNum, netNum, grossNum: netNum * (1 + MWST / 100), sel, hrsNum, rateNum, netNum,
mSum, askServer, monthlyItems, hasMonthly: monthlyItems.length > 0, mSum, askServer, monthlyItems, hasMonthly: monthlyItems.length > 0,
payTitle, payText, flagTitle, flagText, tone, payTitle, payText, flagTitle, flagText, tone,
monthly: eur(mSum) + (askServer ? ' +' : ''), monthly: eur(mSum) + (askServer ? ' +' : ''),
@@ -389,7 +391,7 @@ function summaryText() {
lines.push('') lines.push('')
lines.push(`Summe: ${fmt(t.hrsNum)} Stunden`) lines.push(`Summe: ${fmt(t.hrsNum)} Stunden`)
lines.push(`Stundensatz: ${eur(t.rateNum)}`) lines.push(`Stundensatz: ${eur(t.rateNum)}`)
lines.push(`Projektpreis: ${eur(t.netNum)} netto (${eur(t.grossNum)} brutto inkl. ${MWST} % MwSt.)`) lines.push(`Projektpreis: ${eur(t.netNum)} (Endpreis, ohne Umsatzsteuer)`)
lines.push('', 'ZAHLUNG', `${t.payTitle} — ${t.payText}`) lines.push('', 'ZAHLUNG', `${t.payTitle} — ${t.payText}`)
if (t.hasMonthly) { if (t.hasMonthly) {
@@ -414,7 +416,7 @@ function summaryText() {
if (state.portalEmail) lines.push(`E-Mail: ${state.portalEmail}`) if (state.portalEmail) lines.push(`E-Mail: ${state.portalEmail}`)
if (state.portalPass) lines.push(`Passwort: ${state.portalPass}`) if (state.portalPass) lines.push(`Passwort: ${state.portalPass}`)
} }
lines.push('', 'Alle Preise netto zzgl. gesetzlicher Umsatzsteuer.') lines.push('', `Alle Preise sind Endpreise. ${USt_HINWEIS}`)
return lines.join('\n') return lines.join('\n')
} }
@@ -425,7 +427,7 @@ function renderNumbers(t) {
el('hdr-monthly').textContent = t.hasMonthly ? t.monthly : '–' el('hdr-monthly').textContent = t.hasMonthly ? t.monthly : '–'
el('hero-net').textContent = eur(t.netNum) el('hero-net').textContent = eur(t.netNum)
el('hero-sub').textContent = `netto · ${eur(t.grossNum)} brutto inkl. ${MWST} % MwSt.` el('hero-sub').textContent = 'Endpreis · gemäß § 19 UStG ohne Umsatzsteuer'
el('hero-hrs').textContent = `${fmt(t.hrsNum)} Std` el('hero-hrs').textContent = `${fmt(t.hrsNum)} Std`
el('hero-rate').textContent = `Stundensatz ${eur(t.rateNum)}/Std` el('hero-rate').textContent = `Stundensatz ${eur(t.rateNum)}/Std`
el('hero-monthly').textContent = t.hasMonthly ? t.monthly : '–' el('hero-monthly').textContent = t.hasMonthly ? t.monthly : '–'
+27 -30
View File
@@ -3,62 +3,59 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verträge hier kündigen – Webklar</title> <title>Verträge hier kündigen – WEBklar</title>
<link rel="stylesheet" href="/legal-form.css"> <link rel="stylesheet" href="/legal-form.css">
</head> </head>
<body> <body>
<main> <main>
<nav><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a></nav> <div class="brandline">WEBKLAR</div>
<h1>Verträge hier kündigen</h1> <h1>Verträge hier kündigen</h1>
<div id="intro"> <form id="decl-form" class="card" novalidate>
<p>Hier können Sie Ihr Webklar Wartungs-Abo kündigen, auch ohne Anmeldung. Eine ordentliche Kündigung wirkt zum Ende des bereits bezahlten Monats.</p> <fieldset>
<button type="button" class="big-btn primary" id="start-btn">Verträge hier kündigen</button> <legend data-label-for="kind" data-label="Art der Kündigung">Art der Kündigung</legend>
<p class="muted">Alternativ im Kundenportal unter „Abo verwalten" oder formlos per E-Mail an kenso.gri@gmail.com.</p>
</div>
<form id="decl-form" class="card hidden" novalidate>
<label for="name" data-label-for="name" data-label="Name">Name <small>(Pflicht)</small></label>
<input id="name" name="name" autocomplete="name" required maxlength="200">
<label for="email" data-label-for="email" data-label="E-Mail">E-Mail-Adresse, mit der Sie das Abo abgeschlossen haben <small>(Pflicht)</small></label>
<input id="email" name="email" type="email" autocomplete="email" required maxlength="255">
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional, hilft bei der Zuordnung)</small></label>
<input id="customerNumber" name="customerNumber" maxlength="50">
<label>Vertrag</label>
<input value="Webklar Wartungs-Abo" disabled>
<label data-label-for="kind" data-label="Art der Kündigung">Art der 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="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>
</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>
<textarea id="reason" name="reason" maxlength="2000"></textarea> <textarea id="reason" name="reason" maxlength="2000"></textarea>
</div> </div>
<label for="effective" data-label-for="effective" data-label="Gewünschter Zeitpunkt">Zeitpunkt</label> <label for="name" data-label-for="name" data-label="Name">Name</label>
<select id="effective" name="effective"> <input id="name" name="name" autocomplete="name" required maxlength="200">
<option value="nächstmöglicher Zeitpunkt">zum nächstmöglichen Zeitpunkt</option>
</select> <label for="email" data-label-for="email" data-label="E-Mail für die Bestätigung">E-Mail-Adresse <small>(dorthin schicken wir die Bestätigung)</small></label>
<input id="email" name="email" type="email" autocomplete="email" required maxlength="255">
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional)</small></label>
<input id="customerNumber" name="customerNumber" maxlength="50">
<label>Vertrag</label>
<input value="WEBklar Wartungs-Abo" disabled>
<fieldset>
<legend>Zeitpunkt</legend>
<label class="radio"><input type="radio" name="when" value="naechstmoeglich" checked> zum nächstmöglichen Zeitpunkt</label>
<label class="radio"><input type="radio" name="when" value="datum"> zu einem bestimmten Datum</label>
<input type="date" id="effective-date" class="hidden">
</fieldset>
<p class="error hidden" id="form-error"></p> <p class="error hidden" id="form-error"></p>
<button type="submit" class="big-btn">Jetzt kündigen</button> <button type="submit" class="big-btn">Jetzt kündigen</button>
<p class="muted">Sie erhalten sofort eine Eingangsbestätigung per E-Mail mit Datum und Uhrzeit sowie dem Zeitpunkt, zu dem der Vertrag endet.</p>
</form> </form>
<div id="done" class="card ok hidden"> <div id="done" class="card ok hidden">
<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>Eine Bestätigung mit dem Vertragsende haben wir an Ihre E-Mail-Adresse geschickt. Bitte speichern oder drucken Sie diese Seite für Ihre Unterlagen.</p> <p>Die Bestätigung mit dem Zeitpunkt, zu dem Ihr Vertrag endet, haben wir an Ihre E-Mail-Adresse geschickt.</p>
<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: ['name', 'email', 'customerNumber', 'kind', 'reason', 'effective'] }) initLegalForm({ endpoint: '/api/abo/public/cancel', fields: ['kind', 'reason', 'name', 'email', 'customerNumber', 'effective'] })
</script> </script>
</body> </body>
</html> </html>
+6
View File
@@ -21,3 +21,9 @@ textarea { min-height: 80px; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 12px 0; } dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 12px 0; }
dt { color: #6b6b70; } dt { color: #6b6b70; }
dd { margin: 0; } dd { margin: 0; }
.brandline { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; margin-bottom: 8px; }
fieldset { border: none; padding: 0; margin: 0; }
legend { font-weight: 600; font-size: 14px; margin: 14px 0 4px; }
#effective-date { margin-top: 6px; max-width: 220px; }
.print-btn { margin-top: 14px; background: #fff; color: #1d1d1f; border: 1px solid #cfcac2; border-radius: 10px; padding: 10px 16px; font: inherit; cursor: pointer; }
@media print { .print-btn, .no-print { display: none !important; } body { background: #fff; } .card { border: none; } }
+39 -19
View File
@@ -1,21 +1,34 @@
// Gemeinsame Logik fuer Kuendigungs- (§ 312k BGB) und Widerrufsbutton (§ 356a BGB). // Oeffentliche Erklaerungsseiten: Kuendigung (§ 312k BGB) und Widerruf (§ 356a BGB).
// Oeffentlich, ohne Login. Zweistufig: Einstiegsbutton -> Formular -> Bestaetigungsbutton. // Ohne Login erreichbar, Formular sofort sichtbar (keine Zwischenschritte).
// Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar.
function initLegalForm({ endpoint, fields }) { function initLegalForm({ endpoint, fields }) {
const intro = document.getElementById('intro')
const form = document.getElementById('decl-form') const form = document.getElementById('decl-form')
const done = document.getElementById('done') const done = document.getElementById('done')
const errEl = document.getElementById('form-error') const errEl = document.getElementById('form-error')
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
document.getElementById('start-btn').addEventListener('click', () => { // Angemeldete Kunden nicht erneut identifizieren lassen: Felder vorbefuellen
intro.classList.add('hidden') fetch('/api/auth/me', { credentials: 'same-origin' })
form.classList.remove('hidden') .then((r) => (r.ok ? r.json() : null))
form.querySelector('input').focus() .then((me) => {
const c = me?.customer
if (!c) return
if (form.elements.name && !form.elements.name.value) form.elements.name.value = c.name || ''
if (form.elements.email && !form.elements.email.value) form.elements.email.value = c.email || ''
if (form.elements.customerNumber && !form.elements.customerNumber.value) form.elements.customerNumber.value = c.code || ''
}) })
.catch(() => {})
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'))
)
const dateInput = document.getElementById('effective-date')
form.querySelectorAll('[name="when"]').forEach((r) =>
r.addEventListener('change', () => { r.addEventListener('change', () => {
reasonWrap?.classList.toggle('hidden', form.kind.value !== 'ausserordentlich') const onDate = form.when.value === 'datum'
dateInput?.classList.toggle('hidden', !onDate)
if (onDate) dateInput?.focus()
}) })
) )
@@ -23,7 +36,8 @@ function initLegalForm({ endpoint, fields }) {
ev.preventDefault() ev.preventDefault()
errEl.classList.add('hidden') errEl.classList.add('hidden')
const payload = {} const payload = {}
for (const f of fields) payload[f] = form.elements[f]?.value?.trim?.() ?? form.elements[f]?.value ?? '' for (const f of fields) payload[f] = String(form.elements[f]?.value ?? '').trim()
if (form.when?.value === 'datum') payload.effective = dateInput?.value || ''
const btn = form.querySelector('button[type="submit"]') const btn = form.querySelector('button[type="submit"]')
btn.disabled = true btn.disabled = true
try { try {
@@ -34,17 +48,21 @@ function initLegalForm({ endpoint, fields }) {
}) })
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
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: 'short' }) const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'medium' })
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
document.getElementById('done-at').textContent = at document.getElementById('done-at').textContent = at
document.getElementById('done-list').innerHTML = fields const rows = [['Vertrag', 'WEBklar Wartungs-Abo']]
.filter((f) => payload[f]) for (const f of fields) {
.map((f) => `<dt>${esc(form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f)}</dt><dd>${esc( if (!payload[f] || f === 'effective') continue
form.elements[f]?.type === 'radio' || form.elements[f] instanceof RadioNodeList const label = form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f
? form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f] const value = form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
: payload[f] rows.push([label, value])
)}</dd>`) }
.join('') if (form.when) {
rows.push(['Zeitpunkt', payload.effective
? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)`
: 'zum nächstmöglichen Zeitpunkt'])
}
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')
done.classList.remove('hidden') done.classList.remove('hidden')
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -54,4 +72,6 @@ function initLegalForm({ endpoint, fields }) {
btn.disabled = false btn.disabled = false
} }
}) })
document.getElementById('print-btn')?.addEventListener('click', () => window.print())
} }
+5 -3
View File
@@ -55,9 +55,11 @@
Zugangsdaten erhältst du von Webklar. Passwortänderungen erfolgen über das Ticketsystem. Zugangsdaten erhältst du von Webklar. Passwortänderungen erfolgen über das Ticketsystem.
</p> </p>
<p class="login-hint"> <p class="login-hint">
<a href="/kuendigen.html" style="color:inherit">Verträge hier kündigen</a> · <a href="/kuendigen.html" style="color:#fff;font-weight:700">Verträge hier kündigen</a> ·
<a href="/widerrufen.html" style="color:inherit">Vertrag widerrufen</a> · <a href="/widerrufen.html" style="color:#fff;font-weight:700">Vertrag widerrufen</a><br>
<a href="/abo-bedingungen.html" style="color:inherit">Abo-Bedingungen</a> <a href="/abo-bedingungen.html" style="color:inherit">Vertragsbedingungen</a> ·
<a href="/datenschutz.html" style="color:inherit">Datenschutz</a> ·
<a href="https://webklar.com/impressum" style="color:inherit">Impressum</a>
</p> </p>
</div> </div>
</div> </div>
+12 -17
View File
@@ -3,47 +3,42 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vertrag widerrufen – Webklar</title> <title>Vertrag widerrufen – WEBklar</title>
<link rel="stylesheet" href="/legal-form.css"> <link rel="stylesheet" href="/legal-form.css">
</head> </head>
<body> <body>
<main> <main>
<nav><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html#widerruf">Widerrufsbelehrung</a></nav> <div class="brandline">WEBKLAR</div>
<h1>Vertrag widerrufen</h1> <h1>Vertrag widerrufen</h1>
<div id="intro"> <form id="decl-form" class="card" novalidate>
<p>Als Verbraucher können Sie Ihr Webklar Wartungs-Abo innerhalb von 14 Tagen nach Vertragsschluss ohne Angabe von Gründen widerrufen. Einzelheiten stehen in der <a href="/abo-bedingungen.html#widerruf">Widerrufsbelehrung</a>.</p> <label for="name" data-label-for="name" data-label="Name">Name</label>
<button type="button" class="big-btn primary" id="start-btn">Vertrag widerrufen</button>
</div>
<form id="decl-form" class="card hidden" novalidate>
<label for="name" data-label-for="name" data-label="Name">Name <small>(Pflicht)</small></label>
<input id="name" name="name" autocomplete="name" required maxlength="200"> <input id="name" name="name" autocomplete="name" required maxlength="200">
<label for="email" data-label-for="email" data-label="E-Mail">E-Mail-Adresse, mit der Sie das Abo abgeschlossen haben <small>(Pflicht)</small></label> <label>Vertrag</label>
<input id="email" name="email" type="email" autocomplete="email" required maxlength="255"> <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">
<label>Vertrag</label> <label for="email" data-label-for="email" data-label="E-Mail für die Eingangsbestätigung">E-Mail-Adresse <small>(dorthin schicken wir die Eingangsbestätigung)</small></label>
<input value="Webklar Wartungs-Abo" disabled> <input id="email" name="email" type="email" autocomplete="email" required maxlength="255">
<p class="error hidden" id="form-error"></p> <p class="error hidden" id="form-error"></p>
<button type="submit" class="big-btn">Widerruf bestätigen</button> <button type="submit" class="big-btn">Widerruf bestätigen</button>
<p class="muted">Sie erhalten sofort eine Eingangsbestätigung per E-Mail.</p>
</form> </form>
<div id="done" class="card ok hidden"> <div id="done" class="card ok hidden">
<h2 style="margin-top:0">Ihr Widerruf ist eingegangen</h2> <h2 style="margin-top:0">Ihr Widerruf ist bei uns 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>Eine Eingangsbestätigung haben wir an Ihre E-Mail-Adresse geschickt. Es erfolgen keine weiteren Abbuchungen. Die Erstattung rechnen wir gesondert ab.</p> <p>Eine Eingangsbestätigung mit diesen Angaben haben wir an Ihre E-Mail-Adresse geschickt.</p>
<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/withdraw', fields: ['name', 'email', 'customerNumber'] }) initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['name', 'customerNumber', 'email'] })
</script> </script>
</body> </body>
</html> </html>
+6
View File
@@ -103,6 +103,12 @@ await ensureCollection(
{ type: 'datetime', key: 'cancelRequestedAt' }, { type: 'datetime', key: 'cancelRequestedAt' },
{ type: 'datetime', key: 'withdrawalRequestedAt' }, { type: 'datetime', key: 'withdrawalRequestedAt' },
{ type: 'datetime', key: 'confirmationSentAt' }, { type: 'datetime', key: 'confirmationSentAt' },
// v2 (25.09.2026): Wahl des Leistungsbeginns + Nachweis der Zustimmung
{ type: 'string', key: 'startChoice', size: 16 },
{ type: 'datetime', key: 'trialEnd' },
{ type: 'string', key: 'consentIp', size: 64 },
{ type: 'string', key: 'consentUserAgent', size: 300 },
{ type: 'string', key: 'consentTextVersion', size: 32 },
], ],
[ [
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] }, { key: 'idx_customer', type: 'key', attributes: ['customerId'] },
+10 -1
View File
@@ -70,10 +70,19 @@ export const config = {
}, },
abo: { abo: {
termsVersion: '2026-09-25', // Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-25-v2',
minutesPerMonth: 60, minutesPerMonth: 60,
creditValidityMonths: 12, creditValidityMonths: 12,
usageRoundingMinutes: 15, usageRoundingMinutes: 15,
withdrawalDays: 14,
// "Beginn nach Ablauf der Widerrufsfrist": 15 Tage Probezeit ohne Leistung und ohne Abbuchung
lateStartTrialDays: 15,
// Pilotbetrieb: nur diese Kunden-E-Mails duerfen bestellen (leer = alle)
pilotEmails: (process.env.ABO_PILOT_EMAILS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
}, },
smtp: { smtp: {
+86 -33
View File
@@ -10,6 +10,7 @@ import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { import {
ACTIVE_STATUSES, ACTIVE_STATUSES,
cancelAtPeriodEnd, cancelAtPeriodEnd,
cancelSubscriptionAt,
constructWebhookEvent, constructWebhookEvent,
createAboCheckout, createAboCheckout,
createBillingPortalUrl, createBillingPortalUrl,
@@ -27,20 +28,39 @@ const fmtDate = (iso) =>
const fmtDateTime = (d) => const fmtDateTime = (d) =>
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' }) new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
function addDays(iso, days) {
const d = new Date(iso)
d.setUTCDate(d.getUTCDate() + days)
return d
}
function publicSubscription(sub) { function publicSubscription(sub) {
if (!sub) return null if (!sub) return null
const active = ACTIVE_STATUSES.has(sub.status)
const withdrawalUntil = sub.startedAt ? addDays(sub.startedAt, config.abo.withdrawalDays) : null
return { return {
status: sub.status, status: sub.status,
active: ACTIVE_STATUSES.has(sub.status), active,
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid', paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
startedAt: sub.startedAt, startedAt: sub.startedAt,
currentPeriodEnd: sub.currentPeriodEnd, currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd), cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
endedAt: sub.endedAt, endedAt: sub.endedAt,
priceAmount: sub.priceAmount, priceAmount: sub.priceAmount,
startChoice: sub.startChoice || null,
// Beginn nach Ablauf der Widerrufsfrist: Leistung startet mit Ende der Probezeit
serviceStartsAt: sub.status === 'trialing' ? sub.trialEnd : null,
// Widerrufsbutton im Portal nur waehrend der Frist hervorheben
withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()),
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
} }
} }
function isPilotAllowed(customer) {
if (!config.abo.pilotEmails.length) return true
return config.abo.pilotEmails.includes(String(customer?.email || '').toLowerCase())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Kunde (eingeloggt) // Kunde (eingeloggt)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -49,11 +69,14 @@ function publicSubscription(sub) {
router.get('/', requireSession, async (req, res) => { router.get('/', requireSession, async (req, res) => {
try { try {
const customerId = getSessionCustomerId(req) const customerId = getSessionCustomerId(req)
const sub = await getCurrentSubscription(customerId) const [sub, customer] = await Promise.all([
getCurrentSubscription(customerId),
getDocument(config.collections.customers, customerId).catch(() => null),
])
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status)) const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
const ledger = sub ? await getLedger(customerId, { active }) : null const ledger = sub ? await getLedger(customerId, { active }) : null
return res.json({ return res.json({
available: isStripeConfigured(), available: isStripeConfigured() && isPilotAllowed(customer),
termsVersion: config.abo.termsVersion, termsVersion: config.abo.termsVersion,
subscription: publicSubscription(sub), subscription: publicSubscription(sub),
ledger, ledger,
@@ -69,16 +92,29 @@ router.post('/checkout', requireSession, async (req, res) => {
if (req.session.viewAs) { if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' }) return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
} }
const { acceptTerms, earlyStart, termsVersion } = req.body || {} const { acceptTerms, startChoice, termsVersion } = req.body || {}
if (acceptTerms !== true || earlyStart !== true) { if (acceptTerms !== true) {
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' }) return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
}
if (startChoice !== 'sofort' && startChoice !== 'nach_frist') {
return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' })
} }
if (termsVersion !== config.abo.termsVersion) { if (termsVersion !== config.abo.termsVersion) {
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
} }
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req)) const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
if (!isStripeConfigured() || !isPilotAllowed(customer)) {
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
}
// Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser
const now = new Date().toISOString() const now = new Date().toISOString()
const url = await createAboCheckout(customer, { termsAcceptedAt: now, earlyStartConsentAt: now }) const url = await createAboCheckout(customer, {
termsAcceptedAt: now,
startChoice,
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
consentIp: req.ip,
consentUserAgent: req.get('user-agent') || '',
})
return res.json({ url }) return res.json({ url })
} catch (err) { } catch (err) {
console.error('[abo] Checkout fehlgeschlagen:', err.message) console.error('[abo] Checkout fehlgeschlagen:', err.message)
@@ -105,39 +141,46 @@ router.post('/portal', requireSession, async (req, res) => {
async function sendContractConfirmation(subDoc) { async function sendContractConfirmation(subDoc) {
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
const terms = await readFile(path.join(publicDir, 'abo-bedingungen.html'), 'utf8').catch(() => null) const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
const pdf = await readFile(pdfPath).catch(() => null)
const lateStart = subDoc.startChoice === 'nach_frist'
const consentLine = lateStart
? `Sie haben gewählt, dass wir erst nach Ablauf der Widerrufsfrist beginnen. Leistungsbeginn und erste Abbuchung: ${fmtDate(subDoc.trialEnd)}. Bis dahin erbringen wir keine Leistungen und buchen nichts ab.`
: 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahlen.'
await sendCustomerMail( await sendCustomerMail(
subDoc.email, subDoc.email,
'Ihr Webklar Wartungs-Abo – Vertragsbestätigung', 'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung',
[ [
'Guten Tag,', 'Guten Tag,',
'', '',
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:', 'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
'', '',
' Vertrag: Webklar Wartungs-Abo', ' Vertrag: WEBklar Wartungs-Abo',
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)', ' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`, ` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar', ` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,', ' Laufzeit: unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar',
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)', ' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
'', '',
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`, `Ihre Zustimmung zu den Vertragsbedingungen (Version ${subDoc.termsVersion || config.abo.termsVersion}) haben wir am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} erhalten.`,
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.', consentLine,
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
'', '',
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang', 'Im Anhang finden Sie die vollständigen Vertragsunterlagen als PDF: Vertragsbedingungen mit Leistungsbeschreibung,',
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`, 'Widerrufsbelehrung mit Muster-Widerrufsformular und Auftragsverarbeitungsvertrag.',
'', '',
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`, `Ihr Stundenkontingent sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`, `Vertrag widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
`Verträge hier kündigen: ${config.publicBaseUrl}/kuendigen.html`,
'', '',
'Viele Grüße', 'Viele Grüße',
'Ihr Webklar-Team', 'Ihr WEBklar-Team',
'', '',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · kenso.gri@gmail.com', 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com',
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
], ],
terms pdf
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }] ? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
: [] : []
) )
await updateDocument(config.collections.subscriptions, subDoc.$id, { await updateDocument(config.collections.subscriptions, subDoc.$id, {
@@ -235,7 +278,7 @@ function validDeclaration(body) {
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50), customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich', kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
reason: String(body?.reason || '').trim().slice(0, 2000), reason: String(body?.reason || '').trim().slice(0, 2000),
effective: String(body?.effective || '').trim().slice(0, 40), effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '',
} }
} }
@@ -255,10 +298,16 @@ router.post('/public/cancel', async (req, res) => {
const receivedAt = new Date() const receivedAt = new Date()
let sub = null let sub = null
let effectiveAt = null
try { try {
sub = await findActiveSubscriptionByEmail(d.email) sub = await findActiveSubscriptionByEmail(d.email)
if (sub && d.kind === 'ordentlich') {
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested)
sub = result.subscription
effectiveAt = result.effectiveAt
}
if (sub) { if (sub) {
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
await updateDocument(config.collections.subscriptions, sub.$id, { await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(), cancelRequestedAt: receivedAt.toISOString(),
}) })
@@ -267,9 +316,11 @@ 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 effectiveText = sub const effectiveText = effectiveAt
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).` ? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)} (Ende des Abrechnungsmonats).`
: 'Wir ordnen Ihre Kündigung manuell Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.' : d.kind === 'ausserordentlich'
? '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.'
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},`,
@@ -280,7 +331,7 @@ router.post('/public/cancel', async (req, res) => {
` E-Mail: ${d.email}`, ` E-Mail: ${d.email}`,
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null, d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
' Vertrag: Webklar Wartungs-Abo', ' Vertrag: Webklar Wartungs-Abo',
` Gewünschter Zeitpunkt: ${d.effective || 'nächstmöglicher Zeitpunkt'}`, ` Gewünschter Zeitpunkt: ${d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt'}`,
d.reason ? ` Grund: ${d.reason}` : null, d.reason ? ` Grund: ${d.reason}` : null,
'', '',
effectiveText, effectiveText,
@@ -295,8 +346,10 @@ router.post('/public/cancel', async (req, res) => {
`${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: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
d.reason ? `Grund: ${d.reason}` : '', d.reason ? `Grund: ${d.reason}` : '',
sub effectiveAt
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).` ? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
: sub
? `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.' : '',
]) ])
+67 -8
View File
@@ -174,6 +174,11 @@ export async function syncSubscription(subscriptionId) {
termsVersion: meta.termsVersion || null, termsVersion: meta.termsVersion || null,
termsAcceptedAt: meta.termsAcceptedAt || null, termsAcceptedAt: meta.termsAcceptedAt || null,
earlyStartConsentAt: meta.earlyStartConsentAt || null, earlyStartConsentAt: meta.earlyStartConsentAt || null,
startChoice: meta.startChoice || null,
trialEnd: toIso(sub.trial_end),
consentIp: meta.consentIp || null,
consentUserAgent: meta.consentUserAgent || null,
consentTextVersion: meta.consentTextVersion || null,
}) })
return doc return doc
} }
@@ -259,7 +264,13 @@ async function getOrCreateStripeCustomer(customer) {
return created.id return created.id
} }
export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartConsentAt }) { /**
* Stripe Checkout fuer das Abo. Der Kunde hat seine Bestellung vorher im Portal
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist'
* (15 Tage Probezeit ohne Leistung und ohne Abbuchung, Start nach der Widerrufsfrist).
*/
export async function createAboCheckout(customer, consent) {
const current = await getCurrentSubscription(customer.$id) const current = await getCurrentSubscription(customer.$id)
if (current && ACTIVE_STATUSES.has(current.status)) { if (current && ACTIVE_STATUSES.has(current.status)) {
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.') const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
@@ -271,35 +282,56 @@ export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartC
ensureAboPrice(), ensureAboPrice(),
getOrCreateStripeCustomer(customer), getOrCreateStripeCustomer(customer),
]) ])
const lateStart = consent.startChoice === 'nach_frist'
// Stripe-Metadaten: max. 500 Zeichen je Wert
const meta = { const meta = {
webklarCustomerId: customer.$id, webklarCustomerId: customer.$id,
email: customer.email || '', email: customer.email || '',
termsVersion: config.abo.termsVersion, termsVersion: config.abo.termsVersion,
termsAcceptedAt, consentTextVersion: config.abo.termsVersion,
earlyStartConsentAt, termsAcceptedAt: consent.termsAcceptedAt,
startChoice: consent.startChoice,
earlyStartConsentAt: consent.earlyStartConsentAt || '',
consentIp: String(consent.consentIp || '').slice(0, 64),
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
} }
const session = await stripe.checkout.sessions.create({ const params = {
mode: 'subscription', mode: 'subscription',
customer: stripeCustomerId, customer: stripeCustomerId,
client_reference_id: customer.$id, client_reference_id: customer.$id,
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
payment_method_types: ['card', 'sepa_debit'],
locale: 'de', locale: 'de',
billing_address_collection: 'required', billing_address_collection: 'required',
customer_update: { address: 'auto', name: 'auto' }, customer_update: { address: 'auto', name: 'auto' },
subscription_data: { subscription_data: {
metadata: meta, metadata: meta,
description: 'Webklar Wartungs-Abo', description: config.stripe.productName,
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
}, },
metadata: meta, metadata: meta,
custom_text: { custom_text: {
submit: { submit: {
message: message: lateStart
'Der Vertrag ist mit Ihrer Bestellung im Kundenportal bereits geschlossen. Hier hinterlegen Sie nur noch die Zahlungsart. 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, 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.',
}, },
}, },
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`, success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`, cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
}) }
let session
try {
session = await stripe.checkout.sessions.create(params)
} catch (err) {
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten
if (/sepa_debit|payment_method_types/i.test(err.message || '')) {
console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message)
session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] })
} else {
throw err
}
}
return session.url return session.url
} }
@@ -326,6 +358,33 @@ export async function cancelAtPeriodEnd(subscriptionId) {
return syncSubscription(subscriptionId) return syncSubscription(subscriptionId)
} }
/**
* Kuendigung zu einem Wunschtermin: wirksam zum Ende des Abrechnungsmonats, in dem
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
*/
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null) {
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId)
const periodEnd = periodEndOf(sub)
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
if (!requested || !periodEnd || requested <= periodEnd) {
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })
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',
})
const doc = await syncSubscription(subscriptionId)
return { subscription: doc, effectiveAt: boundary.toISOString() }
}
export function constructWebhookEvent(rawBody, signature) { export function constructWebhookEvent(rawBody, signature) {
if (!config.stripe.webhookSecret) { if (!config.stripe.webhookSecret) {
const err = new Error('STRIPE_WEBHOOK_SECRET fehlt') const err = new Error('STRIPE_WEBHOOK_SECRET fehlt')