Buchungskalender: Zahlung – Sitzung wiederverwenden, Zahlung nach Storno erstatten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca0f762272
commit
a94b50fbc1
5 files changed
+233
-52
No files matched your search
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user