Shop D3: Connect-Webhook (account.updated, Checkout-Abschluss/Verwerfen, charge.refunded nachtragen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
987b184aa1
commit
7464a4ef55
6 files changed
+593
No files matched your search
@@ -0,0 +1,90 @@
|
||||
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 { 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 })
|
||||
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
|
||||
Reference in new issue
Block a user