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

+172 -8
View File
@@ -2,20 +2,21 @@ import { randomInt } from 'node:crypto'
import express, { Router } from 'express'
import { config } from '../config.js'
import { analyseDomains, analyseHosts } from '../services/analyse.js'
import { legeAnfrageAn } from '../services/unterkunft/ablauf.js'
import { bucheVerbindlich, legeAnfrageAn } from '../services/unterkunft/ablauf.js'
import { belegteBereiche } from '../services/unterkunft/belegung.js'
import { istTag } from '../services/unterkunft/datum.js'
import { istTag, naechte } from '../services/unterkunft/datum.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js'
import { ID_RE, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
import { formToken, ipHashVon, pruefeFormToken } from '../services/unterkunft/token.js'
import { ID_RE, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js'
import { formToken, ipHashVon, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js'
import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js'
import { heute } from '../services/termine/zeit.js'
/**
* Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md).
* Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js).
* Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage. Task 7 ergaenzt die Buchungsseite.
* Konfiguration, Belegung, Anfrage (Task 6); Buchungsseite GET|POST /:siteKey/buchung/:id, POST …/zahlung, GET …/rechnung.pdf (Task 7).
*
* - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals
* (config.publicBaseUrl) oder gleicher Herkunft; sonst 403.
@@ -70,7 +71,7 @@ function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
res.status(status)
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
if (status === 204) return res.end()
const inhalt = typeof body === 'string' ? body : JSON.stringify(body)
const inhalt = Buffer.isBuffer(body) || typeof body === 'string' ? body : JSON.stringify(body)
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
return res.end(inhalt)
}
@@ -189,8 +190,21 @@ router.options(
}),
)
router.options(
['/:siteKey/buchung/:id', '/:siteKey/buchung/:id/zahlung'],
route(async (req, now, cors) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return vorabantwort('*')
pruefeHerkunft(req, cors, ctx.project)
return vorabantwort(cors.erlaubt)
}),
)
// ---------------------------------------------------------------- Konfiguration
const akzentfarbe = (e) => (AKZENT_RE.test(e?.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT)
const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' })
function datenschutzUrl(project) {
@@ -213,7 +227,7 @@ router.get(
body: {
aktiv: true,
anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) },
akzentfarbe: AKZENT_RE.test(e.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT,
akzentfarbe: akzentfarbe(e),
formToken: formToken(req.params.siteKey, now),
unterkuenfte: unterkuenfte.map((u) => ({
id: u.$id,
@@ -328,6 +342,156 @@ router.use((err, req, res, next) => {
return next(err)
})
// Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf
// ---------------------------------------------------------------- Buchungsseite (Task 7)
const BUCHUNG = { mindestdauer: true }
const ART_RE = /^(rechnung|storno)$/
/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion und falsches Token dieselbe 404-Antwort. */
async function ladeMitToken(ctx, id, token) {
if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden()
const a = await ladeAnfrage(id)
if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token)) throw nichtGefunden()
return a
}
/** Kontext der Buchungsseite: Lesen je IP begrenzt; Schreiben nur von erlaubter Herkunft und je IP als Buchungsversuch gezaehlt. */
async function buchungsKontext(req, now, cors, { schreiben }) {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) throw nichtGefunden()
if (schreiben) {
pruefeHerkunft(req, cors, ctx.project)
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
} else if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
return ctx
}
const onlineMoeglich = (ctx) => Boolean(ctx.einstellungen?.onlineZahlung && ctx.einstellungen?.stripeAccountId)
const wort = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '')
async function buchungAntwort(ctx, a) {
const [u, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null])
const e = ctx.einstellungen ?? {}
const adr = a.rechnungsadresse
return {
status: a.status,
nummer: a.nummer,
unterkunft: { name: u?.name ?? '', checkIn: u?.checkIn ?? '', checkOut: u?.checkOut ?? '' },
von: a.von,
bis: a.bis,
naechte: naechte(a.von, a.bis),
personen: a.personen,
posten: Array.isArray(a.posten) ? a.posten : [],
summen: a.summen ?? null,
steuerart: a.summen?.steuerart ?? '',
fristAt: a.fristAt ?? '',
stornobedingungen: wort(a.stornobedingungenText, 5000),
anbieter: {
firma: wort(e.firma),
inhaber: wort(e.inhaber),
strasse: wort(e.strasse),
plz: wort(e.plz),
ort: wort(e.ort),
email: wort(e.email),
telefon: wort(e.telefon),
datenschutzUrl: datenschutzUrl(ctx.project),
},
akzentfarbe: akzentfarbe(e),
gast: { name: wort(a.gast?.name), firma: wort(a.gast?.firma) },
...(adr && typeof adr === 'object'
? { rechnungsadresse: { name: wort(adr.name), firma: wort(adr.firma), strasse: wort(adr.strasse), plz: wort(adr.plz), ort: wort(adr.ort), land: wort(adr.land), ustId: wort(adr.ustId, 20) } }
: {}),
zahlarten: onlineMoeglich(ctx) ? ['vorort', 'online'] : ['vorort'],
...(a.zahlart ? { zahlart: a.zahlart } : {}),
bezahlt: a.status === 'bezahlt',
...(rechnung?.nummer ? { rechnungNummer: rechnung.nummer } : {}),
}
}
router.get(
'/:siteKey/buchung/:id',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
let a = await ladeMitToken(ctx, req.params.id, req.query.t)
// Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen
a = await schliesseAbWennBezahlt(ctx, a, { now })
return { body: await buchungAntwort(ctx, a) }
},
BUCHUNG,
),
)
router.post(
'/:siteKey/buchung/:id',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
const a = await ladeMitToken(ctx, req.params.id, req.query.t)
const b = req.body
if (!istObjekt(b)) throw ungueltig()
const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, {
rechnungsadresse: istObjekt(b.rechnungsadresse) ? b.rechnungsadresse : {},
zahlart: text(b.zahlart),
now,
})
const body = { status: anfrage.status, rechnungNummer: rechnung?.nummer ?? '' }
if (anfrage.status === 'gebucht' && anfrage.zahlart === 'online') {
try {
body.zahlungUrl = (await checkoutFuer(ctx, anfrage, { now, token: req.query.t })).url
} catch (err) {
// Gebucht ist gebucht: die Seite holt die Zahlung ueber POST .../zahlung nach
console.warn(`[publicUnterkunft] Zahlungssitzung nicht angelegt: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)
}
}
return { status: 200, body }
},
BUCHUNG,
),
)
router.post(
'/:siteKey/buchung/:id/zahlung',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: true })
let a = await ladeMitToken(ctx, req.params.id, req.query.t)
a = await schliesseAbWennBezahlt(ctx, a, { now })
if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig')
try {
return { body: await checkoutFuer(ctx, a, { now, token: req.query.t }) }
} catch (err) {
if (err instanceof UkFehler) throw err
throw new UkFehler(502, 'zahlung_fehlgeschlagen')
}
},
BUCHUNG,
),
)
router.get(
'/:siteKey/buchung/:id/rechnung.pdf',
route(
async (req, now, cors) => {
const ctx = await buchungsKontext(req, now, cors, { schreiben: false })
const a = await ladeMitToken(ctx, req.params.id, req.query.t)
const art = typeof req.query.art === 'string' ? req.query.art : 'rechnung'
if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' })
const dok = await findeRechnung(a.$id, art)
if (!dok) throw nichtGefunden()
let modul
try {
modul = await import('../services/unterkunft/rechnungPdf.js')
} catch {
return { status: 501, body: { fehler: 'nicht_verfuegbar' } }
}
const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum })
const dateiname = `${art === 'storno' ? 'Stornorechnung' : 'Rechnung'}-${String(dok.nummer).replace(/[^A-Za-z0-9_-]/g, '')}.pdf`
return { body: pdf, typ: 'application/pdf', headers: { 'Content-Disposition': `inline; filename="${dateiname}"` } }
},
BUCHUNG,
),
)
export default router