Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite. analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged, analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg, Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen). Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte). Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
21 KiB
JavaScript
559 lines
21 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 { createFeatureTicket } from './ticketAdmin.js'
|
|
|
|
export const FEATURE_KEYS = ['news']
|
|
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
|
|
|
|
export const DISABLED_MESSAGE =
|
|
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
export async function listCustomerFeatures(customerId) {
|
|
const projects = await listDocuments(config.collections.websiteProjects, [
|
|
Query.equal('customerId', customerId),
|
|
Query.orderDesc('$createdAt'),
|
|
Query.limit(1000),
|
|
])
|
|
const docs = await listDocuments(config.collections.portalFeatures, [
|
|
Query.equal('customerId', customerId),
|
|
Query.limit(1000),
|
|
])
|
|
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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09).
|
|
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht.
|
|
*/
|
|
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({
|
|
projekt: await projectName(doc.projectId).catch(() => ''),
|
|
state: doc.state,
|
|
orderedAt: doc.orderedAt || '',
|
|
ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
|
assertKey(key)
|
|
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
|
if (doc.state === 'setup' || doc.state === 'active') {
|
|
throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet')
|
|
}
|
|
if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE)
|
|
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 { ticketId } = await createFeatureTicket({
|
|
customerId: doc.customerId,
|
|
project,
|
|
featureKey: doc.featureKey,
|
|
siteKey: doc.siteKey,
|
|
})
|
|
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(),
|
|
...(note ? { adminNote: note } : {}),
|
|
})
|
|
}
|
|
|
|
export async function disable(docId, { by, adminNote } = {}) {
|
|
const note = adminNote ?? byNote(by, 'Abgeschaltet')
|
|
const doc = await updateDocument(config.collections.portalFeatures, docId, {
|
|
...stateFields('disabled'),
|
|
disabledAt: new Date().toISOString(),
|
|
...(note ? { adminNote: note } : {}),
|
|
})
|
|
if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc)
|
|
return doc
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
export async function findActiveBySiteKey(siteKey) {
|
|
if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null
|
|
const [doc] = await listDocuments(config.collections.portalFeatures, [
|
|
Query.equal('siteKey', siteKey),
|
|
Query.equal('featureKey', 'news'),
|
|
Query.equal('state', 'active'),
|
|
Query.limit(1),
|
|
])
|
|
return doc || null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stripe-Webhook, Mails und Freischalt-Job
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €`
|
|
const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news`
|
|
|
|
/** 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)) || '' : ''
|
|
}
|
|
|
|
async function teamNotice(doc, customer, { failed = false } = {}) {
|
|
await sendAdminNotice({
|
|
template: 'T16',
|
|
values: {
|
|
kunde_name: customer?.name || customer?.email || doc.customerId,
|
|
email: customer?.email || '',
|
|
projekt: await projectName(doc.projectId),
|
|
woid: failed ? '' : await ticketWoidOf(doc),
|
|
sitekey: failed ? '' : doc.siteKey || '',
|
|
sitzung: '',
|
|
},
|
|
blocks: { zahlung_fehlgeschlagen: failed, ungeklaert: false },
|
|
})
|
|
}
|
|
|
|
/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */
|
|
async function unmatchedNotice(session) {
|
|
const { customerId, projectId } = session.metadata || {}
|
|
const customer = customerId ? await customerOf(customerId).catch(() => null) : null
|
|
await sendAdminNotice({
|
|
template: 'T16',
|
|
values: {
|
|
kunde_name: customer?.name || customer?.email || customerId || 'unbekannt',
|
|
email: customer?.email || '',
|
|
projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '',
|
|
woid: '',
|
|
sitekey: '',
|
|
sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`,
|
|
},
|
|
blocks: { zahlung_fehlgeschlagen: false, 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 {
|
|
await sendCustomerMail(customer.email, {
|
|
template: 'K15',
|
|
values: {
|
|
kunde_name: customer.name || '',
|
|
projekt: await projectName(doc.projectId),
|
|
preis: eur(config.newsSetup.amountCents),
|
|
zeit: config.funktionen.setupEstimate,
|
|
rechnung_url: invoiceUrl,
|
|
},
|
|
blocks: { rechnung: Boolean(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
|
|
await sendCustomerMail(customer.email, {
|
|
template: 'K16',
|
|
values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() },
|
|
})
|
|
} catch (err) {
|
|
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
|
}
|
|
}
|
|
return activated
|
|
}
|