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:
1 parent
93381a81c5
commit
e54f97144c
5 files changed
+144
-27
No files matched your search
+1
-3
@@ -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
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }] } : {}),
|
||||
|
||||
Reference in new issue
Block a user