Buchungskalender: Abschluss – S1 keine doppelte Erstattung nach Heilung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 15:20:23 +00:00
1 parent 2df56eccc5
commit 435dd0b746
3 files changed
+64 -11

No files matched your search

+15 -9
View File
@@ -248,8 +248,7 @@ export async function markiereBezahlt(ctx, anfrageId, { via, paymentIntentId, no
return r.anfrage return r.anfrage
} }
function pruefeErstattung(a, betragCent) { function pruefeErstattung(a, betragCent, bisher = a.erstattetCent || 0) {
const bisher = a.erstattetCent || 0
const brutto = a.summen?.bruttoCent 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 (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) { 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 * Erstattung einer stornierten, online bezahlten Buchung. Vor dem Stripe-Aufruf wird der bei Stripe LIVE erstattete Betrag der Zahlung
* Rollback. Ein Absturz zwischen Stripe-Erfolg und Schreiben heilt sich durch Wiederholung mit demselben idempotencyKey * gelesen; bisher = max(erstattetCent, live), die Obergrenze wird gegen diesen Wert geprueft (kein doppeltes Erstatten, wenn ein frueherer
* (Stripe liefert dieselbe Erstattung) und durch den charge.refunded-Webhook (Task 7). * 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)) const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId))
return withLocks(withLocksKey(pre.unterkunftId), async () => { 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') 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}` }) const res = await zahlung.erstatte(ctx, a, betragCent, { now, idempotencyKey: `uk-erstattung-${anfrageId}-${bisher + betragCent}` })
return aktualisiereAnfrage(a.$id, { erstattetCent: bisher + res.erstattetCent }) 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 let anfrage = r.anfrage
if (erstattungCent > 0) { if (erstattungCent > 0) {
try { try {
anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now }) anfrage = await erstatteBetrag(ctx, anfrageId, { betragCent: erstattungCent, now, wiederholung: !r.neu })
} catch (err) { } catch (err) {
if (err instanceof UkFehler && err.status < 500 && err.code !== 'online_aus') throw err if (err instanceof UkFehler && err.status < 500 && err.code !== 'online_aus') throw err
throw new UkFehler(502, 'erstattung_fehlgeschlagen') throw new UkFehler(502, 'erstattung_fehlgeschlagen')
+19 -1
View File
@@ -1,6 +1,6 @@
import { config } from '../../config.js' import { config } from '../../config.js'
import { withLocks } from '../keyedLock.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 { formatTag } from './datum.js'
import { UkFehler } from './fehler.js' import { UkFehler } from './fehler.js'
import { aktualisiereAnfrage, ladeAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.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 }) if (testProtokoll()) _aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey })
return erstatter(ctx, anfrage, betragCent, { now, 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)
}
+30 -1
View File
@@ -261,7 +261,7 @@ test('Erstattung echt: Stripe-Aufruf mit Metadaten unterkunft und Idempotency-Ke
st.calls.length = 0 st.calls.length = 0
const r = await ab.storniere(ctx, id, { grund: 'Krank', erstattungCent: 12000, now: NOW }) const r = await ab.storniere(ctx, id, { grund: 'Krank', erstattungCent: 12000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 12000) 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.account, ACCOUNT)
assert.equal(call.body.payment_intent, 'pi_6') assert.equal(call.body.payment_intent, 'pi_6')
assert.equal(call.body.amount, '12000') 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) 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 () => { test('Webhook charge.refunded: erstattetCent = Maximum, nie zurueck', async () => {
const { id, ctx } = await bezahlt('pi_8') const { id, ctx } = await bezahlt('pi_8')
await ab.storniere(ctx, id, { now: NOW }) await ab.storniere(ctx, id, { now: NOW })