Buchungskalender: Abschluss – S1 keine doppelte Erstattung nach Heilung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2df56eccc5
commit
435dd0b746
3 files changed
+64
-11
No files matched your search
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
Reference in new issue
Block a user