Buchungskalender: Zahlung – Sitzung wiederverwenden, Zahlung nach Storno erstatten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:23:06 +00:00
1 parent ca0f762272
commit a94b50fbc1
5 files changed
+233 -52

No files matched your search

+122 -9
View File
@@ -66,6 +66,7 @@ beforeEach(async () => {
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
zahlung.setzeErstattungFuerTests(null)
zahlung.leereAbgleichFuerTests()
W.setzeUkEreignisHandlerFuerTests(null)
st.calls.length = 0
setzeUhrFuerTests(() => NOW)
@@ -181,14 +182,6 @@ test('POST buchung online: zahlungUrl vom Stripe-Nachbau, Sitzung auf dem Konto
assert.ok((await sp.ladeAnfrage(id)).stripeSessionId.startsWith('cs_'))
})
test('SEPA nur bei gemeldetem Status', async () => {
await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: true, sepa_debit: true }) })
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const call = st.calls.find((c) => c.path === '/v1/checkout/sessions')
assert.equal(call.body['payment_method_types[1]'], 'sepa_debit')
})
test('Webhook checkout.session.completed: bezahlt, G20, zweimal nur ein Uebergang', async () => {
const { id, token } = await freigegeben()
const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
@@ -225,7 +218,7 @@ test('Rueckkehr mit wk_zahlung=ok schliesst ohne Webhook selbst ab', async () =>
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const sid = (await sp.ladeAnfrage(id)).stripeSessionId
const offen = await send('GET', url(id, token, '') + '&wk_zahlung=ok')
const offen = await send('GET', url(id, token))
assert.equal(offen.body.status, 'gebucht')
mails._aufrufe.length = 0
st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_3' })
@@ -311,3 +304,123 @@ test('rechnung.pdf: 200 mit PDF oder 501 ohne Erzeuger; falsches Token 404', asy
assert.equal((await send('GET', url(id, token, '/rechnung.pdf') + '&art=storno')).status, 404)
assert.equal((await send('GET', url(id, token, '/rechnung.pdf') + '&art=x')).status, 400)
})
const sitzungen = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST')
const sekunden = (n) => new Date(NOW.getTime() + n * 1000)
test('Doppelklick: zwei Aufrufe in einer Minute -> dieselbe URL, eine Sitzung; nach Ablauf eine neue', async () => {
const { id, token, ctx } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }).catch(() => {})
await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT })
const f = await freigegeben('2026-12-10', '2026-12-12')
const b = await ab.bucheVerbindlich(f.ctx, f.id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })
st.calls.length = 0
const x = await zahlung.checkoutFuer(f.ctx, b.anfrage, { now: sekunden(5), token: f.token })
const y = await zahlung.checkoutFuer(f.ctx, await sp.ladeAnfrage(f.id), { now: sekunden(40), token: f.token })
assert.equal(x.url, y.url)
assert.equal(sitzungen().length, 1)
assert.equal(Number(sitzungen()[0].body.expires_at) % 60, 0)
const sid = (await sp.ladeAnfrage(f.id)).stripeSessionId
st.accountSessions(ACCOUNT).set(sid, { ...st.accountSessions(ACCOUNT).get(sid), status: 'expired' })
const z = await zahlung.checkoutFuer(f.ctx, await sp.ladeAnfrage(f.id), { now: sekunden(150), token: f.token })
assert.notEqual(z.url, x.url)
assert.equal(sitzungen().length, 2)
assert.ok(ctx)
})
async function onlineGebucht(von, bis) {
const f = await freigegeben(von, bis)
const r = await post(url(f.id, f.token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
assert.ok(r.body.zahlungUrl)
return { ...f, sid: (await sp.ladeAnfrage(f.id)).stripeSessionId }
}
const erstattungen = (pi) => [...st.refunds(ACCOUNT).values()].filter((r) => r.payment_intent === pi)
const refunds = () => st.calls.filter((c) => c.path === '/v1/refunds' && c.method === 'POST')
test('Storno beendet die offene Sitzung; danach keine neue Sitzung (409), auch bei abgelaufener Buchung', async () => {
const f = await onlineGebucht('2026-11-10', '2026-11-12')
await ab.storniere(f.ctx, f.id, { now: NOW })
assert.equal(st.accountSessions(ACCOUNT).get(f.sid).status, 'expired')
const r = await post(url(f.id, f.token, '/zahlung'), {})
assert.equal(r.status, 409)
assert.equal(sitzungen().length, 1)
// checkoutFuer liest frisch: eine veraltete Anfrage-Kopie (gebucht) erzeugt trotzdem keine Sitzung
await assert.rejects(zahlung.checkoutFuer(f.ctx, { ...(await sp.ladeAnfrage(f.id)), status: 'gebucht' }, { now: NOW, token: f.token }), (e) => e.status === 409)
assert.equal(sitzungen().length, 1)
// abgelaufen (Frist): freigegeben, nie gebucht
const g = await freigegeben('2026-12-10', '2026-12-12')
await ab.laufeFristenAb(new Date(NOW.getTime() + 4 * 86400000))
assert.equal((await sp.ladeAnfrage(g.id)).status, 'abgelaufen')
assert.equal((await post(url(g.id, g.token, '/zahlung'), {})).status, 409)
})
test('Zahlung nach Storno: automatisch voll erstattet, Status bleibt, Wiederholung ohne zweite Erstattung', async () => {
const f = await onlineGebucht('2026-11-10', '2026-11-12')
await ab.storniere(f.ctx, f.id, { now: NOW })
st.setPaymentIntent(ACCOUNT, 'pi_spaet', { amount: 20000 })
const session = st.completeSession(ACCOUNT, f.sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_spaet' })
const event = { type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } }
st.calls.length = 0
await W.verarbeiteUkEreignis(event, NOW)
await W.verarbeiteUkEreignis(event, NOW)
const [call] = refunds()
assert.equal(call.body.payment_intent, 'pi_spaet')
assert.equal(call.body['metadata[webklar]'], 'unterkunft')
assert.equal(call.idempotencyKey, 'uk-rueck-pi_spaet')
assert.equal(call.account, ACCOUNT)
assert.equal(erstattungen('pi_spaet').length, 1)
assert.equal((await sp.ladeAnfrage(f.id)).status, 'storniert')
})
test('Zahlung nach Frist-Ablauf der Sitzung/Storno ueber Rueckkehr-Abgleich: ebenfalls erstattet', async () => {
const f = await onlineGebucht('2026-11-10', '2026-11-12')
st.setPaymentIntent(ACCOUNT, 'pi_sp2', { amount: 20000 })
st.completeSession(ACCOUNT, f.sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_sp2' })
// Storno zwischen Abgleich-Lesen und Buchen: markiereBezahlt wird abgelehnt
await ab.storniere(f.ctx, f.id, { now: NOW })
const a = await zahlung.verbucheZahlung(f.ctx, f.id, 'pi_sp2', { now: NOW })
assert.equal(a.status, 'storniert')
assert.equal(erstattungen('pi_sp2').length, 1)
})
test('Zweite Zahlung auf bereits bezahlte Buchung (andere Zahlung): automatisch erstattet; dieselbe Zahlung nicht', async () => {
const f = await onlineGebucht('2026-11-10', '2026-11-12')
st.setPaymentIntent(ACCOUNT, 'pi_a', { amount: 20000 })
st.setPaymentIntent(ACCOUNT, 'pi_b', { amount: 20000 })
const ev = (pi) => ({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: st.completeSession(ACCOUNT, f.sid, { payment_status: 'paid', amount_total: 20000, payment_intent: pi }) } })
await W.verarbeiteUkEreignis(ev('pi_a'), NOW)
assert.equal((await sp.ladeAnfrage(f.id)).status, 'bezahlt')
await W.verarbeiteUkEreignis(ev('pi_a'), NOW)
assert.equal(erstattungen('pi_a').length, 0)
await W.verarbeiteUkEreignis(ev('pi_b'), NOW)
await W.verarbeiteUkEreignis(ev('pi_b'), NOW)
assert.equal(erstattungen('pi_b').length, 1)
const a = await sp.ladeAnfrage(f.id)
assert.equal(a.status, 'bezahlt')
assert.equal(a.stripePaymentIntentId, 'pi_a')
// spaeterer Storno + Wiederholung der echten Zahlung erstattet nichts
await ab.storniere(f.ctx, f.id, { now: NOW })
await W.verarbeiteUkEreignis(ev('pi_a'), NOW)
assert.equal(erstattungen('pi_a').length, 0)
})
test('GET: Stripe-Abgleich hoechstens alle 30 s je Sitzung, abgelaufene Sitzung nie wieder', async () => {
zahlung.leereAbgleichFuerTests()
const f = await onlineGebucht('2026-11-10', '2026-11-12')
const abrufe = () => st.calls.filter((c) => c.method === 'GET' && c.path === `/v1/checkout/sessions/${f.sid}`).length
const vorherAbrufe = abrufe()
await send('GET', url(f.id, f.token))
await send('GET', url(f.id, f.token))
assert.equal(abrufe() - vorherAbrufe, 1)
await send('GET', url(f.id, f.token) + '&wk_zahlung=ok')
assert.equal(abrufe() - vorherAbrufe, 2)
setzeUhrFuerTests(() => sekunden(31))
await send('GET', url(f.id, f.token))
assert.equal(abrufe() - vorherAbrufe, 3)
st.accountSessions(ACCOUNT).set(f.sid, { ...st.accountSessions(ACCOUNT).get(f.sid), status: 'expired' })
setzeUhrFuerTests(() => sekunden(70))
await send('GET', url(f.id, f.token))
setzeUhrFuerTests(() => sekunden(200))
await send('GET', url(f.id, f.token))
assert.equal(abrufe() - vorherAbrufe, 4)
})