Wartungs-Abo v2: Rechtsrunde umgesetzt

- Bestellseite: Pflichtangaben direkt ueber dem Button, Zahlungsarten,
  Wahl des Leistungsbeginns (sofort / nach Widerrufsfrist via 15 Tage
  Stripe-Probezeit), keine Vorauswahl
- Zustimmungsnachweis: Zeitpunkt, Version, Wahl, IP, Browser in Stripe-
  Metadaten und Appwrite (neue Attribute, Setup-Skript ergaenzt)
- Checkout: Karte + SEPA (Fallback nur Karte), korrigierter Hinweistext
- Vertragsbestaetigung mit PDF der Vertragsunterlagen statt HTML
- Kuendigen/Widerrufen: Formular direkt (keine Zwischenseite), Wunsch-
  termin, Druck/PDF der Bestaetigung, Vorbefuellung bei Login
- Widerrufsbutton im Abo waehrend der Frist, 'beginnt am'-Status
- Pilotbetrieb per ABO_PILOT_EMAILS
- Vertragsbedingungen v2, Datenschutzerklaerung, Kalkulator ohne MwSt
  und mit 100/80 EUR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 14:10:21 +00:00
1 parent c0214725c0
commit 4ab154bfa2
18 files changed
+354 -163

No files matched your search

+88 -35
View File
@@ -10,6 +10,7 @@ import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import {
ACTIVE_STATUSES,
cancelAtPeriodEnd,
cancelSubscriptionAt,
constructWebhookEvent,
createAboCheckout,
createBillingPortalUrl,
@@ -27,20 +28,39 @@ 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 publicSubscription(sub) {
if (!sub) return null
const active = ACTIVE_STATUSES.has(sub.status)
const withdrawalUntil = sub.startedAt ? addDays(sub.startedAt, config.abo.withdrawalDays) : null
return {
status: sub.status,
active: ACTIVE_STATUSES.has(sub.status),
active,
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
startedAt: sub.startedAt,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
endedAt: sub.endedAt,
priceAmount: sub.priceAmount,
startChoice: sub.startChoice || null,
// Beginn nach Ablauf der Widerrufsfrist: Leistung startet mit Ende der Probezeit
serviceStartsAt: sub.status === 'trialing' ? sub.trialEnd : null,
// Widerrufsbutton im Portal nur waehrend der Frist hervorheben
withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()),
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
}
}
function isPilotAllowed(customer) {
if (!config.abo.pilotEmails.length) return true
return config.abo.pilotEmails.includes(String(customer?.email || '').toLowerCase())
}
// ---------------------------------------------------------------------------
// Kunde (eingeloggt)
// ---------------------------------------------------------------------------
@@ -49,11 +69,14 @@ function publicSubscription(sub) {
router.get('/', requireSession, async (req, res) => {
try {
const customerId = getSessionCustomerId(req)
const sub = await getCurrentSubscription(customerId)
const [sub, customer] = await Promise.all([
getCurrentSubscription(customerId),
getDocument(config.collections.customers, customerId).catch(() => null),
])
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
const ledger = sub ? await getLedger(customerId, { active }) : null
return res.json({
available: isStripeConfigured(),
available: isStripeConfigured() && isPilotAllowed(customer),
termsVersion: config.abo.termsVersion,
subscription: publicSubscription(sub),
ledger,
@@ -69,16 +92,29 @@ 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, earlyStart, termsVersion } = req.body || {}
if (acceptTerms !== true || earlyStart !== true) {
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' })
const { acceptTerms, startChoice, termsVersion } = req.body || {}
if (acceptTerms !== true) {
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
}
if (startChoice !== 'sofort' && startChoice !== 'nach_frist') {
return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' })
}
if (termsVersion !== config.abo.termsVersion) {
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
if (!isStripeConfigured() || !isPilotAllowed(customer)) {
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
}
// Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser
const now = new Date().toISOString()
const url = await createAboCheckout(customer, { termsAcceptedAt: now, earlyStartConsentAt: now })
const url = await createAboCheckout(customer, {
termsAcceptedAt: now,
startChoice,
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
consentIp: req.ip,
consentUserAgent: req.get('user-agent') || '',
})
return res.json({ url })
} catch (err) {
console.error('[abo] Checkout fehlgeschlagen:', err.message)
@@ -105,39 +141,46 @@ router.post('/portal', requireSession, async (req, res) => {
async function sendContractConfirmation(subDoc) {
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
const terms = await readFile(path.join(publicDir, 'abo-bedingungen.html'), 'utf8').catch(() => null)
const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
const pdf = await readFile(pdfPath).catch(() => null)
const lateStart = subDoc.startChoice === 'nach_frist'
const consentLine = lateStart
? `Sie haben gewählt, dass wir erst nach Ablauf der Widerrufsfrist beginnen. Leistungsbeginn und erste Abbuchung: ${fmtDate(subDoc.trialEnd)}. Bis dahin erbringen wir keine Leistungen und buchen nichts ab.`
: 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen, und wurden darüber informiert, dass Sie bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahlen.'
await sendCustomerMail(
subDoc.email,
'Ihr Webklar Wartungs-Abo – Vertragsbestätigung',
'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung',
[
'Guten Tag,',
'',
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
'',
' Vertrag: Webklar Wartungs-Abo',
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar',
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)',
' Vertrag: WEBklar Wartungs-Abo',
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar',
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
'',
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`,
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.',
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
`Ihre Zustimmung zu den Vertragsbedingungen (Version ${subDoc.termsVersion || config.abo.termsVersion}) haben wir am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} erhalten.`,
consentLine,
'',
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang',
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`,
'Im Anhang finden Sie die vollständigen Vertragsunterlagen als PDF: Vertragsbedingungen mit Leistungsbeschreibung,',
'Widerrufsbelehrung mit Muster-Widerrufsformular und Auftragsverarbeitungsvertrag.',
'',
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
`Ihr Stundenkontingent sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Vertrag widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
`Verträge hier kündigen: ${config.publicBaseUrl}/kuendigen.html`,
'',
'Viele Grüße',
'Ihr Webklar-Team',
'Ihr WEBklar-Team',
'',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · kenso.gri@gmail.com',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com',
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
],
terms
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }]
pdf
? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', content: pdf, contentType: 'application/pdf' }]
: []
)
await updateDocument(config.collections.subscriptions, subDoc.$id, {
@@ -235,7 +278,7 @@ function validDeclaration(body) {
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
reason: String(body?.reason || '').trim().slice(0, 2000),
effective: String(body?.effective || '').trim().slice(0, 40),
effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '',
}
}
@@ -255,10 +298,16 @@ router.post('/public/cancel', async (req, res) => {
const receivedAt = new Date()
let sub = null
let effectiveAt = null
try {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub && d.kind === 'ordentlich') {
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested)
sub = result.subscription
effectiveAt = result.effectiveAt
}
if (sub) {
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
})
@@ -267,9 +316,11 @@ router.post('/public/cancel', async (req, res) => {
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
}
const effectiveText = sub
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).`
: 'Wir ordnen Ihre Kündigung manuell Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
const effectiveText = effectiveAt
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)} (Ende des Abrechnungsmonats).`
: d.kind === 'ausserordentlich'
? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.'
: 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
`Guten Tag ${d.name},`,
@@ -280,7 +331,7 @@ router.post('/public/cancel', async (req, res) => {
` E-Mail: ${d.email}`,
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
' Vertrag: Webklar Wartungs-Abo',
` Gewünschter Zeitpunkt: ${d.effective || 'nächstmöglicher Zeitpunkt'}`,
` Gewünschter Zeitpunkt: ${d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt'}`,
d.reason ? ` Grund: ${d.reason}` : null,
'',
effectiveText,
@@ -295,9 +346,11 @@ router.post('/public/cancel', async (req, res) => {
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
d.reason ? `Grund: ${d.reason}` : '',
sub
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
effectiveAt
? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
: sub
? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
])