Files
ClaudeandClaude Sonnet 5.5 72b5ac5265 Kundenansicht neu E-Fix F5: Fassung 6 ohne PDF behauptet keinen Anhang, das Team bekommt den Hinweis
Die PDFs der Fassung 6 entstehen erst nach der Freigabe. Bis dahin gingen K01, K12 und K13 ohne Anhang raus, sagten aber "im Anhang" bzw.
"beigefügt", und das Team erfuhr es nicht (nur K38/T24 hatten dafür den Block anhang_fehlt).

- Neuer Helfer vertragsAnhang.js: lädt das PDF der zugestimmten Fassung und meldet fehlt nur bei Fassung 6 ohne lesbare Datei.
- K01, K12, K13: Block anhang (Anhangsaussagen) und ohne_anhang (Satz, dass die Unterlagen gesondert kommen).
- T02, T12, T13: Block anhang_fehlt "Unterlagen von Hand nachsenden" (T12 auch beim späteren Wirksamwerden, T13 auch ohne Aufgabe).
- Alte Fassungen unverändert: Hash-Pins auf die Wiedergabe der Vorlagen vor der Änderung, Anhang und Texte wie bisher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:38:54 +00:00

1529 lines
76 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js'
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js'
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
import {
handlePendingUpdateApplied,
handlePendingUpdateExpired,
handleSwitchInvoicePaid,
handleSwitchPaymentFailed,
previewSwitch,
reconcilePendingSwitch,
switchToWartung,
wechselFunktionVon,
} from '../services/planSwitch.js'
import { assertOwnProject, handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
import { featureConfig } from '../services/featureRegistry.js'
import { istAboModell } from '../services/aboModell.js'
import { ENTHALTEN_IN, aboModellFuerKunde } from '../services/aboFunktionen.js'
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
import { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, WECHSEL_GESPERRT, bestelltexteFuerClient, bestellZeile, nachweisTexte } from '../services/bestelltexte.js'
import { kundenartWirksam } from '../services/kundenart.js'
import { fassungDerAnfrage, istNeueFassung, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js'
import { ladeVertragsAnhang } from '../services/vertragsAnhang.js'
import { duFehlerAntworten } from '../services/duFehler.js'
import { PRODUCT_KEYS, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
cancelAtPeriodEnd,
cancelSubscriptionAt,
constructWebhookEvent,
createAboCheckout,
createBillingPortalUrl,
getCurrentSubscription,
getStripe,
grantCreditForInvoice,
loadInvoice,
minTermMonthsFor,
isStripeConfigured,
syncSubscription,
} from '../services/stripeBilling.js'
const router = Router()
router.use(duFehlerAntworten) // neue Ansicht: bekannte Fehlertexte in du (duFehler.js), sonst unverändert
const fmtDate = (iso) =>
iso
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
: '–'
const fmtDateTime = (d) =>
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
const fmtEuro = (cents) =>
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
function addMonthsIso(iso, months) {
const d = new Date(iso)
const day = d.getUTCDate()
d.setUTCDate(1)
d.setUTCMonth(d.getUTCMonth() + months)
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
d.setUTCDate(Math.min(day, last))
return d.toISOString()
}
/** Mindestlaufzeit ab dem Wechseltag (termStartAt), sonst ab dem Leistungsbeginn (Probezeit-Ende bzw. Abo-Start). */
function minTermEndOf(sub) {
const start = sub?.termStartAt || sub?.trialEnd || sub?.startedAt
const months = minTermMonthsFor(sub?.termsVersion)
return start && months ? addMonthsIso(start, months) : null
}
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
const productKeyOf = (sub) => sub?.productKey || 'wartung'
const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner
const ABO_FUNKTIONEN = ['analyse', 'termine']
// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
// gefundenes Abo die aktuelle Fassung (F08)
const endParagraphOf = (sub) => termsDocFor(sub ? sub.termsVersion : config.abo.termsVersion).endParagraph
/**
* Abo fuers Portal. events: Vertragsereignisse des Kunden (listContractEvents); es zaehlen die
* dieses Abos (subscriptionId = Stripe-Abo-ID = Dokument-ID). Daraus kommen der Speicherstand
* (offene Aufgaben), ein Wechsel, der auf die Zahlung wartet (F12), und die Widerrufsfristen je
* Bestellung. Abos von vor Plan 2 haben kein Ereignis abschluss: fuer sie zaehlt die Frist wie
* bisher ab startedAt (F14).
*/
function publicSubscription(sub, events = []) {
if (!sub) return null
const active = ACTIVE_STATUSES.has(sub.status)
const productKey = productKeyOf(sub)
const own = events.filter((e) => e.subscriptionId === sub.$id)
const openOf = (type) => own.filter((e) => e.type === type && e.status === 'offen')
// Offene Speicher-Aufgaben nach Art (Task 8, Fix 1): "einrichten" (Plus ueber die bezahlte Menge, konsoleH
// noch nicht umgestellt) und "periodenende" (Minus oder Plus bis zur bezahlten Menge, gilt ab Periodenende)
const openStorage = openOf('speicher')
const pendingSetup = openStorage.some((e) => storageTaskKind(e) === 'einrichten')
const pendingReduction = openStorage.find((e) => storageTaskKind(e) === 'periodenende') || null
const pendingSwitch = openOf('wechsel')[0] || null
// Plus/Minus und Wechsel nur bei Status active, ohne Kuendigung und ohne offene Wechselzahlung
const cancelled = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
const canChangeStorage = sub.status === 'active' && !cancelled && !pendingSwitch
// Widerrufsfristen je Bestellung (Abschluss, Wechsel, Speicher-Plus), Fristende 24 Uhr, bei
// Sa/So/Feiertag am naechsten Werktag (deadlines.js)
const withdrawals = active ? openWithdrawals(own) : []
if (active && sub.startedAt && !own.some((e) => e.type === 'abschluss')) {
const until = withdrawalDeadline(sub.startedAt).endsAt
if (until > new Date()) withdrawals.push({ type: 'abschluss', eventId: null, until })
}
const withdrawalUntil = withdrawals.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null)
const terms = termsDocFor(sub.termsVersion)
return {
status: sub.status,
active,
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
startedAt: sub.startedAt,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
cancelAt: sub.cancelAt || (sub.cancelAtPeriodEnd ? sub.currentPeriodEnd : null),
minTermEnd: minTermEndOf(sub),
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,
// Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...")
offerText: sub.offerText || null,
productKey,
termsVersion: sub.termsVersion || null,
termStartAt: sub.termStartAt || null,
// Unterlagen in der zugestimmten Fassung (Spec 4.2): Fassung 3 fuer Bestandskunden, sonst Fassung 4
termsUrl: terms.url,
termsPdfUrl: `/${terms.pdfFile}`,
storage: {
units: Number(sub.storageUnits) || 0,
// Plus bestellt, konsoleH noch nicht umgestellt ("wird eingerichtet")
pendingSetup,
// Zielgroesse ab Periodenende ("ab TT.MM. X GB")
pendingReduction: pendingReduction ? { units: pendingReduction.details?.toUnits ?? null, effectiveAt: pendingReduction.dueAt || null } : null,
},
canChangeStorage,
canSwitch: canChangeStorage && productKey === 'hosting',
// Wechsel bestellt, Zahlung offen (Karte mit 3-D Secure): Link zur Stripe-Zahlungsseite
switchPending: pendingSwitch
? { since: pendingSwitch.consentAt || pendingSwitch.$createdAt, paymentUrl: pendingSwitch.details?.paymentUrl || null }
: null,
withdrawals: withdrawals.map((w) => ({ type: w.type, eventId: w.eventId, until: w.until.toISOString() })),
// Widerrufsbutton im Portal waehrend einer laufenden Frist hervorheben
withdrawalOpen: withdrawals.length > 0,
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
}
}
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb).
// Ein individuelles Angebot schaltet die Bestellung fuer diesen Kunden ebenfalls frei.
function isPilotAllowed(customer, offer = null) {
const list = config.abo.pilotEmails
if (offer || !list.length || list.includes('*')) return true
return list.includes(String(customer?.email || '').toLowerCase())
}
// ---------------------------------------------------------------------------
// Kunde (eingeloggt)
// ---------------------------------------------------------------------------
/**
* publicSubscription, in der neuen Ansicht mit wechselFunktion (die beim Wechsel gewuenschte Funktion, die noch einzuschalten ist, sonst null;
* planSwitch.wechselFunktionVon). In der alten Ansicht fehlt der Schluessel. Ein Lesefehler ergibt null (die Seite bleibt nutzbar).
*/
async function subscriptionMitWechselFunktion(req, customerId, sub, events) {
const view = publicSubscription(sub, events)
if (!view || !neueAnsicht(req)) return view
try {
return { ...view, wechselFunktion: await wechselFunktionVon(customerId, events.filter((e) => e.subscriptionId === sub.$id)) }
} catch (err) {
console.error('[abo] Gewünschte Funktion des Wechsels nicht lesbar:', err.message)
return { ...view, wechselFunktion: null }
}
}
/** Katalog, Abo-Status mit Speicherstand und Wechsel-Moeglichkeit, Angebote je Produkt, Stundenkonto (nur Wartung). */
router.get('/', requireSession, async (req, res) => {
try {
const customerId = getSessionCustomerId(req)
// Ohne lesbare Ereignisse (z. B. Collection noch nicht angelegt) bleibt die Seite nutzbar: die
// Widerrufsfrist des Abschlusses zaehlt dann ab startedAt (F14), offene Aufgaben fehlen
const readEvents = () =>
listContractEvents(customerId).catch((e) => {
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
return []
})
let [sub, customer, events] = await Promise.all([
getCurrentSubscription(customerId),
getDocument(config.collections.customers, customerId).catch(() => null),
readEvents(),
])
// Wechsel wartet laenger als Stripe eine Aenderung zurueckhaelt (Webhook nicht angekommen): mit Stripe
// abgleichen (Task 9, Fix 1); Fehler landen nur im Log
const pendingSwitch = sub && events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === sub.$id)
if (pendingSwitch && (await reconcilePendingSwitch(pendingSwitch))) {
sub = await getCurrentSubscription(customerId)
events = await readEvents()
}
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
const [ledger, hostingOffer, wartungOffer] = await Promise.all([
// Arbeitszeit gibt es nur im Wartungs-Abo
sub && productKeyOf(sub) === 'wartung' ? getLedger(customerId, { active }) : null,
// Ein offenes Angebot zeigt das Portal nur vor der Bestellung (mit Abo ist es entweder schon
// eingeloest oder betrifft ein anderes Produkt, das ohnehin nicht bestellbar ist)
active ? null : getOpenOffer(customerId, 'hosting').catch(() => null),
active ? null : getOpenOffer(customerId, 'wartung').catch(() => null),
])
// Fassung 6 (P7): nur in der neuen Ansicht und mit gesetzter Neu-Fassung; sonst bleibt die Antwort wie sie war (neun Schluessel)
const fassung6 = neueAnsicht(req) && Boolean(config.abo.termsVersionNeu)
return res.json({
available: isStripeConfigured() && isPilotAllowed(customer, hostingOffer || wartungOffer),
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
termsVersion: fassung6 ? config.abo.termsVersionNeu : config.abo.termsVersion,
...(fassung6
? { bestelltexte: bestelltexteFuerClient({ fassung: config.abo.termsVersionNeu, termsUrl: termsDocFor(config.abo.termsVersionNeu).url }) }
: {}),
// Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite
lateStartAt: lateStartAt().toISOString(),
products: [productView('hosting'), productView('wartung')],
mailStorage: {
includedGb: config.mailStorage.includedGb,
gbPerUnit: config.mailStorage.gbPerUnit,
unitPriceCents: config.mailStorage.amountCents,
maxUnits: config.mailStorage.maxUnits,
},
offers: { hosting: hostingOffer ? offerView(hostingOffer) : null, wartung: wartungOffer ? offerView(wartungOffer) : null },
ledger,
subscription: await subscriptionMitWechselFunktion(req, customerId, sub, events),
})
} catch (err) {
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
}
})
/**
* Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart).
* Fassung 6 (neue Ansicht, body.termsVersion = config.abo.termsVersionNeu): Die Kundenart kommt allein aus dem Kundendokument
* (kundenartWirksam), nie aus dem Body; Betriebe beginnen sofort ohne Haekchen, Privatkunden nur mit earlyStart === true sofort, sonst
* nach der Widerrufsfrist. Der Nachweis (angezeigter Wortlaut samt Kundenart) wird vor der Stripe-Sitzung geschrieben. Alle anderen
* Anfragen laufen wie bisher (Fassung 5, Body mit startChoice).
*/
router.post('/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart, earlyStart } = req.body || {}
if (!PRODUCT_KEYS.includes(productKey)) {
return res.status(400).json({ error: 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).' })
}
const fassung = fassungDerAnfrage(req, termsVersion)
const sechs = fassung === 'neu'
if (acceptTerms !== true) {
return sechs
? res.status(400).json({ error: FEHLER_UNVOLLSTAENDIG })
: res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
}
// startChoice verlangt nur der alte Weg; eine Bestellung mit Fassung 6 oder aus der neuen Ansicht mit fremder Version bekommt gleich
// den Hinweis auf die geaenderten Bedingungen
const alterWeg = fassung === 'alt' || (fassung === null && !neueAnsicht(req) && !istNeueFassung(termsVersion))
if (alterWeg && startChoice !== 'sofort' && startChoice !== 'nach_frist') {
return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' })
}
if (fassung === null) {
return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
const customerId = getSessionCustomerId(req)
let customer
if (sechs) {
// Ein Lesefehler ist ein Serverfehler, nie ein Rueckfall auf eine Kundenart
try {
customer = await getDocument(config.collections.customers, customerId)
} catch (err) {
console.error('[abo] Kunde für die Bestellung nicht lesbar:', err.message)
return res.status(500).json({ error: FEHLER_NACHWEIS })
}
} else {
customer = await getDocument(config.collections.customers, customerId)
}
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat: das offene Angebot fuer
// genau dieses Produkt (F32). Gehoert die mitgeschickte Angebots-ID zum jeweils anderen Produkt,
// ist die Meldung eindeutiger als das allgemeine "hat sich geändert".
const offer = await getOpenOffer(customer.$id, productKey)
if ((offer?.$id || '') !== String(offerId || '')) {
if (offerId) {
const otherKey = productKey === 'hosting' ? 'wartung' : 'hosting'
const other = await getOpenOffer(customer.$id, otherKey)
if (other?.$id === offerId) {
return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' })
}
}
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' })
}
if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) {
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
}
// Beginn: im alten Weg die Wahl des Kunden; in Fassung 6 allein die Kundenart des Servers und earlyStart === true
const kundenart = sechs ? kundenartWirksam(customer) : null
const beginnWahl = sechs ? (kundenart === 'unternehmen' || earlyStart === true ? 'sofort' : 'nach_frist') : startChoice
const haekchenGesetzt = sechs && kundenart !== 'unternehmen' && earlyStart === true
// Ebenso genau der Leistungsbeginn, den die Bestellseite gezeigt hat (verschiebt sich um Mitternacht)
const serviceStartAt = beginnWahl === '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 angebot = offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null
if (sechs) {
// Der Wortlaut, den der Kunde gesehen hat, wird gespeichert, bevor Stripe etwas anlegt; scheitert das, passiert nichts
const produkt = productView(productKey)
await speichereNachweisFuerRoute({
customerId: customer.$id,
art: 'abo',
bezug: productKey,
texte: nachweisTexte('abo', {
kundenart,
fassung: termsVersion,
beginnGehakt: haekchenGesetzt,
bestellung: bestellZeile('abo', {
produkt: productLabel(productKey),
monatCents: produkt.amountCents,
mindestMonate: produkt.minTermMonths,
beginn: beginnWahl,
beginnAm: serviceStartAt?.toISOString(),
angebot: angebot?.text || undefined,
}),
}),
fassungen: { zustimmung: termsVersion },
kundenart,
req,
at: now,
})
}
const url = await createAboCheckout(
customer,
{
termsAcceptedAt: now,
startChoice: beginnWahl,
earlyStartConsentAt: beginnWahl === 'sofort' && (!sechs || haekchenGesetzt) ? now : '',
serviceStartAt,
consentIp: req.ip,
consentUserAgent: req.get('user-agent') || '',
...(sechs ? { termsVersion } : {}),
},
productKey,
angebot
)
return res.json({ url })
} catch (err) {
console.error('[abo] Checkout fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.message || 'Bestellung fehlgeschlagen' })
}
})
/** Stripe-Kundenportal: Zahlungsart, Rechnungen, Kuendigung. */
router.post('/portal', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
const url = await createBillingPortalUrl(getSessionCustomerId(req))
return res.json({ url })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Portal nicht verfügbar' })
}
})
// ---------------------------------------------------------------------------
// E-Mail-Zusatzspeicher (Spec 3.4, 4.3): Vorschau und Aenderung (mailStorage.js)
// ---------------------------------------------------------------------------
/** Vorschau fuer { units }: Richtung, Gesamtgroesse, Mehrpreis, anteiliger Betrag, naechste Abbuchung, Wirksamkeit. */
router.post('/storage/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
return res.json(await previewStorageChange(getSessionCustomerId(req), req.body?.units))
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' })
}
})
/**
* Aenderung { units, acceptTerms, termsVersion }: ein Plus ist eine Bestellung ("Zahlungspflichtig bestellen")
* und braucht acceptTerms zur aktuellen Fassung - der Nachweis (Fassung, Zeitpunkt, IP, Browser) landet im
* Ereignis `speicher`; ein Minus ("Speichern") braucht kein Haekchen und speichert keine Zustimmungsdaten.
* Antwort: die Abo-Ansicht wie in GET /api/abo (mit Speicherstand) und die Aenderung selbst.
*/
router.post('/storage', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht kann der Zusatzspeicher nicht geändert werden.' })
}
const customerId = getSessionCustomerId(req)
const { units, acceptTerms, termsVersion } = req.body || {}
let consent = null
if (acceptTerms === true) {
const fassung = fassungDerAnfrage(req, termsVersion)
if (fassung === null) {
return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
consent = {
termsVersion,
consentAt: new Date().toISOString(),
consentIp: req.ip,
consentUserAgent: req.get('user-agent') || '',
}
// Fassung 6: Der gezeigte Wortlaut samt Kundenart (aus dem Kundendokument) wird gespeichert, bevor Stripe etwas aendert. Eine ungueltige
// Menge ueberlaesst die Ablehnung wie bisher changeStorage (400, ohne Nachweis)
const mengeOk = Number.isInteger(units) && units >= 1 && units <= config.mailStorage.maxUnits
if (fassung === 'neu' && mengeOk) {
let kundenart
try {
kundenart = kundenartWirksam(await getDocument(config.collections.customers, customerId))
} catch (err) {
console.error('[abo] Kunde für den Zusatzspeicher nicht lesbar:', err.message)
return res.status(500).json({ error: FEHLER_NACHWEIS })
}
await speichereNachweisFuerRoute({
customerId,
art: 'speicher',
bezug: `units=${units}`,
texte: nachweisTexte('speicher', {
kundenart,
fassung: termsVersion,
bestellung: bestellZeile('speicher', { units, gb: units * config.mailStorage.gbPerUnit, monatCents: units * config.mailStorage.amountCents }),
}),
fassungen: { zustimmung: termsVersion },
kundenart,
req,
at: consent.consentAt,
})
}
}
const { subscription, change } = await changeStorage(customerId, units, consent)
const events = await listContractEvents(customerId).catch((e) => {
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
return []
})
return res.json({ subscription: publicSubscription(subscription, events), change })
} catch (err) {
console.error('[abo] Zusatzspeicher-Änderung fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.message || 'Änderung fehlgeschlagen' })
}
})
// ---------------------------------------------------------------------------
// Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5, Bedingungen § 9): Vorschau und Bestellung (planSwitch.js)
// ---------------------------------------------------------------------------
/** Vorschau fuer die Bestelluebersicht: Gutschrift, heute faellig, Monatspreis, neuer Abbuchungstag, Mindestlaufzeit. */
router.post('/switch/preview', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht nicht möglich.' })
}
return res.json(await previewSwitch(getSessionCustomerId(req)))
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' })
}
})
/**
* Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11) aus dem Body: { key, projectId } oder null. Gueltig ist ein Schluessel
* aus dem Register, den nur das Wartungs-Abo enthaelt (ENTHALTEN_IN nur 'wartung': news, termine, shop, unterkunft, zeiten; nicht analyse) und
* ein Projekt des Kunden (assertOwnProject). Alles andere wird verworfen, nie abgelehnt: die Bequemlichkeit darf den bezahlten Vertrag nicht
* blockieren. Nur die beiden Schluessel key und projectId werden uebernommen.
*/
async function wechselFunktionAusBody(customerId, funktion) {
if (!funktion || typeof funktion !== 'object' || Array.isArray(funktion)) return null
const { key, projectId } = funktion
if (typeof key !== 'string' || typeof projectId !== 'string' || !projectId) return null
const enthalten = Object.hasOwn(ENTHALTEN_IN, key) ? ENTHALTEN_IN[key] : null
if (!enthalten || enthalten.length !== 1 || enthalten[0] !== 'wartung') return null
try {
await assertOwnProject(customerId, projectId)
} catch {
return null
}
return { key, projectId }
}
/**
* Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate, amountDueCents } ("Zahlungspflichtig
* bestellen"); prorationDate und amountDueCents kommen aus der Vorschau.
* Nachweis: Fassung, Zeitpunkt, IP, Browser und die Zustimmung zum Sofortbeginn (§ 357a Abs. 2 BGB) landen am
* Abo und im Ereignis `wechsel`. Antwort: die Abo-Ansicht wie in GET /api/abo und paymentUrl (Karte: Zahlung
* noch zu bestaetigen, das Portal leitet zur Stripe-Zahlungsseite weiter; sonst null).
*/
router.post('/switch', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht kann nicht gewechselt werden.' })
}
const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {}
const fassung = fassungDerAnfrage(req, termsVersion)
const sechs = fassung === 'neu'
if (acceptTerms !== true) {
return res.status(400).json({ error: sechs ? FEHLER_UNVOLLSTAENDIG : 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' })
}
if (!sechs) {
// Fassung 6 aus der alten Ansicht oder fremde Version aus der neuen Ansicht: gleich der Hinweis auf die geaenderten Bedingungen
if (fassung === null && (neueAnsicht(req) || istNeueFassung(termsVersion))) {
return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
if (earlyStart !== true) {
return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' })
}
}
if (!termsVersion || !Number.isInteger(prorationDate) || !Number.isInteger(amountDueCents) || (sechs && amountDueCents < 0)) {
return res.status(400).json({ error: sechs ? FEHLER_UNVOLLSTAENDIG : 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' })
}
if (termsVersion !== config.abo.termsVersion && !sechs) {
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
}
const customerId = getSessionCustomerId(req)
const now = new Date().toISOString()
// Fassung 6: Kundenart allein aus dem Kundendokument (ein Lesefehler ist ein Serverfehler, nie ein Rueckfall); Privatkunden brauchen das Haekchen
let kundenart = null
if (sechs) {
try {
kundenart = kundenartWirksam(await getDocument(config.collections.customers, customerId))
} catch (err) {
console.error('[abo] Kunde für den Wechsel nicht lesbar:', err.message)
return res.status(500).json({ error: FEHLER_NACHWEIS })
}
if (kundenart !== 'unternehmen' && earlyStart !== true) return res.status(400).json({ error: WECHSEL_GESPERRT })
}
// Gewuenschte Funktion (nur neue Ansicht): geprueft oder verworfen, der Wechsel laeuft in jedem Fall
const funktion = neueAnsicht(req) ? await wechselFunktionAusBody(customerId, req.body?.funktion) : null
if (sechs) {
// Der gezeigte Wortlaut wird gespeichert, bevor Stripe etwas aendert; scheitert das, passiert nichts
await speichereNachweisFuerRoute({
customerId,
art: 'wechsel',
bezug: 'hosting>wartung',
texte: nachweisTexte('wechsel', {
kundenart,
fassung: termsVersion,
beginnGehakt: kundenart !== 'unternehmen',
bestellung: bestellZeile('wechsel', { heuteCents: amountDueCents, monatCents: config.products.wartung.amountCents, mindestMonate: config.abo.minTermMonths }),
}),
fassungen: { zustimmung: termsVersion },
kundenart,
req,
at: now,
})
}
const { subscription, paymentUrl } = await switchToWartung(
customerId,
{
termsVersion,
consentAt: now,
earlyStartConsentAt: sechs && kundenart === 'unternehmen' ? '' : now,
...(sechs ? { kundenart } : {}),
consentIp: req.ip,
consentUserAgent: req.get('user-agent') || '',
},
prorationDate,
// Fix 1 (8): der in der Bestelluebersicht gezeigte Betrag; weicht die neue Vorschau ab, 409
{ expectedAmountCents: amountDueCents, funktion }
)
const events = await listContractEvents(customerId).catch((e) => {
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
return []
})
return res.json({ subscription: publicSubscription(subscription, events), paymentUrl })
} catch (err) {
console.error('[abo] Wechsel fehlgeschlagen:', err.message)
return res.status(err.status || 500).json({ error: err.message || 'Wechsel fehlgeschlagen' })
}
})
// ---------------------------------------------------------------------------
// Stripe-Webhook
// ---------------------------------------------------------------------------
/**
* Welche Funktionen hat der Kunde dieses Abos? `alt`: die Website-Analyse oder der Terminplaner (Funktionsdokument in portalFeatures
* mit featureKey analyse oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht); dann gilt der
* Hinweis in K02 und K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). `modell`: mindestens
* ein Dokument des Abo-Modells (istAboModell) in einem Zustand ausser off; dann gilt statt dessen der Hinweis funktionen_abo
* (Kundenansicht neu, P6). Der Kunde ist der des Abos (sub.customerId, wie in customerNameOf); gelesen wird wie sonst auch ueber
* listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als { alt: true, modell: false } (der Kunde wird eher informiert als
* nicht), eine Logzeile, die Mail geht in jedem Fall raus.
*/
async function hatAboFunktionen(sub) {
try {
if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID')
const docs = await listCustomerFeatureDocs(sub.customerId)
const laeuft = (doc) => (doc.state || 'off') !== 'off'
return {
alt: docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && laeuft(doc)),
modell: docs.some((doc) => istAboModell(doc) && laeuft(doc)),
}
} catch (err) {
console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`)
return { alt: true, modell: false }
}
}
/** Blöcke funktionen (alter Hinweis) und funktionen_abo (Abo-Modell) der Kündigungsmails K02 und K03; nur beim Wartungs-Abo, sonst wird nichts gelesen. */
async function funktionenBloecke(sub) {
const { alt, modell } = isWartung(sub) ? await hatAboFunktionen(sub) : { alt: false, modell: false }
return { funktionen: alt && !modell, funktionen_abo: modell }
}
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
async function customerNameOf(sub, fallback = '') {
const customer = sub?.customerId
? await getDocument(config.collections.customers, sub.customerId).catch(() => null)
: null
return customer?.name || fallback || ''
}
/**
* K01 an den Kunden: Vertragsbestaetigung je Produkt (Katalogname, Preis, Liste "Enthalten" ueber die Bloecke
* hosting/wartung, fuer ein Wartungs-Abo nach Fassung 3 wartung_v3 mit der Zeile der Fassung 3) mit den
* Vertragsunterlagen der Fassung, der der Kunde zugestimmt hat (§ 312f BGB, contractAttachmentFor: v3 Archiv-PDF,
* v4 Archiv-PDF, v5 aktuelles PDF). Versionsangabe und Anhang kommen aus derselben Quelle (termsDocFor.version): ohne gespeicherte
* Fassung beides Fassung 3. Ohne lesbare Datei geht die Mail ohne Anhang raus.
*/
async function sendContractConfirmation(subDoc, fallbackName = '', unterlagen = null) {
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
const terms = termsDocFor(subDoc.termsVersion)
// Fassung 6 ohne PDF: keine Behauptung "im Anhang" (Block ohne_anhang), T02 sagt es dem Team (F5); ältere Fassungen wie bisher.
// Der Aufrufer (Webhook) hat die Unterlagen für T02 schon geladen und reicht sie durch.
const { attachments, fehlt: anhangFehlt } = unterlagen ?? (await ladeVertragsAnhang(terms.version, 'abo'))
const lateStart = subDoc.startChoice === 'nach_frist'
const productKey = productKeyOf(subDoc)
await sendCustomerMail(subDoc.email, {
template: 'K01',
values: {
name: await customerNameOf(subDoc, fallbackName),
produkt: productLabel(productKey),
preis: `${fmtEuro(config.products[productKey].amountCents)} / Monat`,
angebot_text: subDoc.offerText || '',
vertragsschluss: fmtDate(subDoc.startedAt),
leistungsbeginn: fmtDate(lateStart ? subDoc.trialEnd : subDoc.startedAt),
// Abos nach Bedingungen ohne Mindestlaufzeit (vor v3) entstehen nicht mehr; sie hätten hier "–"
mindestlaufzeit_bis: fmtDate(minTermEndOf(subDoc)),
bedingungen_version: terms.version,
zustimmung_zeitpunkt: fmtDateTime(subDoc.termsAcceptedAt || Date.now()),
},
blocks: {
angebot: Boolean(subDoc.offerText),
hosting: productKey === 'hosting',
wartung: productKey === 'wartung' && isTermsV4OrNewer(terms.version),
wartung_v3: productKey === 'wartung' && !isTermsV4OrNewer(terms.version),
// Fassung 6: ein Betrieb (Sofortbeginn ohne Zeitstempel) hat nichts verlangt; die Mail behauptet es nicht
sofortbeginn: !lateStart && !(istNeueFassung(subDoc.termsVersion) && !subDoc.earlyStartConsentAt),
beginn_nach_widerrufsfrist: lateStart,
anhang: !anhangFehlt,
ohne_anhang: anhangFehlt,
},
attachments,
})
await updateDocument(config.collections.subscriptions, subDoc.$id, {
confirmationSentAt: new Date().toISOString(),
})
}
/** Kuendigung direkt bei Stripe (Dashboard/Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
async function sendPortalCancellationConfirmation(sub, createdUnix) {
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
const endsAt = sub.cancelAt || sub.currentPeriodEnd
await sendCustomerMail(sub.email, {
template: 'K02',
values: {
name: await customerNameOf(sub),
produkt: productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt),
vertragsende: fmtDate(endsAt),
vertragsende_paragraph: endParagraphOf(sub),
},
// Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner (funktionen)
// bzw. der Funktionen des Abo-Modells (funktionen_abo) nur beim Wartungs-Abo eines Kunden, der sie hat (funktionenBloecke)
blocks: { wartung: isWartung(sub), ...(await funktionenBloecke(sub)) },
})
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
}).catch(() => {})
await sendAdminNotice({
template: 'T05',
values: { email: sub.email, produkt: productLabelOf(sub), stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt), vertragsende_paragraph: endParagraphOf(sub) },
})
}
/**
* Nachweis des Neuabschlusses als Vertragsereignis. Der Vertrag kommt mit dem Abschluss bei Stripe
* zustande, die Widerrufsfrist zaehlt ab dann (details.contractAt = startedAt, F04); die Zustimmung
* (Fassung, Zeitpunkt, IP, Browser) kommt aus den Abo-Metadaten, die der Spiegel traegt. Feste
* Dokument-ID je Abo (eventIdFor, 36 Zeichen): eine Webhook-Wiederholung legt kein zweites
* Ereignis an (F05).
*/
async function recordAbschluss(sub, session) {
return recordContractEvent({
id: eventIdFor('abschluss', sub.$id),
customerId: sub.customerId,
subscriptionId: sub.$id,
type: 'abschluss',
productKey: productKeyOf(sub),
details: {
contractAt: sub.startedAt,
startChoice: sub.startChoice || null,
serviceStartAt: sub.trialEnd || sub.startedAt,
amountCents: sub.priceAmount ?? null,
checkoutSessionId: session.id || null,
...(sub.offerId ? { offerId: sub.offerId, offerText: sub.offerText || '' } : {}),
// Fassung 6: Sofortbeginn ohne Zeitstempel gibt es nur fuer Betriebe (Grund fuer die Mails und den Nachweis)
...(istNeueFassung(sub.termsVersion) && sub.startChoice === 'sofort' && !sub.earlyStartConsentAt ? { kundenart: 'unternehmen' } : {}),
},
consent: {
termsVersion: sub.termsVersion || null,
consentAt: sub.termsAcceptedAt || null,
consentIp: sub.consentIp || null,
consentUserAgent: sub.consentUserAgent || null,
earlyStartConsentAt: sub.earlyStartConsentAt || null,
},
})
}
async function handleStripeEvent(event) {
const obj = event.data.object
// Einmalkauf einer Funktion (News-Pinnwand): eigener Weg, der Abo-Zweig unten bleibt unberuehrt
if (isFeatureCheckoutEvent(event)) {
await handleFeatureCheckoutEvent(event)
return
}
switch (event.type) {
case 'checkout.session.completed': {
if (obj.mode !== 'subscription' || !obj.subscription) return
const sub = await syncSubscription(obj.subscription)
// Nachweis: voruebergehend nicht speicherbar (Appwrite nicht erreichbar, 5xx) -> 500, Stripe
// wiederholt und der Nachweis kommt nach. Dauerhaft nicht speicherbar (4xx, etwa Collection
// oder Attribut fehlt) -> Angebot, T02 und K01 laufen trotzdem, T02 sagt es dem Team; eine
// Wiederholung wuerde nichts aendern, der Kunde soll seine Bestaetigung nicht warten lassen.
let proofProblem = false
if (sub) {
try {
await recordAbschluss(sub, obj)
} catch (err) {
if (!err.status || err.status >= 500) throw err
console.error(`[abo] Nachweis des Abschlusses ${obj.subscription} nicht gespeichert (${err.status}):`, err.message)
proofProblem = true
}
}
const offerProblem = await activateOfferFromCheckout(obj).then(
() => false,
(e) => {
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
return true
}
)
// Vertragsunterlagen der zugestimmten Fassung einmal laden: T02 meldet fehlende PDFs der Fassung 6 (anhang_fehlt), K01 hängt sie an
const unterlagen = sub ? await ladeVertragsAnhang(termsDocFor(sub.termsVersion).version, 'abo') : null
await sendAdminNotice({
template: 'T02',
values: {
produkt: productLabel(sub ? productKeyOf(sub) : obj.metadata?.productKey),
email: sub?.email || obj.customer_details?.email || '',
kunde_name: obj.customer_details?.name || obj.customer_details?.email || '',
appwrite_id: sub?.customerId || obj.client_reference_id || '',
stripe_abo_id: obj.subscription,
angebot_text: obj.metadata?.offerText || '',
},
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem, nachweis_fehlt: proofProblem, anhang_fehlt: unterlagen?.fehlt === true },
})
await sendContractConfirmation(sub, obj.customer_details?.name, unterlagen).catch((e) =>
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
)
return
}
case 'invoice.paid': {
// Einmal frisch laden, mit allen Zeilen (F18): Stundengutschrift (Wartung) und bezahlte Speichermenge
const fresh = await loadInvoice(obj.id)
const result = await grantCreditForInvoice(fresh, { fresh: true })
if (result.granted) {
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
}
const storage = await recordPaidStorage(fresh)
if (storage.recorded) {
console.log(`[abo] Zusatzspeicher bezahlt: Abo ${storage.subscriptionId}, ${storage.units} Pakete bis ${storage.periodEnd}`)
}
// Wechselrechnung bezahlt (F26): SEPA-Wechsel erledigt; Karte, falls vor pending_update_applied
const switched = await handleSwitchInvoicePaid(fresh)
if (switched.completed) console.log(`[abo] Wechsel ins Wartungs-Abo bezahlt: Ereignis ${switched.eventId}`)
return
}
case 'invoice.payment_failed': {
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
// Wechselrechnung per SEPA-Lastschrift nicht eingeloest: T12 mit den Handgriffen (vor T04, damit eine
// Wiederholung nach einem Fehler hier beide Hinweise nachholt)
await handleSwitchPaymentFailed(obj)
await sendAdminNotice({
template: 'T04',
values: {
email: sub?.email || obj.customer_email || '',
rechnung_nr: obj.number || obj.id,
betrag: fmtEuro(obj.amount_due),
},
})
return
}
case 'customer.subscription.created':
await syncSubscription(obj.id)
return
// Wechsel ins Wartungs-Abo mit Karte (Rechtsrunde 28.09.2026): Stripe hat die zurueckgehaltene Aenderung
// mit der Zahlung uebernommen (erledigt, T12) oder verworfen (gescheitert, K14, T12). Am Stripe-Endpunkt
// freischalten (Task 20).
case 'customer.subscription.pending_update_applied':
await handlePendingUpdateApplied(obj.id)
return
case 'customer.subscription.pending_update_expired':
await handlePendingUpdateExpired(obj.id)
return
case 'customer.subscription.updated': {
const sub = await syncSubscription(obj.id)
const prev = event.data.previous_attributes || {}
const becameCanceled =
(prev.cancel_at_period_end === false && obj.cancel_at_period_end === true) ||
('cancel_at' in prev && !prev.cancel_at && obj.cancel_at)
const reactivated =
(prev.cancel_at_period_end === true && obj.cancel_at_period_end === false) ||
('cancel_at' in prev && prev.cancel_at && !obj.cancel_at)
// Kuendigung ueber das Stripe-Kundenportal oder das Dashboard: Bestaetigung hier verschicken.
// Ueber Kuendigungs-/Widerrufsseite gesetzte Kuendigungen tragen cancelSource und sind schon bestaetigt.
if (becameCanceled && !obj.metadata?.cancelSource && sub) {
await sendPortalCancellationConfirmation(sub, event.created).catch((e) =>
console.error('[abo] Kuendigungsbestaetigung (Kundenportal) fehlgeschlagen:', e.message)
)
}
if (reactivated && sub) {
await sendAdminNotice({ template: 'T06', values: { email: sub.email, produkt: productLabelOf(sub), stripe_abo_id: obj.id } })
}
return
}
case 'customer.subscription.deleted': {
const sub = await syncSubscription(obj.id)
await sendAdminNotice({
template: 'T07',
values: { email: sub?.email || '', produkt: productLabelOf(sub), stripe_abo_id: obj.id, status: obj.status, vertragsende_paragraph: endParagraphOf(sub) },
// Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo (Fix 1)
blocks: { wartung: isWartung(sub) },
})
return
}
default:
}
}
router.post('/webhook', async (req, res) => {
let event
try {
event = constructWebhookEvent(req.rawBody, req.get('stripe-signature'))
} catch (err) {
console.warn('[abo] Webhook-Signatur ungueltig:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
try {
await handleStripeEvent(event)
return res.json({ received: true })
} catch (err) {
// 500 -> Stripe wiederholt das Event (Handler sind idempotent)
console.error(`[abo] Webhook ${event.type} fehlgeschlagen:`, err.message)
return res.status(500).json({ error: 'processing failed' })
}
})
// ---------------------------------------------------------------------------
// Oeffentlich: Kuendigungsbutton (§ 312k BGB) + Widerrufsbutton (§ 356a BGB)
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
// ---------------------------------------------------------------------------
// Ratenbegrenzung: hoechstens 10 Erklaerungen je Stunde und IP-Adresse (Datenschutzerklaerung Abschnitt 7: die
// IP-Adresse dient nur dazu, die Zahl der Anfragen zu begrenzen, sie wird nicht mit der Erklaerung gespeichert).
// Eintraege, die aelter als eine Stunde sind, fliegen bei jedem Aufruf raus - auch die anderer IP-Adressen (F30).
const RATE_WINDOW_MS = 60 * 60 * 1000
const RATE_MAX = 10
const recentRequests = new Map() // ip -> [timestamps]
/** Eintraege aelter als eine Stunde (zum Zeitpunkt now) entfernen; laeuft bei jeder Anfrage, exportiert fuer Tests. */
export function purgeRateLimit(now = Date.now()) {
for (const [ip, list] of recentRequests) {
const fresh = list.filter((t) => now - t < RATE_WINDOW_MS)
if (fresh.length) recentRequests.set(ip, fresh)
else recentRequests.delete(ip)
}
}
function rateLimited(ip, now = Date.now()) {
purgeRateLimit(now)
const list = recentRequests.get(ip) || []
list.push(now)
recentRequests.set(ip, list)
return list.length > RATE_MAX
}
/** Nur fuer Tests: alle Zaehler der Ratenbegrenzung loeschen. */
export function resetPublicRateLimit() {
recentRequests.clear()
}
/** Nur fuer Tests: Zahl der IP-Adressen, die gerade in der Map stehen. */
export function publicRateLimitState() {
return { ips: recentRequests.size }
}
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'unterkunft', 'zeiten', 'projekt']
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
/**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'unterkunft' | 'zeiten' | 'projekt'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/
function validDeclaration(body) {
const name = String(body?.name || '').trim().slice(0, 200)
const email = String(body?.email || '').trim().slice(0, 255)
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null
const contract = CONTRACTS.includes(body?.contract) ? body.contract : 'abo'
const order = ORDERS.includes(body?.order) ? body.order : 'abschluss'
let packages = null
if (contract === 'zusatzspeicher') {
const raw = String(body?.packages ?? '').trim()
if (raw) {
packages = /^\d+$/.test(raw) ? Number(raw) : NaN
if (!(packages >= 1 && packages <= config.mailStorage.maxUnits)) {
return { error: `Bitte geben Sie 1 bis ${config.mailStorage.maxUnits} Pakete an oder lassen Sie das Feld leer (alle Pakete).` }
}
}
}
return {
name,
email,
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
kind: ['ausserordentlich', 'geschaeftsaufgabe'].includes(body?.kind) ? body.kind : 'ordentlich',
reason: String(body?.reason || '').trim().slice(0, 2000),
effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '',
contract,
order,
packages,
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
}
}
/** Ratenbegrenzung und Erklaerung pruefen; bei 429/400 ist die Antwort schon geschickt (null). */
function readDeclaration(req, res) {
if (rateLimited(req.ip)) {
res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
return null
}
const d = validDeclaration(req.body)
if (!d) {
res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
return null
}
if (d.error) {
res.status(400).json({ error: d.error })
return null
}
return d
}
async function findActiveSubscriptionByEmail(email) {
const customer =
(await getCustomerByEmail(email).catch(() => null)) ||
(await getCustomerByEmail(email.toLowerCase()).catch(() => null))
if (!customer) return null
const sub = await getCurrentSubscription(customer.$id)
return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null
}
/** Name des Abos aus dem Katalog ("WEBklar Hosting-Abo"), null ohne Abo. */
const productNameOf = (sub) => (sub ? config.products[productKeyOf(sub)]?.name || 'WEBklar Abo' : null)
/** Beschriftung des gekuendigten Vertrags fuer K03/T08: das tatsaechliche Abo (Spec 4.5) bzw. der Zusatzspeicher. */
function contractLabel(contract, sub) {
const abo = productNameOf(sub) || 'WEBklar Abo (Hosting- oder Wartungs-Abo)'
return contract === 'zusatzspeicher' ? `E-Mail-Zusatzspeicher${sub ? ` zum ${abo}` : ''}` : abo
}
/** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */
function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) {
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
if (order === 'termine') return 'Terminplaner (Einrichtung)'
if (order === 'shop') return 'Webshop (Einrichtung)'
if (order === 'unterkunft') return 'Buchungskalender (Einrichtung)'
if (order === 'zeiten') return 'Öffnungszeiten live'
const abo = productNameOf(sub)
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
}
/**
* Nachweis der Erklaerung als Vertragsereignis kuendigung bzw. widerruf (F31, Datenschutz Abschnitt 7: ohne
* IP-Adresse und Browser). Feste ID je Abo und Eingangszeitpunkt (eventIdFor, 36 Zeichen). Laesst es sich
* nicht speichern, geht die Erklaerung trotzdem weiter: die Mails sind der Nachweis, T08/T09 sagen es dem Team.
*/
async function recordDeclarationEvent(sub, type, receivedAt, details, status) {
try {
return await recordContractEvent({
id: eventIdFor(type, `${sub.$id}:${receivedAt.toISOString()}`),
customerId: sub.customerId,
subscriptionId: sub.$id,
type,
productKey: productKeyOf(sub),
details,
consent: null,
status,
})
} catch (err) {
console.error(`[abo] Ereignis ${type} für Abo ${sub.$id} nicht gespeichert:`, err.message)
return null
}
}
/** Kuendigung des Abos wie bisher: Stripe (ausser ausserordentlich), Eingang am Abo, Ereignis kuendigung. */
async function cancelAbo(sub, d, receivedAt) {
let effectiveAt = null
try {
if (d.kind !== 'ausserordentlich') {
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton', d.kind, receivedAt)
sub = result.subscription || sub
effectiveAt = result.effectiveAt
}
await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString() })
} catch (err) {
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
}
// Erledigt nur, wenn nichts mehr zu tun ist; ausserordentlich, Geschaeftsaufgabe (Nachweis) und Stripe-Fehler
// bleiben als Aufgabe offen
const status = effectiveAt && d.kind === 'ordentlich' ? 'erledigt' : 'offen'
const event = await recordDeclarationEvent(sub, 'kuendigung', receivedAt, { contract: 'abo', kind: d.kind, effectiveAt }, status)
return { sub, effectiveAt, event, outcome: effectiveAt ? 'gekuendigt' : 'offen' }
}
/**
* Wechsel ins Wartungs-Abo, der noch auf die Kartenzahlung wartet (offenes Ereignis wechsel ohne effectiveAt
* oder pending_update am Stripe-Abo): eine Aenderung des Abos wuerde die Wechselrechnung stornieren. Ein
* abgelaufener Wechsel wird vorher wie in GET /api/abo mit Stripe abgeglichen.
*/
async function pendingCardSwitch(sub, stripeSub) {
const readOpen = async () =>
(await listContractEvents(sub.customerId)).find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === sub.$id) || null
let open = await readOpen()
if (open && (await reconcilePendingSwitch(open))) open = await readOpen()
return Boolean((open && !open.details?.effectiveAt) || stripeSub?.pending_update)
}
/**
* Kuendigung nur des Zusatzspeichers (Bedingungen § 8 Abs. 7/8, § 10 Abs. 5): gebuchte Pakete minus packages
* (leer = alle) ueber changeStorage mit viaCancellation (Task 8: nur Verringerung, in jedem laufenden Status,
* auch bei gekuendigtem Abo), wirksam zum Ende des Abrechnungsmonats. Ohne gebuchten Speicher, waehrend ein
* Kartenwechsel aussteht oder wenn changeStorage scheitert, aendert sich nichts: K03 sagt "wir pruefen", T08
* traegt den Grund (F20). Liefert { outcome, error, event, details? } (details nur nach einer Verringerung).
*/
async function cancelStorage(sub, d, receivedAt) {
// Gebuchte Menge frisch aus Stripe (der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken); die
// Antwort zeigt auch, ob eine Aenderung bis zur Zahlung zurueckgehalten wird
let stripeSub = null
let fromUnits = Number(sub.storageUnits) || 0
try {
stripeSub = await getStripe().subscriptions.retrieve(sub.$id)
fromUnits = describeItems(stripeSub.items?.data).storageUnits
} catch (err) {
console.error(`[abo] Stripe-Abo ${sub.$id} nicht lesbar, Speicherstand aus dem Spiegel:`, err.message)
}
const base = { contract: 'zusatzspeicher', effectiveAt: null, packages: d.packages, fromUnits, toUnits: null }
const pending = async (outcome, error = null) => ({
outcome,
error,
event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, base, 'offen'),
})
if (fromUnits <= 0) return pending('kein_speicher')
// Laesst sich nicht klaeren, ob ein Wechsel aussteht, bleibt Stripe unangetastet
try {
if (await pendingCardSwitch(sub, stripeSub)) return pending('wechsel_ausstehend')
} catch (err) {
console.error(`[abo] Wechsel-Prüfung für Abo ${sub.$id} nicht möglich:`, err.message)
return pending('fehlgeschlagen', `Vertragsereignisse nicht lesbar: ${err.message}`)
}
const restUnits = Math.max(0, fromUnits - (d.packages ?? fromUnits))
try {
const { change } = await changeStorage(sub.customerId, restUnits, null, { viaCancellation: true })
const details = {
contract: 'zusatzspeicher',
effectiveAt: change.effectiveAt,
packages: change.fromUnits - change.toUnits,
fromUnits: change.fromUnits,
toUnits: change.toUnits,
}
return { outcome: 'reduziert', error: null, details, event: await recordDeclarationEvent(sub, 'kuendigung', receivedAt, details, 'erledigt') }
} catch (err) {
console.error(`[abo] Zusatzspeicher über die Kündigung nicht verringert (Abo ${sub.$id}):`, err.message)
return pending('fehlgeschlagen', err.message || 'unbekannter Fehler')
}
}
router.post('/public/cancel', async (req, res) => {
const d = readDeclaration(req, res)
if (!d) return
const receivedAt = new Date()
const storage = d.contract === 'zusatzspeicher'
let sub = null
let result = { outcome: 'kein_abo', effectiveAt: null, details: null, event: null, error: null }
try {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub) {
result = storage ? await cancelStorage(sub, d, receivedAt) : await cancelAbo(sub, d, receivedAt)
sub = result.sub || sub
}
} catch (err) {
console.error('[abo] Kuendigung nicht verarbeitbar:', err.message)
}
const reduced = result.outcome === 'reduziert'
const effectiveAt = storage ? result.details?.effectiveAt || null : result.effectiveAt
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
const common = {
name: d.name,
email: d.email,
kundennummer: d.customerNumber,
vertrag: contractLabel(d.contract, sub),
produkt: productLabelOf(sub),
// Eine Verringerung des Speichers wirkt immer zum Ende des Abrechnungsmonats
wunschtermin: storage ? 'Ende des Abrechnungsmonats' : d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt',
grund: d.reason,
vertragsende: fmtDate(effectiveAt),
// {{pakete}} = tatsaechlich gekuendigte Pakete (F31, nur nach einer Verringerung); {{pakete_angabe}} = die
// Erklaerung des Kunden (Inhalt der Erklaerung, § 312k Abs. 4 BGB) in jedem Speicherpfad
pakete: reduced ? result.details.packages : '',
pakete_angabe: d.packages ? `${d.packages} Paket(e)` : 'alle gebuchten Pakete',
}
// Eine ausserordentliche Kuendigung oder Geschaeftsaufgabe des Speichers wird trotzdem zum Periodenende
// verringert; die Pruefung der Art kommt dazu (Fix 1, 6)
const storageKindCheck = storage && d.kind !== 'ordentlich'
await sendCustomerMail(d.email, {
template: 'K03',
values: { ...common, pakete_neu: reduced ? result.details.toUnits : '', kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt), vertragsende_paragraph: endParagraphOf(sub) },
blocks: {
kundennummer: Boolean(d.customerNumber),
grund: Boolean(d.reason),
abo: !storage,
// Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo)
wartung: !storage && isWartung(sub),
// Website-Analyse und Terminplaner (funktionen) bzw. die Funktionen des Abo-Modells (funktionen_abo) enden mit dem
// Wartungs-Abo (Bloecke innerhalb von abo, nie bei der Speicher-Kündigung), nur fuer einen Kunden, der sie hat (funktionenBloecke)
...(storage ? { funktionen: false, funktionen_abo: false } : await funktionenBloecke(sub)),
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
// Abo: nicht automatisch gekuendigt (kein Abo oder Stripe-Fehler) -> "wir ordnen zu"; Speicher: kein Abo
kein_abo_gefunden: storage ? !sub : !effectiveAt && d.kind !== 'ausserordentlich',
zusatzspeicher: reduced,
pruefen: storage && Boolean(sub) && !reduced,
speicher_angaben: storage,
speicher_kind_pruefung: storageKindCheck && reduced,
},
}).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
await sendAdminNotice({
template: 'T08',
values: {
...common,
kuendigungsart: kindLabel[0].toUpperCase() + kindLabel.slice(1),
stripe_abo_id: sub?.stripeSubscriptionId || '',
fehler: result.error || '',
},
blocks: {
kundennummer: Boolean(d.customerNumber),
grund: Boolean(d.reason),
automatisch_gekuendigt: !storage && Boolean(effectiveAt),
sonderkuendigung: !storage && d.kind === 'geschaeftsaufgabe',
// Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler
ausserordentlich: !storage && !effectiveAt && Boolean(sub),
kein_abo_gefunden: !sub,
zusatzspeicher: reduced,
kein_speicher: result.outcome === 'kein_speicher',
wechsel_ausstehend: result.outcome === 'wechsel_ausstehend',
speicher_fehlgeschlagen: result.outcome === 'fehlgeschlagen',
nachweis_fehlt: Boolean(sub) && !result.event,
speicher_angaben: storage,
speicher_kind_pruefung: storageKindCheck,
},
})
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
})
/** Spaetestes Fristende einer Liste von Widerrufsfristen (openWithdrawals) oder null. */
const latestUntil = (list) => list.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null)
/**
* Widerruf der gewaehlten Bestellung: liegt eine offene Frist des Typs vor (openWithdrawals; Altbestand ohne
* Ereignis abschluss: ab startedAt, F14), bekommen alle Bezugsereignisse den Status widerrufen (F03), beim
* Abschluss wird das Abo wie bisher auf Kuendigung gesetzt; Wechsel und Zusatzspeicher setzt das Team von Hand
* zurueck (T09). Ohne offene Frist aendert sich nichts. Immer entsteht das Ereignis widerruf mit
* details { order, eventId, eventIds, inTime }.
*/
async function withdrawOrder(sub, d, receivedAt) {
const type = ORDER_EVENT_TYPE[d.order]
// Ereignisse nicht lesbar: nichts automatisch aendern (ausser dem Rueckfall auf startedAt beim Abschluss),
// T09 sagt "von Hand pruefen" (Fix 1, 4)
let events = []
let unreadable = false
try {
events = (await listContractEvents(sub.customerId)).filter((e) => e.subscriptionId === sub.$id && e.type === type)
} catch (err) {
unreadable = true
console.error(`[abo] Vertragsereignisse für Widerruf (Abo ${sub.$id}) nicht lesbar:`, err.message)
}
let open = openWithdrawals(events, receivedAt)
let deadline = latestUntil(open)
let startMissing = false
if (type === 'abschluss' && !events.length) {
if (sub.startedAt) {
const { endsAt } = withdrawalDeadline(sub.startedAt)
deadline = endsAt
if (receivedAt <= endsAt) open = [{ type, eventId: null, until: endsAt }]
} else {
startMissing = true
}
}
if (!deadline) {
// Frist schon vorbei: das spaeteste Fristende der Bestellungen dieses Typs fuer den Hinweis ans Team
const dates = events.map((e) => e.details?.contractAt ?? e.consentAt).filter(Boolean)
deadline = latestUntil(dates.map((at) => ({ until: withdrawalDeadline(at).endsAt })))
}
const inTime = open.length > 0
const ids = open.map((w) => w.eventId).filter(Boolean)
const withdrawn = []
let canceled = false
let cancelFailed = false
if (inTime) {
for (const id of ids) {
try {
await updateContractEvent(id, { status: 'widerrufen', doneAt: receivedAt.toISOString(), doneBy: 'widerrufsbutton' })
withdrawn.push(events.find((e) => e.$id === id))
} catch (err) {
console.error(`[abo] Ereignis ${id} nicht auf widerrufen gesetzt:`, err.message)
}
}
if (type === 'abschluss') {
try {
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
sub = (await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')) || sub
canceled = true
await updateDocument(config.collections.subscriptions, sub.$id, { withdrawalRequestedAt: receivedAt.toISOString() })
} catch (err) {
cancelFailed = true
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
}
}
}
if (inTime && type === 'wechsel') {
// Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran). Nicht in der neuen Ansicht
// (Kundenansicht neu, P6): dort enthaelt auch das Hosting-Abo die Analyse, sie bleibt. Ist das nicht lesbar, bleibt sie ebenfalls (der
// taegliche Abo-Check der Analyse raeumt bei Bedarf auf).
try {
const neueAnsichtKunde = await aboModellFuerKunde(sub.customerId)
if (!neueAnsichtKunde) {
const { endAnalyseForCustomer } = await import('../services/analyse.js')
await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt })
}
} catch (err) {
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
// Ebenso der Terminplaner (gehoert zum Wartungs-Abo): sofort abschalten und K22 (bzw. T19 bei bezahlter Einrichtung) melden, ohne
// Abo-Pruefung - der Spiegel steht bis zur Handarbeit des Teams noch auf Wartung. Der Widerruf scheitert nie daran; was hier
// misslingt, holt der Tagesabgleich nach (aboLage kennt den widerrufenen Wechsel).
try {
const { endeTermineFuerKunde } = await import('../services/termine/aboAbgleich.js')
await endeTermineFuerKunde(sub.customerId, { now: receivedAt })
} catch (err) {
console.error(`[abo] Terminplaner nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
// Funktionen des Abo-Modells (News, Terminplaner, Webshop, Buchungskalender, Oeffnungszeiten live; auch im Projekt eingebaute): ebenso sofort,
// K39 mit dem Hosting-Satz; was misslingt, holt der Tagesabgleich nach
try {
const { beendeWartungsFunktionen } = await import('../services/aboFunktionenAbgleich.js')
await beendeWartungsFunktionen(sub.customerId, { now: receivedAt })
} catch (err) {
console.error(`[abo] Funktionen des Abo-Modells nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
}
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
return {
sub,
inTime,
deadline,
withdrawn,
canceled,
cancelFailed,
event,
unreadable,
startMissing,
// Dieselbe Bestellung wurde schon widerrufen (Fix 1, 5)
alreadyWithdrawn: !inTime && events.some((e) => e.status === 'widerrufen'),
// "NACH Fristende" nur, wenn die Frist wirklich vorbei ist
late: !inTime && Boolean(deadline) && receivedAt > deadline,
// Kartenwechsel, der noch auf die Zahlung wartet: die offene Wechselrechnung muss storniert werden (Fix 1, 7)
openSwitchWithdrawn: withdrawn.some((e) => e.type === 'wechsel' && !e.details?.effectiveAt),
}
}
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
/**
* Widerruf einer Funktion (News-Pinnwand 30 €, Terminplaner-Einrichtung 149 € oder Webshop-Einrichtung 299 €, je einmalig): gehoert zu keinem Abo
* und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse;
* das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von
* Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt
* (Zeit des Klicks der bezahlten Sitzung, ohne Nachweis orderedAt); das spaeteste Fristende entscheidet ueber "rechtzeitig".
* Dokumente ohne featureKey stammen aus der Zeit, als es nur die News gab.
*/
async function withdrawFeature(d, receivedAt, featureKey) {
const customer =
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
const alle = customer ? await paidFeatureOrders(customer.$id) : []
const orders = alle.filter((o) => (o.featureKey || 'news') === featureKey)
const endOf = (o) => {
const anker = o.vertragAt || o.orderedAt
return anker ? new Date(withdrawalDeadline(anker).endsAt) : null
}
const ends = orders.map(endOf).filter(Boolean)
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => e.getTime()))) : null
const lines = orders
.map((o) => {
const ende = endOf(o)
return `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${ende ? `, Fristende ${fmtDate(ende)}` : ''}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`
})
.join('\n')
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
async function withdrawProjekt(d, receivedAt) {
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
}
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
const ergebnisText = {
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
nach_frist: () => 'nichts geändert',
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
}[ergebnis]
return {
ereignisse: ergebnisText
? ergebnisText()
: AB_NUMMER.test(abNummer)
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
}
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
function describeWithdrawn(events) {
return events
.map((e) => {
const at = fmtDate(e.details?.contractAt ?? e.consentAt)
const what = e.type === 'speicher' ? `${e.details?.fromUnits ?? '?'} → ${e.details?.toUnits ?? '?'} Pakete` : `${e.type} ${e.details?.switchedAt ? `vom ${fmtDate(e.details.switchedAt)}` : ''}`.trim()
return `${e.$id}: ${what} (bestellt am ${at})`
})
.join('\n')
}
router.post('/public/withdraw', async (req, res) => {
const d = readDeclaration(req, res)
if (!d) return
const receivedAt = new Date()
let sub = null
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null
const projekt = d.order === 'projekt'
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try {
if (projekt) {
pr = await withdrawProjekt(d, receivedAt)
} else if (feature) {
fn = await withdrawFeature(d, receivedAt, featureKey)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub) {
result = await withdrawOrder(sub, d, receivedAt)
sub = result.sub || sub
}
}
} catch (err) {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
}
const { inTime } = result
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
// Fuer T09: warum sich die Frist nicht pruefen liess
const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : ''
await sendCustomerMail(d.email, {
template: 'K04',
values: {
name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }),
produkt: produktText,
eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email,
kundennummer: d.customerNumber,
},
blocks: {
kundennummer: Boolean(d.customerNumber),
abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature,
pruefen: !inTime && !feature && !projekt,
projekt,
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
},
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
// Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zaehlen muss
await sendAdminNotice({
template: 'T09',
values: {
email: d.email,
name: d.name,
kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }),
produkt: produktText,
preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: projekt
? projektAngaben.rechtzeitig
: feature
? !fn.customer
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
: !fn.orders.length
? 'ungeprüft (keine bezahlte Funktion gefunden)'
: inTime
? 'RECHTZEITIG'
: 'NACH Fristende, vor einer Ablehnung bitte prüfen'
: !sub
? 'ungeprüft (kein Abo gefunden)'
: inTime
? 'RECHTZEITIG'
: result.alreadyWithdrawn
? 'bereits widerrufen'
: pruefhinweis
? `ungeprüft (${pruefhinweis})`
: result.late
? 'NACH Fristende, vor einer Ablehnung bitte prüfen'
: result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn),
pruefhinweis,
},
blocks: {
kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: result.canceled,
kein_abo_gefunden: !sub && !feature && !projekt,
kuendigung_fehlgeschlagen: result.cancelFailed,
abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature,
keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn,
nachweis_fehlt: Boolean(sub) && !result.event,
wechsel_offen: result.openSwitchWithdrawn,
abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn,
nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis),
bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn,
projekt,
},
})
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
})
export default router