Files
Webklar-Kundenbereich/server/routes/publicUnterkunft.js
T

518 lines
22 KiB
JavaScript

import { randomInt } from 'node:crypto'
import express, { Router } from 'express'
import { config } from '../config.js'
import { analyseDomains, analyseHosts } from '../services/analyse.js'
import { bucheVerbindlich, legeAnfrageAn } from '../services/unterkunft/ablauf.js'
import { belegteBereiche } from '../services/unterkunft/belegung.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, belegungsStand, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js'
import { formToken, ipHashVon, linkGueltig, 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).
* 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.
* - Grenzen: je IP 120 Abrufe je Minute, je Seite 600 je Minute; Anfragen je IP 10 je 10 Minuten und je Seite
* config.unterkunft.anfragenJeSiteStunde je Stunde.
* - Fehlerform: { fehler: code, felder? }.
*/
const router = Router()
const BODY_MAX = 16 * 1024
const STUNDE_MS = 60 * 60 * 1000
const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'
const STANDARD_AKZENT = '#8a6a3f'
const AKZENT_RE = /^#[0-9a-fA-F]{6}$/
let uhr = () => new Date()
export function setzeUhrFuerTests(neueUhr) {
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
}
// ---------------------------------------------------------------- Antworten und Fehler
const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(fehler), { status, fehlerCode: fehler, ...extra })
const zuViele = (retryAfter) => fehlerMit(429, 'zu_viele_anfragen', { retryAfter })
const nichtErlaubt = () => fehlerMit(403, 'nicht_erlaubt')
const nichtGefunden = () => fehlerMit(404, 'nicht_gefunden')
const ungueltig = (felder) => fehlerMit(400, 'ungueltig', felder ? { felder } : {})
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function fehlerErgebnis(err) {
if (err instanceof UkFehler && err.status >= 400 && err.status < 500) {
const felder = Object.fromEntries(Object.entries(err.felder ?? {}).filter(([, v]) => typeof v === 'string' || typeof v === 'number'))
return { status: err.status, body: { fehler: err.code, ...(Object.keys(felder).length ? { felder } : {}) } }
}
if (typeof err?.fehlerCode === 'string' && err.status >= 400 && err.status < 500) {
const body = { fehler: err.fehlerCode }
if (err.felder) body.felder = err.felder
return { status: err.status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
}
console.error(`[publicUnterkunft] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
return { status: 503, body: { fehler: 'nicht_erreichbar' } }
}
/** Schreibt { status, body, cache, headers }; GET/HEAD tragen immer `*`, POST nur mit geprueftem cors.erlaubt (Meldungen ohne Daten auch `*`). */
function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis
const lesen = req.method === 'GET' || req.method === 'HEAD'
if (lesen) res.set('Access-Control-Allow-Origin', '*')
else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' })
else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*')
res.status(status)
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
if (status === 204) return res.end()
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)
}
function notantwort(res, err) {
try {
console.error(`[publicUnterkunft] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
if (res.headersSent) return void res.end()
const inhalt = JSON.stringify({ fehler: 'nicht_erreichbar' })
res.statusCode = 503
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Length', String(inhalt.length))
return void res.end(inhalt)
} catch {
try {
res.destroy()
} catch {
// nichts mehr zu tun
}
}
}
const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const minAntwortMs = () => (Number.isFinite(config.unterkunft?.minAntwortMs) && config.unterkunft.minAntwortMs >= 0 ? config.unterkunft.minAntwortMs : 300)
/**
* Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder wirft;
* cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs.
*/
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
try {
const cors = { erlaubt: null }
let ergebnis
try {
ergebnis = await fn(req, uhr(), cors)
} catch (err) {
ergebnis = fehlerErgebnis(err)
}
if (mindestdauer) {
const rest = minAntwortMs() - (performance.now() - req.ukStart)
if (rest > 0) await warte(rest)
}
schreibe(req, res, ergebnis, cors)
} catch (err) {
notantwort(res, err)
}
}
// ---------------------------------------------------------------- Beginn: Zeit, Rumpf
router.use((req, res, next) => {
req.ukStart = performance.now()
if (Number(req.get('content-length')) > BODY_MAX) {
res.set('Connection', 'close')
return schreibe(req, res, { status: 413, body: { fehler: 'zu_gross' } })
}
return next()
})
router.use(express.json({ limit: BODY_MAX }))
// ---------------------------------------------------------------- Herkunft
/**
* Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project), Host von config.publicBaseUrl
* oder gleiche Herkunft wie die Anfrage. Sonst null.
*/
function erlaubteHerkunft(req, project) {
const origin = req.get('origin')
if (typeof origin !== 'string' || origin === '') return null
let url
try {
url = new URL(origin)
} catch {
return null
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null
const erlaubt = new Set([String(req.get('host') || '').toLowerCase()])
try {
erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase())
} catch {
// Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft
}
if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain)
return erlaubt.has(url.host) ? origin : null
}
function pruefeHerkunft(req, cors, project) {
const origin = erlaubteHerkunft(req, project)
if (!origin) throw nichtErlaubt()
cors.erlaubt = origin
}
const vorabantwort = (origin) => ({
status: 204,
body: '',
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
},
})
// Preflight: ohne erreichbare Seite gibt es nichts zu pruefen und nichts zu verraten.
router.options(
'/:siteKey/:was',
route(async (req, now, cors) => {
if (req.params.was !== 'anfrage') throw nichtGefunden()
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)
}),
)
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) {
if (!project) return null
const hosts = analyseHosts(project) // [Vorschau-Subdomain, Live-Domain]: die Live-Domain, wenn es eine gibt
const host = hosts[hosts.length - 1]
return host ? `https://${host}/datenschutz` : null
}
router.get(
'/:siteKey',
route(async (req, now) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return aktivFalse()
if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
const e = ctx.einstellungen ?? {}
const unterkuenfte = await listeUnterkuenfte(ctx.featureId, { nurAktive: true })
return {
body: {
aktiv: true,
anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) },
akzentfarbe: akzentfarbe(e),
formToken: formToken(req.params.siteKey, now),
unterkuenfte: unterkuenfte.map((u) => ({
id: u.$id,
name: u.name,
maxPersonen: u.maxPersonen,
mindestNaechte: u.mindestNaechte,
checkIn: u.checkIn,
checkOut: u.checkOut,
})),
},
}
}),
)
// ---------------------------------------------------------------- Belegung
function monateDazu(tag, n) {
const [j, m, t] = tag.split('-').map(Number)
const d = new Date(Date.UTC(j, m - 1 + n, 1))
const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
d.setUTCDate(Math.min(t, letzter))
return d.toISOString().slice(0, 10)
}
/** Fasst sich beruehrende oder ueberlappende Bereiche (nach von sortiert) zusammen; die Quelle wird nicht ausgegeben. */
function verschmelze(bereiche) {
const aus = []
for (const b of bereiche) {
const letzter = aus[aus.length - 1]
if (letzter && b.von <= letzter.bis) {
if (b.bis > letzter.bis) letzter.bis = b.bis
} else aus.push({ von: b.von, bis: b.bis })
}
return aus
}
// Antwort von /belegung 10 s im Speicher je siteKey+unterkunft+von+bis; jedes Schreiben an Sperren/Anfragen (speicher.belegungsStand) macht sie ungueltig
const BELEGUNG_TTL_MS = 10 * 1000
const BELEGUNG_MAX = 2000
const belegungSpeicher = new Map()
export const leereBelegungSpeicherFuerTests = () => belegungSpeicher.clear()
router.get(
'/:siteKey/belegung',
route(async (req, now) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return aktivFalse()
if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
if (typeof req.query.unterkunft === 'string' && !ID_RE.test(req.query.unterkunft)) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' })
const { unterkunft, von, bis } = req.query
if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis < von) {
throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' })
}
const tag = heute(now)
const nowMs = now instanceof Date ? now.getTime() : Number(now)
const speicherKey = `${req.params.siteKey}|${unterkunft}|${von}|${bis}`
const gemerkt = belegungSpeicher.get(speicherKey)
if (gemerkt && gemerkt.bis > nowMs && gemerkt.stand === belegungsStand() && gemerkt.tag === tag) return { body: gemerkt.body }
const stand = belegungsStand()
const bisMax = monateDazu(tag, config.unterkunft.horizontMonate)
const bisBis = bis > bisMax ? bisMax : bis // Fenster des Widgets (UTC-Datum des Browsers) darf nie an einem Tag Abweichung scheitern
const u = await ladeUnterkunft(unterkunft)
if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden()
const ab = von < tag ? tag : von
const belegt = ab < bisBis ? verschmelze(await belegteBereiche(u.$id, ab, bisBis, { now })) : []
const body = { belegt, heute: tag, bisMax }
if (belegungSpeicher.size >= BELEGUNG_MAX) {
for (const [k, e] of belegungSpeicher) if (e.bis <= nowMs) belegungSpeicher.delete(k)
if (belegungSpeicher.size >= BELEGUNG_MAX) belegungSpeicher.delete(belegungSpeicher.keys().next().value)
}
belegungSpeicher.set(speicherKey, { bis: nowMs + BELEGUNG_TTL_MS, stand, tag, body })
return { body }
}),
)
// ---------------------------------------------------------------- Anfrage
function scheinNummer() {
let n = 'A-'
for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)]
return n
}
const text = (wert) => (typeof wert === 'string' ? wert : '')
router.post(
'/:siteKey/anfrage',
route(
async (req, now, cors) => {
const { siteKey } = req.params
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(siteKey, { now })
if (!ctx) return { body: { aktiv: false } }
pruefeHerkunft(req, cors, ctx.project)
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
const b = req.body
if (!istObjekt(b)) throw ungueltig()
// Honigtopf gefuellt oder zu schnell abgeschickt: dieselbe Erfolgsantwort, gespeichert und gesendet wird nichts.
if (b.kontakt_url !== undefined && b.kontakt_url !== null && b.kontakt_url !== '') return { status: 201, body: { nummer: scheinNummer() } }
const pruefung = pruefeFormToken(siteKey, b.formToken, now)
if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } }
if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden')
if (!ID_RE.test(text(b.unterkunft))) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' })
if (!istTag(b.von) || !istTag(b.bis)) throw ungueltig({ zeitraum: 'Ungültige Daten.' })
// je Website erst nach der Feldpruefung zaehlen: fehlerhafte Formulare verbrauchen das Stundenkontingent nicht
if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600)
const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen
const anfrage = await legeAnfrageAn(
ctx,
{
unterkunftId: text(b.unterkunft),
von: b.von,
bis: b.bis,
personen,
gast: { name: text(b.name), firma: text(b.firma), email: text(b.email), telefon: text(b.telefon), nachricht: text(b.nachricht) },
},
{ now, ipHash: ipHashVon(req.ip) },
)
return { status: 201, body: { nummer: anfrage.nummer } }
},
{ mindestdauer: true },
),
)
// Rumpf nicht lesbar (kaputtes JSON): 400 in der Fehlerform der Schnittstelle
router.use((err, req, res, next) => {
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.too.large') {
return schreibe(req, res, { status: err.type === 'entity.too.large' ? 413 : 400, body: { fehler: err.type === 'entity.too.large' ? 'zu_gross' : 'ungueltig' } })
}
return next(err)
})
// ---------------------------------------------------------------- Buchungsseite (Task 7)
const BUCHUNG = { mindestdauer: true }
const ART_RE = /^(rechnung|storno)$/
/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion, falsches Token und abgelaufenen Link (token.linkGueltig) dieselbe 404-Antwort. */
async function ladeMitToken(ctx, id, token, now) {
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) || !linkGueltig(a, now)) 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, now)
// Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' })
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, now)
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, now)
a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true })
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, now)
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 (err) {
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err
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