import { Router } from 'express' import { config } from '../config.js' import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' import { withLocks } from '../services/keyedLock.js' import { statusAusKonto } from '../services/shop/connect.js' import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' import { unterkunftModus } from '../services/unterkunft/schalter.js' import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen } from '../services/unterkunft/speicher.js' import { erstatteUeberzaehligeZahlung, verbucheZahlung, zahlungPasst } from '../services/unterkunft/zahlung.js' /** * Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto) * leitet Ereignisse mit metadata.wkUk === '1' (Checkout-Sitzungen) oder metadata.webklar === 'unterkunft' (Erstattungen) hierher * und ruft bei account.updated zusaetzlich kontoAktualisiertUk. Alles idempotent; ein Fehler bei Checkout- und Erstattungsereignissen => 500 (Stripe wiederholt), bei account.updated nur ein Log-Eintrag (shopWebhook.js). */ const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/ const text = (v) => (typeof v === 'string' ? v : '') const ID_OK = /^[A-Za-z0-9_-]{1,36}$/ /** Kontext fuer ablauf.js: voll, solange die Funktion existiert; sonst der Mindestkontext (eine eingegangene Zahlung geht nie verloren). */ async function kontextFuer(anfrage) { const voll = await ladeFuerProjekt(anfrage.customerId, anfrage.projectId).catch(() => null) if (voll && voll.featureId === anfrage.featureId) return voll return { featureId: anfrage.featureId, customerId: anfrage.customerId ?? '', projectId: anfrage.projectId ?? '', siteKey: '', besitzerEmail: '', einstellungen: await ladeEinstellungen(anfrage.featureId), project: null, } } /** Anfrage zum Ereignis: nur wenn das Konto des Ereignisses das Konto der Funktion ist (sonst Fremdereignis, ohne Wirkung). */ async function anfrageZu(event, anfrageId) { if (!ID_OK.test(text(anfrageId))) return null const anfrage = await ladeAnfrage(anfrageId) if (!anfrage) return null const ctx = await kontextFuer(anfrage) if (!ctx.einstellungen?.stripeAccountId || ctx.einstellungen.stripeAccountId !== event.account) return null return { anfrage, ctx } } async function zahlungEingegangen(event, session, now) { const treffer = await anfrageZu(event, session.metadata?.anfrageId) if (!treffer) return const { anfrage, ctx } = treffer const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id if (!zahlungPasst(anfrage, session)) { console.warn(`[unterkunft] Webhook: Betrag oder Währung weicht ab (Buchung ${anfrage.nummer}), Zahlung wird erstattet`) await erstatteUeberzaehligeZahlung(ctx, pi) return } await verbucheZahlung(ctx, anfrage.$id, pi, { now }) } async function erstattungGebucht(event, charge) { const treffer = await anfrageZu(event, charge.metadata?.anfrageId) if (!treffer) return const { anfrage } = treffer // nur die Zahlung, die diese Buchung bezahlt hat (fremde oder ueberzaehlige Zahlungen mit gleicher anfrageId zaehlen nicht) if (!anfrage.stripePaymentIntentId || typeof charge.payment_intent !== 'string' || charge.payment_intent !== anfrage.stripePaymentIntentId) return const stand = charge.amount_refunded if (!Number.isInteger(stand) || stand <= 0) return await withLocks(['unterkunft:' + anfrage.unterkunftId], async () => { const frisch = await ladeAnfrage(anfrage.$id) if (frisch && stand > (frisch.erstattetCent || 0)) await aktualisiereAnfrage(frisch.$id, { erstattetCent: stand }) }) } /** * Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten (idempotent; Fehler => 500, Stripe wiederholt). * checkout.session.completed (bezahlt) und async_payment_succeeded: Buchung als bezahlt markieren. charge.refunded: erstattetCent auf * mindestens den Stand der Zahlung heben (heilt einen Absturz zwischen Stripe-Erfolg und Schreiben). * @param {object} event Stripe-Ereignis (signiert und geprueft) * @param {Date} [now] */ export async function verarbeiteUkEreignis(event, now = new Date()) { return ereignisHandler(event, now) } async function verarbeite(event, now) { const objekt = event?.data?.object if (!objekt || typeof objekt !== 'object') return switch (event.type) { case 'checkout.session.completed': if (objekt.metadata?.wkUk === '1' && objekt.payment_status === 'paid') await zahlungEingegangen(event, objekt, now) break case 'checkout.session.async_payment_succeeded': if (objekt.metadata?.wkUk === '1') await zahlungEingegangen(event, objekt, now) break case 'charge.refunded': await erstattungGebucht(event, objekt) break default: } } let ereignisHandler = verarbeite // Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen) export function setzeUkEreignisHandlerFuerTests(fn) { ereignisHandler = fn ?? verarbeite } /** * account.updated: Bereitschaftsstatus (stripeStatus) aller Einstellungen mit diesem Konto neu speichern. Bei UNTERKUNFT_ENABLED=off * und solange die Collection fehlt (Setup-Skript noch nicht gelaufen) ohne Wirkung. * @returns {Promise} Zahl aktualisierter Einstellungen */ export async function kontoAktualisiertUk(accountId, konto, now = new Date()) { if (unterkunftModus() === 'off' || !ACCOUNT_RE.test(accountId || '')) return 0 let dokumente try { dokumente = await listDocuments(config.collections.ukEinstellungen, [Query.equal('stripeAccountId', accountId), Query.limit(25)]) } catch (err) { if (err?.status === 404) return 0 throw err } let n = 0 for (const doc of dokumente) { await withLocks([`uk-stripe:${doc.$id}`], async () => { const frisch = await getDocument(config.collections.ukEinstellungen, doc.$id) if (frisch.stripeAccountId !== accountId) return await updateDocument(config.collections.ukEinstellungen, doc.$id, { stripeStatus: JSON.stringify(statusAusKonto(konto, now)) }) n++ }) } return n } // Kein Endpunkt: die Datei haengt nur Funktionen an. Der leere Router erlaubt ein spaeteres Einhaengen ohne Aenderung am Import. export default Router()