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
+5
-11
@@ -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
@@ -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