Files
Webklar-Kundenbereich/test/unterkunftBuchung.test.js
T

527 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})