106 lines
4.6 KiB
JavaScript
106 lines
4.6 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 { connectEreignis, statusAusKonto, statusMitHinweis } from '../services/shop/connect.js'
|
|
import { meldeEingang, schliesseAb, verwirf } from '../services/shop/bestellungen.js'
|
|
import { bucheStripeErstattung } from '../services/shop/erstattungen.js'
|
|
import { ladeShop, leereCache } from '../services/shop/kontext.js'
|
|
import { kontoAktualisiertUk, verarbeiteUkEreignis } from './unterkunftWebhook.js'
|
|
|
|
/**
|
|
* Stripe-Connect-Webhook des Webshops: POST /api/shop/stripe-webhook (Plan 2026-10-05-webshop.md, Task D3).
|
|
* Ohne Sitzung und ohne CORS; die Signatur ist die Anmeldung. Gemountet vor dem /api/shop-Router, nutzt req.rawBody des globalen
|
|
* JSON-Parsers. Alles idempotent (Stripe wiederholt): unbekannte Konten und fremde Sitzungen ⇒ 200 ohne Wirkung, Fehler ⇒ 500.
|
|
*/
|
|
|
|
const router = Router()
|
|
const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/
|
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
|
|
|
/** account.updated: Bereitschaftsstatus aller Shops mit diesem Konto neu speichern und ihren Cache leeren. */
|
|
async function kontoAktualisiert(accountId, konto, now) {
|
|
if (!ACCOUNT_RE.test(accountId || '')) return
|
|
const documents = await listDocuments(config.collections.shopConfig, [Query.equal('stripeAccountId', accountId), Query.limit(25)])
|
|
for (const doc of documents) {
|
|
await withLocks([`shop-stripe:${doc.$id}`], async () => {
|
|
const frisch = await getDocument(config.collections.shopConfig, doc.$id)
|
|
if (frisch.stripeAccountId !== accountId) return
|
|
const shop = ladeShop(frisch)
|
|
const status = statusAusKonto(konto, now)
|
|
const neu = statusMitHinweis(shop.stripeStatus, status, shop.einstellungen?.zahlungsarten)
|
|
await updateDocument(config.collections.shopConfig, doc.$id, { stripeStatus: JSON.stringify(neu) })
|
|
})
|
|
leereCache(doc.siteKey)
|
|
}
|
|
}
|
|
|
|
async function verarbeite(event, now) {
|
|
const objekt = event.data?.object
|
|
if (!istObjekt(objekt)) return
|
|
const accountId = typeof event.account === 'string' ? event.account : undefined
|
|
|
|
if (event.type === 'account.updated') {
|
|
await kontoAktualisiert(accountId || objekt.id, objekt, now)
|
|
// dasselbe Konto kann auch im Buchungskalender haengen
|
|
try {
|
|
await kontoAktualisiertUk(accountId || objekt.id, objekt, now)
|
|
} catch (err) {
|
|
// ein Fehler im Buchungskalender darf die Antwort fuer den Shop nie beeinflussen (ohne Kundendaten im Log)
|
|
console.warn(`[shop] Webhook account.updated: Buchungskalender nicht aktualisiert: ${err?.name}`)
|
|
}
|
|
return
|
|
}
|
|
if (!accountId) return
|
|
|
|
// Buchungskalender: Checkout-Sitzungen mit wkUk, Erstattungen nach den Metadaten der Zahlung
|
|
if (objekt.metadata?.wkUk === '1' || (event.type === 'charge.refunded' && objekt.metadata?.webklar === 'unterkunft')) {
|
|
await verarbeiteUkEreignis(event, now)
|
|
return
|
|
}
|
|
|
|
if (event.type === 'charge.refunded') {
|
|
await bucheStripeErstattung({ accountId, charge: objekt, now })
|
|
return
|
|
}
|
|
|
|
// Ab hier Checkout-Sitzungen; nur die des Webshops
|
|
if (objekt.metadata?.wkShop !== '1') return
|
|
const orderId = objekt.metadata.orderId
|
|
switch (event.type) {
|
|
case 'checkout.session.completed':
|
|
if (objekt.payment_status === 'paid') await schliesseAb({ accountId, session: objekt, now })
|
|
else await meldeEingang({ accountId, session: objekt, now })
|
|
break
|
|
case 'checkout.session.async_payment_succeeded':
|
|
await schliesseAb({ accountId, session: objekt, now })
|
|
break
|
|
case 'checkout.session.async_payment_failed':
|
|
await verwirf(orderId, 'zahlung_fehlgeschlagen', { now, mail: true, email: objekt.customer_details?.email, accountId })
|
|
break
|
|
case 'checkout.session.expired':
|
|
await verwirf(orderId, 'abgelaufen', { now, accountId })
|
|
break
|
|
default:
|
|
}
|
|
}
|
|
|
|
router.post('/', async (req, res) => {
|
|
let event
|
|
try {
|
|
event = connectEreignis(req.rawBody, req.get('stripe-signature'))
|
|
} catch (err) {
|
|
return res.status(err?.status === 503 ? 503 : 400).json({ error: err?.status === 503 ? 'Webhook nicht eingerichtet.' : 'Ungültige Signatur.' })
|
|
}
|
|
if (!event) return res.json({ ignoriert: true })
|
|
try {
|
|
await verarbeite(event, new Date())
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
console.error(`[shop] Webhook ${String(event.type).slice(0, 60)} (${String(event.id).slice(0, 40)}) fehlgeschlagen: ${err?.name}`)
|
|
return res.status(500).json({ error: 'Verarbeitung fehlgeschlagen.' })
|
|
}
|
|
})
|
|
|
|
export default router
|