import { createHash, randomBytes } from 'node:crypto' import { config } from '../config.js' import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' import { speichereZustimmung, zustimmungFuerSitzung } from './featureConsents.js' import { aboLage, laeuft } from './aboStatus.js' import { berlinDateTime } from './berlinTime.js' import { loeschTag } from './termine/frist.js' import { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = featureKeys() /** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */ export const FEATURE_LABELS = Object.fromEntries( FEATURE_KEYS.map((key) => { const { name, keine } = featureConfig(key).label return [key, { name, keine }] }) ) export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] /** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */ export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht'] export const DISABLED_MESSAGE = featureConfig('news').disabledMessage const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567' function httpError(status, message) { const err = new Error(message) err.status = status return err } /** 16 Zeichen base32 (a-z, 2-7) aus 80 zufaelligen Bits. */ export function newSiteKey() { const bytes = randomBytes(10) let bits = 0n for (const b of bytes) bits = (bits << 8n) | BigInt(b) let out = '' for (let i = 15; i >= 0; i -= 1) out += BASE32[Number((bits >> BigInt(i * 5)) & 31n)] return out } /** * Feste Dokument-ID je (Projekt, Funktion): macht (Kunde, Projekt, key) eindeutig. Passt sie nicht in * Appwrites 36 Zeichen, wird sie per sha1 gekuerzt (wie eventIdFor). */ function featureDocId(projectId, key) { const plain = `f_${projectId}_${key}` if (plain.length <= 36 && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(plain)) return plain return `f_${createHash('sha1').update(`${projectId}_${key}`).digest('hex').slice(0, 34)}` } function assertKey(key) { if (!FEATURE_KEYS.includes(key)) throw httpError(400, 'Unbekannte Funktion') } /** state und enabled werden immer zusammen geschrieben. */ export function stateFields(state) { return { state, enabled: state === 'active' } } export async function getFeatureDoc(customerId, projectId, key) { try { const doc = await getDocument(config.collections.portalFeatures, featureDocId(projectId, key)) return doc.customerId === customerId ? doc : null } catch (err) { if (err.status === 404) return null throw err } } /** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */ export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) { let project = null try { project = await getDocument(config.collections.websiteProjects, projectId) } catch (err) { if (err.status !== 404) throw err } if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') if (forNewOrder && project.archived) { throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.') } return project } /** Dokument holen oder mit state 'off' und dauerhaftem siteKey anlegen. */ export async function ensureFeatureDoc(customerId, projectId, key) { const existing = await getFeatureDoc(customerId, projectId, key) if (existing) return existing try { return await createDocument( config.collections.portalFeatures, { customerId, projectId, featureKey: key, ...stateFields('off'), unlockedByPurchase: false, purchaseStatus: '', siteKey: newSiteKey(), }, featureDocId(projectId, key) ) } catch (err) { if (err.status === 409) { const again = await getFeatureDoc(customerId, projectId, key) if (again) return again } throw err } } async function ticketWoid(ticketId) { if (!ticketId) return null try { const ticket = await getDocument(config.collections.workorders, ticketId) return ticket.woid || null } catch (err) { if (err.status === 404) return null throw err } } /** * Ansicht einer Funktion fuer Kunde und Team. Abschaltgrund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt * (disabledAt) und Berliner Loeschtag (loeschungAm, 30 Tage danach, termine/frist.js) gibt es nur im Zustand `disabled`; * in jedem anderen Zustand sind alle drei null, auch wenn am Dokument noch Reste einer frueheren Abschaltung stehen. * loeschungAm gibt es nur fuer den Terminplaner (R105: der 30-Tage-Loeschlauf in jobs.js loescht nur Terminplaner-Daten, * bei News verspraeche das Datum eine Loeschung, die nie kommt) und ist null, wenn disabledAt fehlt oder nicht lesbar ist. * disabledReason und disabledAt gelten fuer jede Funktion. */ async function featureView(doc) { const abgeschaltet = doc?.state === 'disabled' return { state: doc?.state || 'off', siteKey: doc?.siteKey || null, ticketWoid: doc?.ticketId ? await ticketWoid(doc.ticketId) : null, orderedAt: doc?.orderedAt || null, activatedAt: doc?.activatedAt || null, invoiceUrl: doc?.invoiceUrl || null, disabledReason: abgeschaltet ? doc.disabledReason || null : null, disabledAt: abgeschaltet ? doc.disabledAt || null : null, loeschungAm: abgeschaltet && doc.featureKey === 'termine' ? loeschTag(doc.disabledAt) : null, } } /** Alle Funktionsdokumente eines Kunden (alle Projekte, alle Funktionen). */ export function listCustomerFeatureDocs(customerId) { return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) } /** * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. * includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort * eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen. */ export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId)) const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), ])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id)) const result = [] for (const p of projects) { const features = {} for (const key of FEATURE_KEYS) { const doc = docs.find((d) => d.projectId === p.$id && d.featureKey === key) features[key] = await featureView(doc) } result.push({ projectId: p.$id, projectName: p.projectName || '', liveUrl: p.liveDomain ? `https://${p.liveDomain}` : '', features, }) } return result } /** * Rechnungslink nachtragen: Stripe legt die Rechnung eines Zahlungs-Checkouts erst kurz nach der Zahlung an, * der Webhook sieht sie oft noch nicht. Best effort - Fehler werden geloggt und nie weitergereicht. */ export async function backfillInvoiceLinks(customerId) { try { const docs = await listDocuments(config.collections.portalFeatures, [ Query.equal('customerId', customerId), Query.limit(1000), ]) for (const doc of docs) { if ((doc.state !== 'setup' && doc.state !== 'active') || doc.invoiceUrl || !doc.stripeSessionId) continue try { const session = await retrieveCheckoutSession(doc.stripeSessionId, { expandInvoice: true }) const invoice = session.invoice && typeof session.invoice === 'object' ? session.invoice : null if (!invoice?.hosted_invoice_url) continue await updateDocument(config.collections.portalFeatures, doc.$id, { stripeInvoiceId: invoice.id || doc.stripeInvoiceId || '', invoiceUrl: invoice.hosted_invoice_url, }) } catch (err) { console.warn(`[funktionen] Rechnungslink fuer Sitzung ${doc.stripeSessionId} nicht nachgetragen:`, err.message) } } } catch (err) { console.warn('[funktionen] Rechnungslinks nicht nachgetragen:', err.message) } } /** * Zeitpunkt des Vertragsschlusses einer bezahlten Funktion (Anker der Widerrufsfrist): Zeit des Klicks aus dem Nachweis * zur bezahlten Stripe-Sitzung, sonst das Zustimmungsdatum am Dokument (Altbestand), sonst orderedAt. Eine spaetere, * unbezahlte Zustimmung (andere Sitzung) zaehlt nie. Die Widerrufspruefung darf an der Nachweis-Collection nicht * scheitern: ein Lesefehler faellt auf orderedAt zurueck (mit Log). */ async function vertragAt(doc) { try { const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId) if (nachweis?.acceptedAt) return nachweis.acceptedAt } catch (err) { console.warn(`[funktionen] Zustimmungsnachweis zu Sitzung ${doc.stripeSessionId} nicht lesbar, Fristanker ist orderedAt:`, err.message) return doc.orderedAt || '' } return doc.consentAt || doc.orderedAt || '' } /** * Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09). * Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht. vertragAt ist der Anker der Widerrufsfrist. */ export async function paidFeatureOrders(customerId) { const docs = await listDocuments(config.collections.portalFeatures, [ Query.equal('customerId', customerId), Query.limit(1000), ]) const paid = docs.filter((d) => ['setup', 'active', 'disabled'].includes(d.state) && d.unlockedByPurchase) const result = [] for (const doc of paid) { result.push({ featureKey: doc.featureKey || '', projekt: await projectName(doc.projectId).catch(() => ''), state: doc.state, orderedAt: doc.orderedAt || '', vertragAt: await vertragAt(doc), ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null, }) } return result } /** * Wortlaut der Zustimmung am Dokument (consentText): die Texte der Haken-Items der Funktion, durch eine Leerzeile getrennt. * Eine bequeme Kopie fuer das Team; massgeblich ist der vollstaendige Nachweis in featureConsents. */ export function hakenTexte(feature) { return feature.consent.items .filter((item) => item.haken) .map((item) => item.text) .join('\n\n') } /** * Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. * consent: { text, at } - Wortlaut (Kopie fuer das Team) und Zeit des Klicks. IP und Browser der Bestellung stehen NUR im Nachweis * (featureConsents, Loeschfrist wie dort, Datenschutzerklaerung Abschnitt 7) und werden am Funktionsdokument nie gespeichert: die Felder * consentIp und consentUserAgent werden leer geschrieben, das leert auch einen Wert aus einer frueheren Bestellung desselben Dokuments. */ export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { assertKey(key) const feature = featureConfig(key) const doc = await ensureFeatureDoc(customerId, projectId, key) if (doc.state === 'setup' || doc.state === 'active') throw httpError(409, feature.orderedConflictMessage) if (doc.state === 'disabled') throw httpError(409, feature.disabledMessage) return updateDocument(config.collections.portalFeatures, doc.$id, { featureKey: key, ...stateFields('ordered'), purchaseStatus: 'ordered', stripeSessionId: sessionId, orderedAt: new Date().toISOString(), consentText: String(consent.text || '').slice(0, 1000), consentAt: consent.at || null, consentIp: '', consentUserAgent: '', }) } async function findBySession(sessionId) { if (!sessionId) return null const [doc] = await listDocuments(config.collections.portalFeatures, [ Query.equal('stripeSessionId', sessionId), Query.limit(1), ]) return doc || null } /** Faelle einer Zahlung, die kein bestellbares Dokument trifft (portalFeatures.zahlungMeldung); 'gemeldet' setzt erst die Meldung (B10b). */ const ZAHLUNG_FAELLE = ['bereits_aktiv', 'abgeschaltet', 'abo_ende'] const NOTE_MAX = 300 /** Fall einer Zahlung auf ein Dokument im Zustand active, disabled oder setup mit Ticket. */ function zahlungsFall(doc) { if (doc.state !== 'disabled') return 'bereits_aktiv' return doc.disabledReason === 'abo_ende_vor_einrichtung' ? 'abo_ende' : 'abgeschaltet' } /** Der noch zu meldende Fall am Dokument (zahlungMeldung), sonst null; '', 'gemeldet' und alles andere ist kein offener Fall. */ function offenerFall(doc) { return ZAHLUNG_FAELLE.includes(doc.zahlungMeldung) ? doc.zahlungMeldung : null } /** Neuester Eintrag zuerst, hoechstens 300 Zeichen (wie die Notiz im Ticketsystem). */ function notizVorn(previous, entry) { const neu = entry.slice(0, NOTE_MAX) const aelter = String(previous || '') return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu } /** * Eine Zahlung trifft ein Dokument, das nicht (mehr) auf die Bezahlung wartet: active, disabled oder setup mit Ticket. Sie wird * einmalig verbucht (purchaseStatus 'paid', Rechnung, Notiz) und der Fall im eigenen Feld zahlungMeldung vermerkt - in einem einzigen * Schreibzugriff, damit nie eine verbuchte Zahlung ohne Marker bleibt. purchaseStatus bekommt nie einen Meldezustand (die * Widerrufspruefung liest ihn). Ein Folgeereignis bucht nichts mehr, liefert den Fall aber erneut, solange er nicht gemeldet ist. * Aufruf nur unter der Sperre der Sitzung. */ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) { if (doc.purchaseStatus === 'paid') return { doc, created: false, fall: offenerFall(doc) } const fall = zahlungsFall(doc) const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { purchaseStatus: 'paid', unlockedByPurchase: true, stripeInvoiceId: invoiceId || '', invoiceUrl: invoiceUrl || '', adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`), zahlungMeldung: fall, }) return { doc: updated, created: false, fall } } /** * Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst * wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId * (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook: * 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus). * * Liefert { doc, created, fall }. fall ist null, ausser die Zahlung traf ein Dokument, das nicht mehr bestellt ist ('active', * 'disabled', 'setup' mit Ticket): dann wird sie einmalig verbucht und 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' geliefert, * auch bei jedem Folgeereignis, bis zahlungMeldung 'gemeldet' ist (siehe bucheSpaeteZahlung). Ein Dokument im Zustand 'off' * bleibt unveraendert (die Uebernahme macht adoptPaidSession). */ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { if (!sessionId) return { doc: null, created: false, fall: null } return withLocks([`feature-paid:${sessionId}`], async () => { const doc = await findBySession(sessionId) if (!doc) return { doc: null, created: false, fall: null } if (doc.state === 'off') return { doc, created: false, fall: null } const resumable = doc.state === 'setup' && !doc.ticketId if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) const setup = resumable ? doc : await updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('setup'), purchaseStatus: 'paid', unlockedByPurchase: true, stripeInvoiceId: invoiceId || '', invoiceUrl: invoiceUrl || '', zahlungMeldung: '', }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) const customer = await customerOf(doc.customerId).catch(() => null) const ticketId = await legeTicketAn(setup, project, customer) const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) return { doc: updated, created: true, fall: null } }) } /** * Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern. * Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins * Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail. * @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey) * @param {object} project websiteProjects-Dokument * @param {object|null} customer Kundendokument, best effort * @returns {Promise} ID des neuen Tickets */ async function legeTicketAn(doc, project, customer) { const feature = featureConfig(doc.featureKey) const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {} const { ticketId } = await createFeatureTicket({ customerId: doc.customerId, project, featureKey: doc.featureKey, siteKey: doc.siteKey, ...texte, }) return ticketId } /** * Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. * Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte * Bestellung ('setup') oder eine aktive Funktion anfasst. */ export async function markCheckoutFailed(sessionId) { if (!sessionId) return null return withLocks([`feature-paid:${sessionId}`], async () => { const doc = await findBySession(sessionId) if (!doc || doc.state !== 'ordered') return doc return updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('off'), purchaseStatus: 'failed', }) }) } function byNote(by, verb) { return by ? `${verb} von ${by}`.slice(0, 300) : undefined } export async function activate(docId, { by, adminNote } = {}) { const note = adminNote ?? byNote(by, 'Freigeschaltet') return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('active'), activatedAt: new Date().toISOString(), // Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker); // Grund und Marker der Abo-Ende-Mail gehoeren zur Abschaltung und enden mit ihr. zahlungMeldung bleibt bewusst unberuehrt: // ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen. disabledAt: null, disabledReason: null, aboEndeMailAt: null, ...(note ? { adminNote: note } : {}), }) } function assertReason(reason) { if (!DISABLED_REASONS.includes(reason)) throw httpError(400, 'Unbekannter Abschaltgrund') } /** * Funktion abschalten. Idempotent: steht das Dokument schon auf `disabled`, bleibt alles wie es ist (disabledAt als Beginn der * 30-Tage-Loeschfrist, Grund, Notiz) und es gibt keinen Schreibzugriff; ein Doppelklick im WOMS, die Kundenloeschung oder ein * zweiter Lauf verschieben die Frist also nie. Einzige Ausnahme Altbestand: fehlt einem `disabled`-Dokument disabledAt, wird nur * dieses Feld gesetzt (sonst fiele es nie in die Loeschung). Die Nacharbeit der Analyse (Sammeldienst) laeuft bei jedem Aufruf. * @param {string} docId * @param {{ by?: string, adminNote?: string, reason?: 'team'|'abo_ende'|'abo_ende_vor_einrichtung'|'kunde_geloescht' }} [options] * @throws {Error} 400 bei unbekanntem Grund (ohne Schreibzugriff), 404 bei unbekanntem Dokument */ export async function disable(docId, { by, adminNote, reason = 'team' } = {}) { assertReason(reason) // Dieselbe Sperre wie die Freischaltung (activateClosedTickets, freischaltenNachAbnahme): beide schreiben den Zustand desselben Dokuments und // duerfen sich nicht ueberholen. Kein Halter von feature-activate: ruft disable() auf; die Reihenfolge feature-admin -> feature-activate // besteht schon. Keine Mehrfach-Sperre (withLocks mit mehreren Schluesseln kann sich verklemmen). const doc = await withLocks([`feature-activate:${docId}`], async () => { const current = await getDocument(config.collections.portalFeatures, docId) if (current.state === 'disabled') { if (current.disabledAt) return current return updateDocument(config.collections.portalFeatures, docId, { disabledAt: new Date().toISOString() }) } const note = adminNote ?? byNote(by, 'Abgeschaltet') return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('disabled'), disabledAt: new Date().toISOString(), disabledReason: reason, ...(note ? { adminNote: note } : {}), }) }) if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc) if (doc?.featureKey === 'shop' && doc.siteKey) await leereShopCache(doc.siteKey) return doc } // Shop abgeschaltet (auch ueber disableCustomerFeatures): der Kontext-Cache der oeffentlichen Schnittstelle haelt den Zustand bis zu // 30 Sekunden, solange wuerden noch Bestellungen angenommen. Best effort, der Zustand ist schon gespeichert. // Dynamischer Import: shop/kontext.js importiert dieses Modul. async function leereShopCache(siteKey) { try { const { leereCache } = await import('./shop/kontext.js') leereCache(siteKey) } catch (err) { console.warn('[siteFeatures] Shop-Cache nicht geleert:', err.message) } } /** * Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete * behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem * abgeschaltet und danach der erste Fehler weitergereicht. * @returns {Promise} die abgeschalteten Dokumente */ export async function disableCustomerFeatures(customerId, key, { by, adminNote, reason = 'team' } = {}) { assertKey(key) assertReason(reason) const docs = (await listCustomerFeatureDocs(customerId)).filter( (d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled' ) const disabled = [] let failure = null for (const doc of docs) { try { disabled.push(await disable(doc.$id, { by, adminNote, reason })) } catch (err) { failure ||= err } } if (failure) throw failure return disabled } // Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert. // Dynamischer Import: analyse.js importiert dieses Modul. async function stopAnalyseCollecting(doc) { try { const { analyseDisabled } = await import('./analyse.js') await analyseDisabled(doc) } catch (err) { console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message) } } const KEIN_ABO_AKTIVIERUNG = 'Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.' /** * Schutz der Team-Aktivierung (E9): ein vom Kauf freigeschalteter Terminplaner gehoert zum Wartungs-Abo. Laeuft es nicht, wuerde der * naechste Abgleichslauf die Kulanz zuruecknehmen und K22 ein zweites Mal senden; darum 409 ohne Schreibzugriff. Frisch gelesen * (aboLage direkt, nie der Memo), mit der Wechsel-Regel des Abgleichs. Ein Lesefehler ist ein Fehler (503, geloggt), nie "laeuft". */ async function assertAboLaeuftFuerAktivierung(customerId) { let laeuftJetzt try { laeuftJetzt = (await aboLage(customerId)).laeuft } catch (err) { console.error(`[funktionen] Wartungs-Abo von Kunde ${customerId} für die Aktivierung nicht lesbar:`, err.message) throw httpError(503, 'Das Wartungs-Abo des Kunden konnte nicht geprüft werden. Bitte später erneut versuchen.') } if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG) } const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' } /** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */ export const TEXTFORM_KEYS = ['termine', 'shop'] export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : config.termine.avvVersion) /** * Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im * Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = textformFassung(key). */ export function textformNachweisText({ date, channel }, fassung, key = 'termine') { const [jahr, monat, tag] = String(date).split('-') if (key === 'shop') { return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${fassung}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${fassung} abgibt.` } return `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${fassung}) am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.` } // Erklaerungen der Webshop-Bestelltexte, die auch bei der Aktivierung in Textform gelten (ohne Widerrufsverlangen und Pflichtinformationen: // die gehoeren zur kostenpflichtigen Online-Bestellung) const SHOP_TEXTFORM_ERKLAERUNGEN = ['unterlagen', 'verantwortung', 'stripe'] /** Texte des Nachweises in Speicherreihenfolge: Termine = ein Satz; Webshop = Satz mit Datum und Weg, danach der Wortlaut der Erklaerungen der Fassung. */ function textformNachweisTexte(textform, fassung, key) { const satz = { id: 'textform', text: textformNachweisText(textform, fassung, key) } if (key !== 'shop') return [satz] const items = featureConfig('shop').consent.items.filter((i) => SHOP_TEXTFORM_ERKLAERUNGEN.includes(i.id)) return [satz, ...items.map(({ id, text }) => ({ id, text }))] } /** * Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf * freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler). * News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen. * textform (nur Terminplaner und Webshop, schon geprueft: { date, channel }): Zustimmung des Kunden in Textform. Der Nachweis * (featureConsents, ohne Stripe-Sitzung, IP und Browser; Konto = Mitarbeiter; Fassung = textformFassung(key)) wird VOR der Freischaltung * gespeichert: scheitert er, bleibt die Funktion aus. Der Aufrufer sendet danach K16 mit den Unterlagen (meldeFreigeschaltet). */ export async function adminActivate(customerId, projectId, key, { by, adminNote, textform = null, now = new Date() } = {}) { assertKey(key) if (textform && !TEXTFORM_KEYS.includes(key)) throw httpError(400, 'Zustimmung in Textform gibt es nur für den Terminplaner und den Webshop') await assertOwnProject(customerId, projectId) const doc = await ensureFeatureDoc(customerId, projectId, key) if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId) if (textform) { const fassung = textformFassung(key) await speichereZustimmung({ customerId, projectId, featureKey: key, stripeSessionId: '', fassungen: { leistungsbeschreibung: fassung, avv: fassung }, texte: textformNachweisTexte(textform, fassung, key), ip: '', userAgent: '', konto: `Mitarbeiter: ${by || 'Mitarbeiter'}`, at: now, }) } return activate(doc.$id, { by, adminNote }) } /** * Haken freischaltbar(doc) der Funktion, falls das Register einen hat (nur true gibt frei). Liefert null (frei) oder den Grund, * warum das Dokument in 'setup' bleibt: false => Voraussetzungen offen, Fehler (z. B. Abo oder Einstellung nicht lesbar) => * Pruefung nicht moeglich; der Fehler wird geloggt, nie geworfen (ein Dokument darf die uebrigen nicht aufhalten). */ async function freischaltHindernis(doc) { const { freischaltbar } = featureHaken(doc.featureKey) if (typeof freischaltbar !== 'function') return null try { return (await freischaltbar(doc)) === true ? null : 'Voraussetzungen noch nicht erfüllt' } catch (err) { console.error(`[funktionen] Freischaltung ${doc.featureKey} (${doc.$id}) nicht prüfbar:`, err.message) return 'Prüfung nicht möglich' } } /** * Eine blockierte Freischaltung einmal in adminNote vermerken (" System: Freischaltung wartet: ", neuester Eintrag * vorn). Steht derselbe Eintrag schon vorn, bleibt alles wie es ist: der 5-Minuten-Job schreibt nicht alle 5 Minuten und sendet * keine Mail (der Hinweis kein_abo kam mit der Zahlung, siehe onFeaturePaid). Ein Schreibfehler wird nur geloggt. * Aufruf unter der Sperre des Dokuments mit dem frisch gelesenen Dokument. */ async function vermerkeWartet(doc, grund, now) { const eintrag = `System: Freischaltung wartet: ${grund}` const neuester = String(doc.adminNote || '').split(' | ')[0] if (neuester.endsWith(eintrag)) return console.warn(`[funktionen] Freischaltung ${doc.featureKey} (${doc.$id}) wartet: ${grund}`) try { await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: notizVorn(doc.adminNote, `${now.toISOString()} ${eintrag}`) }) } catch (err) { console.error(`[funktionen] Wartenotiz für ${doc.$id} nicht gespeichert:`, err.message) } } /** * Alle 'setup'-Funktionen (optional nur eines Kunden) mit ticketFreischaltung im Register (ticketFreischaltungKeys), deren Ticket * 'Closed' ist, werden 'active' - sofern der Haken freischaltbar der Funktion (Termine: Einstellung gespeichert und Wartungs-Abo * laeuft) es erlaubt. Das prueft der Job unter der Sperre feature-activate: nach dem frischen Lesen, damit ein Abo, das zwischen * Listenlesen und Sperre endet, nicht mehr freischaltet. Gesperrte Dokumente bleiben 'setup' (Notiz und Log, keine Mail). */ export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) { const keys = ticketFreischaltungKeys() if (keys.length === 0) return [] const queries = [Query.equal('featureKey', keys), Query.equal('state', 'setup'), Query.limit(1000)] if (customerId) queries.push(Query.equal('customerId', customerId)) const pending = await listDocuments(config.collections.portalFeatures, queries) const activated = [] for (const doc of pending) { if (!doc.ticketId) continue let ticket = null try { ticket = await getDocument(config.collections.workorders, doc.ticketId) } catch (err) { if (err.status === 404) continue throw err } if (ticket.status !== 'Closed') continue // Je Dokument gesperrt und neu gelesen: GET /api/funktionen, ein zweiter Tab und der 5-Minuten-Job // duerfen dieselbe Funktion nicht beide freischalten (und beide benachrichtigen) const updated = await withLocks([`feature-activate:${doc.$id}`], async () => { const fresh = await getDocument(config.collections.portalFeatures, doc.$id) if (fresh.state !== 'setup') return null const grund = await freischaltHindernis(fresh) if (grund) { await vermerkeWartet(fresh, grund, now) return null } return updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('active'), activatedAt: now.toISOString(), }) }) if (updated) activated.push(updated) } return activated } // --------------------------------------------------------------------------- // Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen) // --------------------------------------------------------------------------- const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.' const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.' /** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */ const ABNAHME_HINDERNIS = { kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'], keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'], abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'], } /** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */ async function dokumentFuerTeamAktion(customerId, projectId, key) { assertKey(key) const doc = await getFeatureDoc(customerId, projectId, key) if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`) return doc } /** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */ async function frischesDokument(vorab, key) { try { return await getDocument(config.collections.portalFeatures, vorab.$id) } catch (err) { if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`) throw err } } /** * Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen (Terminplaner: * laufendes Wartungs-Abo und gespeicherte Einstellung); fehlt der Haken (News), gibt es keine Pruefung und kein Abo wird gelesen. * Die Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). */ async function abnahmeHindernis(doc) { if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js') return pruefe(doc) } /** * "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen * wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben. * Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate: wie der Freischalt-Job * (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts. * - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}". * - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false. * - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503. * @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args * @returns {Promise<{ doc: object, activated: boolean }>} */ export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) { const vorab = await dokumentFuerTeamAktion(customerId, projectId, key) const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => { const doc = await frischesDokument(vorab, key) if (doc.state === 'active') return { doc, activated: false } if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP) const grund = await abnahmeHindernis(doc) if (grund) { const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.'] throw httpError(status, text) } const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)') const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('active'), activatedAt: now.toISOString(), ...(note ? { adminNote: note } : {}), }) return { doc: updated, activated: true } }) if (ergebnis.activated) { const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') await meldeFreigeschaltet(ergebnis.doc) } return ergebnis } /** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */ async function ticketBesteht(ticketId) { try { await getDocument(config.collections.workorders, ticketId) return true } catch (err) { if (err.status === 404) return false throw err } } /** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */ async function bezahlteSitzung(doc) { if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.') let session try { session = await retrieveCheckoutSession(doc.stripeSessionId) } catch (err) { console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message) throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.') } if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.') return session } /** * Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie * im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das * Ticket und sendet nichts. Keine Sperre feature-paid: hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar). */ async function ticketNachZahlung(doc, note) { const session = await bezahlteSitzung(doc) await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } }) const nachher = await getDocument(config.collections.portalFeatures, doc.$id) if (!nachher.ticketId) return { doc: nachher, created: false } if (!note) return { doc: nachher, created: true } try { return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true } } catch (err) { // Ticket und Mails sind raus; die Notiz ist nur ein Vermerk console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message) return { doc: nachher, created: true } } } /** * Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim * ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:), damit kein Stripe-Retry daneben * schreibt; das Dokument wird darunter erneut gelesen. */ async function ersetzeGeloeschtesTicket(doc, note) { return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => { const frisch = await getDocument(config.collections.portalFeatures, doc.$id) if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false } const project = await getDocument(config.collections.websiteProjects, frisch.projectId) const customer = await customerOf(frisch.customerId).catch(() => null) const ticketId = await legeTicketAn(frisch, project, customer) const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) }) return { doc: updated, created: true } }) } /** * "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409). * (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen, * Ticket + K15 + T16 genau einmal (ticketNachZahlung); * (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket); * (c) Ticket vorhanden: nichts, created false. * Eine Sperre feature-ticket: je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten * (ein Ticket, created nur einmal true). * @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args * @returns {Promise<{ doc: object, created: boolean }>} */ export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) { const vorab = await dokumentFuerTeamAktion(customerId, projectId, key) const note = adminNote ?? byNote(by, 'Ticket nachgelegt') return withLocks([`feature-ticket:${vorab.$id}`], async () => { const doc = await frischesDokument(vorab, key) if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) if (!doc.ticketId) return ticketNachZahlung(doc, note) if (await ticketBesteht(doc.ticketId)) return { doc, created: false } return ersetzeGeloeschtesTicket(doc, note) }) } /** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */ export async function findActiveBySiteKey(siteKey, featureKey = 'news') { if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null const [doc] = await listDocuments(config.collections.portalFeatures, [ Query.equal('siteKey', siteKey), Query.equal('featureKey', featureKey), Query.equal('state', 'active'), Query.limit(1), ]) return doc || null } // --------------------------------------------------------------------------- // Stripe-Webhook, Mails und Freischalt-Job // --------------------------------------------------------------------------- const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €` /** Adresse der Funktion im Kundenportal (Anker aus dem Register: returnHash). */ const portalUrl = (feature) => `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}` /** Preis der Einrichtung aus dem Katalog der Funktion (feature.product: newsSetup | termineSetup); config.* bei jedem Aufruf gelesen. */ function einrichtungsPreis(feature) { const amountCents = config[feature.product]?.amountCents if (!Number.isInteger(amountCents)) throw new Error(`Katalogpreis ${feature.product} fehlt`) return eur(amountCents) } /** * Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und * die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News). * @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null} */ export function unterlagenAdressen(feature) { const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {} if (!lb?.url || !avv?.url || !widerruf?.url) return null const absolut = (pfad) => `${config.publicBaseUrl}${pfad}` return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) } } /** * K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag * (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab. * Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt * fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText` * (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen * Registerhaken dafuer. */ export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '' }) { const { funktion, formen, preisHinweis, danach } = feature.mails return { template: 'K15', values: { kunde_name: kundeName, projekt, funktion, funktion_gen: formen.gen, funktion_akk: formen.akk, funktion_nom: formen.nom, funktion_pron: formen.pron, preis: einrichtungsPreis(feature), preis_hinweis: preisHinweis, zeit: feature.setupEstimate, danach, portal_url: portalUrl(feature), rechnung_url: invoiceUrl || '', fassung: unterlagen?.fassung ?? '', lb_url: unterlagen?.lb ?? '', avv_url: unterlagen?.avv ?? '', widerruf_url: unterlagen?.widerruf ?? '', zustimmungen_text: zustimmungenText, }, blocks: { rechnung: Boolean(invoiceUrl), anlagen: unterlagen !== null, anhang: unterlagen !== null && anhang === true, zustimmungen: zustimmungenText !== '' }, } } /** * Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der * Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis. * @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte) */ export function zustimmungenText(nachweis) { let texte try { texte = JSON.parse(nachweis?.texte ?? '') } catch { return '' } if (!Array.isArray(texte)) return '' const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '') if (haken.length === 0) return '' const am = new Date(nachweis.acceptedAt) const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am)) const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '') return [ `Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`, ...haken.map((t) => `– ${t.text}`), ].join('\n') } /** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */ export function bereitMail(feature, { kundeName, projekt }) { const { funktion, formen, bereit, knopf } = feature.mails return { template: 'K16', values: { kunde_name: kundeName, projekt, funktion, funktion_dein: formen.dein, funktion_nom: formen.nom, bereit, knopf, portal_url: portalUrl(feature), }, } } /** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */ export const FEATURE_EVENT_TYPES = [ 'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', ] export function isFeatureCheckoutEvent(event) { const obj = event?.data?.object return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type) } /** Projektname zur ID; ein unbekanntes Projekt ergibt ''. */ export async function projectName(projectId) { try { return (await getDocument(config.collections.websiteProjects, projectId)).projectName || '' } catch (err) { if (err.status === 404) return '' throw err } } /** Kundendokument zur ID; ein unbekannter Kunde ergibt null. */ export async function customerOf(customerId) { try { return await getDocument(config.collections.customers, customerId) } catch (err) { if (err.status === 404) return null throw err } } /** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */ async function invoiceOf(invoiceRef) { const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id if (!id) return { invoiceId: '', invoiceUrl: '' } try { const invoice = await getStripe().invoices.retrieve(id) return { invoiceId: id, invoiceUrl: invoice.hosted_invoice_url || '' } } catch (err) { console.warn(`[funktionen] Rechnung ${id} nicht lesbar:`, err.message) return { invoiceId: id, invoiceUrl: '' } } } async function ticketWoidOf(doc) { return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : '' } /** * T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus. * Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der * Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice). */ function sendT16({ funktion, ...werte }, bloecke) { return sendAdminNotice({ template: 'T16', values: { kunde_name: '', email: '', projekt: '', funktion: funktion || 'Funktion', woid: '', sitekey: '', sitzung: '', zustand: '', anleitung_url: '', gesundheit_text: '', ...werte, }, blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, anhang_fehlt: false, ...bloecke, }, }) } /** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */ const GESUNDHEIT_TEXT_MAX = 500 /** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */ function gesundheitText(meldungen) { const alle = meldungen.join('; ') if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle let text = '' let n = 0 for (const meldung of meldungen) { const naechster = text ? `${text}; ${meldung}` : meldung if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break text = naechster n += 1 } return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' }) } /** * T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs * (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei * jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil * termine/kontext.js dieses Modul importiert. */ async function gesundheitHinweis(doc) { if (doc.featureKey !== 'termine') return '' try { const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')]) const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung) return meldungen.length ? gesundheitText(meldungen) : '' } catch (err) { console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message) return '' } } /** * keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). * anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt). */ async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) { const mails = featureConfig(doc.featureKey)?.mails // Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde const anleitung = Boolean(mails?.anleitung) && !failed const gesundheit = failed ? '' : await gesundheitHinweis(doc) await sendT16( { kunde_name: customer?.name || customer?.email || doc.customerId, email: customer?.email || '', projekt: await projectName(doc.projectId), funktion: mails?.funktion, woid: failed ? '' : await ticketWoidOf(doc), sitekey: failed ? '' : doc.siteKey || '', anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '', gesundheit_text: gesundheit, }, { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt } ) } /** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */ async function unmatchedNotice(session) { const { customerId, projectId, featureKey } = session.metadata || {} const customer = customerId ? await customerOf(customerId).catch(() => null) : null await sendT16( { kunde_name: customer?.name || customer?.email || customerId || 'unbekannt', email: customer?.email || '', projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '', funktion: featureConfig(featureKey)?.mails.funktion, sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`, }, { ungeklaert: true } ) } /** * Bezahlte Sitzung, die kein Dokument ueber die Sitzungs-ID findet (zwei parallele Bestellungen haben die * ID ueberschrieben): das Dokument aus den Metadaten nehmen, wenn es noch 'ordered' oder 'off' ist, und die * bezahlte Sitzung eintragen. Sonst laut loggen - der Kunde hat gezahlt und nichts wird eingerichtet. */ async function adoptPaidSession(session) { const { customerId, projectId, featureKey } = session.metadata || {} const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null // Erneut zugestelltes Ereignis ("Resend event" in Stripe) zu einer Bestellung, die laengst eingerichtet, abgeschaltet und nach 30 Tagen // geloescht wurde (off, purchaseStatus 'zurueckgesetzt', dieselbe Sitzung): sie ist ausgeliefert, nichts wird neu eingerichtet und // niemand benachrichtigt (kein Ticket, keine K15, kein T16). if (doc?.state === 'off' && doc.purchaseStatus === 'zurueckgesetzt' && doc.stripeSessionId === session.id) { console.warn(`[funktionen] Sitzung ${session.id} gehoert zu einer Funktion, die nach dem Abo-Ende schon geloescht und zurueckgesetzt wurde - nicht uebernommen, nichts gesendet`) return false } if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) { console.error( `[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen` ) try { await unmatchedNotice(session) } catch (err) { console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err.message) } return false } await updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('ordered'), stripeSessionId: session.id, }) return true } /** * Meldung zu einer Zahlung genau einmal (T16 bereits_aktiv, T19): unter feature-meldung: das Dokument frisch lesen; * zahlungMeldung === 'gemeldet' => { sent: false, schonGemeldet: true } ohne Versand. Sonst senden(frisch) (liefert { sent }) und * erst bei sent === true den Marker 'gemeldet' schreiben (auch wenn dort vorher ein offener Fall stand). Wirft nie: jeder Fehler * (Lesen, Aufbau, Versand) ergibt { sent: false } samt Log, der Aufrufer wiederholt. Scheitert nur das Schreiben des Markers, nachdem * die Mail raus ist, bleibt es bei { sent: true } (Log): die Spur ist da, eine Wiederholung durch den Aufrufer waere ein Doppel. * Nie innerhalb einer feature-paid:*-Sperre aufrufen (withLocks ist nicht wiederbetretbar). */ async function meldeEinmal(doc, was, senden) { try { return await withLocks([`feature-meldung:${doc.$id}`], async () => { const frisch = await getDocument(config.collections.portalFeatures, doc.$id) if (frisch.zahlungMeldung === 'gemeldet') return { sent: false, schonGemeldet: true } const { sent } = await senden(frisch) if (!sent) return { sent: false } try { await updateDocument(config.collections.portalFeatures, frisch.$id, { zahlungMeldung: 'gemeldet' }) } catch (err) { console.error(`[funktionen] ${was} zur Sitzung ${frisch.stripeSessionId} gesendet, aber der Marker gemeldet blieb ungeschrieben:`, err.message) } return { sent: true } }) } catch (err) { console.error(`[funktionen] ${was} zur Sitzung ${doc.stripeSessionId} nicht gesendet:`, err.message) return { sent: false } } } /** Kunde, Projekt und Funktionsname fuer die Team-Meldungen; was sich nicht lesen laesst, faellt auf IDs zurueck (die Meldung geht trotzdem raus). */ async function meldeKopf(doc, customer) { const kunde = customer ?? (await customerOf(doc.customerId).catch(() => null)) return { kunde_name: kunde?.name || kunde?.email || doc.customerId, email: kunde?.email || '', projekt: (await projectName(doc.projectId).catch(() => '')) || doc.projectId, funktion: featureConfig(doc.featureKey)?.mails.funktion, } } /** * Zahlung auf eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet war (markPaid-Fall 'bereits_aktiv' | * 'abgeschaltet'): T16 nur mit Block bereits_aktiv (zustand "freigeschaltet", "in Einrichtung" bzw. "abgeschaltet", woid leer), keine * K15, kein Ticket. Genau einmal je Dokument (siehe meldeEinmal). * @param {object} doc portalFeatures-Dokument * @param {'bereits_aktiv'|'abgeschaltet'} fall * @param {{ customer?: object|null }} [options] bereits gelesener Kunde, sonst wird er gelesen * @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>} */ export function meldeZahlungFall(doc, fall, { customer = null } = {}) { return meldeEinmal(doc, 'T16 bereits_aktiv', async (frisch) => { const zustand = fall === 'abgeschaltet' ? 'abgeschaltet' : frisch.state === 'setup' ? 'in Einrichtung' : 'freigeschaltet' return sendT16({ ...(await meldeKopf(frisch, customer)), sitzung: frisch.stripeSessionId || '', zustand }, { bereits_aktiv: true }) }) } /** * T19 "Erstattung faellig" an das Team: die einzige Stelle, die T19 sendet (Zahlung nach dem Abo-Ende hier, die Meldung des * Abo-Abgleichs fuer bezahlte, abgeschaltete Einrichtungen in Block E). Blocks: zahlung_nach_ende, wenn nachAboEnde gesetzt ist oder * am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet; * ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink. * Genau einmal je Dokument (siehe meldeEinmal), wirft nie. * @param {object} doc portalFeatures-Dokument * @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean }} [options] * @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>} */ export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) { return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => { const kopf = await meldeKopf(frisch, customer) const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : '' return sendAdminNotice({ template: 'T19', values: { ...kopf, funktion: kopf.funktion || 'Funktion', betrag: featureConfig(frisch.featureKey)?.priceLabel || '', sitzung: sitzung || '', woid, rechnung_url: frisch.invoiceUrl || '', }, blocks: { zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende', ticket_offen: Boolean(frisch.ticketId), rechnung: Boolean(frisch.invoiceUrl), }, }) }) } /** * Eine Zahlung traf ein Dokument, das nicht mehr bestellt war (markPaid-Fall): gebucht ist sie schon, hier geht die Meldung ans Team - * 'abo_ende' als T19 (Erstattung), 'bereits_aktiv' und 'abgeschaltet' als T16. Kein ordentliches Ticket, keine K15. * Wirft, wenn die Meldung weder raus ist noch schon draussen war: der Webhook antwortet 500, Stripe wiederholt, markPaid liefert den * noch offenen Fall erneut und der naechste Versuch sendet genau einmal. */ async function meldeFallZurZahlung(doc, fall, session) { console.error( `[funktionen] BEZAHLTE Sitzung ${session.id} traf die Funktion ${doc.featureKey} im Zustand ${doc.state} (Fall ${fall}, Kunde ${doc.customerId}, Projekt ${doc.projectId}) - nichts eingerichtet, das Team wird benachrichtigt` ) const customer = await customerOf(doc.customerId).catch((err) => { console.error('[funktionen] Kunde fuer die Zahlungsmeldung nicht lesbar:', err.message) return null }) const ergebnis = fall === 'abo_ende' ? await meldeErstattung(doc, { customer, nachAboEnde: true }) : await meldeZahlungFall(doc, fall, { customer }) if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`) } /** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */ const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false }) /** * Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs und Adressen der Fassung, der der Kunde bei der Bestellung * zugestimmt hat (Fassung des Nachweises zur bezahlten Sitzung), und der Wortlaut seiner Zustimmungen. Das ist meist die aktuelle * Fassung (legalDocs.featureLegalAttachments, erst beim Aufruf geladen; kein Registerhaken). Wurde die Fassung zwischen Klick und * Zahlung angehoben, gehen die Unterlagen der vereinbarten Fassung raus, wenn es sie gibt; fehlen sie, geht K15 ohne PDFs und Links * raus (bei der aktuellen Fassung bleiben die Links, nur die PDFs fehlen). Ist der Nachweis nicht lesbar oder seine Fassung nicht bestimmbar, ist die vereinbarte Fassung unbekannt: nie die aktuelle * ungeprueft anhaengen, K15 geht ohne PDFs und Links raus. Ohne Nachweis (Altbestand) gilt die aktuelle Fassung. In allen Faellen ohne * Unterlagen ist fehlt true (T16 sagt "von Hand nachsenden") und der Grund steht im Log. Wirft nie. * @returns {Promise<{ attachments: object[], unterlagen: { fassung: string, lb: string, avv: string, widerruf: string } | null, anhang: boolean, zustimmungen: string, fehlt: boolean }>} */ async function k15Rechtsunterlagen(doc, feature, session) { const ohne = (zustimmungen) => ({ attachments: [], unterlagen: null, anhang: false, zustimmungen, fehlt: true }) let nachweis = null try { nachweis = await zustimmungFuerSitzung(session.id) } catch (err) { console.error(`[funktionen] K15 (Funktion ${doc.$id}): Zustimmungsnachweis zu Sitzung ${session.id} nicht lesbar, die vereinbarte Fassung ist unbekannt - ohne Unterlagen, das Team sendet sie von Hand:`, err.message) return ohne('') } const zustimmungen = zustimmungenText(nachweis) const aktuell = feature.consent.version let vereinbart = aktuell if (nachweis) { try { vereinbart = JSON.parse(nachweis.fassungen)?.zustimmung } catch { vereinbart = undefined } if (typeof vereinbart !== 'string' || vereinbart === '') { console.error(`[funktionen] K15 (Funktion ${doc.$id}): Fassung im Zustimmungsnachweis nicht bestimmbar - ohne Unterlagen, das Team sendet sie von Hand`) return ohne(zustimmungen) } } try { const legal = await import('./legalDocs.js') const eigene = vereinbart === aktuell ? {} : { fassung: vereinbart } if (vereinbart !== aktuell) { console.error(`[funktionen] K15 (Funktion ${doc.$id}): Der Kunde hat der Fassung ${vereinbart} zugestimmt, aktuell ist ${aktuell} - die Unterlagen der vereinbarten Fassung gehen raus, wenn es sie gibt`) if (!(await legal.featureLegalReady(doc.featureKey, eigene))) throw new Error(`Unterlagen der Fassung ${vereinbart} fehlen`) } const attachments = await legal.featureLegalAttachments(doc.featureKey, eigene) if (attachments.length === 0) throw new Error('keine Anhaenge fuer diese Funktion') const adressen = legal.featureLegalPaths(doc.featureKey, eigene) const absolut = (pfad) => `${config.publicBaseUrl}${pfad}` const unterlagen = { fassung: vereinbart, lb: absolut(adressen.lb), avv: absolut(adressen.avv), widerruf: absolut(adressen.widerruf) } return { attachments, unterlagen, anhang: true, zustimmungen, fehlt: false } } catch (err) { console.error(`[funktionen] K15 (Funktion ${doc.$id}): Unterlagen nicht lesbar, K15 geht ohne PDFs raus:`, err.message) // die Seiten der aktuellen (= vereinbarten) Fassung bleiben verlinkt; Links auf eine andere Fassung gibt es nur, wenn deren Dateien da sind return { ...ohne(zustimmungen), unterlagen: vereinbart === aktuell ? unterlagenAdressen(feature) : null } } } async function onFeaturePaid(session) { const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) const paid = { sessionId: session.id, invoiceId, invoiceUrl } let { doc, created, fall } = await markPaid(paid) // Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created, fall } = await markPaid(paid)) // Das Dokument wartet nicht mehr auf die Zahlung (aktiv, abgeschaltet, in Einrichtung): nur Meldung, kein Ticket, keine K15 if (fall) return meldeFallZurZahlung(doc, fall, session) if (!created) return // Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und // wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200. const feature = featureConfig(doc.featureKey) let customer = null try { customer = await customerOf(doc.customerId) } catch (err) { console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message) } // K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem // Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team // "Unterlagen von Hand nachsenden" (Block anhang_fehlt). const mitRechtsdokumenten = feature ? unterlagenAdressen(feature) !== null : false let anhangFehlt = false if (customer?.email) { try { if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) const rechtlich = mitRechtsdokumenten ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN anhangFehlt = rechtlich.fehlt await sendCustomerMail(customer.email, { ...bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl, unterlagen: rechtlich.unterlagen, anhang: rechtlich.anhang, zustimmungenText: rechtlich.zustimmungen, }), attachments: [...rechtlich.attachments], }) } catch (err) { console.error('[funktionen] K15 fehlgeschlagen:', err.message) if (mitRechtsdokumenten) anhangFehlt = true } } else if (mitRechtsdokumenten) { console.error(`[funktionen] K15 (Funktion ${doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt - die Vertragsunterlagen muessen von Hand nachgesendet werden`) anhangFehlt = true } // Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo). // Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500. let keinAbo = false if (feature?.requiresWartungsAbo) { try { keinAbo = !(await laeuft(doc.customerId)) } catch (err) { console.error(`[funktionen] Wartungs-Abo von Kunde ${doc.customerId} fuer T16 nicht lesbar, Hinweis kein_abo entfaellt:`, err.message) } } try { await teamNotice(doc, customer, { keinAbo, anhangFehlt }) } catch (err) { console.error('[funktionen] T16 fehlgeschlagen:', err.message) } } /** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */ export async function handleFeatureCheckoutEvent(event) { const session = event.data.object switch (event.type) { case 'checkout.session.completed': // SEPA: die Sitzung ist abgeschlossen, das Geld noch nicht da -> erst async_payment_succeeded if (session.payment_status !== 'paid') return await onFeaturePaid(session) return case 'checkout.session.async_payment_succeeded': await onFeaturePaid(session) return case 'checkout.session.async_payment_failed': { const doc = await markCheckoutFailed(session.id) if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true }) return } case 'checkout.session.expired': await markCheckoutFailed(session.id) return } } /** * Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden (meldeFreigeschaltet: Fehler * werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf). termine/freischaltung.js importiert diese * Datei, darum wird es hier erst im Aufruf geladen. */ export async function runActivation({ now = new Date(), customerId = null } = {}) { const activated = await activateClosedTickets({ now, customerId }) if (activated.length === 0) return activated const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') for (const doc of activated) await meldeFreigeschaltet(doc) return activated }