780 lines
35 KiB
JavaScript
780 lines
35 KiB
JavaScript
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 } from '../services/mailStorage.js'
|
||
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
||
import {
|
||
handlePendingUpdateApplied,
|
||
handlePendingUpdateExpired,
|
||
handleSwitchInvoicePaid,
|
||
handleSwitchPaymentFailed,
|
||
previewSwitch,
|
||
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')
|
||
const pendingPlus = openOf('speicher').find((e) => e.details?.toUnits > e.details?.fromUnits)
|
||
const pendingMinus = openOf('speicher').find((e) => e.details?.toUnits < e.details?.fromUnits)
|
||
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: Boolean(pendingPlus),
|
||
// Minus bestellt, wirkt zum Periodenende ("ab TT.MM. X GB")
|
||
pendingReduction: pendingMinus ? { units: pendingMinus.details.toUnits, effectiveAt: pendingMinus.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)
|
||
const [sub, customer, events] = await Promise.all([
|
||
getCurrentSubscription(customerId),
|
||
getDocument(config.collections.customers, customerId).catch(() => null),
|
||
// 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
|
||
listContractEvents(customerId).catch((e) => {
|
||
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
|
||
return []
|
||
}),
|
||
])
|
||
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 } ("Zahlungspflichtig bestellen").
|
||
* 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 } = 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)) {
|
||
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
|
||
)
|
||
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
|