/** * 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) }