import { readFile } from 'node:fs/promises' 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 { duFehlerAntworten } from '../services/duFehler.js' import { PRODUCT_KEYS, contractAttachmentFor, 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 = '') { if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return const terms = termsDocFor(subDoc.termsVersion) const attachment = contractAttachmentFor(terms.version) const pdf = await readFile(attachment.path).catch((err) => { console.error(`[abo] Vertragsunterlagen ${attachment.filename} nicht lesbar:`, err.message) return null }) 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, }, attachments: pdf ? [{ filename: attachment.filename, content: pdf, contentType: 'application/pdf' }] : [], }) 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 } ) 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 }, }) await sendContractConfirmation(sub, obj.customer_details?.name).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