1032 lines
46 KiB
JavaScript
1032 lines
46 KiB
JavaScript
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 { zustimmungFuerSitzung } from './featureConsents.js'
|
|
import { aboLage, laeuft } from './aboStatus.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
|
|
}
|
|
}
|
|
|
|
export async function assertOwnProject(customerId, projectId) {
|
|
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')
|
|
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. */
|
|
export async function listCustomerFeatures(customerId, { docs: geladen } = {}) {
|
|
const projects = await listDocuments(config.collections.websiteProjects, [
|
|
Query.equal('customerId', customerId),
|
|
Query.orderDesc('$createdAt'),
|
|
Query.limit(1000),
|
|
])
|
|
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
|
|
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. */
|
|
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: String(consent.ip || '').slice(0, 64),
|
|
consentUserAgent: String(consent.userAgent || '').slice(0, 300),
|
|
})
|
|
}
|
|
|
|
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 feature = featureConfig(doc.featureKey)
|
|
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
|
|
const { ticketId } = await createFeatureTicket({
|
|
customerId: doc.customerId,
|
|
project,
|
|
featureKey: doc.featureKey,
|
|
siteKey: doc.siteKey,
|
|
...texte,
|
|
})
|
|
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
|
return { doc: updated, created: true, fall: null }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
const doc = await withLocks([`feature-disable:${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)
|
|
return doc
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
|
|
assertKey(key)
|
|
await assertOwnProject(customerId, projectId)
|
|
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
|
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
|
|
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 ("<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
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an.
|
|
*/
|
|
export function bestellMail(feature, { kundeName, projekt, invoiceUrl }) {
|
|
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 || '',
|
|
},
|
|
blocks: { rechnung: Boolean(invoiceUrl) },
|
|
}
|
|
}
|
|
|
|
/** 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, ...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). */
|
|
async function teamNotice(doc, customer, { failed = false, keinAbo = 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 !== '' }
|
|
)
|
|
}
|
|
|
|
/** 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
|
|
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.
|
|
* @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`)
|
|
}
|
|
|
|
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)
|
|
}
|
|
if (customer?.email) {
|
|
try {
|
|
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
|
await sendCustomerMail(
|
|
customer.email,
|
|
bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl })
|
|
)
|
|
} catch (err) {
|
|
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
|
|
}
|
|
}
|
|
// 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 })
|
|
} 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. */
|
|
export async function runActivation({ now = new Date(), customerId = null } = {}) {
|
|
const activated = await activateClosedTickets({ now, customerId })
|
|
for (const doc of activated) {
|
|
try {
|
|
const customer = await customerOf(doc.customerId)
|
|
if (!customer?.email) continue
|
|
const feature = featureConfig(doc.featureKey)
|
|
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
|
await sendCustomerMail(
|
|
customer.email,
|
|
bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
|
|
)
|
|
} catch (err) {
|
|
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
|
}
|
|
}
|
|
return activated
|
|
}
|