Die PDFs der Fassung 6 entstehen erst nach der Freigabe. Bis dahin gingen K01, K12 und K13 ohne Anhang raus, sagten aber "im Anhang" bzw. "beigefügt", und das Team erfuhr es nicht (nur K38/T24 hatten dafür den Block anhang_fehlt). - Neuer Helfer vertragsAnhang.js: lädt das PDF der zugestimmten Fassung und meldet fehlt nur bei Fassung 6 ohne lesbare Datei. - K01, K12, K13: Block anhang (Anhangsaussagen) und ohne_anhang (Satz, dass die Unterlagen gesondert kommen). - T02, T12, T13: Block anhang_fehlt "Unterlagen von Hand nachsenden" (T12 auch beim späteren Wirksamwerden, T13 auch ohne Aufgabe). - Alte Fassungen unverändert: Hash-Pins auf die Wiedergabe der Vorlagen vor der Änderung, Anhang und Texte wie bisher. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1529 lines
76 KiB
JavaScript
1529 lines
76 KiB
JavaScript
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 { findAuftragByNummer } from '../services/auftraege/auftragStore.js'
|
||
import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.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,
|
||
wechselFunktionVon,
|
||
} from '../services/planSwitch.js'
|
||
import { assertOwnProject, handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
|
||
import { featureConfig } from '../services/featureRegistry.js'
|
||
import { istAboModell } from '../services/aboModell.js'
|
||
import { ENTHALTEN_IN, aboModellFuerKunde } from '../services/aboFunktionen.js'
|
||
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
|
||
import { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, WECHSEL_GESPERRT, bestelltexteFuerClient, bestellZeile, nachweisTexte } from '../services/bestelltexte.js'
|
||
import { kundenartWirksam } from '../services/kundenart.js'
|
||
import { fassungDerAnfrage, istNeueFassung, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js'
|
||
import { ladeVertragsAnhang } from '../services/vertragsAnhang.js'
|
||
import { duFehlerAntworten } from '../services/duFehler.js'
|
||
import { PRODUCT_KEYS, 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()
|
||
router.use(duFehlerAntworten) // neue Ansicht: bekannte Fehlertexte in du (duFehler.js), sonst unverändert
|
||
|
||
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'
|
||
// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner
|
||
const ABO_FUNKTIONEN = ['analyse', 'termine']
|
||
// {{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)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* publicSubscription, in der neuen Ansicht mit wechselFunktion (die beim Wechsel gewuenschte Funktion, die noch einzuschalten ist, sonst null;
|
||
* planSwitch.wechselFunktionVon). In der alten Ansicht fehlt der Schluessel. Ein Lesefehler ergibt null (die Seite bleibt nutzbar).
|
||
*/
|
||
async function subscriptionMitWechselFunktion(req, customerId, sub, events) {
|
||
const view = publicSubscription(sub, events)
|
||
if (!view || !neueAnsicht(req)) return view
|
||
try {
|
||
return { ...view, wechselFunktion: await wechselFunktionVon(customerId, events.filter((e) => e.subscriptionId === sub.$id)) }
|
||
} catch (err) {
|
||
console.error('[abo] Gewünschte Funktion des Wechsels nicht lesbar:', err.message)
|
||
return { ...view, wechselFunktion: null }
|
||
}
|
||
}
|
||
|
||
/** 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),
|
||
])
|
||
// Fassung 6 (P7): nur in der neuen Ansicht und mit gesetzter Neu-Fassung; sonst bleibt die Antwort wie sie war (neun Schluessel)
|
||
const fassung6 = neueAnsicht(req) && Boolean(config.abo.termsVersionNeu)
|
||
return res.json({
|
||
available: isStripeConfigured() && isPilotAllowed(customer, hostingOffer || wartungOffer),
|
||
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
|
||
termsVersion: fassung6 ? config.abo.termsVersionNeu : config.abo.termsVersion,
|
||
...(fassung6
|
||
? { bestelltexte: bestelltexteFuerClient({ fassung: config.abo.termsVersionNeu, termsUrl: termsDocFor(config.abo.termsVersionNeu).url }) }
|
||
: {}),
|
||
// 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: await subscriptionMitWechselFunktion(req, customerId, 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).
|
||
* Fassung 6 (neue Ansicht, body.termsVersion = config.abo.termsVersionNeu): Die Kundenart kommt allein aus dem Kundendokument
|
||
* (kundenartWirksam), nie aus dem Body; Betriebe beginnen sofort ohne Haekchen, Privatkunden nur mit earlyStart === true sofort, sonst
|
||
* nach der Widerrufsfrist. Der Nachweis (angezeigter Wortlaut samt Kundenart) wird vor der Stripe-Sitzung geschrieben. Alle anderen
|
||
* Anfragen laufen wie bisher (Fassung 5, Body mit startChoice).
|
||
*/
|
||
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, earlyStart } = req.body || {}
|
||
if (!PRODUCT_KEYS.includes(productKey)) {
|
||
return res.status(400).json({ error: 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).' })
|
||
}
|
||
const fassung = fassungDerAnfrage(req, termsVersion)
|
||
const sechs = fassung === 'neu'
|
||
if (acceptTerms !== true) {
|
||
return sechs
|
||
? res.status(400).json({ error: FEHLER_UNVOLLSTAENDIG })
|
||
: res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
|
||
}
|
||
// startChoice verlangt nur der alte Weg; eine Bestellung mit Fassung 6 oder aus der neuen Ansicht mit fremder Version bekommt gleich
|
||
// den Hinweis auf die geaenderten Bedingungen
|
||
const alterWeg = fassung === 'alt' || (fassung === null && !neueAnsicht(req) && !istNeueFassung(termsVersion))
|
||
if (alterWeg && startChoice !== 'sofort' && startChoice !== 'nach_frist') {
|
||
return res.status(400).json({ error: 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.' })
|
||
}
|
||
if (fassung === null) {
|
||
return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||
}
|
||
const customerId = getSessionCustomerId(req)
|
||
let customer
|
||
if (sechs) {
|
||
// Ein Lesefehler ist ein Serverfehler, nie ein Rueckfall auf eine Kundenart
|
||
try {
|
||
customer = await getDocument(config.collections.customers, customerId)
|
||
} catch (err) {
|
||
console.error('[abo] Kunde für die Bestellung nicht lesbar:', err.message)
|
||
return res.status(500).json({ error: FEHLER_NACHWEIS })
|
||
}
|
||
} else {
|
||
customer = await getDocument(config.collections.customers, customerId)
|
||
}
|
||
// 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.' })
|
||
}
|
||
// Beginn: im alten Weg die Wahl des Kunden; in Fassung 6 allein die Kundenart des Servers und earlyStart === true
|
||
const kundenart = sechs ? kundenartWirksam(customer) : null
|
||
const beginnWahl = sechs ? (kundenart === 'unternehmen' || earlyStart === true ? 'sofort' : 'nach_frist') : startChoice
|
||
const haekchenGesetzt = sechs && kundenart !== 'unternehmen' && earlyStart === true
|
||
// Ebenso genau der Leistungsbeginn, den die Bestellseite gezeigt hat (verschiebt sich um Mitternacht)
|
||
const serviceStartAt = beginnWahl === '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 angebot = offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null
|
||
if (sechs) {
|
||
// Der Wortlaut, den der Kunde gesehen hat, wird gespeichert, bevor Stripe etwas anlegt; scheitert das, passiert nichts
|
||
const produkt = productView(productKey)
|
||
await speichereNachweisFuerRoute({
|
||
customerId: customer.$id,
|
||
art: 'abo',
|
||
bezug: productKey,
|
||
texte: nachweisTexte('abo', {
|
||
kundenart,
|
||
fassung: termsVersion,
|
||
beginnGehakt: haekchenGesetzt,
|
||
bestellung: bestellZeile('abo', {
|
||
produkt: productLabel(productKey),
|
||
monatCents: produkt.amountCents,
|
||
mindestMonate: produkt.minTermMonths,
|
||
beginn: beginnWahl,
|
||
beginnAm: serviceStartAt?.toISOString(),
|
||
angebot: angebot?.text || undefined,
|
||
}),
|
||
}),
|
||
fassungen: { zustimmung: termsVersion },
|
||
kundenart,
|
||
req,
|
||
at: now,
|
||
})
|
||
}
|
||
const url = await createAboCheckout(
|
||
customer,
|
||
{
|
||
termsAcceptedAt: now,
|
||
startChoice: beginnWahl,
|
||
earlyStartConsentAt: beginnWahl === 'sofort' && (!sechs || haekchenGesetzt) ? now : '',
|
||
serviceStartAt,
|
||
consentIp: req.ip,
|
||
consentUserAgent: req.get('user-agent') || '',
|
||
...(sechs ? { termsVersion } : {}),
|
||
},
|
||
productKey,
|
||
angebot
|
||
)
|
||
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) {
|
||
const fassung = fassungDerAnfrage(req, termsVersion)
|
||
if (fassung === null) {
|
||
return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||
}
|
||
consent = {
|
||
termsVersion,
|
||
consentAt: new Date().toISOString(),
|
||
consentIp: req.ip,
|
||
consentUserAgent: req.get('user-agent') || '',
|
||
}
|
||
// Fassung 6: Der gezeigte Wortlaut samt Kundenart (aus dem Kundendokument) wird gespeichert, bevor Stripe etwas aendert. Eine ungueltige
|
||
// Menge ueberlaesst die Ablehnung wie bisher changeStorage (400, ohne Nachweis)
|
||
const mengeOk = Number.isInteger(units) && units >= 1 && units <= config.mailStorage.maxUnits
|
||
if (fassung === 'neu' && mengeOk) {
|
||
let kundenart
|
||
try {
|
||
kundenart = kundenartWirksam(await getDocument(config.collections.customers, customerId))
|
||
} catch (err) {
|
||
console.error('[abo] Kunde für den Zusatzspeicher nicht lesbar:', err.message)
|
||
return res.status(500).json({ error: FEHLER_NACHWEIS })
|
||
}
|
||
await speichereNachweisFuerRoute({
|
||
customerId,
|
||
art: 'speicher',
|
||
bezug: `units=${units}`,
|
||
texte: nachweisTexte('speicher', {
|
||
kundenart,
|
||
fassung: termsVersion,
|
||
bestellung: bestellZeile('speicher', { units, gb: units * config.mailStorage.gbPerUnit, monatCents: units * config.mailStorage.amountCents }),
|
||
}),
|
||
fassungen: { zustimmung: termsVersion },
|
||
kundenart,
|
||
req,
|
||
at: consent.consentAt,
|
||
})
|
||
}
|
||
}
|
||
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' })
|
||
}
|
||
})
|
||
|
||
/**
|
||
* Die beim Wechsel gewuenschte Funktion (Kundenansicht neu, P6, V11) aus dem Body: { key, projectId } oder null. Gueltig ist ein Schluessel
|
||
* aus dem Register, den nur das Wartungs-Abo enthaelt (ENTHALTEN_IN nur 'wartung': news, termine, shop, unterkunft, zeiten; nicht analyse) und
|
||
* ein Projekt des Kunden (assertOwnProject). Alles andere wird verworfen, nie abgelehnt: die Bequemlichkeit darf den bezahlten Vertrag nicht
|
||
* blockieren. Nur die beiden Schluessel key und projectId werden uebernommen.
|
||
*/
|
||
async function wechselFunktionAusBody(customerId, funktion) {
|
||
if (!funktion || typeof funktion !== 'object' || Array.isArray(funktion)) return null
|
||
const { key, projectId } = funktion
|
||
if (typeof key !== 'string' || typeof projectId !== 'string' || !projectId) return null
|
||
const enthalten = Object.hasOwn(ENTHALTEN_IN, key) ? ENTHALTEN_IN[key] : null
|
||
if (!enthalten || enthalten.length !== 1 || enthalten[0] !== 'wartung') return null
|
||
try {
|
||
await assertOwnProject(customerId, projectId)
|
||
} catch {
|
||
return null
|
||
}
|
||
return { key, projectId }
|
||
}
|
||
|
||
/**
|
||
* 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 || {}
|
||
const fassung = fassungDerAnfrage(req, termsVersion)
|
||
const sechs = fassung === 'neu'
|
||
if (acceptTerms !== true) {
|
||
return res.status(400).json({ error: sechs ? FEHLER_UNVOLLSTAENDIG : 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' })
|
||
}
|
||
if (!sechs) {
|
||
// Fassung 6 aus der alten Ansicht oder fremde Version aus der neuen Ansicht: gleich der Hinweis auf die geaenderten Bedingungen
|
||
if (fassung === null && (neueAnsicht(req) || istNeueFassung(termsVersion))) {
|
||
return res.status(409).json({ error: neueAnsicht(req) ? FEHLER_BEDINGUNGEN : 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||
}
|
||
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) || (sechs && amountDueCents < 0)) {
|
||
return res.status(400).json({ error: sechs ? FEHLER_UNVOLLSTAENDIG : 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' })
|
||
}
|
||
if (termsVersion !== config.abo.termsVersion && !sechs) {
|
||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||
}
|
||
const customerId = getSessionCustomerId(req)
|
||
const now = new Date().toISOString()
|
||
// Fassung 6: Kundenart allein aus dem Kundendokument (ein Lesefehler ist ein Serverfehler, nie ein Rueckfall); Privatkunden brauchen das Haekchen
|
||
let kundenart = null
|
||
if (sechs) {
|
||
try {
|
||
kundenart = kundenartWirksam(await getDocument(config.collections.customers, customerId))
|
||
} catch (err) {
|
||
console.error('[abo] Kunde für den Wechsel nicht lesbar:', err.message)
|
||
return res.status(500).json({ error: FEHLER_NACHWEIS })
|
||
}
|
||
if (kundenart !== 'unternehmen' && earlyStart !== true) return res.status(400).json({ error: WECHSEL_GESPERRT })
|
||
}
|
||
// Gewuenschte Funktion (nur neue Ansicht): geprueft oder verworfen, der Wechsel laeuft in jedem Fall
|
||
const funktion = neueAnsicht(req) ? await wechselFunktionAusBody(customerId, req.body?.funktion) : null
|
||
if (sechs) {
|
||
// Der gezeigte Wortlaut wird gespeichert, bevor Stripe etwas aendert; scheitert das, passiert nichts
|
||
await speichereNachweisFuerRoute({
|
||
customerId,
|
||
art: 'wechsel',
|
||
bezug: 'hosting>wartung',
|
||
texte: nachweisTexte('wechsel', {
|
||
kundenart,
|
||
fassung: termsVersion,
|
||
beginnGehakt: kundenart !== 'unternehmen',
|
||
bestellung: bestellZeile('wechsel', { heuteCents: amountDueCents, monatCents: config.products.wartung.amountCents, mindestMonate: config.abo.minTermMonths }),
|
||
}),
|
||
fassungen: { zustimmung: termsVersion },
|
||
kundenart,
|
||
req,
|
||
at: now,
|
||
})
|
||
}
|
||
const { subscription, paymentUrl } = await switchToWartung(
|
||
customerId,
|
||
{
|
||
termsVersion,
|
||
consentAt: now,
|
||
earlyStartConsentAt: sechs && kundenart === 'unternehmen' ? '' : now,
|
||
...(sechs ? { kundenart } : {}),
|
||
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, funktion }
|
||
)
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Welche Funktionen hat der Kunde dieses Abos? `alt`: die Website-Analyse oder der Terminplaner (Funktionsdokument in portalFeatures
|
||
* mit featureKey analyse oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht); dann gilt der
|
||
* Hinweis in K02 und K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). `modell`: mindestens
|
||
* ein Dokument des Abo-Modells (istAboModell) in einem Zustand ausser off; dann gilt statt dessen der Hinweis funktionen_abo
|
||
* (Kundenansicht neu, P6). Der Kunde ist der des Abos (sub.customerId, wie in customerNameOf); gelesen wird wie sonst auch ueber
|
||
* listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als { alt: true, modell: false } (der Kunde wird eher informiert als
|
||
* nicht), eine Logzeile, die Mail geht in jedem Fall raus.
|
||
*/
|
||
async function hatAboFunktionen(sub) {
|
||
try {
|
||
if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID')
|
||
const docs = await listCustomerFeatureDocs(sub.customerId)
|
||
const laeuft = (doc) => (doc.state || 'off') !== 'off'
|
||
return {
|
||
alt: docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && laeuft(doc)),
|
||
modell: docs.some((doc) => istAboModell(doc) && laeuft(doc)),
|
||
}
|
||
} catch (err) {
|
||
console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`)
|
||
return { alt: true, modell: false }
|
||
}
|
||
}
|
||
|
||
/** Blöcke funktionen (alter Hinweis) und funktionen_abo (Abo-Modell) der Kündigungsmails K02 und K03; nur beim Wartungs-Abo, sonst wird nichts gelesen. */
|
||
async function funktionenBloecke(sub) {
|
||
const { alt, modell } = isWartung(sub) ? await hatAboFunktionen(sub) : { alt: false, modell: false }
|
||
return { funktionen: alt && !modell, funktionen_abo: modell }
|
||
}
|
||
|
||
/** 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 = '', unterlagen = null) {
|
||
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
||
const terms = termsDocFor(subDoc.termsVersion)
|
||
// Fassung 6 ohne PDF: keine Behauptung "im Anhang" (Block ohne_anhang), T02 sagt es dem Team (F5); ältere Fassungen wie bisher.
|
||
// Der Aufrufer (Webhook) hat die Unterlagen für T02 schon geladen und reicht sie durch.
|
||
const { attachments, fehlt: anhangFehlt } = unterlagen ?? (await ladeVertragsAnhang(terms.version, 'abo'))
|
||
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),
|
||
// Fassung 6: ein Betrieb (Sofortbeginn ohne Zeitstempel) hat nichts verlangt; die Mail behauptet es nicht
|
||
sofortbeginn: !lateStart && !(istNeueFassung(subDoc.termsVersion) && !subDoc.earlyStartConsentAt),
|
||
beginn_nach_widerrufsfrist: lateStart,
|
||
anhang: !anhangFehlt,
|
||
ohne_anhang: anhangFehlt,
|
||
},
|
||
attachments,
|
||
})
|
||
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 nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner (funktionen)
|
||
// bzw. der Funktionen des Abo-Modells (funktionen_abo) nur beim Wartungs-Abo eines Kunden, der sie hat (funktionenBloecke)
|
||
blocks: { wartung: isWartung(sub), ...(await funktionenBloecke(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 || '' } : {}),
|
||
// Fassung 6: Sofortbeginn ohne Zeitstempel gibt es nur fuer Betriebe (Grund fuer die Mails und den Nachweis)
|
||
...(istNeueFassung(sub.termsVersion) && sub.startChoice === 'sofort' && !sub.earlyStartConsentAt ? { kundenart: 'unternehmen' } : {}),
|
||
},
|
||
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
|
||
}
|
||
)
|
||
// Vertragsunterlagen der zugestimmten Fassung einmal laden: T02 meldet fehlende PDFs der Fassung 6 (anhang_fehlt), K01 hängt sie an
|
||
const unterlagen = sub ? await ladeVertragsAnhang(termsDocFor(sub.termsVersion).version, 'abo') : null
|
||
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, anhang_fehlt: unterlagen?.fehlt === true },
|
||
})
|
||
await sendContractConfirmation(sub, obj.customer_details?.name, unterlagen).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', 'shop', 'unterkunft', 'zeiten', 'projekt']
|
||
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
|
||
// 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', shop: 'shop', unterkunft: 'unterkunft', zeiten: 'zeiten' }
|
||
// 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' | 'shop' | 'unterkunft' | 'zeiten' | 'projekt'; 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,
|
||
abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20),
|
||
}
|
||
}
|
||
|
||
/** 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, abNummer = '' } = {}) {
|
||
if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)'
|
||
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)'
|
||
if (order === 'shop') return 'Webshop (Einrichtung)'
|
||
if (order === 'unterkunft') return 'Buchungskalender (Einrichtung)'
|
||
if (order === 'zeiten') return 'Öffnungszeiten live'
|
||
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 (funktionen) bzw. die Funktionen des Abo-Modells (funktionen_abo) enden mit dem
|
||
// Wartungs-Abo (Bloecke innerhalb von abo, nie bei der Speicher-Kündigung), nur fuer einen Kunden, der sie hat (funktionenBloecke)
|
||
...(storage ? { funktionen: false, funktionen_abo: false } : await funktionenBloecke(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). Nicht in der neuen Ansicht
|
||
// (Kundenansicht neu, P6): dort enthaelt auch das Hosting-Abo die Analyse, sie bleibt. Ist das nicht lesbar, bleibt sie ebenfalls (der
|
||
// taegliche Abo-Check der Analyse raeumt bei Bedarf auf).
|
||
try {
|
||
const neueAnsichtKunde = await aboModellFuerKunde(sub.customerId)
|
||
if (!neueAnsichtKunde) {
|
||
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)
|
||
}
|
||
// Funktionen des Abo-Modells (News, Terminplaner, Webshop, Buchungskalender, Oeffnungszeiten live; auch im Projekt eingebaute): ebenso sofort,
|
||
// K39 mit dem Hosting-Satz; was misslingt, holt der Tagesabgleich nach
|
||
try {
|
||
const { beendeWartungsFunktionen } = await import('../services/aboFunktionenAbgleich.js')
|
||
await beendeWartungsFunktionen(sub.customerId, { now: receivedAt })
|
||
} catch (err) {
|
||
console.error(`[abo] Funktionen des Abo-Modells 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 €, Terminplaner-Einrichtung 149 € oder Webshop-Einrichtung 299 €, 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 }
|
||
}
|
||
|
||
/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */
|
||
async function withdrawProjekt(d, receivedAt) {
|
||
const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null
|
||
const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase())
|
||
if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
|
||
return widerrufeAuftrag(auftrag, { jetzt: receivedAt })
|
||
}
|
||
|
||
const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' }
|
||
|
||
/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */
|
||
function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) {
|
||
const ergebnisText = {
|
||
widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`,
|
||
bereits_widerrufen: () => 'war schon widerrufen, nichts geändert',
|
||
kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert',
|
||
nach_frist: () => 'nichts geändert',
|
||
nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`,
|
||
fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen',
|
||
}[ergebnis]
|
||
return {
|
||
ereignisse: ergebnisText
|
||
? ergebnisText()
|
||
: AB_NUMMER.test(abNummer)
|
||
? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert`
|
||
: 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen',
|
||
fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden',
|
||
rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft',
|
||
}
|
||
}
|
||
|
||
/** 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, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
|
||
const feature = featureKey !== null
|
||
const projekt = d.order === 'projekt'
|
||
let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null }
|
||
const funktion = feature ? featureConfig(featureKey) : null
|
||
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
|
||
try {
|
||
if (projekt) {
|
||
pr = await withdrawProjekt(d, receivedAt)
|
||
} else 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)
|
||
if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null }
|
||
}
|
||
const { inTime } = result
|
||
const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null
|
||
const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub)
|
||
// 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, abNummer: d.abNummer }),
|
||
produkt: produktText,
|
||
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 && !projekt,
|
||
projekt,
|
||
projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht',
|
||
},
|
||
}).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, { abNummer: d.abNummer }),
|
||
produkt: produktText,
|
||
preis: feature ? funktion.priceLabel : '',
|
||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||
abo_beginn: fmtDate(sub?.startedAt),
|
||
fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
|
||
rechtzeitig: projekt
|
||
? projektAngaben.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: projekt ? projektAngaben.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 && !projekt,
|
||
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,
|
||
projekt,
|
||
},
|
||
})
|
||
|
||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||
})
|
||
|
||
export default router
|