diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index 66548f2..0787cd7 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -248,8 +248,7 @@ export async function markiereBezahlt(ctx, anfrageId, { via, paymentIntentId, no return r.anfrage } -function pruefeErstattung(a, betragCent) { - const bisher = a.erstattetCent || 0 +function pruefeErstattung(a, betragCent, bisher = a.erstattetCent || 0) { const brutto = a.summen?.bruttoCent if (a.bezahltVia !== 'stripe') throw new UkFehler(409, 'status_ungueltig', { erstattungCent: 'Erstattung ist nur bei Online-Zahlung möglich.' }) if (!Number.isInteger(betragCent) || betragCent <= 0 || !Number.isInteger(brutto) || betragCent > brutto - bisher) { @@ -259,16 +258,23 @@ function pruefeErstattung(a, betragCent) { } /** - * Erstattung einer stornierten, online bezahlten Buchung. erstattetCent wird ERST NACH dem Stripe-Erfolg geschrieben, ohne - * Rollback. Ein Absturz zwischen Stripe-Erfolg und Schreiben heilt sich durch Wiederholung mit demselben idempotencyKey - * (Stripe liefert dieselbe Erstattung) und durch den charge.refunded-Webhook (Task 7). + * Erstattung einer stornierten, online bezahlten Buchung. Vor dem Stripe-Aufruf wird der bei Stripe LIVE erstattete Betrag der Zahlung + * gelesen; bisher = max(erstattetCent, live), die Obergrenze wird gegen diesen Wert geprueft (kein doppeltes Erstatten, wenn ein frueherer + * Versuch bei Stripe durchging, aber nicht gespeichert wurde). Liegt die Datenbank hinter Stripe zurueck, wird sie zuerst nachgezogen. + * erstattetCent wird ERST NACH dem Stripe-Erfolg geschrieben, ohne Rollback; der charge.refunded-Webhook heilt ebenfalls. + * wiederholung: Wiederholung eines Storno-Aufrufs - ist der Betrag bei Stripe schon erstattet, wird nur nachgezogen (nichts erstattet). */ -export async function erstatteBetrag(ctx, anfrageId, { betragCent, now = new Date() } = {}) { +export async function erstatteBetrag(ctx, anfrageId, { betragCent, now = new Date(), wiederholung = false } = {}) { const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId)) return withLocks(withLocksKey(pre.unterkunftId), async () => { - const a = fuerFeature(ctx, await ladeAnfrage(anfrageId)) + let a = fuerFeature(ctx, await ladeAnfrage(anfrageId)) if (a.status !== 'storniert') throw new UkFehler(409, 'status_ungueltig') - const bisher = pruefeErstattung(a, betragCent) + if (a.bezahltVia !== 'stripe') pruefeErstattung(a, betragCent) + const live = await zahlung.liveErstattet(ctx, a) + const bisher = Math.max(a.erstattetCent || 0, live) + if (live > (a.erstattetCent || 0)) a = await aktualisiereAnfrage(a.$id, { erstattetCent: live }) + if (wiederholung && Number.isInteger(betragCent) && betragCent > 0 && live >= betragCent) return a + pruefeErstattung(a, betragCent, bisher) const res = await zahlung.erstatte(ctx, a, betragCent, { now, idempotencyKey: `uk-erstattung-${anfrageId}-${bisher + betragCent}` }) return aktualisiereAnfrage(a.$id, { erstattetCent: bisher + res.erstattetCent }) }) @@ -295,7 +301,7 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0 let anfrage = r.anfrage if (erstattungCent > 0) { try { - anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now }) + anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now, wiederholung: !r.neu }) } catch (err) { if (err instanceof UkFehler && err.status < 500 && err.code !== 'online_aus') throw err throw new UkFehler(502, 'erstattung_fehlgeschlagen') diff --git a/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js index cc092a6..cc55c8a 100644 --- a/server/services/unterkunft/zahlung.js +++ b/server/services/unterkunft/zahlung.js @@ -1,6 +1,6 @@ import { config } from '../../config.js' import { withLocks } from '../keyedLock.js' -import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from '../shop/connect.js' +import { checkoutAnlegen, erstatten, erstattungenZurZahlung, sessionAblaufen, sessionHolen } from '../shop/connect.js' import { formatTag } from './datum.js' import { UkFehler } from './fehler.js' import { aktualisiereAnfrage, ladeAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js' @@ -185,3 +185,21 @@ export async function erstatte(ctx, anfrage, betragCent, { now, idempotencyKey } if (testProtokoll()) _aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey }) return erstatter(ctx, anfrage, betragCent, { now, idempotencyKey }) } + +async function liveErstattetEcht(ctx, anfrage) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (!accountId || !anfrage?.stripePaymentIntentId) return 0 + const liste = await erstattungenZurZahlung(accountId, anfrage.stripePaymentIntentId) + return liste.filter((r) => r?.status !== 'failed' && r?.status !== 'canceled').reduce((s, r) => s + (Number.isInteger(r?.amount) ? r.amount : 0), 0) +} + +let liveLeser = liveErstattetEcht +/** Nur fuer Tests: ersetzt die Abfrage des bei Stripe erstatteten Betrags (null stellt die echte wieder her). */ +export function setzeLiveErstattetFuerTests(fn) { + liveLeser = typeof fn === 'function' ? fn : liveErstattetEcht +} + +/** Summe der bei Stripe tatsaechlich erstatteten Betraege dieser Zahlung (0 ohne Konto oder Zahlung); Stripe-Fehler gehen nach oben. */ +export async function liveErstattet(ctx, anfrage) { + return liveLeser(ctx, anfrage) +} diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js index 0073f39..74b3b74 100644 --- a/test/unterkunftBuchung.test.js +++ b/test/unterkunftBuchung.test.js @@ -261,7 +261,7 @@ test('Erstattung echt: Stripe-Aufruf mit Metadaten unterkunft und Idempotency-Ke st.calls.length = 0 const r = await ab.storniere(ctx, id, { grund: 'Krank', erstattungCent: 12000, now: NOW }) assert.equal(r.anfrage.erstattetCent, 12000) - const [call] = st.calls.filter((c) => c.path === '/v1/refunds') + const [call] = st.calls.filter((c) => c.path === '/v1/refunds' && c.method === 'POST') assert.equal(call.account, ACCOUNT) assert.equal(call.body.payment_intent, 'pi_6') assert.equal(call.body.amount, '12000') @@ -280,6 +280,35 @@ test('Erstattung: Stripe scheitert -> 502, Storno bleibt, erstattetCent unveraen assert.equal(a.erstattetCent ?? 0, 0) }) +test('Erstattung: Stripe ging durch, Speichern scheiterte, Webhook heilt -> Wiederholung erstattet nicht zweimal', async () => { + const { id, ctx } = await bezahlt('pi_h1') + st.setPaymentIntent(ACCOUNT, 'pi_h1', { amount: 20000 }) + await ab.storniere(ctx, id, { now: NOW }) + aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 }) + await assert.rejects(ab.erstatteBetrag(ctx, id, { betragCent: 20000, now: NOW })) + assert.equal(refunds().length, 1) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent ?? 0, 0) + await W.verarbeiteUkEreignis({ type: 'charge.refunded', account: ACCOUNT, data: { object: { id: 'ch_h1', payment_intent: 'pi_h1', amount_refunded: 20000, metadata: { wkUk: '1', webklar: 'unterkunft', anfrageId: id } } } }, NOW) + const r = await ab.storniere(ctx, id, { erstattungCent: 20000, now: NOW }) + assert.equal(refunds().length, 1) + assert.equal(r.anfrage.erstattetCent, 20000) +}) + +test('Erstattung: Stripe ging durch, Speichern scheiterte, KEIN Webhook -> Wiederholung zieht nach, kein zweiter Refund; weiterer Betrag nutzt den Live-Stand', async () => { + const { id, ctx } = await bezahlt('pi_h2') + st.setPaymentIntent(ACCOUNT, 'pi_h2', { amount: 20000 }) + await ab.storniere(ctx, id, { now: NOW }) + aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 }) + await assert.rejects(ab.erstatteBetrag(ctx, id, { betragCent: 5000, now: NOW })) + const r = await ab.storniere(ctx, id, { erstattungCent: 5000, now: NOW }) + assert.equal(refunds().length, 1) + assert.equal(r.anfrage.erstattetCent, 5000) + const a = await ab.erstatteBetrag(ctx, id, { betragCent: 15000, now: NOW }) + assert.equal(a.erstattetCent, 20000) + assert.equal(refunds().at(-1).idempotencyKey, `uk-erstattung-${id}-20000`) + assert.equal(erstattungen('pi_h2').reduce((s, x) => s + x.amount, 0), 20000) +}) + test('Webhook charge.refunded: erstattetCent = Maximum, nie zurueck', async () => { const { id, ctx } = await bezahlt('pi_8') await ab.storniere(ctx, id, { now: NOW })