Buchungskalender: Buchungsseite, Stripe-Zahlung und Webhook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:16:17 +00:00
1 parent 40a366b8ae
commit ca0f762272
6 files changed
+679 -18

No files matched your search

+5
View File
@@ -29,6 +29,11 @@ beforeEach(async () => {
for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id)
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
// Erstattung ohne Stripe: wie der fruehere Stub (die echte Erstattung testet unterkunftBuchung.test.js)
zahlung.setzeErstattungFuerTests(async (c, a, betragCent) => {
if (!c?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus')
return { erstattetCent: betragCent }
})
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung', maxPersonen: 4, mindestNaechte: 2, aktiv: true })
aw.seed('ukPreise', {
$id: 'p1', unterkunftId: 'u1', featureId: 'f1', staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]),
+313
View File
@@ -0,0 +1,313 @@
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', ...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)
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', 'K32'])
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('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('SEPA nur bei gemeldetem Status', async () => {
await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: true, sepa_debit: true }) })
const { id, token } = await freigegeben()
await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
const call = st.calls.find((c) => c.path === '/v1/checkout/sessions')
assert.equal(call.body['payment_method_types[1]'], 'sepa_debit')
})
test('Webhook checkout.session.completed: bezahlt, G20, zweimal nur ein Uebergang', async () => {
const { id, token } = await freigegeben()
const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' })
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', 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, '') + '&wk_zahlung=ok')
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', 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')
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('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 oder 501 ohne Erzeuger; 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.ok([200, 501].includes(r.status), String(r.status))
if (r.status === 200) assert.equal(r.headers.get('content-type'), 'application/pdf')
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)
})