server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung, zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash, zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register). paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung verschiebt den Anker nicht. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1218 lines
57 KiB
JavaScript
1218 lines
57 KiB
JavaScript
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 { 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),
|
||
},
|
||
blocks: { wartung: 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']
|
||
// 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'; 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)'
|
||
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),
|
||
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)
|
||
}
|
||
}
|
||
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 (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags-
|
||
* ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team
|
||
* bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand.
|
||
*/
|
||
async function withdrawFeature(d, receivedAt) {
|
||
const customer =
|
||
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
|
||
const orders = customer ? await paidFeatureOrders(customer.$id) : []
|
||
// Anker der Frist ist die Zeit des Klicks der bezahlten Sitzung (vertragAt), ohne Nachweis orderedAt
|
||
const ends = orders.filter((o) => o.vertragAt || o.orderedAt).map((o) => withdrawalDeadline(o.vertragAt || o.orderedAt).endsAt)
|
||
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null
|
||
const lines = orders
|
||
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${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: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||
const feature = d.order === 'funktion'
|
||
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
|
||
try {
|
||
if (feature) {
|
||
fn = await withdrawFeature(d, receivedAt)
|
||
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 ? 'News-Pinnwand' : 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 ? 'News-Pinnwand' : productLabelOf(sub),
|
||
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
|