Abo: Widerrufsfrist nach §§ 187, 188, 193 BGB statt 14 x 24 Stunden

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 18:30:27 +00:00
1 parent 93381a81c5
commit e54f97144c
5 files changed
+144 -27

No files matched your search

+5 -11
View File
@@ -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('')}</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' })
}
// Laufzeit-Hinweis unter der Abo-Karte (§ 8 der Bedingungen)
function aboTermText(sub) {
if (sub.cancelAtPeriodEnd) {
@@ -764,7 +758,7 @@ function renderAboOrderView(view, data) {
<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>
<span><strong>Nach Ablauf der 14-tägigen Widerrufsfrist</strong>${data.lateStartAt ? ` (am ${fmtDateShort(data.lateStartAt)})` : ''}. 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>
@@ -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) {
+1 -3
View File
@@ -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(',')
+19 -10
View File
@@ -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() })
+114
View File
@@ -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)
}
+5 -3
View File
@@ -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 }] } : {}),