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

+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() })