From e54f97144c66880ea3711314ba7c53303c8f1f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:30:27 +0000 Subject: [PATCH] =?UTF-8?q?Abo:=20Widerrufsfrist=20nach=20=C2=A7=C2=A7=201?= =?UTF-8?q?87,=20188,=20193=20BGB=20statt=2014=20x=2024=20Stunden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher endete die Frist rechnerisch 14 x 24 h nach Vertragsschluss, und der spaetere Beginn lag pauschal 15 Tage danach. Gesetzlich beginnt die Frist am Folgetag und endet um 24 Uhr des 14. Tages; faellt der auf Sa/So/Feiertag, am naechsten Werktag. Beispiel: Abschluss Sa 26.09. -> Frist bis Mo 12.10., 24 Uhr. Vorher verschwand "Vertrag widerrufen" schon Sa 10.10. vormittags, und "Beginn nach Ablauf der Frist" buchte am So 11.10. ab. - server/services/deadlines.js: Fristende und Leistungsbeginn, deutscher Kalender, Feiertage aller Bundeslaender (zugunsten des Kunden, § 193 BGB: Erklaerungsort). - Portal: "Vertrag widerrufen · bis TT.MM." bleibt bis zum echten Fristende sichtbar. - Beginn nach Ablauf der Frist: Stripe trial_end = 6 Uhr am Tag nach dem spaetesten moeglichen Fristende (Checkout gilt hoechstens 24 h). Die Bestellseite zeigt genau dieses Datum; hat es sich um Mitternacht verschoben, kommt "Seite neu laden". - Team-Mail beim Widerruf nennt das Fristende und ob der Widerruf rechtzeitig kam. - Datumsanzeige im Portal nach deutscher Zeit (auch bei Kunden im Ausland). Vertragstexte unveraendert (termsVersion bleibt v3). Co-Authored-By: Claude Opus 5.5 --- public/app.js | 16 ++--- server/config.js | 4 +- server/routes/abo.js | 29 +++++--- server/services/deadlines.js | 114 +++++++++++++++++++++++++++++++ server/services/stripeBilling.js | 8 ++- 5 files changed, 144 insertions(+), 27 deletions(-) create mode 100644 server/services/deadlines.js diff --git a/public/app.js b/public/app.js index e740cab..8b46ec1 100644 --- a/public/app.js +++ b/public/app.js @@ -49,7 +49,7 @@ function fmtDateShort(value) { if (/^\d{2}\.\d{2}\.\d{4}$/.test(String(value))) return escapeHtml(value) const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) - return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) } function fmtDateTime(value) { @@ -641,9 +641,10 @@ function euroShort(cents) { return cents % 100 === 0 ? `${cents / 100} €` : fmtEuro(cents / 100) } +// Fristen gelten nach deutschem Kalender, auch wenn der Kunde gerade im Ausland ist function fmtDayMonth(value) { const d = new Date(value) - return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' }) } function aboFeaturesHtml() { @@ -652,13 +653,6 @@ 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' }) -} - // Laufzeit-Hinweis unter der Abo-Karte (§ 8 der Bedingungen) function aboTermText(sub) { if (sub.cancelAtPeriodEnd) { @@ -764,7 +758,7 @@ function renderAboOrderView(view, data) { + Nach Ablauf der 14-tägigen Widerrufsfrist${data.lateStartAt ? ` (am ${fmtDateShort(data.lateStartAt)})` : ''}. Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.

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

@@ -808,7 +802,7 @@ function renderAboOrderView(view, data) { try { const { url } = await api('/api/abo/checkout', { method: 'POST', - body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '' }), + body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '', lateStartAt: data.lateStartAt || '' }), }) window.location.href = url } catch (err) { diff --git a/server/config.js b/server/config.js index 235a110..fee536c 100644 --- a/server/config.js +++ b/server/config.js @@ -81,9 +81,7 @@ export const config = { minutesPerMonth: 60, creditValidityMonths: 12, usageRoundingMinutes: 15, - withdrawalDays: 14, - // "Beginn nach Ablauf der Widerrufsfrist": 15 Tage Probezeit ohne Leistung und ohne Abbuchung - lateStartTrialDays: 15, + // Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist": server/services/deadlines.js // Pilotbetrieb: nur diese Kunden-E-Mails duerfen bestellen (leer = alle) pilotEmails: (process.env.ABO_PILOT_EMAILS || '') .split(',') diff --git a/server/routes/abo.js b/server/routes/abo.js index 82a3a3b..9a589f7 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -5,6 +5,7 @@ import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js' +import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js' @@ -30,12 +31,6 @@ 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 addMonthsIso(iso, months) { const d = new Date(iso) const day = d.getUTCDate() @@ -55,7 +50,8 @@ function minTermEndOf(sub) { 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 + // Fristende 24 Uhr, bei Sa/So/Feiertag am nächsten Werktag (deadlines.js) + const withdrawalUntil = sub.startedAt ? withdrawalDeadline(sub.startedAt).endsAt : null return { status: sub.status, active, @@ -110,6 +106,8 @@ router.get('/', requireSession, async (req, res) => { subscription: publicSubscription(sub), offer: offer ? offerView(offer) : null, ledger, + // Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite + lateStartAt: lateStartAt().toISOString(), }) } catch (err) { return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' }) @@ -122,7 +120,7 @@ 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, startChoice, termsVersion, offerId } = req.body || {} + const { acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {} if (acceptTerms !== true) { return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' }) } @@ -141,6 +139,11 @@ router.post('/checkout', requireSession, async (req, res) => { if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) { return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' }) } + // Ebenso genau der Leistungsbeginn, den die Bestellseite gezeigt hat (verschiebt sich um Mitternacht) + const serviceStartAt = startChoice === 'nach_frist' ? lateStartAt() : null + if (serviceStartAt && shownLateStart !== serviceStartAt.toISOString()) { + return res.status(409).json({ error: 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.' }) + } // Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser const now = new Date().toISOString() const url = await createAboCheckout( @@ -149,6 +152,7 @@ router.post('/checkout', requireSession, async (req, res) => { termsAcceptedAt: now, startChoice, earlyStartConsentAt: startChoice === 'sofort' ? now : '', + serviceStartAt, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '', }, @@ -501,13 +505,18 @@ router.post('/public/withdraw', async (req, res) => { 'Ihr Webklar-Team', ].filter((l) => l !== null)).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) + // Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss + const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [ `${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`, sub ? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.` : 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!', - 'TODO: Widerrufsfrist prüfen (14 Tage ab Vertragsschluss), Abo in Stripe sofort beenden,', - 'Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen erstatten (Stripe -> Rückerstattung).', + deadline + ? `Widerrufsfrist bis ${fmtDate(deadline.endsAt)}, 24 Uhr – Eingang ${receivedAt <= deadline.endsAt ? 'RECHTZEITIG' : 'NACH Fristende, vor einer Ablehnung bitte prüfen'}.` + : 'Widerrufsfrist: 14 Tage ab dem Tag nach Vertragsschluss bis 24 Uhr; fällt der letzte Tag auf Sa/So/Feiertag, am nächsten Werktag.', + 'TODO: Abo in Stripe sofort beenden, Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen', + 'erstatten (Stripe -> Rückerstattung).', ]) return res.json({ ok: true, receivedAt: receivedAt.toISOString() }) diff --git a/server/services/deadlines.js b/server/services/deadlines.js new file mode 100644 index 0000000..a06bb9d --- /dev/null +++ b/server/services/deadlines.js @@ -0,0 +1,114 @@ +/** + * Widerrufsfrist und "Beginn nach Ablauf der Widerrufsfrist" (§ 355 Abs. 2, §§ 187, 188, 193 BGB). + * + * Die Frist beginnt am Tag nach dem Vertragsschluss und endet mit Ablauf (24 Uhr) des 14. Tages. + * Fällt dieser Tag auf einen Samstag, Sonntag oder Feiertag, endet sie mit Ablauf des nächsten Werktags. + * Zugunsten des Kunden zählt jeder gesetzliche Feiertag, der in mindestens einem Bundesland gilt: + * § 193 BGB stellt auf den Erklärungsort ab, und der Kunde kann in jedem Bundesland sitzen. + * Gerechnet wird mit dem Kalender in Deutschland (Europe/Berlin). + */ + +const TZ = 'Europe/Berlin' +const DAY_MS = 86400000 +export const WITHDRAWAL_DAYS = 14 + +// Formatierer einmal anlegen (neu anlegen ist teuer) +const dayFormat = new Intl.DateTimeFormat('sv-SE', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' }) +const wallFormat = new Intl.DateTimeFormat('en-US', { + timeZone: TZ, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', +}) + +// Kalendertag (YYYY-MM-DD) in Deutschland +const berlinDay = (date) => dayFormat.format(new Date(date)) + +// Reine Kalendertage rechnen wir als UTC-Mitternacht, damit Sommer-/Winterzeit keine Rolle spielt +const dayToDate = (ymd) => new Date(`${ymd}T00:00:00Z`) +const plusDays = (ymd, n) => new Date(dayToDate(ymd).getTime() + n * DAY_MS).toISOString().slice(0, 10) + +// Ostersonntag im gregorianischen Kalender (Algorithmus nach Meeus/Jones/Butcher) +function easterSunday(year) { + const a = year % 19 + const b = Math.floor(year / 100) + const c = year % 100 + const d = Math.floor(b / 4) + const e = b % 4 + const f = Math.floor((b + 8) / 25) + const g = Math.floor((b - f + 1) / 3) + const h = (19 * a + b - d - g + 15) % 30 + const i = Math.floor(c / 4) + const k = c % 4 + const l = (32 + 2 * e + 2 * i - h - k) % 7 + const m = Math.floor((a + 11 * h + 22 * l) / 451) + const month = Math.floor((h + l - 7 * m + 114) / 31) + const day = ((h + l - 7 * m + 114) % 31) + 1 + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` +} + +const holidayCache = new Map() + +/** Gesetzliche Feiertage, die in mindestens einem Bundesland gelten (Sonntage wie Ostern fehlen, sie zählen ohnehin). */ +export function holidaysOf(year) { + if (holidayCache.has(year)) return holidayCache.get(year) + const easter = easterSunday(year) + const fixed = [ + '01-01', // Neujahr + '01-06', // Heilige Drei Könige (BW, BY, ST) + '03-08', // Internationaler Frauentag (BE, MV) + '05-01', // Tag der Arbeit + '08-08', // Augsburger Friedensfest (Stadt Augsburg) + '08-15', // Mariä Himmelfahrt (SL, Teile von BY) + '09-20', // Weltkindertag (TH) + '10-03', // Tag der Deutschen Einheit + '10-31', // Reformationstag (BB, HB, HH, MV, NI, SN, ST, SH, TH) + '11-01', // Allerheiligen (BW, BY, NW, RP, SL) + '12-25', // 1. Weihnachtstag + '12-26', // 2. Weihnachtstag + ].map((md) => `${year}-${md}`) + // Karfreitag, Ostermontag, Christi Himmelfahrt, Pfingstmontag, Fronleichnam (BW, BY, HE, NW, RP, SL, Teile von SN/TH) + const movable = [-2, 1, 39, 50, 60].map((n) => plusDays(easter, n)) + // Buß- und Bettag (SN): Mittwoch vor dem 23. November + const nov23 = `${year}-11-23` + const bussUndBettag = plusDays(nov23, -(((dayToDate(nov23).getUTCDay() - 3 + 7) % 7) || 7)) + const set = new Set([...fixed, ...movable, bussUndBettag]) + holidayCache.set(year, set) + return set +} + +function isWorkday(ymd) { + const weekday = dayToDate(ymd).getUTCDay() + return weekday !== 0 && weekday !== 6 && !holidaysOf(Number(ymd.slice(0, 4))).has(ymd) +} + +// Abstand der Berliner Uhrzeit zu UTC im Moment t (in ms, z. B. +2 h im Sommer) +function berlinOffset(t) { + const parts = Object.fromEntries(wallFormat.formatToParts(new Date(t)).map((p) => [p.type, p.value])) + const wall = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second) + return wall - Math.floor(t / 1000) * 1000 +} + +/** Zeitpunkt "Tag ymd, Uhrzeit in Deutschland" als Date (die Zeitumstellung liegt nie auf den genutzten Uhrzeiten). */ +function berlinTime(ymd, hour, minute = 0, second = 0, ms = 0) { + const [y, mo, d] = ymd.split('-').map(Number) + const wall = Date.UTC(y, mo - 1, d, hour, minute, second, ms) + const guess = wall - berlinOffset(wall) + return new Date(wall - berlinOffset(guess)) +} + +/** Widerrufsfrist zu einem Vertragsschluss: letzter Tag (YYYY-MM-DD) und Ende (24 Uhr an diesem Tag). */ +export function withdrawalDeadline(contractAt) { + let lastDay = plusDays(berlinDay(contractAt), WITHDRAWAL_DAYS) + while (!isWorkday(lastDay)) lastDay = plusDays(lastDay, 1) + return { lastDay, endsAt: berlinTime(lastDay, 23, 59, 59, 999) } +} + +/** + * Leistungsbeginn bei "Beginn nach Ablauf der Widerrufsfrist": 6 Uhr am Tag nach dem Fristende. + * Der Vertrag kommt erst mit dem Abschluss bei Stripe zustande, und eine Checkout-Sitzung gilt höchstens + * 24 Stunden. Gerechnet wird deshalb mit dem spätesten möglichen Vertragsschluss (jetzt + 24 Stunden), + * damit Leistung und erste Abbuchung nie in die laufende Frist fallen. + */ +export function lateStartAt(now = new Date()) { + const { lastDay } = withdrawalDeadline(new Date(new Date(now).getTime() + DAY_MS)) + return berlinTime(plusDays(lastDay, 1), 6) +} diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 39e8fbf..f62fc0c 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -7,6 +7,7 @@ import { Query, updateDocument, } from './appwriteAdmin.js' +import { lateStartAt } from './deadlines.js' /** * Wartungs-Abo ueber Stripe Billing. @@ -328,8 +329,8 @@ async function ensureTestCoupon() { /** * 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). + * startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist' (Probezeit ohne Leistung + * und ohne Abbuchung bis consent.serviceStartAt, dem Tag nach dem Ende der Widerrufsfrist, deadlines.js). * offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten * bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred). */ @@ -346,6 +347,7 @@ export async function createAboCheckout(customer, consent, offer = null) { getOrCreateStripeCustomer(customer), ]) const lateStart = consent.startChoice === 'nach_frist' + const serviceStartAt = lateStart ? consent.serviceStartAt || lateStartAt() : null const isTestAccount = config.abo.testEmails.includes(String(customer.email || '').toLowerCase()) const testCoupon = isTestAccount ? await ensureTestCoupon() : null // Stripe-Metadaten: max. 500 Zeichen je Wert @@ -378,7 +380,7 @@ export async function createAboCheckout(customer, consent, offer = null) { subscription_data: { metadata: meta, description: config.stripe.productName, - ...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}), + ...(serviceStartAt ? { trial_end: Math.floor(serviceStartAt.getTime() / 1000) } : {}), }, metadata: meta, ...(coupon ? { discounts: [{ coupon }] } : {}),