Files
Webklar-Kundenbereich/server/routes/shopWebhook.js
T
ClaudeandClaude Opus 5.5 77fb8c8f44 Shop Review B I-1: Eingangsmail G15 bei SEPA, G09 nimmt die Bestellung ausdruecklich an
G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung
abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt,
aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der
Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des
Anbieters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:44:48 +00:00

92 lines
3.9 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'
/**
* 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)
return
}
if (!accountId) 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