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 { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js' import { ACTIVE_STATUSES, cancelAtPeriodEnd, cancelSubscriptionAt, constructWebhookEvent, createAboCheckout, createBillingPortalUrl, getCurrentSubscription, grantCreditForInvoice, 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() } function minTermEndOf(sub) { const start = sub?.trialEnd || sub?.startedAt const months = minTermMonthsFor(sub?.termsVersion) return start && months ? addMonthsIso(start, months) : null } function publicSubscription(sub) { if (!sub) return null const active = ACTIVE_STATUSES.has(sub.status) // Fristende 24 Uhr, bei Sa/So/Feiertag am nächsten Werktag (deadlines.js) const withdrawalUntil = sub.startedAt ? withdrawalDeadline(sub.startedAt).endsAt : null return { status: sub.status, active, 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, // Widerrufsbutton im Portal nur waehrend der Frist hervorheben withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()), withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null, // Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...") offerText: sub.offerText || 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) // --------------------------------------------------------------------------- /** Abo-Status + Stundenkonto fuer die Startseite. */ router.get('/', requireSession, async (req, res) => { try { const customerId = getSessionCustomerId(req) const [sub, customer] = await Promise.all([ getCurrentSubscription(customerId), getDocument(config.collections.customers, customerId).catch(() => null), ]) const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status)) const [ledger, offer] = await Promise.all([ sub ? 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, subscription: publicSubscription(sub), offer: offer ? offerView(offer) : null, ledger, // Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite lateStartAt: lateStartAt().toISOString(), }) } catch (err) { return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' }) } }) /** 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 { acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {} if (acceptTerms !== true) { return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' }) } 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 const 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.' }) } 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') || '', }, 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' }) } }) // --------------------------------------------------------------------------- // 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) }, }) } 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) 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 }, }) await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) => console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message) ) return } case 'invoice.paid': { const result = await grantCreditForInvoice(obj) if (result.granted) { console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`) } 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 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 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