136 lines
6.1 KiB
JavaScript
136 lines
6.1 KiB
JavaScript
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<number>} 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()
|