527 lines
30 KiB
JavaScript
527 lines
30 KiB
JavaScript
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)
|
||
})
|