diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index 0852737..203b357 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -416,7 +416,7 @@ router.get( const ctx = await buchungsKontext(req, now, cors, { schreiben: false }) let a = await ladeMitToken(ctx, req.params.id, req.query.t) // Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen - a = await schliesseAbWennBezahlt(ctx, a, { now }) + a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' }) return { body: await buchungAntwort(ctx, a) } }, BUCHUNG, @@ -457,7 +457,7 @@ router.post( async (req, now, cors) => { const ctx = await buchungsKontext(req, now, cors, { schreiben: true }) let a = await ladeMitToken(ctx, req.params.id, req.query.t) - a = await schliesseAbWennBezahlt(ctx, a, { now }) + a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true }) if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig') try { return { body: await checkoutFuer(ctx, a, { now, token: req.query.t }) } @@ -483,7 +483,8 @@ router.get( let modul try { modul = await import('../services/unterkunft/rechnungPdf.js') - } catch { + } catch (err) { + if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err return { status: 501, body: { fehler: 'nicht_verfuegbar' } } } const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum }) diff --git a/server/routes/unterkunftWebhook.js b/server/routes/unterkunftWebhook.js index 5797fa5..24ef671 100644 --- a/server/routes/unterkunftWebhook.js +++ b/server/routes/unterkunftWebhook.js @@ -3,11 +3,10 @@ import { config } from '../config.js' import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' import { withLocks } from '../services/keyedLock.js' import { statusAusKonto } from '../services/shop/connect.js' -import { markiereBezahlt } from '../services/unterkunft/ablauf.js' -import { UkFehler } from '../services/unterkunft/fehler.js' import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' import { unterkunftModus } from '../services/unterkunft/schalter.js' import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen } from '../services/unterkunft/speicher.js' +import { verbucheZahlung } from '../services/unterkunft/zahlung.js' /** * Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto) @@ -54,13 +53,7 @@ async function zahlungEingegangen(event, session, now) { console.warn(`[unterkunft] Webhook: Betrag weicht ab (Buchung ${anfrage.nummer})`) } const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id - try { - await markiereBezahlt(ctx, anfrage.$id, { via: 'stripe', paymentIntentId: pi || undefined, now }) - } catch (err) { - // Buchung inzwischen storniert o. Ae.: Stripe nicht endlos wiederholen lassen, der Vermieter erstattet von Hand - if (err instanceof UkFehler && err.status === 409) console.warn(`[unterkunft] Webhook: Zahlung fuer Buchung ${anfrage.nummer} im Status ${anfrage.status} eingegangen`) - else throw err - } + await verbucheZahlung(ctx, anfrage.$id, pi, { now }) } async function erstattungGebucht(event, charge) { diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index 38223f1..b8c2d5d 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -283,6 +283,7 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0 const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) }) return { anfrage, stornoRechnung, neu: true } }) + if (r.neu) await sende(() => zahlung.beendeOffeneZahlung(ctx, r.anfrage)) if (r.neu) await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: erstattungCent, grund: text(grund, 500) })) let anfrage = r.anfrage if (erstattungCent > 0) { diff --git a/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js index df153dd..4bc54cd 100644 --- a/server/services/unterkunft/zahlung.js +++ b/server/services/unterkunft/zahlung.js @@ -1,8 +1,9 @@ import { config } from '../../config.js' -import { checkoutAnlegen, erstatten, sessionHolen } from '../shop/connect.js' +import { withLocks } from '../keyedLock.js' +import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from '../shop/connect.js' import { formatTag } from './datum.js' import { UkFehler } from './fehler.js' -import { aktualisiereAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js' +import { aktualisiereAnfrage, ladeAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js' import { markiereBezahlt } from './ablauf.js' /** @@ -18,16 +19,17 @@ const KOERPER_MAX = 250 const sekunden = (now) => Math.floor((now instanceof Date ? now.getTime() : Number(now)) / 1000) -/** SEPA nur, wenn der gespeicherte Kontostatus es ausdruecklich meldet. */ -const sepaAktiv = (status) => status?.sepa_debit === true || status?.sepa_debit_payments === 'active' - /** Buchungsseite des Gastes (Rueckkehr von Stripe); das Token liegt nur beim Gast, nicht in der Datenbank. */ export function buchungsUrl(ctx, anfrageId, token, zusatz = '') { return `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrageId}?t=${encodeURIComponent(token)}${zusatz}` } +const lockKey = (a) => ['unterkunft:' + a.unterkunftId] + /** - * Checkout-Sitzung fuer eine gebuchte Online-Zahlung anlegen. + * Checkout-Sitzung fuer eine gebuchte Online-Zahlung. Unter der Sperre der Unterkunft wird die Anfrage frisch gelesen (nach einem Storno + * entsteht keine Sitzung mehr). Eine noch offene, nicht abgelaufene Sitzung der Anfrage wird wiederverwendet (Doppelklick); sonst entsteht + * eine neue mit auf die volle Minute gerundetem Ablauf, sodass Wiederholungen derselben Minute mit dem Idempotency-Key identisch sind. * @param {object} ctx Kontext mit einstellungen.stripeAccountId und siteKey * @param {object} anfrage Anfrage im Status gebucht (zahlart online) * @param {{ now?: Date, token: string }} opt token: das Buchungs-Token des Gastes (fuer die Rueckkehr-Links) @@ -37,44 +39,115 @@ export async function checkoutFuer(ctx, anfrage, { now = new Date(), token } = { const einst = ctx?.einstellungen ?? {} const accountId = einst.stripeAccountId if (!einst.onlineZahlung || !accountId) throw new UkFehler(409, 'online_aus') - if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online') throw new UkFehler(409, 'status_ungueltig') if (typeof token !== 'string' || token === '') throw new UkFehler(400, 'ungueltig') - const brutto = anfrage.summen?.bruttoCent - if (!Number.isInteger(brutto) || brutto <= 0) throw new UkFehler(409, 'status_ungueltig') - const [unterkunft, rechnung] = await Promise.all([ladeUnterkunft(anfrage.unterkunftId), anfrage.rechnungId ? ladeRechnung(anfrage.rechnungId) : null]) - const name = `Aufenthalt ${unterkunft?.name ?? ''} ${formatTag(anfrage.von)}–${formatTag(anfrage.bis)}${rechnung?.nummer ? `, Rechnung ${rechnung.nummer}` : ''}`.replace(/\s+/g, ' ').slice(0, KOERPER_MAX) - const metadata = { wkUk: '1', anfrageId: anfrage.$id } - const params = { - mode: 'payment', - payment_method_types: sepaAktiv(einst.stripeStatus) ? ['card', 'sepa_debit'] : ['card'], - locale: 'de', - line_items: [{ quantity: 1, price_data: { currency: 'eur', unit_amount: brutto, product_data: { name } } }], - expires_at: sekunden(now) + SITZUNG_MINUTEN * 60, - metadata, - payment_intent_data: { metadata: { ...metadata, webklar: 'unterkunft' } }, - success_url: buchungsUrl(ctx, anfrage.$id, token, '&wk_zahlung=ok'), - cancel_url: buchungsUrl(ctx, anfrage.$id, token, '&wk_zahlung=abbruch'), - ...(typeof anfrage.gast?.email === 'string' && anfrage.gast.email ? { customer_email: anfrage.gast.email } : {}), + if (!anfrage?.$id) throw new UkFehler(409, 'status_ungueltig') + const nowMs = now instanceof Date ? now.getTime() : Number(now) + return withLocks(lockKey(anfrage), async () => { + const a = await ladeAnfrage(anfrage.$id) + if (!a || a.featureId !== ctx.featureId || a.status !== 'gebucht' || a.zahlart !== 'online') throw new UkFehler(409, 'status_ungueltig') + const brutto = a.summen?.bruttoCent + if (!Number.isInteger(brutto) || brutto <= 0) throw new UkFehler(409, 'status_ungueltig') + if (a.stripeSessionId) { + try { + const alt = await sessionHolen(accountId, a.stripeSessionId) + if (alt?.status === 'open' && alt.url && !(Number(alt.expires_at) * 1000 <= nowMs)) return { url: alt.url } + } catch (err) { + console.warn(`[unterkunft] Alte Zahlungssitzung nicht lesbar: ${err?.name ?? 'Fehler'}`) + } + } + const [unterkunft, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null]) + const name = `Aufenthalt ${unterkunft?.name ?? ''} ${formatTag(a.von)}–${formatTag(a.bis)}${rechnung?.nummer ? `, Rechnung ${rechnung.nummer}` : ''}`.replace(/\s+/g, ' ').slice(0, KOERPER_MAX) + const metadata = { wkUk: '1', anfrageId: a.$id } + const minute = Math.floor(nowMs / 60000) + const params = { + mode: 'payment', + payment_method_types: ['card'], + locale: 'de', + line_items: [{ quantity: 1, price_data: { currency: 'eur', unit_amount: brutto, product_data: { name } } }], + expires_at: minute * 60 + SITZUNG_MINUTEN * 60, + metadata, + payment_intent_data: { metadata: { ...metadata, webklar: 'unterkunft' } }, + success_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=ok'), + cancel_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=abbruch'), + ...(typeof a.gast?.email === 'string' && a.gast.email ? { customer_email: a.gast.email } : {}), + } + const session = await checkoutAnlegen(accountId, params, { idempotencyKey: `uk-zahlung-${a.$id}-${minute}` }) + if (!session.url) throw new UkFehler(502, 'zahlung_fehlgeschlagen') + await aktualisiereAnfrage(a.$id, { stripeSessionId: session.id }) + return { url: session.url } + }) +} + +/** Nach dem Storno: eine noch offene, unbezahlte Checkout-Sitzung beenden (Fehler nur loggen: sie laeuft ohnehin in 31 Minuten ab). */ +export async function beendeOffeneZahlung(ctx, anfrage) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (!anfrage?.stripeSessionId || !accountId || anfrage.bezahltAt) return + try { + await sessionAblaufen(accountId, anfrage.stripeSessionId) + } catch (err) { + console.warn(`[unterkunft] Offene Zahlungssitzung nicht beendet: ${err?.name ?? 'Fehler'}`) } - const minute = Math.floor((now instanceof Date ? now.getTime() : Number(now)) / 60000) - const session = await checkoutAnlegen(accountId, params, { idempotencyKey: `uk-zahlung-${anfrage.$id}-${minute}` }) - if (!session.url) throw new UkFehler(502, 'zahlung_fehlgeschlagen') - await aktualisiereAnfrage(anfrage.$id, { stripeSessionId: session.id }) - return { url: session.url } +} + +/** Volle Rueckerstattung einer Zahlung, die zu keiner offenen Buchung mehr passt (nach Storno, Ablauf oder doppelt gezahlt). */ +export async function erstatteUeberzaehligeZahlung(ctx, paymentIntentId) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (!accountId || typeof paymentIntentId !== 'string' || paymentIntentId === '') return + await erstatten(accountId, { paymentIntent: paymentIntentId, grund: 'Zahlung ohne offene Buchung', idempotencyKey: `uk-rueck-${paymentIntentId}`, webklar: 'unterkunft' }) + console.warn('[unterkunft] Zahlung ohne offene Buchung automatisch erstattet') +} + +/** + * Eingegangene Zahlung buchen. Passt sie nicht (Buchung storniert/abgelaufen oder schon mit anderer Zahlung bezahlt), wird sie voll erstattet; + * der Buchungsstatus bleibt dann unveraendert. Idempotent. Gibt die aktuelle Anfrage zurueck. + */ +export async function verbucheZahlung(ctx, anfrageId, paymentIntentId, { now = new Date() } = {}) { + const pi = typeof paymentIntentId === 'string' && paymentIntentId !== '' ? paymentIntentId : undefined + const vorher = await ladeAnfrage(anfrageId) + if (vorher?.status === 'bezahlt') { + if (pi && vorher.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi) + return vorher + } + try { + return await markiereBezahlt(ctx, anfrageId, { via: 'stripe', paymentIntentId: pi, now }) + } catch (err) { + if (!(err instanceof UkFehler) || err.status !== 409) throw err + const jetzt = await ladeAnfrage(anfrageId) + // dieselbe Zahlung, die schon gebucht wurde (Wiederholung des Ereignisses nach spaeterem Storno): nichts erstatten + if (pi && jetzt?.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi) + return jetzt ?? vorher + } +} + +// Abgleich mit Stripe hoechstens alle 30 Sekunden je Sitzung; abgelaufene Sitzungen nie wieder +const ABGLEICH_MS = 30 * 1000 +const ABGLEICH_MAX = 5000 +const abgleich = new Map() +export const leereAbgleichFuerTests = () => abgleich.clear() +function merkeAbgleich(schluessel, eintrag) { + abgleich.delete(schluessel) + if (abgleich.size >= ABGLEICH_MAX) abgleich.delete(abgleich.keys().next().value) + abgleich.set(schluessel, eintrag) } /** * Hat der Gast bezahlt, ohne dass der Webhook schon da war? Sitzung bei Stripe nachsehen und bei complete+paid als bezahlt buchen. - * Gibt die (ggf. neue) Anfrage zurueck; Stripe-Fehler werden geloggt und lassen die Anfrage unveraendert. + * Gibt die (ggf. neue) Anfrage zurueck; Stripe-Fehler werden geloggt und lassen die Anfrage unveraendert. erzwingen: ohne Pause (Rueckkehr von Stripe). */ -export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date() } = {}) { +export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date(), erzwingen = false } = {}) { const accountId = ctx?.einstellungen?.stripeAccountId if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online' || !anfrage.stripeSessionId || !accountId) return anfrage + const schluessel = `${anfrage.$id}:${anfrage.stripeSessionId}` + const nowMs = now instanceof Date ? now.getTime() : Number(now) + const bekannt = abgleich.get(schluessel) + if (bekannt?.abgelaufen) return anfrage + if (!erzwingen && bekannt && Math.abs(nowMs - bekannt.at) < ABGLEICH_MS) return anfrage try { const session = await sessionHolen(accountId, anfrage.stripeSessionId) + merkeAbgleich(schluessel, { at: nowMs, abgelaufen: session?.status === 'expired' }) if (session?.status === 'complete' && session.payment_status === 'paid') { const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id - return await markiereBezahlt(ctx, anfrage.$id, { via: 'stripe', paymentIntentId: pi || undefined, now }) + return await verbucheZahlung(ctx, anfrage.$id, pi, { now }) } } catch (err) { console.warn(`[unterkunft] Zahlungsabgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`) diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js index d8cdd55..e6adca0 100644 --- a/test/unterkunftBuchung.test.js +++ b/test/unterkunftBuchung.test.js @@ -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) +})