Files
Webklar-Kundenbereich/server/services/siteFeatures.js
T

1619 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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
}
}
/**
* Das eigene Projekt des Kunden (sonst 403). forNewOrder: fuer alles, was eine Funktion neu einschaltet oder bestellt; ein archiviertes
* Projekt ist dann 409 (Spec 11: eine laufende Funktion laeuft weiter, aber Neubestellung ist gesperrt; der Kunde sieht archivierte
* Projekte nicht mehr).
*/
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
}
}
/** Ticketnummer (woid) zur Ticket-ID; ein unbekanntes Ticket ergibt null. */
export 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
}
}
/**
* Wodurch die Funktion freigeschaltet wurde: 'kauf' | 'abo' | 'team'. Altbestand ohne unlockedBy: unlockedByPurchase => 'kauf', sonst
* 'team' (Kulanz, Pilot, Team-Aktivierung).
*/
export const unlockedByVon = (doc) => doc?.unlockedBy || (doc?.unlockedByPurchase ? 'kauf' : 'team')
/**
* 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 fuer Oeffnungszeiten live, dort nur beim Abschaltgrund
* abo_ende (bei einer Abschaltung durch das Team gibt es keine Loeschfrist); es ist null, wenn disabledAt fehlt oder nicht lesbar ist.
* disabledReason und disabledAt gelten fuer jede Funktion.
* Funktionen mit aboInklusive (Oeffnungszeiten live) tragen zusaetzlich unlockedBy (kauf | abo | team, null solange die Funktion
* aus ist), aboEndeAt (Ende des Wartungs-Abos erkannt) und einbau (angestossen | manuell, Ergebnis des Neubaus).
* key: Funktionsschluessel (auch ohne Dokument); ohne Angabe der des Dokuments.
*/
export async function featureView(doc, key = doc?.featureKey) {
const abgeschaltet = doc?.state === 'disabled'
const mitAbo = featureConfig(key)?.aboInklusive === true
const loeschtag = abgeschaltet && (key === 'termine' || (mitAbo && doc.disabledReason === 'abo_ende'))
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: loeschtag ? loeschTag(doc.disabledAt) : null,
...(mitAbo
? {
unlockedBy: doc && doc.state !== 'off' ? unlockedByVon(doc) : null,
aboEndeAt: doc?.aboEndeAt || null,
einbau: doc?.einbau || 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, key)
}
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). */
export function notizVorn(previous, entry) {
const neu = entry.slice(0, NOTE_MAX)
const aelter = String(previous || '')
return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu
}
/**
* Funktionen mit aboInklusive (Oeffnungszeiten live) halten am Dokument fest, wodurch sie freigeschaltet wurden (unlockedBy) und was der
* Neubau fuer den Einbau ergab (einbau). Fuer die uebrigen Funktionen (News, Terminplaner) schreibt der Kaufweg beides nicht: das Schema
* dieser Felder gibt es nur, wo zeiten-setup.mjs gelaufen ist.
*/
const mitAbo = (key) => featureConfig(key)?.aboInklusive === true
/**
* Neubau fuer den Einbau des Zeiten-Skripts (Spec 4.6) beim Kauf, VOR dem Einbau-Ticket: das Ticket nennt das Ergebnis (einbau).
* Steht einbau schon am Dokument (Wiederholung des Webhooks nach einem gescheiterten Ticket), wird nicht noch einmal gebaut.
* stosseNeubauAn nimmt die eigene Sperre 'zeiten-neubau' (andere Schluessel als feature-paid, also kein Wiederbetreten) und wirft nie;
* zeiten/* nur per dynamischem Import (siehe Kopf). Liefert das Dokument mit dem gespeicherten einbau.
*/
async function baueZeitenEin(doc, project) {
if (doc.einbau) return doc
const { stosseNeubauAn } = await import('./zeiten/neubau.js')
const einbau = await stosseNeubauAn(project)
return updateDocument(config.collections.portalFeatures, doc.$id, { einbau })
}
/**
* 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,
// Eine Zahlung, die nichts einrichtet, aendert nicht, wodurch das Dokument freigeschaltet wurde: ein abo oder team bleibt (der
// Abo-Abgleich und die Loeschfrist haengen an unlockedBy abo); nur ohne Angabe (Altbestand) wird es zu kauf, passend zu unlockedByPurchase
...(mitAbo(doc.featureKey) && !doc.unlockedBy ? { unlockedBy: 'kauf' } : {}),
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).
* Funktionen mit aboInklusive (Oeffnungszeiten live): 'setup' traegt unlockedBy 'kauf' (und einbau leer), danach stoesst baueZeitenEin den
* Neubau an und speichert einbau, erst dann entsteht das Ticket mit dem Ergebnis. Fortsetzbar ist dort nur setup mit unlockedBy kauf; ein
* per Abo oder Team eingeschaltetes Dokument ohne Ticket heilt holeZeitenNach, die Zahlung ist fuer es ein Fall (bereits_aktiv).
*
* 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 mitAboKauf = mitAbo(doc.featureKey)
// Funktionen mit aboInklusive: nur ein Dokument, das der Kauf auf setup gestellt hat (unlockedBy kauf), ist fortsetzbar. Eines, das per Abo
// oder vom Team auf setup steht, bekommt sein Ticket vom Nachholen (holeZeitenNach); die Zahlung ist dort ein Fall fuers Team
const resumable = doc.state === 'setup' && !doc.ticketId && (!mitAboKauf || unlockedByVon(doc) === 'kauf')
if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl })
let setup = resumable
? doc
: await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('setup'),
purchaseStatus: 'paid',
unlockedByPurchase: true,
// einbau beginnt leer: Reste eines frueheren Laufs gelten nicht, der Neubau (baueZeitenEin) setzt es neu
...(mitAboKauf ? { unlockedBy: 'kauf', einbau: '' } : {}),
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
zahlungMeldung: '',
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
// Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6)
if (mitAboKauf) setup = await baueZeitenEin(setup, project)
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<string>} 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',
})
})
}
/**
* Oeffnungszeiten live (Spec 5): das erkannte Abo-Ende und eine offene Behalten-Zahlung gehoeren zum alten Lauf und enden mit der
* Aktivierung. Als `extra` an activate() (nur der Zeiten-Pfad; die Felder legt scripts/zeiten-setup.mjs an, News und Terminplaner
* teilen die Collection).
*/
export const ZEITEN_AKTIVIERUNG_FELDER = Object.freeze({ aboEndeAt: null, behaltenSessionId: '' })
function byNote(by, verb) {
return by ? `${verb} von ${by}`.slice(0, 300) : undefined
}
/**
* Funktion aktivieren (jede Funktion gleich). `extra` sind zusaetzliche Felder, die der Aufrufer mitschreibt: Oeffnungszeiten live
* leert damit sein erkanntes Abo-Ende und eine offene Behalten-Zahlung (ZEITEN_AKTIVIERUNG_FELDER). Diese Felder legt erst
* scripts/zeiten-setup.mjs an; darum schreibt activate() sie nie von selbst, sonst scheiterte die Aktivierung von News und
* Terminplaner mit "Unknown attribute", solange der Code vor dem Skript laeuft.
* @param {string} docId
* @param {{ by?: string, adminNote?: string, extra?: Record<string, unknown> }} [options]
*/
export async function activate(docId, { by, adminNote, extra } = {}) {
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,
...(extra ?? {}),
...(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:<id> 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<object[]>} 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', 'unterkunft']
export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung : key === 'unterkunft' ? config.unterkunft.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.`
}
if (key === 'unterkunft') {
return `Der Kunde hat am ${tag}.${monat}.${jahr} per ${TEXTFORM_WEG[channel]} in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${fassung}) zustimmt.`
}
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, Webshop und Buchungskalender, 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, den Webshop und den Buchungskalender')
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, ...(key === 'zeiten' ? { extra: ZEITEN_AKTIVIERUNG_FELDER } : {}) })
}
/**
* 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 ("<Zeit> System: Freischaltung wartet: <Grund>", 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:<id> 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.'],
// Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben
'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'],
'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten 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; fehlt der Haken
* (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die
* Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere
* Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre
* Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun.
*/
async function abnahmeHindernis(doc) {
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
if (doc.featureKey !== 'termine') return freischaltHindernis(doc)
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
return pruefe(doc)
}
/**
* Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash
* merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen).
*/
async function nacharbeitNachFreischaltung(doc) {
const { nachFreischaltung } = featureHaken(doc.featureKey)
if (typeof nachFreischaltung !== 'function') return
try {
await nachFreischaltung(doc)
} catch (err) {
console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message)
}
}
/**
* "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:<id> 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) {
await nacharbeitNachFreischaltung(ergebnis.doc)
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.')
}
// Eine Behalten-Zahlung (Oeffnungszeiten live, zeiten/behalten.js) hat kein Einrichtungs-Ticket nachzulegen. Vor allem aber darf sie nie in
// onFeaturePaid landen: dessen Behalten-Zweig (bucheBehalten) nimmt feature-admin:<projekt>, und die Team-Route haelt diese Sperre schon
// (withLocks ist nicht wiederbetretbar => Stillstand, der den Schluessel dauerhaft blockiert).
if (session?.metadata?.zweck === 'behalten') {
throw httpError(409, 'Zu dieser Funktion gehört nur eine Behalten-Zahlung, es gibt kein Einrichtungs-Ticket nachzulegen.')
}
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:<session> 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 (a) fuer Oeffnungszeiten live ohne Kauf (Abo, Team): es gibt keine Zahlung zu pruefen; das Einrichten holt
* holeZeitenNach nach (Neubau, Einbau-Ticket, K27 nur bei Abo), derselbe Weg wie der 5-Minuten-Job und das Einschalten. Dienst
* erst beim Aufruf laden (zeiten/* nur per await import()). Sperren: feature-ticket (hier gehalten) -> zeiten-nachholen ->
* zeiten-neubau; nichts darin nimmt feature-admin. Wirft, wenn das Ticket nicht angelegt werden kann (der Aufrufer meldet es).
*/
async function ticketNachHolen(doc, note) {
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
const nachher = await holeZeitenNach(doc)
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 Mail 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:<session>), 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); Oeffnungszeiten live ohne Kauf (Abo, Team) haben keine Zahlung: dort holt
* holeZeitenNach Neubau, Ticket und K27 nach (ticketNachHolen);
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
* (c) Ticket vorhanden: nichts, created false.
* Eine Sperre feature-ticket:<id> 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 key === 'zeiten' && unlockedByVon(doc) !== 'kauf' ? ticketNachHolen(doc, note) : 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.
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
* vor dem Schliessen des Tickets pruefen kann); eine leere Liste passt auf nichts und fragt Appwrite gar nicht erst.
*/
export async function findActiveBySiteKey(siteKey, featureKey = 'news', { states = ['active'] } = {}) {
if (typeof siteKey !== 'string' || !/^[a-z2-7]{16}$/.test(siteKey) || !FEATURE_KEYS.includes(featureKey)) return null
if (!Array.isArray(states) || states.length === 0) return null
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', featureKey),
Query.equal('state', states),
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.
* behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts
* neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an.
*/
export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) {
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 !== '',
behalten: Boolean(behalten),
neu: !behalten,
},
}
}
/**
* 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. */
export 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,
abo: false,
team: 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). */
export 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:<docId> 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.
* ticketOffen und rechnungUrl ueberschreiben, was sonst am Dokument steht (Ticket vorhanden, invoiceUrl): bei einer Zahlung, die nicht zu
* dessen Ticket und Rechnung gehoert (Behalten, zeiten/behalten.js), sagt T19 sonst "Ticket offen" und zeigt die Rechnung einer anderen Zahlung.
* rechnungUrl '' heisst "keine Rechnung"; null (Vorgabe) nimmt die des Dokuments.
* @param {object} doc portalFeatures-Dokument
* @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean, ticketOffen?: boolean|null, rechnungUrl?: string|null }} [options]
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
*/
export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false, ticketOffen = null, rechnungUrl = null } = {}) {
return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => {
const kopf = await meldeKopf(frisch, customer)
const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : ''
const rechnung = rechnungUrl ?? frisch.invoiceUrl ?? ''
return sendAdminNotice({
template: 'T19',
values: {
...kopf,
funktion: kopf.funktion || 'Funktion',
betrag: featureConfig(frisch.featureKey)?.priceLabel || '',
sitzung: sitzung || '',
woid,
rechnung_url: rechnung,
},
blocks: {
zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende',
ticket_offen: ticketOffen ?? Boolean(frisch.ticketId),
rechnung: Boolean(rechnung),
},
})
})
}
/**
* 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`)
}
/**
* Zahlung "fuer 30 EUR behalten" (Oeffnungszeiten live nach dem Abo-Ende, Spec 3.3): eigener Weg in zeiten/behalten.js, nie ueber
* markPaid und adoptPaidSession (die schalten nur aus 'ordered' ein und meldeten die Zahlung einer laufenden Funktion als ungeklaert).
* Dynamischer Import: zeiten/* importiert dieses Modul.
*/
async function onBehaltenPaid(session) {
const { bucheBehalten } = await import('./zeiten/behalten.js')
await bucheBehalten(session)
}
/** 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) {
if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(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': {
if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session)
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':
if (session.metadata?.zweck === 'behalten') return verwerfeBehaltenSitzung(session)
await markCheckoutFailed(session.id)
return
}
}
/** Behalten-Zahlung abgelaufen oder fehlgeschlagen: die offene Sitzung am Dokument wird frei (Dynamischer Import wie onBehaltenPaid). */
async function verwerfeBehaltenSitzung(session) {
const { verwerfeBehalten } = await import('./zeiten/behalten.js')
await verwerfeBehalten(session)
}
/**
* Selbstheilung der Oeffnungszeiten (Spec 3.2): fuer jedes Dokument der Zeiten in 'setup' holt holeZeitenNach fehlende Schritte
* nach (Neubau, Einbau-Ticket, K27; es sperrt je Dokument selbst und fasst Kauf, aktiv und aus nicht an). Hierher und nicht in
* activateClosedTickets: dort zaehlen nur Dokumente MIT Ticket. Jedes Dokument fuer sich abgesichert (ein Fehler stoppt weder die
* uebrigen noch den Job; der naechste Lauf versucht es erneut), das Log nennt Dokument-ID und Fehler, nie Kundendaten.
* Dynamischer Import: zeiten/* importiert dieses Modul.
*/
async function heileZeiten({ now }) {
if (!featureConfig('zeiten')) return
let docs
try {
docs = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'zeiten'), Query.equal('state', 'setup'), Query.limit(1000)])
} catch (err) {
console.error('[funktionen] Selbstheilung Öffnungszeiten: Dokumente nicht lesbar:', err.message)
return
}
if (docs.length === 0) return
const { holeZeitenNach } = await import('./zeiten/einschalten.js')
for (const doc of docs) {
try {
await holeZeitenNach(doc, { now })
} catch (err) {
console.error(`[funktionen] Selbstheilung Öffnungszeiten (${doc.$id}) fehlgeschlagen:`, [err?.name ?? 'Fehler', err?.status, err?.message].filter(Boolean).join(' '))
}
}
}
/**
* Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten:
* erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und 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. Zum Schluss, nur im Lauf des Jobs
* (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit
* customerId) schaltet nur frei und baut, bestellt und sendet nichts nach.
*/
export async function runActivation({ now = new Date(), customerId = null } = {}) {
const activated = await activateClosedTickets({ now, customerId })
if (activated.length > 0) {
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
for (const doc of activated) {
await nacharbeitNachFreischaltung(doc)
await meldeFreigeschaltet(doc)
}
}
if (!customerId) await heileZeiten({ now })
return activated
}