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
+354 -163

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>
<tr><th style="width:28%">Bereich</th><th>Maßnahmen</th></tr>
<tr><td>Rechenzentrum und Zutritt</td><td>Betrieb im Rechenzentrum der Hetzner Online GmbH in Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert. Zutritt, Stromversorgung, Klimatisierung und Brandschutz regeln die Maßnahmen von Hetzner.</td></tr>
<tr><td>Zugang zu den Systemen</td><td>Serververwaltung nur 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>Ü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>
+61 -30
View File
@@ -579,10 +579,10 @@ async function pollChatBadge() {
// ---------------------------------------------------------------------------
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 E-Mail-Postfach', 'mit Ihrer eigenen Domain'],
['1 Stunde Arbeitszeit pro Monat', 'sammelt sich an, jede Stunde ist 12 Monate gültig'],
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'],
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
]
function fmtHours(min) {
@@ -598,6 +598,13 @@ function aboFeaturesHtml() {
).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 } = {}) {
const available = data.available
card.innerHTML = `
@@ -612,25 +619,38 @@ function renderAboOffer(card, data, { ended } = {}) {
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
<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>
</div>
<div class="abo-order hidden" id="abo-order">
<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">
<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>
<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>
<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>
<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>
<div class="abo-actions">
<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>`
@@ -647,9 +667,13 @@ function renderAboOffer(card, data, { ended } = {}) {
const errEl = card.querySelector('#abo-error')
hideError(errEl)
const acceptTerms = card.querySelector('#abo-terms').checked
const earlyStart = card.querySelector('#abo-early').checked
if (!acceptTerms || !earlyStart) {
showError(errEl, 'Bitte bestätigen Sie beide Punkte, um zu bestellen.')
const startChoice = card.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
if (!acceptTerms) {
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
}
btn.disabled = true
@@ -657,7 +681,7 @@ function renderAboOffer(card, data, { ended } = {}) {
try {
const { url } = await api('/api/abo/checkout', {
method: 'POST',
body: JSON.stringify({ acceptTerms, earlyStart, termsVersion: data.termsVersion }),
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion }),
})
window.location.href = url
} catch (err) {
@@ -671,45 +695,52 @@ function renderAboOffer(card, data, { ended } = {}) {
function renderAboActive(card, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] }
const statusLine = sub.cancelAtPeriodEnd
? `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>`
: sub.paymentProblem
? '<span class="pill pill-red">Zahlung offen</span>'
: `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>`
let statusLine
if (sub.serviceStartsAt) {
statusLine = `<span class="pill pill-muted">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>`
} else if (sub.cancelAtPeriodEnd) {
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) => `
<tr><td>${fmtDateShort(u.date)}</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) => `
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatliche Gutschrift</td>
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `gültig bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">Ihr Wartungs-Abo</p>
<h2 class="abo-title">Stundenkonto</h2>
<h2 class="abo-title">Stundenkontingent</h2>
</div>
<div>${statusLine}</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.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-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>
${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></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>
<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>
${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>
</div>
${usageRows || creditRows ? `
<details class="abo-history">
<summary>Verlauf anzeigen</summary>
<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>
</table>
</details>` : ''}
+15 -5
View File
@@ -146,8 +146,17 @@
.abo-history { margin-top: 14px; }
.abo-history summary { cursor: pointer; font-size: 14px; opacity: 0.85; }
.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 a { color: inherit; }
.portal-legal { margin-top: 40px; font-size: 13px; display: flex; gap: 18px; flex-wrap: wrap; align-items: center; }
.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>
</head>
<body class="portal-page dashboard-page">
@@ -213,9 +222,10 @@
</section>
<footer class="portal-legal">
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen</a>
<a href="/kuendigen.html">Verträge hier kündigen</a>
<a href="/widerrufen.html">Vertrag widerrufen</a>
<a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a>
<a class="legal-strong" 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>
</footer>
</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>
<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>
<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>
<li>Hetzner Online GmbH: Hosting und Datensicherung (Auftragsverarbeiter)</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>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
</ul>
+3 -3
View File
@@ -13,7 +13,7 @@
<a class="kal-brand" href="/admin.html" title="Zurück zum Admin-Panel">webklar</a>
<span class="kal-spacer"></span>
<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>
<span class="kal-stat">
@@ -33,7 +33,7 @@
<div class="kicker" id="hero-kicker">Ihr Projekt</div>
<div class="hero-price-row">
<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 class="hero-tiles">
<div class="tile">
@@ -152,7 +152,7 @@
<footer>
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>
</main>
+8 -6
View File
@@ -8,7 +8,8 @@
localStorage des Browsers. */
/* --------------------------------------------------------------- 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 = [
{
@@ -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 = {
justin: {
@@ -285,7 +287,7 @@ function totals() {
}
return {
sel, hrsNum, rateNum, netNum, grossNum: netNum * (1 + MWST / 100),
sel, hrsNum, rateNum, netNum,
mSum, askServer, monthlyItems, hasMonthly: monthlyItems.length > 0,
payTitle, payText, flagTitle, flagText, tone,
monthly: eur(mSum) + (askServer ? ' +' : ''),
@@ -389,7 +391,7 @@ function summaryText() {
lines.push('')
lines.push(`Summe: ${fmt(t.hrsNum)} Stunden`)
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}`)
if (t.hasMonthly) {
@@ -414,7 +416,7 @@ function summaryText() {
if (state.portalEmail) lines.push(`E-Mail: ${state.portalEmail}`)
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')
}
@@ -425,7 +427,7 @@ function renderNumbers(t) {
el('hdr-monthly').textContent = t.hasMonthly ? t.monthly : '–'
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-rate').textContent = `Stundensatz ${eur(t.rateNum)}/Std`
el('hero-monthly').textContent = t.hasMonthly ? t.monthly : '–'
+29 -32
View File
@@ -3,62 +3,59 @@
<head>
<meta charset="utf-8">
<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">
</head>
<body>
<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>
<div id="intro">
<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>
<button type="button" class="big-btn primary" id="start-btn">Verträge hier kündigen</button>
<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="ausserordentlich" data-text="Außerordentliche Kündigung"> Außerordentliche Kündigung aus wichtigem Grund</label>
<form id="decl-form" class="card" novalidate>
<fieldset>
<legend data-label-for="kind" data-label="Art der Kündigung">Art der Kündigung</legend>
<label class="radio"><input type="radio" name="kind" value="ordentlich" data-text="Ordentliche Kündigung" checked> Ordentliche Kündigung</label>
<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">
<label for="reason" data-label-for="reason" data-label="Kündigungsgrund">Kündigungsgrund</label>
<textarea id="reason" name="reason" maxlength="2000"></textarea>
</div>
<label for="effective" data-label-for="effective" data-label="Gewünschter Zeitpunkt">Zeitpunkt</label>
<select id="effective" name="effective">
<option value="nächstmöglicher Zeitpunkt">zum nächstmöglichen Zeitpunkt</option>
</select>
<label for="name" data-label-for="name" data-label="Name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="200">
<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>
<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>
<div id="done" class="card ok hidden">
<h2 style="margin-top:0">Ihre Kündigung ist eingegangen</h2>
<p>Eingang: <strong id="done-at"></strong></p>
<dl id="done-list"></dl>
<p>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>
</main>
<script src="/legal-form.js"></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>
</body>
</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; }
dt { color: #6b6b70; }
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; } }
+40 -20
View File
@@ -1,21 +1,34 @@
// Gemeinsame Logik fuer Kuendigungs- (§ 312k BGB) und Widerrufsbutton (§ 356a BGB).
// Oeffentlich, ohne Login. Zweistufig: Einstiegsbutton -> Formular -> Bestaetigungsbutton.
// Oeffentliche Erklaerungsseiten: Kuendigung (§ 312k BGB) und Widerruf (§ 356a BGB).
// Ohne Login erreichbar, Formular sofort sichtbar (keine Zwischenschritte).
// Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar.
function initLegalForm({ endpoint, fields }) {
const intro = document.getElementById('intro')
const form = document.getElementById('decl-form')
const done = document.getElementById('done')
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', () => {
intro.classList.add('hidden')
form.classList.remove('hidden')
form.querySelector('input').focus()
})
// Angemeldete Kunden nicht erneut identifizieren lassen: Felder vorbefuellen
fetch('/api/auth/me', { credentials: 'same-origin' })
.then((r) => (r.ok ? r.json() : null))
.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')
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', () => {
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()
errEl.classList.add('hidden')
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"]')
btn.disabled = true
try {
@@ -34,17 +48,21 @@ function initLegalForm({ endpoint, fields }) {
})
const data = await res.json().catch(() => ({}))
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 esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'medium' })
document.getElementById('done-at').textContent = at
document.getElementById('done-list').innerHTML = fields
.filter((f) => payload[f])
.map((f) => `<dt>${esc(form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f)}</dt><dd>${esc(
form.elements[f]?.type === 'radio' || form.elements[f] instanceof RadioNodeList
? form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
: payload[f]
)}</dd>`)
.join('')
const rows = [['Vertrag', 'WEBklar Wartungs-Abo']]
for (const f of fields) {
if (!payload[f] || f === 'effective') continue
const label = form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f
const value = form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
rows.push([label, value])
}
if (form.when) {
rows.push(['Zeitpunkt', payload.effective
? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)`
: 'zum nächstmöglichen Zeitpunkt'])
}
document.getElementById('done-list').innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')
form.classList.add('hidden')
done.classList.remove('hidden')
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -54,4 +72,6 @@ function initLegalForm({ endpoint, fields }) {
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.
</p>
<p class="login-hint">
<a href="/kuendigen.html" style="color:inherit">Verträge hier kündigen</a> ·
<a href="/widerrufen.html" style="color:inherit">Vertrag widerrufen</a> ·
<a href="/abo-bedingungen.html" style="color:inherit">Abo-Bedingungen</a>
<a href="/kuendigen.html" style="color:#fff;font-weight:700">Verträge hier kündigen</a> ·
<a href="/widerrufen.html" style="color:#fff;font-weight:700">Vertrag widerrufen</a><br>
<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>
</div>
</div>
+12 -17
View File
@@ -3,47 +3,42 @@
<head>
<meta charset="utf-8">
<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">
</head>
<body>
<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>
<div id="intro">
<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>
<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>
<form id="decl-form" class="card" novalidate>
<label for="name" data-label-for="name" data-label="Name">Name</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>Vertrag</label>
<input value="WEBklar Wartungs-Abo" disabled>
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional)</small></label>
<input id="customerNumber" name="customerNumber" maxlength="50">
<label>Vertrag</label>
<input value="Webklar Wartungs-Abo" disabled>
<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 id="email" name="email" type="email" autocomplete="email" required maxlength="255">
<p class="error hidden" id="form-error"></p>
<button type="submit" class="big-btn">Widerruf bestätigen</button>
<p class="muted">Sie erhalten sofort eine Eingangsbestätigung per E-Mail.</p>
</form>
<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>
<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>
</main>
<script src="/legal-form.js"></script>
<script>
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['name', 'email', 'customerNumber'] })
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['name', 'customerNumber', 'email'] })
</script>
</body>
</html>
+6
View File
@@ -103,6 +103,12 @@ await ensureCollection(
{ type: 'datetime', key: 'cancelRequestedAt' },
{ type: 'datetime', key: 'withdrawalRequestedAt' },
{ 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'] },
+10 -1
View File
@@ -70,10 +70,19 @@ export const config = {
},
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,
creditValidityMonths: 12,
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: {
+88 -35
View File
@@ -10,6 +10,7 @@ import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import {
ACTIVE_STATUSES,
cancelAtPeriodEnd,
cancelSubscriptionAt,
constructWebhookEvent,
createAboCheckout,
createBillingPortalUrl,
@@ -27,20 +28,39 @@ const fmtDate = (iso) =>
const fmtDateTime = (d) =>
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) {
if (!sub) return null
const active = ACTIVE_STATUSES.has(sub.status)
const withdrawalUntil = sub.startedAt ? addDays(sub.startedAt, config.abo.withdrawalDays) : null
return {
status: sub.status,
active: ACTIVE_STATUSES.has(sub.status),
active,
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
startedAt: sub.startedAt,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
endedAt: sub.endedAt,
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)
// ---------------------------------------------------------------------------
@@ -49,11 +69,14 @@ function publicSubscription(sub) {
router.get('/', requireSession, async (req, res) => {
try {
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 ledger = sub ? await getLedger(customerId, { active }) : null
return res.json({
available: isStripeConfigured(),
available: isStripeConfigured() && isPilotAllowed(customer),
termsVersion: config.abo.termsVersion,
subscription: publicSubscription(sub),
ledger,
@@ -69,16 +92,29 @@ router.post('/checkout', requireSession, async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
}
const { acceptTerms, earlyStart, termsVersion } = req.body || {}
if (acceptTerms !== true || earlyStart !== true) {
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' })
const { acceptTerms, startChoice, termsVersion } = req.body || {}
if (acceptTerms !== true) {
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) {
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
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 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 })
} catch (err) {
console.error('[abo] Checkout fehlgeschlagen:', err.message)
@@ -105,39 +141,46 @@ router.post('/portal', requireSession, async (req, res) => {
async function sendContractConfirmation(subDoc) {
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(
subDoc.email,
'Ihr Webklar Wartungs-Abo – Vertragsbestätigung',
'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung',
[
'Guten Tag,',
'',
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
'',
' Vertrag: Webklar Wartungs-Abo',
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar',
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)',
' Vertrag: WEBklar Wartungs-Abo',
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar',
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
'',
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`,
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.',
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
`Ihre Zustimmung zu den Vertragsbedingungen (Version ${subDoc.termsVersion || config.abo.termsVersion}) haben wir am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} erhalten.`,
consentLine,
'',
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang',
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`,
'Im Anhang finden Sie die vollständigen Vertragsunterlagen als PDF: Vertragsbedingungen mit Leistungsbeschreibung,',
'Widerrufsbelehrung mit Muster-Widerrufsformular und Auftragsverarbeitungsvertrag.',
'',
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
`Ihr Stundenkontingent sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Vertrag widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
`Verträge hier kündigen: ${config.publicBaseUrl}/kuendigen.html`,
'',
'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
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }]
pdf
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
: []
)
await updateDocument(config.collections.subscriptions, subDoc.$id, {
@@ -235,7 +278,7 @@ function validDeclaration(body) {
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
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()
let sub = null
let effectiveAt = null
try {
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) {
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
})
@@ -267,9 +316,11 @@ router.post('/public/cancel', async (req, res) => {
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
}
const effectiveText = sub
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).`
: 'Wir ordnen Ihre Kündigung manuell Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
const effectiveText = effectiveAt
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)} (Ende des Abrechnungsmonats).`
: 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', [
`Guten Tag ${d.name},`,
@@ -280,7 +331,7 @@ router.post('/public/cancel', async (req, res) => {
` E-Mail: ${d.email}`,
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
' 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,
'',
effectiveText,
@@ -295,9 +346,11 @@ router.post('/public/cancel', async (req, res) => {
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
d.reason ? `Grund: ${d.reason}` : '',
sub
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
effectiveAt
? `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!',
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,
termsAcceptedAt: meta.termsAcceptedAt || 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
}
@@ -259,7 +264,13 @@ async function getOrCreateStripeCustomer(customer) {
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)
if (current && ACTIVE_STATUSES.has(current.status)) {
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
@@ -271,35 +282,56 @@ export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartC
ensureAboPrice(),
getOrCreateStripeCustomer(customer),
])
const lateStart = consent.startChoice === 'nach_frist'
// Stripe-Metadaten: max. 500 Zeichen je Wert
const meta = {
webklarCustomerId: customer.$id,
email: customer.email || '',
termsVersion: config.abo.termsVersion,
termsAcceptedAt,
earlyStartConsentAt,
consentTextVersion: config.abo.termsVersion,
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',
customer: stripeCustomerId,
client_reference_id: customer.$id,
line_items: [{ price: priceId, quantity: 1 }],
payment_method_types: ['card', 'sepa_debit'],
locale: 'de',
billing_address_collection: 'required',
customer_update: { address: 'auto', name: 'auto' },
subscription_data: {
metadata: meta,
description: 'Webklar Wartungs-Abo',
description: config.stripe.productName,
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
},
metadata: meta,
custom_text: {
submit: {
message:
'Der Vertrag ist mit Ihrer Bestellung im Kundenportal bereits geschlossen. Hier hinterlegen Sie nur noch die Zahlungsart. 50,00 € monatlich, monatlich kündbar.',
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 der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, monatlich kündbar.',
},
},
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
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
}
@@ -326,6 +358,33 @@ export async function cancelAtPeriodEnd(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) {
if (!config.stripe.webhookSecret) {
const err = new Error('STRIPE_WEBHOOK_SECRET fehlt')