Files
Webklar-Kundenbereich/server/services/siteFeatures.js
T
ClaudeandClaude Sonnet 5.5 992d7358fa Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig
T16 (Funktion bestellt) nennt die Funktion aus dem Register statt fest
"News-Pinnwand": neue Platzhalter funktion, zustand, anleitung_url und
gesundheit_text, neue Bloecke kein_abo, bereits_aktiv, anleitung (Knopf
"Im Ticketsystem oeffnen" zur Einbauanleitung) und gesundheit. teamNotice
und unmatchedNotice fuellen funktion aus featureConfig (ohne bekannten
Schluessel "Funktion"); die neuen Bloecke kein_abo, bereits_aktiv und
gesundheit bleiben aus, bis B10a und D4 sie fuellen. Die News-Mails
bleiben woertlich (HTML, Text und Betreff byte-gleich).

T19 (Erstattung faellig, Team-Hinweis) ist neu: volle Erstattung von
Hand in Stripe, Bloecke zahlung_nach_ende, ticket_offen und rechnung.
Gesendet wird sie spaeter ueber meldeErstattung (B10b).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:06:39 +00:00

736 lines
29 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, featureKeys, ticketTexte } from './featureRegistry.js'
import { zustimmungFuerSitzung } from './featureConsents.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
}
}
async function featureView(doc) {
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,
}
}
/** 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
}
/**
* 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).
*/
export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
if (!sessionId) return { doc: null, created: false }
return withLocks([`feature-paid:${sessionId}`], async () => {
const doc = await findBySession(sessionId)
if (!doc) return { doc: null, created: false }
const resumable = doc.state === 'setup' && !doc.ticketId
if (doc.state !== 'ordered' && !resumable) return { doc, created: false }
const setup = resumable
? doc
: await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('setup'),
purchaseStatus: 'paid',
unlockedByPurchase: true,
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
})
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 }
})
}
/**
* 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)
}
}
/** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
assertKey(key)
await assertOwnProject(customerId, projectId)
const doc = await ensureFeatureDoc(customerId, projectId, key)
return activate(doc.$id, { by, adminNote })
}
/** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */
export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) {
const queries = [Query.equal('featureKey', 'news'), 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
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)
}
async function projectName(projectId) {
try {
return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
} catch (err) {
if (err.status === 404) return ''
throw err
}
}
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". kein_abo, bereits_aktiv und gesundheit sind in der Vorlage
* deklariert und bleiben hier aus (B10a und D4 fuellen sie).
*/
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 },
})
}
async function teamNotice(doc, customer, { failed = 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
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) : '',
},
{ zahlung_fehlgeschlagen: failed, anleitung }
)
}
/** 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
}
async function onFeaturePaid(session) {
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
let { doc, created } = 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 } = await markPaid(paid))
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.
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 {
const feature = featureConfig(doc.featureKey)
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)
}
}
try {
await teamNotice(doc, customer)
} 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
}