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:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:04:47 +00:00
1 parent 987b184aa1
commit 7464a4ef55
6 files changed
+593

No files matched your search

+3
View File
@@ -18,6 +18,7 @@ import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js' import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js' import termineRoutes from './routes/termine.js'
import shopRoutes from './routes/shop.js' import shopRoutes from './routes/shop.js'
import shopWebhookRoutes from './routes/shopWebhook.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js' import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.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/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes) app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes) 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/shop', shopRoutes)
app.use('/api/team', teamRoutes) app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/gitea', adminGiteaRoutes)
+90
View File
@@ -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
+19
View File
@@ -157,3 +157,22 @@ export function connectEreignis(rawBody, signatur) {
if (String(config.stripe.secretKey).startsWith('sk_live') && event.livemode !== true) return null if (String(config.stripe.secretKey).startsWith('sk_live') && event.livemode !== true) return null
return event 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 : []
}
+86
View File
@@ -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 }
})
}
+390
View File
@@ -0,0 +1,390 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop D3: Connect-Webhook (signierte Ereignisse an POST /api/shop/stripe-webhook). Mails landen in der Senke.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const B = await import('../server/services/shop/bestellungen.js')
const { startTestApp, postWebhook, send } = await import('./support/testApp.js')
const { default: webhookRoutes } = await import('../server/routes/shopWebhook.js')
const app = await startTestApp({ '/api/shop/stripe-webhook': webhookRoutes })
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
after(async () => {
await app.close()
setMailTransportForTests(null)
await aw.close()
await st.close()
})
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const PORTAL = 'https://project.webklar.com'
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true }
const shopDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
...over,
})
const artikel = (id, eingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
}
let sink
let log
let ctx
async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'termineOutbox', 'customers']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' })
aw.seed('portalFeatures', featureDoc)
aw.seed('shopConfig', shopDoc(shop))
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' })
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
return ctx
}
beforeEach(() => {
st.calls.length = 0
config.publicBaseUrl = PORTAL
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'on'
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
K.leereCache()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const orders = () => [...aw.docs('shopOrders').values()]
const order = (id) => aw.docs('shopOrders').get(id)
const daten = (id) => JSON.parse(order(id).bestellung)
const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const shopStatus = () => JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus)
const SECRET = 'whsec_connect_test'
const PFAD = '/api/shop/stripe-webhook'
let nr = 0
const ereignis = (type, objekt, over = {}) => ({ id: `evt_${(nr += 1)}`, object: 'event', type, livemode: false, account: ACCOUNT, data: { object: objekt }, ...over })
const sende = (event, opt = {}) => postWebhook(event, { path: PFAD, secret: SECRET, ...opt })
const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } }
const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } }
async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }]) {
const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE' }, { now: new Date() })
const doc = order(r.orderId)
return { ...r, doc, sessionId: doc.stripeSessionId }
}
const zahle = (b, felder = {}) =>
st.completeSession(ACCOUNT, b.sessionId, {
payment_status: 'paid',
amount_total: b.doc.summeCent,
customer_details: kunde,
shipping: versandAn,
payment_intent: 'pi_test1',
payment_method_types: ['card'],
...felder,
})
const secretVorher = config.shop.connectWebhookSecret
const keyVorher = config.stripe.secretKey
beforeEach(() => {
config.shop.connectWebhookSecret = SECRET
config.stripe.secretKey = keyVorher
})
after(() => {
config.shop.connectWebhookSecret = secretVorher
config.stripe.secretKey = keyVorher
})
test('Signatur: ungültig ⇒ 400, ohne Secret ⇒ 503, nichts geändert', async () => {
await aufbau()
const b = await bestelle()
const e = ereignis('checkout.session.completed', zahle(b))
assert.equal((await sende(e, { secret: 'whsec_falsch' })).status, 400)
assert.equal((await ohneSignatur(e)).status, 400)
assert.equal(order(b.orderId).status, 'offen')
config.shop.connectWebhookSecret = ''
assert.equal((await sende(e)).status, 503)
assert.equal(order(b.orderId).status, 'offen')
})
const ohneSignatur = (event) => send('POST', PFAD, { body: JSON.stringify(event) })
test('livemode: mit Live-Schlüssel werden Testereignisse ignoriert (200), Live-Ereignisse gelten', async () => {
await aufbau()
const b = await bestelle()
config.stripe.secretKey = 'sk_live_fake'
const session = zahle(b)
const test = await sende(ereignis('checkout.session.completed', session, { livemode: false }))
assert.equal(test.status, 200)
assert.deepEqual(test.body, { ignoriert: true })
assert.equal(order(b.orderId).status, 'offen')
const live = await sende(ereignis('checkout.session.completed', session, { livemode: true }))
assert.equal(live.status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('account.updated: speichert den Status des Shops mit diesem Konto, leert den Cache, behält den Hinweis nur wo sinnvoll', async () => {
await aufbau({ shop: { stripeStatus: JSON.stringify({ charges_enabled: false, hinweis: 'Klarna fehlt' }), einstellungen: JSON.stringify({ ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] }) } })
assert.equal(ctx.shop.stripeStatus.charges_enabled, false)
const konto = { id: ACCOUNT, charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null } }
const r = await sende(ereignis('account.updated', konto))
assert.equal(r.status, 200)
const s = shopStatus()
assert.equal(s.charges_enabled, true)
assert.equal(s.payouts_enabled, true)
assert.equal(s.hinweis, 'Klarna fehlt', 'Hinweis bleibt: Klarna angeboten und Konto aktiv')
assert.ok(s.geprueftAt)
const frisch = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
assert.equal(frisch.shop.stripeStatus.charges_enabled, true, 'Cache wurde geleert')
// nur Karte ⇒ Hinweis entfällt
await updateDocument(config.collections.shopConfig, FEATURE, { einstellungen: JSON.stringify(EINSTELLUNGEN) })
await sende(ereignis('account.updated', { ...konto, charges_enabled: false }))
assert.equal(shopStatus().hinweis, undefined)
assert.equal(shopStatus().charges_enabled, false)
// Konto ohne Ereignis-Feld account: Objekt-ID zählt; unbekanntes Konto ⇒ 200 ohne Wirkung
const vorher = JSON.stringify(shopStatus())
assert.equal((await sende(ereignis('account.updated', { id: 'acct_unbekannt', charges_enabled: true }, { account: 'acct_unbekannt' }))).status, 200)
assert.equal((await sende(ereignis('account.updated', { id: ACCOUNT, charges_enabled: true, payouts_enabled: false }, { account: undefined }))).status, 200)
assert.equal(shopStatus().charges_enabled, true)
assert.notEqual(JSON.stringify(shopStatus()), vorher)
})
test('checkout.session.completed (bezahlt) ⇒ bezahlt, Bestand, Mails; Wiederholung ändert nichts', async () => {
await aufbau()
const b = await bestelle()
const e = ereignis('checkout.session.completed', zahle(b))
const r = await sende(e)
assert.equal(r.status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(order(b.orderId).paymentIntentId, 'pi_test1')
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
assert.equal((await sende(e)).status, 200)
assert.equal((await Promise.all([sende(e), sende(e)])).every((x) => x.status === 200), true)
await warteAufVersand()
assert.equal(produkt('a1').bestand, 3)
assert.equal(sink.sent.length, 2)
assert.equal(daten(b.orderId).verlauf.filter((v) => v.was === 'bezahlt').length, 1)
})
test('SEPA: completed unbezahlt ändert nichts, async_payment_succeeded bucht', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
assert.equal((await sende(ereignis('checkout.session.completed', unbezahlt))).status, 200)
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const bezahlt = { ...unbezahlt, payment_status: 'paid' }
assert.equal((await sende(ereignis('checkout.session.async_payment_succeeded', bezahlt))).status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(produkt('a1').bestand, 3)
})
test('async_payment_failed ⇒ storniert, Bestand frei, G14 eingereiht; Wiederholung nur einmal', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const e = ereignis('checkout.session.async_payment_failed', session)
assert.equal((await sende(e)).status, 200)
assert.equal(order(b.orderId).status, 'storniert')
assert.deepEqual(daten(b.orderId).verlauf.map((v) => v.was), ['angelegt', 'zahlung_fehlgeschlagen'])
assert.equal((await B.reserviert(FEATURE, new Date())).get('a1'), undefined)
await warteAufVersand()
assert.deepEqual(eintraege().map((x) => x.art), ['G14'])
assert.equal(eintraege()[0].bookingId, b.orderId)
assert.equal((await sende(e)).status, 200)
await warteAufVersand()
assert.equal(eintraege().length, 1)
})
test('checkout.session.expired ⇒ storniert (abgelaufen), keine Mail; bezahlte Bestellung bleibt', async () => {
await aufbau()
const a = await bestelle()
const e = ereignis('checkout.session.expired', { ...st.accountSessions(ACCOUNT).get(a.sessionId), status: 'expired' })
assert.equal((await sende(e)).status, 200)
assert.equal(order(a.orderId).status, 'storniert')
assert.deepEqual(daten(a.orderId).verlauf.map((v) => v.was), ['angelegt', 'abgelaufen'])
await warteAufVersand()
assert.equal(eintraege().length, 0)
const c = await bestelle([{ produktId: 'a1', menge: 1 }])
await sende(ereignis('checkout.session.completed', zahle(c)))
assert.equal((await sende(ereignis('checkout.session.expired', st.accountSessions(ACCOUNT).get(c.sessionId)))).status, 200)
assert.equal(order(c.orderId).status, 'bezahlt')
})
test('Ereignis eines fremden Kontos kann keine Bestellung eines anderen Shops berühren', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
for (const type of ['checkout.session.completed', 'checkout.session.async_payment_succeeded']) {
assert.equal((await sende(ereignis(type, session, { account: 'acct_fremd' }))).status, 200)
}
assert.equal((await sende(ereignis('checkout.session.async_payment_failed', session, { account: 'acct_fremd' }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.expired', session, { account: 'acct_fremd' }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.completed', session, { account: undefined }))).status, 200)
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.equal(eintraege().length, 0)
})
test('Sitzungen ohne wkShop und unbekannte Ereignisse ⇒ 200 ohne Wirkung', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
assert.equal((await sende(ereignis('checkout.session.completed', { ...session, metadata: { orderId: b.orderId } }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.completed', { ...session, metadata: {} }))).status, 200)
assert.equal((await sende(ereignis('checkout.session.completed', { id: 'cs_x', payment_status: 'paid' }))).status, 200)
assert.equal((await sende(ereignis('payout.paid', { id: 'po_1' }))).status, 200)
assert.equal((await sende(ereignis('charge.refunded', { id: 'ch_1', payment_intent: null }))).status, 200)
assert.equal(order(b.orderId).status, 'offen')
})
test('Fehler bei der Verarbeitung ⇒ 500 (Stripe wiederholt), Log ohne Kundendaten', async () => {
await aufbau()
const b = await bestelle()
const e = ereignis('checkout.session.completed', zahle(b))
aw.failNext({ collection: 'shopOrders', method: 'GET', status: 500 })
const nach500 = await sende(e)
assert.equal(nach500.status, 500)
const logs = log.error.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n')
assert.match(logs, /checkout\.session\.completed/)
assert.doesNotMatch(logs, /max@example|Mustermann/)
assert.equal((await sende(e)).status, 200)
assert.equal(order(b.orderId).status, 'bezahlt')
})
async function bezahlteBestellung(positionen) {
const b = await bestelle(positionen)
await sende(ereignis('checkout.session.completed', zahle(b)))
assert.equal(order(b.orderId).status, 'bezahlt')
return b
}
const refund = (id, amount, extra = {}) => ({ id, object: 'refund', amount, currency: 'eur', status: 'succeeded', payment_intent: 'pi_test1', ...extra })
const charge = (b, refunds, extra = {}) => ({ id: 'ch_1', object: 'charge', payment_intent: 'pi_test1', amount: b.doc.summeCent, metadata: { wkShop: '1', orderId: b.orderId }, ...(refunds ? { refunds: { object: 'list', data: refunds } } : {}), ...extra })
test('charge.refunded: Teilerstattung wird nachgetragen, Wiederholung und Portal-Eintrag zählen nicht doppelt, volle ⇒ erstattet', async () => {
await aufbau()
const b = await bezahlteBestellung()
const summe = b.doc.summeCent
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)])))
assert.equal(order(b.orderId).status, 'bezahlt')
let e = daten(b.orderId).erstattungen
assert.equal(e.length, 1)
assert.deepEqual({ rid: e[0].rid, betragCent: e[0].betragCent, grund: e[0].grund, quelle: e[0].quelle }, { rid: 're_1', betragCent: 500, grund: 'Stripe-Dashboard', quelle: 'stripe' })
assert.ok(e[0].at)
// Wiederholung + bereits über das Portal gebuchte Erstattung (anderes Feld, gleiche rid)
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)])))
assert.equal(daten(b.orderId).erstattungen.length, 1)
const d = daten(b.orderId)
await updateDocument(config.collections.shopOrders, b.orderId, { bestellung: JSON.stringify({ ...d, erstattungen: [...d.erstattungen, { rid: 're_portal', betragCent: 100, grund: 'Kaputt', at: 'x' }] }) })
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500), refund('re_portal', 100)])))
assert.equal(daten(b.orderId).erstattungen.length, 2)
// Rest ⇒ voll erstattet
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500), refund('re_portal', 100), refund('re_2', summe - 600)])))
assert.equal(order(b.orderId).status, 'erstattet')
assert.equal(daten(b.orderId).erstattungen.length, 3)
assert.deepEqual(daten(b.orderId).verlauf.filter((v) => v.was === 'erstattet_stripe').map((v) => v.betragCent), [500, summe - 600])
await warteAufVersand()
assert.equal(eintraege().filter((x) => x.art === 'G13').length, 0, 'keine Käufermail bei Dashboard-Erstattung')
// spätere Wiederholung ändert den Endzustand nicht
await sende(ereignis('charge.refunded', charge(b, [refund('re_2', summe - 600)])))
assert.equal(order(b.orderId).status, 'erstattet')
assert.equal(daten(b.orderId).erstattungen.length, 3)
})
test('charge.refunded: ohne refunds im Ereignis werden sie bei Stripe gelesen; Bestellung ohne Metadaten wird über die Zahlung gefunden', async () => {
await aufbau()
const b = await bezahlteBestellung()
st.setPaymentIntent(ACCOUNT, 'pi_test1', { amount: b.doc.summeCent })
const r = await st.refunds(ACCOUNT)
r.set('re_s1', refund('re_s1', b.doc.summeCent))
await sende(ereignis('charge.refunded', charge(b, null, { metadata: {} })))
assert.equal(order(b.orderId).status, 'erstattet')
assert.equal(daten(b.orderId).erstattungen[0].rid, 're_s1')
assert.ok(st.calls.some((c) => c.path === '/v1/refunds' && c.account === ACCOUNT))
})
test('charge.refunded: fremdes Konto, offene Bestellung, fehlgeschlagene Refunds ändern nichts', async () => {
await aufbau()
const b = await bezahlteBestellung()
await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)]), { account: 'acct_fremd' }))
await sende(ereignis('charge.refunded', charge(b, [refund('re_f', 500, { status: 'failed' }), refund('re_n', 0)])))
assert.equal(daten(b.orderId).erstattungen, undefined)
const offen = await bestelle([{ produktId: 'a1', menge: 1 }])
await sende(ereignis('charge.refunded', charge(offen, [refund('re_o', 100)], { payment_intent: 'pi_offen' })))
assert.equal(order(offen.orderId).status, 'offen')
assert.equal(daten(offen.orderId).erstattungen, undefined)
})
+5
View File
@@ -286,6 +286,11 @@ export async function startFakeStripe() {
refunds.set(obj.id, obj) refunds.set(obj.id, obj)
return [200, obj] return [200, obj]
} }
if (method === 'GET' && path === '/v1/refunds') {
const pi = query.get('payment_intent')
const items = [...bucket(refundMaps, account).values()].filter((r) => !pi || r.payment_intent === pi)
return [200, { object: 'list', data: items, has_more: false, url: '/v1/refunds' }]
}
if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) { if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) {
const found = bucket(refundMaps, account).get(m[1]) const found = bucket(refundMaps, account).get(m[1])
return found ? [200, found] : noSuch('refund', m[1]) return found ? [200, found] : noSuch('refund', m[1])