diff --git a/docs/WEBklar-Datenschutzerklaerung.pdf b/docs/WEBklar-Datenschutzerklaerung.pdf index 43f8168..9486c6a 100644 Binary files a/docs/WEBklar-Datenschutzerklaerung.pdf and b/docs/WEBklar-Datenschutzerklaerung.pdf differ diff --git a/docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf b/docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf index 3e6afe8..68b2665 100644 Binary files a/docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf and b/docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf differ diff --git a/public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf b/public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf index 2a62689..ff3b568 100644 Binary files a/public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf and b/public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf differ diff --git a/public/abo-bedingungen.html b/public/abo-bedingungen.html index 60b1673..4cacb84 100644 --- a/public/abo-bedingungen.html +++ b/public/abo-bedingungen.html @@ -327,7 +327,7 @@ - + diff --git a/public/app.js b/public/app.js index 1d9b840..187ff2f 100644 --- a/public/app.js +++ b/public/app.js @@ -579,10 +579,10 @@ async function pollChatBadge() { // --------------------------------------------------------------------------- const ABO_FEATURES = [ - ['Betrieb & Pflege Ihrer Website/Anwendung', 'Server, Updates, Sicherheit, Backups, Monitoring – damit sie online bleibt'], + ['Betrieb & 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('')}` } +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 } = {}) {
${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.'}
BereichMaßnahmen
Rechenzentrum und ZutrittBetrieb 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.
Zugang zu den SystemenSerververwaltung 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.
Zugang zu den SystemenSerververwaltung 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.
Zugriff auf DatenZugriff 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.
ÜbertragungVerschlüsselte Übertragung (TLS) für alle Websites, das Kundenportal und die Verwaltung. E-Mail-Versand und -Abruf nur verschlüsselt.
NachvollziehbarkeitÄnderungen an Websites laufen über eine Versionsverwaltung (Git) und sind nachvollziehbar. Aufträge und Weisungen werden im Ticketsystem bzw. im Chat des Kundenportals dokumentiert.
- - - - -
Webklar Wartungs-Abo
Leistungen laut Leistungsbeschreibung
50,00 € / Monat
Laufzeitunbestimmt, monatlich kündbar
Zahlungmonatlich im Voraus (Karte, SEPA u. a. über Stripe)
Gesamt heute
Kein Umsatzsteuerausweis (§ 19 UStG)
50,00 €
- + Ich habe die Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag gelesen und stimme ihnen zu. Die Widerrufsbelehrung habe ich zur Kenntnis genommen. + +
+ Wann sollen wir mit der Leistung beginnen? + + +

Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.

+
+ +
+ + + + + + + +
LeistungWEBklar Wartungs-Abo: Betrieb & Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)
Preis50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten
Abrechnungmonatlich im Voraus, erstmals zum Leistungsbeginn
Laufzeitunbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar
ZahlungKreditkarte oder SEPA-Lastschrift über Stripe
AnbieterWEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com
+

Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: Vertragsbedingungen · Datenschutz

+
- Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. + Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.
` @@ -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 - ? `gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}` - : sub.paymentProblem - ? 'Zahlung offen' - : `aktiv nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}` + let statusLine + if (sub.serviceStartsAt) { + statusLine = `beginnt am ${fmtDateShort(sub.serviceStartsAt)}` + } else if (sub.cancelAtPeriodEnd) { + statusLine = `gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}` + } else if (sub.paymentProblem) { + statusLine = 'Zahlung offen' + } else { + statusLine = `aktiv nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}` + } const usageRows = (l.usage || []).map((u) => ` ${fmtDateShort(u.date)} ${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` #${escapeHtml(u.woid)}` : ''} - −${fmtHours(u.minutes)}${u.overageMinutes ? ` +${fmtHours(u.overageMinutes)} extra` : ''}`).join('') + −${fmtHours(u.minutes)}${u.overageMinutes ? ` +${fmtHours(u.overageMinutes)} extra` : ''}`).join('') const creditRows = (l.credits || []).slice().reverse().map((c) => ` - ${fmtDateShort(c.grantedAt)}Monatliche Gutschrift - +${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `gültig bis ${fmtDateShort(c.expiresAt)}`}`).join('') + ${fmtDateShort(c.grantedAt)}Monatskontingent + +${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}`).join('') card.innerHTML = `

Ihr Wartungs-Abo

-

Stundenkonto

+

Stundenkontingent

${statusLine}
${sub.paymentProblem ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.
' : ''} + ${sub.serviceStartsAt ? `
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.
` : ''}
-
${fmtHours(l.balanceMinutes)}verfügbares Guthaben
+
${fmtHours(l.balanceMinutes)}verfügbare Zeit
${fmtHours(l.usedMinutes)}bisher genutzt
${l.nextExpiry ? `
${fmtHours(l.nextExpiry.minutes)}verfallen am ${fmtDateShort(l.nextExpiry.at)}
` : ''}
-

Jeden Monat kommt 1 Stunde dazu. Abgerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. +

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.

+ ${sub.withdrawalOpen ? `Vertrag widerrufenmöglich bis ${fmtDateShort(sub.withdrawalUntil)}` : ''} Leistungen & Bedingungen
${usageRows || creditRows ? `
Verlauf anzeigen - + ${usageRows}${creditRows}
DatumVorgangStunden
DatumVorgangZeit
` : ''} diff --git a/public/dashboard.html b/public/dashboard.html index 49f3bb9..c5bab79 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -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; } @@ -213,9 +222,10 @@ diff --git a/public/datenschutz.html b/public/datenschutz.html index a7e0a66..b2930b1 100644 --- a/public/datenschutz.html +++ b/public/datenschutz.html @@ -86,7 +86,8 @@

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.

8. E-Mail

-

Vertragsbestätigungen, Benachrichtigungen und unsere sonstige Korrespondenz versenden wir per E-Mail über [E-MAIL-ANBIETER, festzulegen]. Die Übertragung ist verschlüsselt. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, bei sonstiger Korrespondenz lit. f DSGVO.

+

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.

+

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.

9. Buchhaltung und Rechnungen

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.

@@ -102,7 +103,7 @@ diff --git a/public/kalkulation.html b/public/kalkulation.html index 8c1bbc3..bdb0f25 100644 --- a/public/kalkulation.html +++ b/public/kalkulation.html @@ -13,7 +13,7 @@ webklar - Netto + Projektpreis 0 € @@ -33,7 +33,7 @@
Ihr Projekt
0 €
-
netto · 0 € brutto inkl. 19 % MwSt.
+
Endpreis · gemäß § 19 UStG ohne Umsatzsteuer
@@ -152,7 +152,7 @@
webklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · 0170 4969375
- Alle Preise netto zzgl. gesetzlicher Umsatzsteuer. Stundenwerte sind Richtwerte. + Alle Preise sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). Stundenwerte sind Richtwerte.
diff --git a/public/kalkulation.js b/public/kalkulation.js index c0c99d1..c8f4644 100644 --- a/public/kalkulation.js +++ b/public/kalkulation.js @@ -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 : '–' diff --git a/public/kuendigen.html b/public/kuendigen.html index b9e326c..317f027 100644 --- a/public/kuendigen.html +++ b/public/kuendigen.html @@ -3,62 +3,59 @@ - Verträge hier kündigen – Webklar + Verträge hier kündigen – WEBklar
- +
WEBKLAR

Verträge hier kündigen

-
-

Hier können Sie Ihr Webklar Wartungs-Abo kündigen, auch ohne Anmeldung. Eine ordentliche Kündigung wirkt zum Ende des bereits bezahlten Monats.

- -

Alternativ im Kundenportal unter „Abo verwalten" oder formlos per E-Mail an kenso.gri@gmail.com.

-
- -
diff --git a/public/legal-form.css b/public/legal-form.css index 57e7bfc..2b1fa52 100644 --- a/public/legal-form.css +++ b/public/legal-form.css @@ -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; } } diff --git a/public/legal-form.js b/public/legal-form.js index 6eb830a..a11dd88 100644 --- a/public/legal-form.js +++ b/public/legal-form.js @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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) => `
${esc(form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f)}
${esc( - form.elements[f]?.type === 'radio' || form.elements[f] instanceof RadioNodeList - ? form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f] - : payload[f] - )}
`) - .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]) => `
${esc(k)}
${esc(v)}
`).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()) } diff --git a/public/login.html b/public/login.html index 25d5d54..65e9a6f 100644 --- a/public/login.html +++ b/public/login.html @@ -55,9 +55,11 @@ Zugangsdaten erhältst du von Webklar. Passwortänderungen erfolgen über das Ticketsystem.

diff --git a/public/widerrufen.html b/public/widerrufen.html index cdeac73..c9e98c2 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -3,47 +3,42 @@ - Vertrag widerrufen – Webklar + Vertrag widerrufen – WEBklar
- +
WEBKLAR

Vertrag widerrufen

-
-

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 Widerrufsbelehrung.

- -
- -
diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs index 1e9a6ed..6427bf5 100644 --- a/scripts/setup-abo-collections.mjs +++ b/scripts/setup-abo-collections.mjs @@ -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'] }, diff --git a/server/config.js b/server/config.js index 034fc4b..3229c19 100644 --- a/server/config.js +++ b/server/config.js @@ -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: { diff --git a/server/routes/abo.js b/server/routes/abo.js index 66a752a..8e1991d 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -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.' : '', ]) diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 12d0c6d..2b0d5a3 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -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')