diff --git a/server/index.js b/server/index.js index 68c527c..3bbb0ae 100644 --- a/server/index.js +++ b/server/index.js @@ -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) diff --git a/server/routes/shopWebhook.js b/server/routes/shopWebhook.js new file mode 100644 index 0000000..e76db4e --- /dev/null +++ b/server/routes/shopWebhook.js @@ -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 diff --git a/server/services/shop/connect.js b/server/services/shop/connect.js index a84401a..367f489 100644 --- a/server/services/shop/connect.js +++ b/server/services/shop/connect.js @@ -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 : [] +} diff --git a/server/services/shop/erstattungen.js b/server/services/shop/erstattungen.js new file mode 100644 index 0000000..950e6b1 --- /dev/null +++ b/server/services/shop/erstattungen.js @@ -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 } + }) +} diff --git a/test/shopWebhook.test.js b/test/shopWebhook.test.js new file mode 100644 index 0000000..ec0c778 --- /dev/null +++ b/test/shopWebhook.test.js @@ -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) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 31fa612..0df7463 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -286,6 +286,11 @@ export async function startFakeStripe() { refunds.set(obj.id, 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\/([^/]+)$/))) { const found = bucket(refundMaps, account).get(m[1]) return found ? [200, found] : noSuch('refund', m[1])