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
@@ -18,6 +18,7 @@ import aboRoutes from './routes/abo.js'
|
||||
import funktionenRoutes from './routes/funktionen.js'
|
||||
import termineRoutes from './routes/termine.js'
|
||||
import shopRoutes from './routes/shop.js'
|
||||
import shopWebhookRoutes from './routes/shopWebhook.js'
|
||||
import takeoverCreditRoutes from './routes/takeoverCredits.js'
|
||||
import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||
@@ -83,6 +84,8 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
|
||||
app.use('/api/abo', aboRoutes)
|
||||
app.use('/api/funktionen', funktionenRoutes)
|
||||
app.use('/api/termine', termineRoutes)
|
||||
// Stripe-Connect-Webhook des Webshops (ohne Sitzung): vor dem Router, braucht rawBody des globalen Parsers
|
||||
app.use('/api/shop/stripe-webhook', shopWebhookRoutes)
|
||||
app.use('/api/shop', shopRoutes)
|
||||
app.use('/api/team', teamRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
|
||||
@@ -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
|
||||
@@ -157,3 +157,22 @@ export function connectEreignis(rawBody, signatur) {
|
||||
if (String(config.stripe.secretKey).startsWith('sk_live') && event.livemode !== true) return null
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer Statuswert fuer das Speichern: ein gemerkter Hinweis (Klarna/SEPA beim Haendler nicht freigeschaltet) bleibt, solange das
|
||||
* Konto Zahlungen annimmt und der Shop Klarna/SEPA anbietet; sonst gilt der frisch gelesene Status. Gleiche Regel wie
|
||||
* POST /api/shop/stripe/aktualisieren.
|
||||
*/
|
||||
export function statusMitHinweis(alt, status, zahlungsarten) {
|
||||
const arten = Array.isArray(zahlungsarten) ? zahlungsarten : ['card']
|
||||
const behalte = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && arten.some((z) => z === 'klarna' || z === 'sepa')
|
||||
return behalte ? { ...status, hinweis: alt.hinweis } : status
|
||||
}
|
||||
|
||||
/** Alle Erstattungen einer Zahlung des Haendlerkontos (bis 100; Stripe liefert sie im charge.refunded-Ereignis nicht immer mit). */
|
||||
export async function erstattungenZurZahlung(accountId, paymentIntent) {
|
||||
pruefeKonto(accountId)
|
||||
if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.')
|
||||
const liste = await getStripe().refunds.list({ payment_intent: paymentIntent, limit: 100 }, { stripeAccount: accountId })
|
||||
return Array.isArray(liste?.data) ? liste.data : []
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { bestellDaten, bestellungLesen } from './bestellungen.js'
|
||||
import { erstattungenZurZahlung } from './connect.js'
|
||||
|
||||
/**
|
||||
* Erstattungen, die der Haendler im Stripe-Dashboard ausloest (Webhook charge.refunded), in die Bestellung nachtragen
|
||||
* (Plan 2026-10-05-webshop.md, Task D3). Ueber das Portal gebuchte Erstattungen stehen schon unter ihrer Refund-ID (rid) in
|
||||
* erstattungen[] und werden nicht doppelt gebucht. Keine Mail an den Kaeufer: der Haendler hat die Erstattung selbst veranlasst.
|
||||
*/
|
||||
|
||||
const ERSTATTBAR = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen', 'erstattet']
|
||||
const MAX_JSON = 60000
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
const stripeId = (wert) => (typeof wert === 'string' ? wert : typeof wert?.id === 'string' ? wert.id : '')
|
||||
|
||||
async function shopDokument(shopId) {
|
||||
try {
|
||||
return await getDocument(config.collections.shopConfig, shopId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Bestellung zur Zahlung: erst ueber die Metadaten der Zahlung (orderId), sonst Suche in den bezahlten Bestellungen der Shops des Kontos. */
|
||||
async function bestellungZurZahlung(accountId, paymentIntent, orderId) {
|
||||
if (typeof orderId === 'string' && orderId) {
|
||||
const order = await bestellungLesen(orderId)
|
||||
if (order && order.paymentIntentId === paymentIntent) return order
|
||||
}
|
||||
const shops = await listAllDocuments(config.collections.shopConfig, [Query.equal('stripeAccountId', accountId)])
|
||||
for (const shop of shops) {
|
||||
for (const status of ERSTATTBAR) {
|
||||
const docs = await listAllDocuments(config.collections.shopOrders, [Query.equal('shopId', shop.$id), Query.equal('status', status)])
|
||||
const doc = docs.find((d) => d.paymentIntentId === paymentIntent)
|
||||
if (doc) return { ...doc, daten: bestellDaten(doc) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucht noch unbekannte Erstattungen einer Zahlung nach (idempotent ueber die Refund-ID).
|
||||
* @param {{ accountId: string, charge: object, now?: Date }} p charge = Stripe-Charge aus charge.refunded
|
||||
* @returns {Promise<{ order: object|null, neu: number }>} neu = Anzahl nachgetragener Erstattungen
|
||||
*/
|
||||
export async function bucheStripeErstattung({ accountId, charge, now = new Date() }) {
|
||||
const paymentIntent = stripeId(charge?.payment_intent)
|
||||
if (!istObjekt(charge) || typeof accountId !== 'string' || !paymentIntent) return { order: null, neu: 0 }
|
||||
const gefunden = await bestellungZurZahlung(accountId, paymentIntent, charge.metadata?.wkShop === '1' ? charge.metadata.orderId : undefined)
|
||||
if (!gefunden) return { order: null, neu: 0 }
|
||||
const shop = await shopDokument(gefunden.shopId)
|
||||
if (!shop || shop.stripeAccountId !== accountId) return { order: null, neu: 0 }
|
||||
|
||||
// Refunds: aus dem Ereignis, sonst bei Stripe lesen (neuere API-Versionen liefern charge.refunds nicht mehr mit)
|
||||
const refunds = Array.isArray(charge.refunds?.data) ? charge.refunds.data : await erstattungenZurZahlung(accountId, paymentIntent)
|
||||
const at = new Date(now).toISOString()
|
||||
|
||||
return withLocks([`shop-order:${gefunden.$id}`], async () => {
|
||||
const order = await bestellungLesen(gefunden.$id)
|
||||
if (!order || order.paymentIntentId !== paymentIntent || !ERSTATTBAR.includes(order.status)) return { order, neu: 0 }
|
||||
const daten = order.daten
|
||||
const bisher = Array.isArray(daten.erstattungen) ? daten.erstattungen : []
|
||||
const bekannt = new Set(bisher.map((e) => e?.rid))
|
||||
const neue = []
|
||||
for (const r of refunds) {
|
||||
const rid = stripeId(r)
|
||||
if (!rid || bekannt.has(rid) || (r.status && r.status !== 'succeeded' && r.status !== 'pending')) continue
|
||||
if (!Number.isInteger(r.amount) || r.amount <= 0) continue
|
||||
bekannt.add(rid)
|
||||
neue.push({ rid, betragCent: r.amount, grund: 'Stripe-Dashboard', at, quelle: 'stripe' })
|
||||
}
|
||||
if (!neue.length) return { order, neu: 0 }
|
||||
|
||||
const erstattungen = [...bisher, ...neue]
|
||||
const summe = erstattungen.reduce((s, e) => s + (Number.isInteger(e?.betragCent) ? e.betragCent : 0), 0)
|
||||
const voll = Number.isInteger(order.summeCent) && summe >= order.summeCent
|
||||
const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), ...neue.map((e) => ({ at, was: 'erstattet_stripe', betragCent: e.betragCent }))]
|
||||
const felder = { bestellung: JSON.stringify({ ...daten, erstattungen, verlauf }), ...(voll ? { status: 'erstattet' } : {}) }
|
||||
if (felder.bestellung.length > MAX_JSON) throw new Error('Bestellung zu gross für weitere Erstattungen')
|
||||
const doc = await updateDocument(config.collections.shopOrders, order.$id, felder)
|
||||
return { order: { ...doc, daten: bestellDaten(doc) }, neu: neue.length }
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user