import { readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' 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 } from '../services/contractEvents.js' import { lateStartAt, withdrawalDeadline } from '../services/deadlines.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, } from '../services/planSwitch.js' import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, cancelAtPeriodEnd, cancelSubscriptionAt, constructWebhookEvent, createAboCheckout, createBillingPortalUrl, getCurrentSubscription, grantCreditForInvoice, loadInvoice, minTermMonthsFor, isStripeConfigured, syncSubscription, } from '../services/stripeBilling.js' const router = Router() const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public') 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' /** * 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) // --------------------------------------------------------------------------- /** 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, offer] = await Promise.all([ // Arbeitszeit gibt es nur im Wartungs-Abo sub && productKeyOf(sub) === 'wartung' ? getLedger(customerId, { active }) : null, active ? null : getOpenOffer(customerId).catch(() => null), ]) return res.json({ available: isStripeConfigured() && isPilotAllowed(customer, offer), testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()), termsVersion: config.abo.termsVersion, // 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, }, // Angebote gibt es bis Aufgabe 11 nur fuer das Wartungs-Abo offers: { hosting: null, wartung: offer ? offerView(offer) : null }, ledger, subscription: publicSubscription(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). */ router.post('/checkout', requireSession, async (req, res) => { try { if (req.session.viewAs) { return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' }) } const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {} if (!PRODUCT_KEYS.includes(productKey)) { return res.status(400).json({ error: 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).' }) } if (acceptTerms !== true) { return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' }) } if (startChoice !== 'sofort' && startChoice !== 'nach_frist') { return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' }) } if (termsVersion !== config.abo.termsVersion) { return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) } const customer = await getDocument(config.collections.customers, getSessionCustomerId(req)) // Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat. Angebote gibt es bis // Aufgabe 11 nur fuer das Wartungs-Abo; ein Angebot bei einer Hosting-Bestellung passt nie. let offer = null if (productKey === 'wartung') { offer = await getOpenOffer(customer.$id) if ((offer?.$id || '') !== String(offerId || '')) { return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' }) } } else if (offerId) { return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' }) } if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) { return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' }) } // Ebenso genau der Leistungsbeginn, den die Bestellseite gezeigt hat (verschiebt sich um Mitternacht) const serviceStartAt = startChoice === 'nach_frist' ? lateStartAt() : null if (serviceStartAt && shownLateStart !== serviceStartAt.toISOString()) { return res.status(409).json({ error: 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.' }) } // Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser const now = new Date().toISOString() const url = await createAboCheckout( customer, { termsAcceptedAt: now, startChoice, earlyStartConsentAt: startChoice === 'sofort' ? now : '', serviceStartAt, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '', }, productKey, offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null ) 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: 'Im "Als Kunde ansehen"-Modus 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: 'Im "Als Kunde ansehen"-Modus 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: 'Im "Als Kunde ansehen"-Modus kann der Zusatzspeicher nicht geändert werden.' }) } const customerId = getSessionCustomerId(req) const { units, acceptTerms, termsVersion } = req.body || {} let consent = null if (acceptTerms === true) { if (termsVersion !== config.abo.termsVersion) { return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) } consent = { termsVersion, consentAt: new Date().toISOString(), consentIp: req.ip, consentUserAgent: req.get('user-agent') || '', } } 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: 'Im "Als Kunde ansehen"-Modus nicht möglich.' }) } return res.json(await previewSwitch(getSessionCustomerId(req))) } catch (err) { return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' }) } }) /** * 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: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' }) } const { acceptTerms, earlyStart, termsVersion, prorationDate, amountDueCents } = req.body || {} if (acceptTerms !== true) { return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' }) } 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)) { return res.status(400).json({ error: 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' }) } if (termsVersion !== config.abo.termsVersion) { return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) } const customerId = getSessionCustomerId(req) const now = new Date().toISOString() const { subscription, paymentUrl } = await switchToWartung( customerId, { termsVersion, consentAt: now, earlyStartConsentAt: now, 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 } ) 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 // --------------------------------------------------------------------------- /** 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 || '' } async function sendContractConfirmation(subDoc, fallbackName = '') { if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return const pdfPath = path.join(publicDir, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') const pdf = await readFile(pdfPath).catch(() => null) const lateStart = subDoc.startChoice === 'nach_frist' await sendCustomerMail(subDoc.email, { template: 'K01', values: { name: await customerNameOf(subDoc, fallbackName), 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: subDoc.termsVersion || config.abo.termsVersion, zustimmung_zeitpunkt: fmtDateTime(subDoc.termsAcceptedAt || Date.now()), }, blocks: { angebot: Boolean(subDoc.offerText), sofortbeginn: !lateStart, beginn_nach_widerrufsfrist: lateStart, }, attachments: pdf ? [{ filename: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf', 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), eingang_zeitpunkt: fmtDateTime(receivedAt), vertragsende: fmtDate(endsAt), }, }) await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString(), }).catch(() => {}) await sendAdminNotice({ template: 'T05', values: { email: sub.email, stripe_abo_id: sub.stripeSubscriptionId, vertragsende: fmtDate(endsAt) }, }) } /** * 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 || '' } : {}), }, 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 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: { 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, stripe_abo_id: obj.id } }) } return } case 'customer.subscription.deleted': { const sub = await syncSubscription(obj.id) await sendAdminNotice({ template: 'T07', values: { email: sub?.email || '', stripe_abo_id: obj.id, status: obj.status }, }) 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. // --------------------------------------------------------------------------- const recentRequests = new Map() // ip -> [timestamps] function rateLimited(ip) { const now = Date.now() const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000) list.push(now) recentRequests.set(ip, list) return list.length > 10 } 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 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) : '', } } 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 } router.post('/public/cancel', async (req, res) => { if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }) const d = validDeclaration(req.body) if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' }) const receivedAt = new Date() let sub = null let effectiveAt = null try { sub = await findActiveSubscriptionByEmail(d.email) if (sub && 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 effectiveAt = result.effectiveAt } if (sub) { await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString(), }) } } catch (err) { console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message) } 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, wunschtermin: d.effective ? fmtDate(`${d.effective}T12:00:00Z`) : 'nächstmöglicher Zeitpunkt', grund: d.reason, vertragsende: fmtDate(effectiveAt), } await sendCustomerMail(d.email, { template: 'K03', values: { ...common, kuendigungsart: kindLabel, eingang_zeitpunkt: fmtDateTime(receivedAt) }, blocks: { kundennummer: Boolean(d.customerNumber), grund: Boolean(d.reason), automatisch_gekuendigt: Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe', sonderkuendigung: Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe', ausserordentlich: !effectiveAt && d.kind === 'ausserordentlich', kein_abo_gefunden: !effectiveAt && d.kind !== 'ausserordentlich', }, }).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 || '', }, blocks: { kundennummer: Boolean(d.customerNumber), grund: Boolean(d.reason), automatisch_gekuendigt: Boolean(effectiveAt), sonderkuendigung: d.kind === 'geschaeftsaufgabe', // Abo gefunden, aber nicht gekuendigt: ausserordentlich oder Stripe-Fehler ausserordentlich: !effectiveAt && Boolean(sub), kein_abo_gefunden: !sub, }, }) return res.json({ ok: true, receivedAt: receivedAt.toISOString() }) }) router.post('/public/withdraw', async (req, res) => { if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }) const d = validDeclaration(req.body) if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' }) const receivedAt = new Date() let sub = null let canceled = false try { sub = await findActiveSubscriptionByEmail(d.email) if (sub) { // Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf') canceled = true await updateDocument(config.collections.subscriptions, sub.$id, { withdrawalRequestedAt: receivedAt.toISOString(), }) } } catch (err) { console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message) } await sendCustomerMail(d.email, { template: 'K04', values: { name: d.name, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber }, blocks: { kundennummer: Boolean(d.customerNumber) }, }).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) // Fristende wie im Portal berechnet (§§ 187, 188, 193 BGB), damit das Team nicht selbst zählen muss const deadline = sub?.startedAt ? withdrawalDeadline(sub.startedAt) : null await sendAdminNotice({ template: 'T09', values: { email: d.email, name: d.name, kundennummer: d.customerNumber, stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), fristende: fmtDate(deadline?.endsAt), rechtzeitig: !sub ? 'ungeprüft (kein Abo gefunden)' : !deadline ? 'ungeprüft (Abo-Beginn fehlt)' : receivedAt <= deadline.endsAt ? 'RECHTZEITIG' : 'NACH Fristende, vor einer Ablehnung bitte prüfen', }, blocks: { kundennummer: Boolean(d.customerNumber), abo_gefunden: Boolean(sub), auf_kuendigung_gesetzt: canceled, kein_abo_gefunden: !sub, kuendigung_fehlgeschlagen: Boolean(sub) && !canceled, }, }) return res.json({ ok: true, receivedAt: receivedAt.toISOString() }) }) export default router