Files
Webklar-Kundenbereich/server/routes/abo.js
T

1247 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 { 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 { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
import { featureConfig } from '../services/featureRegistry.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()
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'
// {{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)
// ---------------------------------------------------------------------------
/** 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),
])
return res.json({
available: isStripeConfigured() && isPilotAllowed(customer, hostingOffer || wartungOffer),
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,
},
offers: { hosting: hostingOffer ? offerView(hostingOffer) : null, wartung: wartungOffer ? offerView(wartungOffer) : 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: 'In der Kundenansicht 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: 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.' })
}
// 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: '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) {
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: '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' })
}
})
/**
* 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 || {}
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 || ''
}
/**
* 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),
sofortbeginn: !lateStart,
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 und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo
blocks: { wartung: isWartung(sub), funktionen: isWartung(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 || '' } : {}),
},
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']
// 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' }
// 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'; 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,
}
}
/** 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 } = {}) {
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)'
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 enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung)
funktionen: !storage && isWartung(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)
try {
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)
}
}
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 € oder Terminplaner-Einrichtung 149 €, 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 }
}
/** 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 oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
const feature = featureKey !== null
const funktion = feature ? featureConfig(featureKey) : null
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try {
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)
}
const { inTime } = result
// 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 }),
produkt: feature ? funktion.label.name : productLabelOf(sub),
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,
},
}).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),
produkt: feature ? funktion.label.name : productLabelOf(sub),
preis: feature ? funktion.priceLabel : '',
stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
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: 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,
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,
},
})
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
})
export default router