import { test, after, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Buchungskalender Task 7: Buchungsseite, Stripe-Zahlung, Webhook (Stripe ist der Nachbau) const NAMEN = ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen'] const aw = await startAppwriteMock({ schema: Object.fromEntries(NAMEN.filter((n) => !['websiteProjects', 'customers'].includes(n)).map((n) => [n, schemaKeys(n)])), datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, defaultLimit: 25, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, send } = await import('./support/testApp.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const K = await import('../server/services/unterkunft/kontext.js') const mails = await import('../server/services/unterkunft/mails.js') const ab = await import('../server/services/unterkunft/ablauf.js') const sp = await import('../server/services/unterkunft/speicher.js') const zahlung = await import('../server/services/unterkunft/zahlung.js') const W = await import('../server/routes/unterkunftWebhook.js') const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js') const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes }) after(async () => { await Promise.all([app.close(), aw.close(), st.close()]) }) const SITE = 'abcdefghijklmnop' const FEATURE = 'f_uk' const ACCOUNT = 'acct_haendler1' const NOW = new Date('2026-11-01T12:00:00.000Z') const BASE = `/api/public/unterkunft/v1/${SITE}` const ORIGIN = 'https://donau-home.example' const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } const vorher = { ...config.unterkunft } const einstellungen = (extra = {}) => ({ $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Str 1', plz: '78576', ort: 'Stetten', email: 'x@y.de', steuernummer: '12/345', iban: 'DE00', akzentfarbe: '#112233', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', ...extra, }) async function seedBasis(einstExtra = {}) { for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' }) aw.seed('ukEinstellungen', einstellungen(einstExtra)) aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', maxPersonen: 4, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true }) aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vorher.', }) K.leereCache() } beforeEach(async () => { Object.assign(config.unterkunft, vorher, { mode: 'on', minAntwortMs: 0 }) resetTermineLimitsForTests() mails._aufrufe.length = 0 zahlung._aufrufe.length = 0 zahlung.setzeErstattungFuerTests(null) zahlung.leereAbgleichFuerTests() W.setzeUkEreignisHandlerFuerTests(null) st.calls.length = 0 setzeUhrFuerTests(() => NOW) await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT }) }) afterEach(() => Object.assign(config.unterkunft, vorher)) /** Freigegebene Anfrage mit Token (wie der Vermieter sie freigibt). */ async function freigegeben(von = '2026-11-10', bis = '2026-11-12') { const ctx = await K.unterkunftPerSiteKey(SITE, { now: NOW }) const a = await ab.legeAnfrageAn(ctx, { unterkunftId: 'u1', von, bis, personen: 2, gast: { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: '' } }, { now: NOW, ipHash: 'x' }) const { token } = await ab.gibFrei(ctx, a.$id, { now: NOW }) mails._aufrufe.length = 0 return { id: a.$id, token, ctx } } const url = (id, t, rest = '') => `${BASE}/buchung/${id}${rest}?t=${encodeURIComponent(t)}` const post = (pfad, body, headers = { origin: ORIGIN }) => send('POST', pfad, { body, headers }) const arten = () => mails._aufrufe.map((m) => m.art || m.template) test('GET buchung: falsches Token und unbekannte ID sind identisch 404', async () => { const { id, token } = await freigegeben() const falsch = await send('GET', url(id, 'falsch')) const fremd = await send('GET', url('gibtsnicht', token)) const kaputt = await send('GET', url('a.b', token)) const ohne = await send('GET', `${BASE}/buchung/${id}`) for (const r of [falsch, fremd, kaputt, ohne]) { assert.equal(r.status, 404) assert.deepEqual(r.body, { fehler: 'nicht_gefunden' }) } }) test('GET buchung: Vertrag, ohne interne Felder, zahlarten ohne online wenn Stripe fehlt', async () => { await seedBasis({}) const { id, token } = await freigegeben() const r = await send('GET', url(id, token)) assert.equal(r.status, 200) assert.equal(r.body.status, 'freigegeben') assert.equal(r.body.unterkunft.name, 'Erste') assert.equal(r.body.naechte, 2) assert.equal(r.body.summen.bruttoCent, 20000) assert.equal(r.body.akzentfarbe, '#112233') assert.equal(r.body.anbieter.datenschutzUrl, 'https://donau-home.example/datenschutz') assert.equal(r.body.anbieter.firma, 'Donau Home') assert.deepEqual(r.body.zahlarten, ['vorort']) assert.equal(r.body.bezahlt, false) assert.equal(r.body.rechnungNummer, undefined) assert.equal(r.body.stornobedingungen, 'Kostenlos bis 7 Tage vorher.') const roh = JSON.stringify(r.body) for (const verboten of ['tokenHash', 'ipHash', 'steuernummer', 'iban', 'featureId', 'customerId', 'eva@example.com']) assert.ok(!roh.includes(verboten), verboten) const mitOnline = await (async () => { await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT }) const f = await freigegeben() return send('GET', url(f.id, f.token)) })() assert.deepEqual(mitOnline.body.zahlarten, ['vorort', 'online']) }) test('POST buchung vor Ort: gebucht, Rechnung; zweites POST gleiche Antwort ohne zweite Rechnung', async () => { const { id, token } = await freigegeben() const r1 = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) assert.equal(r1.status, 200) assert.deepEqual(r1.body, { status: 'gebucht', rechnungNummer: 'RE-2026-0001' }) assert.equal(r1.headers.get('access-control-allow-origin'), ORIGIN) const r2 = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) assert.deepEqual(r2.body, r1.body) assert.equal(aw.docs('ukRechnungen').size, 1) assert.deepEqual(arten(), ['G19', 'K37']) const g = await send('GET', url(id, token)) assert.equal(g.body.status, 'gebucht') assert.equal(g.body.rechnungNummer, 'RE-2026-0001') assert.equal(g.body.zahlart, 'vorort') assert.equal(g.body.rechnungsadresse.ort, 'Berlin') }) test('Buchungslink: gilt bis 90 Tage nach Abreise, abgelehnt/abgelaufen 30 Tage nach Statuswechsel, danach 404; Seite und PDF', async () => { const { id, token } = await freigegeben('2026-11-10', '2026-11-12') assert.equal((await send('GET', url(id, token))).status, 200) setzeUhrFuerTests(() => new Date('2027-02-10T12:00:00Z')) // 90 Tage nach 12.11.2026 = 10.02.2027 assert.equal((await send('GET', url(id, token))).status, 200) setzeUhrFuerTests(() => new Date('2027-02-11T12:00:00Z')) for (const r of [await send('GET', url(id, token)), await send('GET', url(id, token, '/rechnung.pdf'))]) { assert.equal(r.status, 404) assert.deepEqual(r.body, { fehler: 'nicht_gefunden' }) } assert.equal((await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' })).status, 404) // abgelehnt: 30 Tage nach dem Statuswechsel setzeUhrFuerTests(() => NOW) const g = await freigegeben('2026-12-10', '2026-12-12') await sp.aktualisiereAnfrage(g.id, { status: 'abgelehnt', abgelehntAt: NOW.toISOString() }) assert.equal((await send('GET', url(g.id, g.token))).status, 200) setzeUhrFuerTests(() => new Date(NOW.getTime() + 31 * 86400000)) assert.equal((await send('GET', url(g.id, g.token))).status, 404) }) test('POST buchung: falsches Token 404, fremde Herkunft 403, Adresse fehlt 400, Frist abgelaufen 410', async () => { const { id, token } = await freigegeben() const falsch = await post(url(id, 'falsch'), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) assert.equal(falsch.status, 404) assert.deepEqual(falsch.body, { fehler: 'nicht_gefunden' }) assert.equal((await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }, { origin: 'https://boese.example' })).status, 403) const luecke = await post(url(id, token), { rechnungsadresse: { name: 'x' }, zahlart: 'vorort' }) assert.equal(luecke.status, 400) assert.equal(luecke.body.fehler, 'ungueltig') setzeUhrFuerTests(() => new Date(NOW.getTime() + 4 * 86400000)) const spaet = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) assert.equal(spaet.status, 410) assert.deepEqual(spaet.body, { fehler: 'frist_abgelaufen' }) assert.equal(aw.docs('ukRechnungen').size, 0) }) test('POST buchung online: zahlungUrl vom Stripe-Nachbau, Sitzung auf dem Konto des Vermieters', async () => { const { id, token } = await freigegeben() const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) assert.equal(r.status, 200) assert.equal(r.body.status, 'gebucht') assert.match(r.body.zahlungUrl, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/) const [call] = st.calls.filter((c) => c.path === '/v1/checkout/sessions') assert.equal(call.account, ACCOUNT) assert.equal(call.body.mode, 'payment') assert.equal(call.body['metadata[wkUk]'], '1') assert.equal(call.body['metadata[anfrageId]'], id) assert.equal(call.body['payment_intent_data[metadata][anfrageId]'], id) assert.equal(call.body['payment_intent_data[metadata][webklar]'], 'unterkunft') assert.equal(call.body['line_items[0][price_data][unit_amount]'], '20000') assert.match(call.body['line_items[0][price_data][product_data][name]'], /^Aufenthalt Erste 10\.11\.2026–12\.11\.2026, Rechnung RE-2026-0001$/) assert.equal(call.body['payment_method_types[0]'], 'card') assert.equal(call.body['payment_method_types[1]'], undefined) assert.equal(call.body.locale, 'de') assert.match(call.body.success_url, new RegExp(`/unterkunft/${SITE}/buchung/${id}\\?t=.*&wk_zahlung=ok$`)) assert.match(call.body.cancel_url, /&wk_zahlung=abbruch$/) assert.equal(Number(call.body.expires_at), Math.floor(NOW.getTime() / 1000) + 31 * 60) assert.match(call.idempotencyKey, new RegExp(`^uk-zahlung-${id}-\\d+$`)) assert.ok((await sp.ladeAnfrage(id)).stripeSessionId.startsWith('cs_')) }) 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' }) const sid = (await sp.ladeAnfrage(id)).stripeSessionId mails._aufrufe.length = 0 const session = st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_1' }) const event = { type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } } await W.verarbeiteUkEreignis(event, NOW) await W.verarbeiteUkEreignis(event, NOW) const a = await sp.ladeAnfrage(id) assert.equal(a.status, 'bezahlt') assert.equal(a.bezahltVia, 'stripe') assert.equal(a.stripePaymentIntentId, 'pi_1') assert.deepEqual(arten(), ['G20']) assert.ok(r.body.zahlungUrl) }) test('Webhook: unbezahlt, fremdes Konto, unbekannte Anfrage ohne Wirkung; async_payment_succeeded bucht', async () => { const { id, token } = await freigegeben() await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) const sid = (await sp.ladeAnfrage(id)).stripeSessionId const unbezahlt = { ...st.completeSession(ACCOUNT, sid, { payment_status: 'unpaid', amount_total: 20000, payment_intent: 'pi_2' }) } await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: unbezahlt } }, NOW) assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht') const bezahlt = { ...unbezahlt, payment_status: 'paid' } await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: 'acct_fremd', data: { object: bezahlt } }, NOW) assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht') await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: { ...bezahlt, metadata: { wkUk: '1', anfrageId: 'gibtsnicht' } } } }, NOW) await W.verarbeiteUkEreignis({ type: 'checkout.session.async_payment_succeeded', account: ACCOUNT, data: { object: bezahlt } }, NOW) assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt') }) 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)) assert.equal(offen.body.status, 'gebucht') mails._aufrufe.length = 0 st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_3' }) const r = await send('GET', url(id, token) + '&wk_zahlung=ok') assert.equal(r.body.status, 'bezahlt') assert.equal(r.body.bezahlt, true) assert.deepEqual(arten(), ['G20']) assert.equal((await sp.ladeAnfrage(id)).stripePaymentIntentId, 'pi_3') }) test('POST zahlung: neue Sitzung fuer gebuchte Online-Zahlung; vor Ort oder bezahlt 409', async () => { const { id, token } = await freigegeben() await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) const r = await post(url(id, token, '/zahlung'), {}) assert.equal(r.status, 200) assert.match(r.body.url, /^https:\/\/checkout\.stripe\.test\//) assert.equal((await post(url(id, 'falsch', '/zahlung'), {})).status, 404) const sid = (await sp.ladeAnfrage(id)).stripeSessionId st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_4' }) const nach = await post(url(id, token, '/zahlung'), {}) assert.equal(nach.status, 409) assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt') const g = await freigegeben('2026-12-10', '2026-12-12') await post(url(g.id, g.token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) assert.equal((await post(url(g.id, g.token, '/zahlung'), {})).status, 409) }) async function bezahlt(pi = 'pi_5') { const { id, token, ctx } = await freigegeben() await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) const sid = (await sp.ladeAnfrage(id)).stripeSessionId const session = st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: pi }) await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } }, NOW) return { id, token, ctx } } test('Erstattung echt: Stripe-Aufruf mit Metadaten unterkunft und Idempotency-Key, erstattetCent aus Stripe', async () => { const { id, ctx } = await bezahlt('pi_6') st.setPaymentIntent(ACCOUNT, 'pi_6', { amount: 20000 }) st.calls.length = 0 const r = await ab.storniere(ctx, id, { grund: 'Krank', erstattungCent: 12000, now: NOW }) assert.equal(r.anfrage.erstattetCent, 12000) const [call] = st.calls.filter((c) => c.path === '/v1/refunds' && c.method === 'POST') assert.equal(call.account, ACCOUNT) assert.equal(call.body.payment_intent, 'pi_6') assert.equal(call.body.amount, '12000') assert.equal(call.body['metadata[webklar]'], 'unterkunft') assert.equal(call.idempotencyKey, `uk-erstattung-${id}-12000`) assert.equal(zahlung._aufrufe.length, 1) }) test('Erstattung: Stripe scheitert -> 502, Storno bleibt, erstattetCent unveraendert', async () => { const { id, ctx } = await bezahlt('pi_7') st.setPaymentIntent(ACCOUNT, 'pi_7', { amount: 20000 }) st.failNext({ status: 400, path: '/v1/refunds' }) await assert.rejects(ab.storniere(ctx, id, { erstattungCent: 5000, now: NOW }), (e) => e.status === 502 && e.code === 'erstattung_fehlgeschlagen') const a = await sp.ladeAnfrage(id) assert.equal(a.status, 'storniert') 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('Zahlung mit falschem Betrag oder falscher Waehrung gilt nicht als Bezahlung: Warnung, automatisch voll erstattet, Buchung bleibt gebucht (Webhook und Abgleich)', async (t) => { t.mock.method(console, 'warn', () => {}) const f1 = await freigegeben('2026-11-10', '2026-11-12') await post(url(f1.id, f1.token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) const sid1 = (await sp.ladeAnfrage(f1.id)).stripeSessionId st.setPaymentIntent(ACCOUNT, 'pi_falsch', { amount: 100 }) const zuWenig = st.completeSession(ACCOUNT, sid1, { payment_status: 'paid', amount_total: 100, payment_intent: 'pi_falsch' }) await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: zuWenig } }, NOW) assert.equal((await sp.ladeAnfrage(f1.id)).status, 'gebucht') assert.equal(erstattungen('pi_falsch').length, 1) assert.equal(erstattungen('pi_falsch')[0].amount, 100) // andere Waehrung const f2 = await freigegeben('2026-12-10', '2026-12-12') await post(url(f2.id, f2.token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) const sid2 = (await sp.ladeAnfrage(f2.id)).stripeSessionId st.setPaymentIntent(ACCOUNT, 'pi_usd', { amount: 20000 }) const usd = { ...st.completeSession(ACCOUNT, sid2, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_usd' }), currency: 'usd' } await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: usd } }, NOW) assert.equal((await sp.ladeAnfrage(f2.id)).status, 'gebucht') assert.equal(erstattungen('pi_usd').length, 1) }) test('Webhook charge.refunded: ohne gespeicherte Zahlung der Buchung oder mit fremder Zahlung keine Wirkung', async () => { const { id, ctx } = await bezahlt('pi_r1') await ab.storniere(ctx, id, { now: NOW }) const charge = (pi, betrag) => ({ type: 'charge.refunded', account: ACCOUNT, data: { object: { id: 'ch_r', payment_intent: pi, amount_refunded: betrag, metadata: { wkUk: '1', webklar: 'unterkunft', anfrageId: id } } } }) await W.verarbeiteUkEreignis(charge('pi_fremd', 5000), NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent ?? 0, 0) await sp.aktualisiereAnfrage(id, { stripePaymentIntentId: '' }) await W.verarbeiteUkEreignis(charge('pi_r1', 5000), NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent ?? 0, 0, 'Buchung ohne gespeicherte Zahlung') await sp.aktualisiereAnfrage(id, { stripePaymentIntentId: 'pi_r1' }) await W.verarbeiteUkEreignis(charge('pi_r1', 5000), NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 5000) }) test('Webhook charge.refunded: erstattetCent = Maximum, nie zurueck', async () => { const { id, ctx } = await bezahlt('pi_8') await ab.storniere(ctx, id, { now: NOW }) const charge = (betrag) => ({ type: 'charge.refunded', account: ACCOUNT, data: { object: { id: 'ch_1', payment_intent: 'pi_8', amount_refunded: betrag, metadata: { wkUk: '1', webklar: 'unterkunft', anfrageId: id } } } }) await W.verarbeiteUkEreignis(charge(7000), NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) await W.verarbeiteUkEreignis(charge(3000), NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) await W.verarbeiteUkEreignis(charge(7000), NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) await W.verarbeiteUkEreignis({ ...charge(9000), account: 'acct_fremd' }, NOW) assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) }) test('rechnung.pdf: 200 mit PDF; falsches Token 404', async () => { const { id, token } = await freigegeben() await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) const r = await send('GET', url(id, token, '/rechnung.pdf')) assert.equal(r.status, 200) assert.equal(r.headers.get('content-type'), 'application/pdf') assert.ok(String(r.body).startsWith('%PDF-'), 'PDF-Kopf') assert.equal((await send('GET', url(id, 'falsch', '/rechnung.pdf'))).status, 404) 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('Gleichzeitige Zweitzahlung (beide lesen noch gebucht): genau eine wird gebucht, die andere erstattet', async () => { const f = await onlineGebucht('2026-11-10', '2026-11-12') st.setPaymentIntent(ACCOUNT, 'pi_x', { amount: 20000 }) st.setPaymentIntent(ACCOUNT, 'pi_y', { amount: 20000 }) await Promise.all([zahlung.verbucheZahlung(f.ctx, f.id, 'pi_x', { now: NOW }), zahlung.verbucheZahlung(f.ctx, f.id, 'pi_y', { now: NOW })]) const a = await sp.ladeAnfrage(f.id) assert.equal(a.status, 'bezahlt') const gebucht = a.stripePaymentIntentId const andere = gebucht === 'pi_x' ? 'pi_y' : 'pi_x' assert.equal(erstattungen(gebucht).length, 0) assert.equal(erstattungen(andere).length, 1) assert.equal(erstattungen(andere)[0].metadata?.webklar ?? 'unterkunft', 'unterkunft') }) 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) })