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

697 lines
33 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 { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { withLocks } from '../services/keyedLock.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js'
import * as ablauf from '../services/unterkunft/ablauf.js'
import { istFrei } from '../services/unterkunft/belegung.js'
import { istTag, naechte } from '../services/unterkunft/datum.js'
import { anfrageAnsicht as funktionAnfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/unterkunft/anfrageFunktion.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { ladeFuerProjekt, leereCache } from '../services/unterkunft/kontext.js'
import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.js'
import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js'
import * as sp from '../services/unterkunft/speicher.js'
import * as vw from '../services/unterkunft/verwaltung.js'
/**
* Kunden-API des Buchungskalenders: /api/unterkunft (Session, Projekt per ?projectId= oder Body; Vertrag: docs/funktionen/unterkunft-api.md,
* Antwortformen: ui-erwartungen.md). Jede Route: Sitzung, eigenes Projekt (403), Funktion vorhanden und nicht off (409 nicht_aktiv).
* Schreibende Routen zusaetzlich: nur der Kunde selbst (nicht Kundenansicht/"Ansehen als"), Zustand setup|active, Schreibgrenze
* (300 je Kunde und Stunde). Alle IDs aus dem Pfad werden vor jedem Zugriff geprueft und gehoeren zum Projekt (sonst 404).
* Fehler: { fehler: code, felder? }; Texte von Appwrite oder Stripe verlassen den Server nie. Die Anfrage der Funktion (/anfrage-funktion, nur solange sie off ist) hat eine eigene Grenze (20 je Kunde und Stunde).
*/
const router = Router()
// Jede Route hier braucht eine Sitzung; Kunden-Pruefung je Route (nurKunde)
router.use(requireSession)
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
const MAX_WRITES_PER_HOUR = 300
const MAX_ANFRAGE_WRITES_PER_HOUR = 20
const MELDUNG = {
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.',
plattform: 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.',
erstattung: 'Die Stornierung wurde durchgeführt, aber die Erstattung ist fehlgeschlagen. Bitte erstatte den Betrag im Stripe-Dashboard oder versuch es später noch einmal.',
}
const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR })
const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetUnterkunftRoutesForTests() {
schreibLimiter.reset()
anfrageLimiter.reset()
}
const nurKunde = (req, res, next) => {
if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ fehler: 'kundenansicht', error: MELDUNG.kundenansicht })
return next()
}
const schreib = [nurKunde, limitWrites]
/** Fachfehler und 403 wie sie sind, alles andere 500 ohne Rohtext. */
function fail(res, err) {
if (err instanceof UkFehler) {
const body = { fehler: err.code }
if (err.felder && Object.keys(err.felder).length) body.felder = err.felder
if (err.status === 502 && err.code === 'erstattung_fehlgeschlagen') body.meldung = MELDUNG.erstattung
return res.status(err.status).json(body)
}
if (err?.status === 403 && !err.type) return res.status(403).json({ fehler: 'kein_zugriff', error: err.message })
if (err?.fachlich) return res.status(err.status).json({ fehler: err.fachlich, ...(err.meldung ? { meldung: err.meldung } : {}) })
console.error(`[unterkunft] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim())
return res.status(500).json({ fehler: 'intern', error: 'Interner Serverfehler' })
}
const route = (fn) => async (req, res) => {
try {
await fn(req, res)
} catch (err) {
fail(res, err)
}
}
const stop = (status, fachlich, extra = {}) => Object.assign(new Error(fachlich), { status, fachlich, ...extra })
const ungueltig = (felder) => new UkFehler(400, 'ungueltig', felder)
const nichtGefunden = () => new UkFehler(404, 'nicht_gefunden')
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {})
/** Eigenes Projekt (403) und Funktion des Projekts; schreiben verlangt Zustand setup|active. */
async function mitProjekt(req, { schreiben = false } = {}) {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
const ctx = await ladeFuerProjekt(customerId, projectId)
if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) throw new UkFehler(409, 'nicht_aktiv')
if (schreiben && !['setup', 'active'].includes(ctx.state)) throw new UkFehler(409, 'nicht_aktiv')
return ctx
}
/** Unterkunft aus dem Pfad: ID geprueft, gehoert zum Projekt (sonst 404). */
async function eigeneUnterkunft(ctx, id) {
if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden()
const u = await sp.ladeUnterkunft(id)
if (!u || u.featureId !== ctx.featureId) throw nichtGefunden()
return u
}
async function eigeneAnfrage(ctx, id) {
if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden()
const a = await sp.ladeAnfrage(id)
if (!a || a.featureId !== ctx.featureId) throw nichtGefunden()
return a
}
const sperreKey = (id) => ['unterkunft:' + id]
// ---- Ansichten (nie tokenHash, ipHash, Konto-ID, Stripe-IDs)
const gastAnsicht = (g) => ({ name: g?.name ?? '', firma: g?.firma ?? '', email: g?.email ?? '', telefon: g?.telefon ?? '', nachricht: g?.nachricht ?? '' })
function unterkunftAnsicht(u) {
return {
id: u.$id,
name: u.name ?? '',
beschreibung: u.beschreibung ?? '',
maxPersonen: u.maxPersonen ?? 1,
mindestNaechte: u.mindestNaechte ?? 1,
checkIn: u.checkIn ?? '',
checkOut: u.checkOut ?? '',
sort: u.sort ?? 0,
aktiv: u.aktiv === true,
icalImporte: (Array.isArray(u.icalImporte) ? u.icalImporte : []).map((i) => ({
id: i.id,
name: i.name ?? '',
url: i.url ?? '',
letzterAbrufAt: i.letzterAbrufAt ?? null,
letzterErfolgAt: i.letzterErfolgAt ?? null,
fehler: i.fehler ?? '',
})),
icalExportUrl: vw.exportUrl(u.icalExportToken),
}
}
function anfrageAnsicht(a, { unterkunftName = '', rechnungNummer = null, stornoRechnungId = null, steuerart = null } = {}) {
return {
id: a.$id,
nummer: a.nummer ?? '',
status: a.status ?? '',
von: a.von,
bis: a.bis,
naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0,
personen: a.personen ?? 0,
unterkunftId: a.unterkunftId ?? '',
unterkunftName,
gast: gastAnsicht(a.gast),
rechnungsadresse: a.rechnungsadresse ?? null,
posten: a.posten ?? null,
summen: a.summen ?? null,
steuerart: a.summen?.steuerart ?? steuerart,
zahlart: a.zahlart || null,
bezahltVia: a.bezahltVia || null,
bezahltAt: a.bezahltAt || null,
erstattetCent: a.erstattetCent ?? 0,
rechnungId: a.rechnungId || null,
rechnungNummer,
stornoRechnungId,
fristAt: a.fristAt || null,
nachrichtVermieter: a.nachrichtVermieter ?? '',
}
}
/** Anfrage mit Namen der Unterkunft und Rechnungsnummern (nur Rechnungen dieses Projekts). */
async function volleAnfrageAnsicht(ctx, a, preise) {
const [u, re, storno] = await Promise.all([
sp.ladeUnterkunft(a.unterkunftId),
a.rechnungId ? sp.ladeRechnung(a.rechnungId) : null,
a.status === 'storniert' ? sp.findeRechnung(a.$id, 'storno') : null,
])
return anfrageAnsicht(a, {
unterkunftName: u?.name ?? '',
rechnungNummer: re && re.featureId === ctx.featureId ? re.nummer : null,
stornoRechnungId: storno && storno.featureId === ctx.featureId ? storno.$id : null,
steuerart: preise?.steuerart ?? null,
})
}
const preiseAnsicht = (p) =>
p
? {
staffeln: Array.isArray(p.staffeln) ? p.staffeln : [],
reinigungCent: p.reinigungCent ?? 0,
personenInklusive: p.personenInklusive ?? 1,
aufpreisPersonNachtCent: p.aufpreisPersonNachtCent ?? 0,
direktrabattProzent: p.direktrabattProzent ?? 0,
steuerart: p.steuerart ?? 'regel',
ustUebernachtung: p.ustUebernachtung ?? 0,
ustReinigung: p.ustReinigung ?? 0,
fristTage: p.fristTage ?? 3,
stornobedingungen: p.stornobedingungen ?? '',
}
: null
const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt']
const stripeStatusKnapp = (s) => (s && typeof s === 'object' ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(s, k)).map((k) => [k, s[k]])) : null)
const stripeZustand = (einst) => (!einst?.stripeAccountId ? 'keins' : einst.stripeStatus?.charges_enabled === true ? 'aktiv' : 'onboarding')
/** shopConfig mit Stripe-Konto desselben Projekts (shopConfig hat die ID des Shop-Funktionsdokuments), sonst null. */
async function shopMitKonto(customerId, projectId) {
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc) return null
try {
const shop = await getDocument(config.collections.shopConfig, doc.$id)
return typeof shop?.stripeAccountId === 'string' && shop.stripeAccountId ? shop : null
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
// ---- Status
router.get('/status', route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'unterkunft')
const state = doc?.state ?? 'off'
const antwort = { state, stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 0, rechnungenVorhanden: false }
if (state === 'off') {
// Anfrage der Funktion: offen | abgelehnt | angenommen (zurueckgezogen und keine zaehlen als keine)
let ansicht = null
try {
ansicht = doc ? funktionAnfrageAnsicht(await sp.ladeEinstellungen(doc.$id)) : null
} catch (err) {
console.warn(`[unterkunft] Anfrage im Status nicht lesbar: ${err?.message}`)
}
antwort.anfrage = ansicht && ansicht.status !== 'zurueckgezogen' ? ansicht : null
return res.json(antwort)
}
const [einst, unterkuenfte, rechnungen, shop] = await Promise.all([
sp.ladeEinstellungen(doc.$id),
sp.listeUnterkuenfte(doc.$id),
sp.listeRechnungen(doc.$id),
shopMitKonto(customerId, projectId),
])
antwort.stripe = { status: stripeZustand(einst), vorhandenesShopKonto: Boolean(shop) }
antwort.onlineZahlung = einst?.onlineZahlung === true
antwort.unterkuenfte = unterkuenfte.length
antwort.rechnungenVorhanden = rechnungen.length > 0
const preise = await Promise.all(unterkuenfte.map((u) => sp.ladePreise(u.$id)))
const mitArt = preise.find((p) => p?.steuerart)
if (mitArt) antwort.steuerart = mitArt.steuerart
res.json(antwort)
}))
// ---- Anfrage der Funktion (nur solange sie off ist; kein mitProjekt)
/** Fehler des Anfrage-Dienstes (status, Text, body.code, fehler je Feld) in { fehler: code, felder? }. */
function anfrageFehler(err) {
if (err instanceof UkFehler || err?.status === 403 || err?.fachlich || !(err?.status >= 400 && err.status < 500)) return err
if (err.fehler) return new UkFehler(400, 'ungueltig', err.fehler)
return new UkFehler(err.status, err.body?.code || (err.status === 400 ? 'ungueltig' : 'anfrage'), err.status === 400 ? { projectId: err.message } : {})
}
const anfrageKunde = (req) => ({ name: req.session.name || '', email: req.session.email || '' })
router.post('/anfrage-funktion', nurKunde, limitAnfrage, route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
try {
const anfrage = await stelleAnfrage({ customerId: getSessionCustomerId(req), projectId, eingabe: body(req), person: anfrageKunde(req) })
res.json({ anfrage })
} catch (err) {
throw anfrageFehler(err)
}
}))
router.post('/anfrage-funktion/zurueckziehen', nurKunde, limitAnfrage, route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' })
try {
res.json({ anfrage: await zieheZurueck({ customerId: getSessionCustomerId(req), projectId, person: anfrageKunde(req) }) })
} catch (err) {
throw anfrageFehler(err)
}
}))
// ---- Einstellungen
async function einstellungenAntwort(ctx, einst) {
const e = einst ?? {}
const ansicht = Object.fromEntries(vw.EINSTELLUNG_FELDER.map((k) => [k, e[k] ?? '']))
ansicht.onlineZahlung = e.onlineZahlung === true
ansicht.rechnungAuftrag = Boolean(e.rechnungAuftragAt)
const [shop, rechnungen] = await Promise.all([shopMitKonto(ctx.customerId, ctx.projectId), sp.listeRechnungen(ctx.featureId)])
return {
einstellungen: ansicht,
stripe: { status: stripeZustand(e), vorhandenesShopKonto: Boolean(shop) },
rechnungenVorhanden: rechnungen.length > 0,
}
}
router.get('/einstellungen', route(async (req, res) => {
const ctx = await mitProjekt(req)
res.json(await einstellungenAntwort(ctx, await sp.ladeEinstellungen(ctx.featureId)))
}))
router.put('/einstellungen', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const eingabe = body(req).einstellungen && typeof body(req).einstellungen === 'object' ? body(req).einstellungen : body(req)
const neu = await withLocks(['uk-einst:' + ctx.featureId, 'uk-rechnungsnr:' + ctx.featureId], async () => {
const vorher = (await sp.ladeEinstellungen(ctx.featureId)) ?? {}
const { rechnungAuftrag, ...rest } = eingabe
if (rechnungAuftrag !== undefined && typeof rechnungAuftrag !== 'boolean') throw ungueltig({ rechnungAuftrag: 'Ungültige Eingabe.' })
const r = vw.pruefeEinstellungen(rest, vorher)
if (r.fehler) throw ungueltig(r.fehler)
if (r.werte.onlineZahlung === true && !vorher.onlineZahlung && stripeZustand(vorher) !== 'aktiv') {
throw new UkFehler(409, 'stripe_nicht_aktiv', { onlineZahlung: 'Online-Zahlung ist erst möglich, wenn dein Stripe-Konto aktiv ist.' })
}
if (r.werte.rechnungsPraefix !== undefined && r.werte.rechnungsPraefix !== (vorher.rechnungsPraefix ?? '') && !vw.zaehlerLeer(vorher)) {
throw new UkFehler(409, 'praefix_gesperrt', { rechnungsPraefix: 'Das Präfix kann nach der ersten Rechnung nicht mehr geändert werden.' })
}
const werte = { ...r.werte }
if (rechnungAuftrag === true && !vorher.rechnungAuftragAt) werte.rechnungAuftragAt = new Date().toISOString()
if (rechnungAuftrag === false) werte.rechnungAuftragAt = null
return sp.speichereEinstellungen(ctx.featureId, werte)
})
// die oeffentliche Schnittstelle (Farbe, Firma, Online-Zahlung) merkt sich den Kontext 30 s: sofort frisch
if (ctx.siteKey) leereCache(ctx.siteKey)
res.json(await einstellungenAntwort(ctx, neu))
}))
// ---- Unterkuenfte
router.get('/unterkuenfte', route(async (req, res) => {
const ctx = await mitProjekt(req)
res.json({ unterkuenfte: (await sp.listeUnterkuenfte(ctx.featureId)).map(unterkunftAnsicht) })
}))
router.post('/unterkuenfte', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const r = vw.pruefeUnterkunft(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const u = await withLocks(['uk-neu:' + ctx.featureId], async () => {
const alle = await sp.listeUnterkuenfte(ctx.featureId)
if (alle.length >= vw.MAX_UNTERKUENFTE) throw new UkFehler(409, 'limit_erreicht')
const werte = { ...r.werte }
if (body(req).sort === undefined) werte.sort = alle.reduce((m, x) => Math.max(m, x.sort ?? 0), 0) + 1
return sp.speichereUnterkunft(ctx.featureId, {
...werte,
customerId: ctx.customerId,
projectId: ctx.projectId,
icalImporte: [],
icalExportToken: vw.neuesExportToken(),
})
})
res.status(201).json({ unterkunft: unterkunftAnsicht(u) })
}))
router.put('/unterkuenfte/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeUnterkunft(body(req), { partiell: true })
if (r.fehler) throw ungueltig(r.fehler)
const neu = await sp.speichereUnterkunft(ctx.featureId, r.werte, u.$id)
res.json({ unterkunft: unterkunftAnsicht(neu) })
}))
// Loeschen nur ohne Anfragen (sonst deaktivieren: aktiv:false); die Oberflaeche deaktiviert nur
router.delete('/unterkuenfte/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
await withLocks(sperreKey(u.$id), async () => {
if ((await sp.listeAnfragen(ctx.featureId, { unterkunftId: u.$id })).length > 0) throw new UkFehler(409, 'hat_anfragen')
for (const s of await sp.listeSperren(u.$id)) await sp.loescheSperre(s.$id)
await sp.loeschePreise(u.$id)
await sp.loescheUnterkunft(u.$id)
})
res.json({ ok: true })
}))
// ---- Preise
router.get('/unterkuenfte/:id/preise', route(async (req, res) => {
const ctx = await mitProjekt(req)
const u = await eigeneUnterkunft(ctx, req.params.id)
res.json({ preise: preiseAnsicht(await sp.ladePreise(u.$id)) })
}))
router.put('/unterkuenfte/:id/preise', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const eingabe = body(req).preise && typeof body(req).preise === 'object' ? body(req).preise : body(req)
const r = vw.pruefePreise(eingabe)
if (r.fehler) throw ungueltig(r.fehler)
const neu = await sp.speicherePreise(u.$id, ctx.featureId, r.werte)
res.json({ preise: preiseAnsicht(neu) })
}))
// ---- Kalender, Sperren
const FENSTER_MAX_TAGE = 100
router.get('/unterkuenfte/:id/kalender', route(async (req, res) => {
const ctx = await mitProjekt(req)
const u = await eigeneUnterkunft(ctx, req.params.id)
const { von, bis } = req.query
if (!istTag(von) || !istTag(bis) || naechte(von, bis) < 1 || naechte(von, bis) > FENSTER_MAX_TAGE) {
throw ungueltig({ zeitraum: 'Bitte einen gültigen Zeitraum von höchstens 100 Tagen angeben.' })
}
res.json({ bereiche: await vw.kalenderBereiche(u.$id, von, bis) })
}))
router.post('/unterkuenfte/:id/sperren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeSperre(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const sperre = await withLocks(sperreKey(u.$id), async () => {
if (!(await istFrei(u.$id, r.werte.von, r.werte.bis))) throw new UkFehler(409, 'zeitraum_belegt')
return sp.legeSperreAn({ unterkunftId: u.$id, featureId: ctx.featureId, von: r.werte.von, bis: r.werte.bis, quelle: 'manuell', notiz: r.werte.notiz })
})
res.status(201).json({ sperre: { id: sperre.$id, von: sperre.von, bis: sperre.bis, notiz: sperre.notiz ?? '' } })
}))
router.delete('/sperren/:id', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
if (typeof req.params.id !== 'string' || !sp.ID_RE.test(req.params.id)) throw nichtGefunden()
const s = await sp.ladeSperre(req.params.id)
if (!s || s.featureId !== ctx.featureId) throw nichtGefunden()
if (String(s.quelle ?? 'manuell').startsWith('ical')) throw new UkFehler(409, 'sperre_aus_ical')
await sp.loescheSperre(s.$id)
res.json({ ok: true })
}))
// ---- iCal
router.post('/unterkuenfte/:id/ical-importe', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u0 = await eigeneUnterkunft(ctx, req.params.id)
const r = vw.pruefeIcalImport(body(req))
if (r.fehler) throw ungueltig(r.fehler)
const eintrag = await withLocks(sperreKey(u0.$id), async () => {
const u = await sp.ladeUnterkunft(u0.$id)
const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : []
if (bisher.length >= vw.MAX_ICAL_IMPORTE) throw new UkFehler(409, 'limit_erreicht')
const neu = { id: vw.neueImportId(), name: r.werte.name, url: r.werte.url, letzterAbrufAt: null, letzterErfolgAt: null, fehler: '' }
await sp.speichereUnterkunft(ctx.featureId, { icalImporte: [...bisher, neu] }, u.$id)
return neu
})
res.status(201).json({ import: eintrag })
}))
router.delete('/unterkuenfte/:id/ical-importe/:importId', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u0 = await eigeneUnterkunft(ctx, req.params.id)
const importId = req.params.importId
if (typeof importId !== 'string' || !sp.ID_RE.test(importId)) throw nichtGefunden()
await withLocks(sperreKey(u0.$id), async () => {
const u = await sp.ladeUnterkunft(u0.$id)
const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : []
if (!bisher.some((i) => i.id === importId)) throw nichtGefunden()
await sp.speichereUnterkunft(ctx.featureId, { icalImporte: bisher.filter((i) => i.id !== importId) }, u.$id)
for (const s of await sp.listeSperren(u.$id)) if (s.quelle === `ical:${importId}`) await sp.loescheSperre(s.$id)
})
res.json({ ok: true })
}))
router.post('/unterkuenfte/:id/ical-export/erneuern', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const u = await eigeneUnterkunft(ctx, req.params.id)
const neu = await sp.speichereUnterkunft(ctx.featureId, { icalExportToken: vw.neuesExportToken() }, u.$id)
res.json({ icalExportUrl: vw.exportUrl(neu.icalExportToken) })
}))
// ---- Preisvorschau (rechnet nur, schreibt nichts)
router.post('/preisvorschau', route(async (req, res) => {
const ctx = await mitProjekt(req)
const b = body(req)
const u = await eigeneUnterkunft(ctx, b.unterkunftId)
if (!istTag(b.von) || !istTag(b.bis) || naechte(b.von, b.bis) < 1 || naechte(b.von, b.bis) > 365) throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' })
if (!Number.isInteger(b.personen) || b.personen < 1 || b.personen > 50) throw ungueltig({ personen: 'Ungültige Personenzahl.' })
const preise = await sp.ladePreise(u.$id)
if (!preiseVollstaendig(preise)) throw new UkFehler(409, 'preise_unvollstaendig')
res.json(berechnePreis({ preise, von: b.von, bis: b.bis, personen: b.personen }))
}))
// ---- Anfragen
const FILTER = { offen: ['neu', 'freigegeben'], gebucht: ['gebucht', 'bezahlt'] }
router.get('/anfragen', route(async (req, res) => {
const ctx = await mitProjekt(req)
const filter = req.query.filter === undefined ? 'alle' : String(req.query.filter)
if (filter !== 'alle' && !FILTER[filter]) throw ungueltig({ filter: 'Ungültiger Filter.' })
const [liste, unterkuenfte] = await Promise.all([sp.listeAnfragen(ctx.featureId, { status: FILTER[filter] }), sp.listeUnterkuenfte(ctx.featureId)])
const namen = new Map(unterkuenfte.map((u) => [u.$id, u.name ?? '']))
liste.sort((a, b) => String(b.$createdAt ?? '').localeCompare(String(a.$createdAt ?? '')) || String(b.nummer).localeCompare(String(a.nummer)))
res.json({
anfragen: liste.map((a) => ({
id: a.$id,
nummer: a.nummer ?? '',
von: a.von,
bis: a.bis,
naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0,
personen: a.personen ?? 0,
gast: { name: a.gast?.name ?? '' },
status: a.status ?? '',
summen: a.summen && typeof a.summen.bruttoCent === 'number' ? { bruttoCent: a.summen.bruttoCent } : null,
unterkunftName: namen.get(a.unterkunftId) ?? '',
})),
})
}))
function vorschlagFuer(preise, a) {
const steuerart = preise?.steuerart ?? 'regel'
if (preiseVollstaendig(preise) && istTag(a.von) && istTag(a.bis) && naechte(a.von, a.bis) >= 1) {
return { ...berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }), steuerart }
}
return { posten: [], summen: summiere([], steuerart), steuerart }
}
router.get('/anfragen/:id', route(async (req, res) => {
const ctx = await mitProjekt(req)
const a = await eigeneAnfrage(ctx, req.params.id)
const preise = await sp.ladePreise(a.unterkunftId)
res.json({ anfrage: await volleAnfrageAnsicht(ctx, a, preise), vorschlag: vorschlagFuer(preise, a) })
}))
router.post('/anfragen/:id/freigeben', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const b = body(req)
if (b.fristTage !== undefined && b.fristTage !== null && (!Number.isInteger(b.fristTage) || b.fristTage < 1 || b.fristTage > 14)) {
throw ungueltig({ fristTage: 'Die Frist muss 1 bis 14 Tage betragen.' })
}
if (b.nachricht !== undefined && b.nachricht !== null && (typeof b.nachricht !== 'string' || b.nachricht.length > 2000)) {
throw ungueltig({ nachricht: 'Die Nachricht darf höchstens 2000 Zeichen lang sein.' })
}
const { anfrage, token } = await ablauf.gibFrei(ctx, a.$id, { posten: b.posten, fristTage: b.fristTage ?? undefined, nachricht: b.nachricht ?? '' })
const preise = await sp.ladePreise(anfrage.unterkunftId)
res.json({
anfrage: await volleAnfrageAnsicht(ctx, anfrage, preise),
buchungsLink: `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrage.$id}?t=${token}`,
})
}))
router.post('/anfragen/:id/ablehnen', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const t = body(req).text
if (t !== undefined && t !== null && (typeof t !== 'string' || t.length > 2000)) throw ungueltig({ text: 'Der Text darf höchstens 2000 Zeichen lang sein.' })
const neu = await ablauf.lehneAb(ctx, a.$id, { text: t ?? '' })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) })
}))
router.post('/anfragen/:id/bezahlt', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const neu = await ablauf.markiereBezahlt(ctx, a.$id, { via: 'vorort' })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) })
}))
router.post('/anfragen/:id/stornieren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const a = await eigeneAnfrage(ctx, req.params.id)
const b = body(req)
const grund = b.grund === undefined || b.grund === null ? '' : b.grund
if (typeof grund !== 'string' || grund.length > 500) throw ungueltig({ grund: 'Der Grund darf höchstens 500 Zeichen lang sein.' })
const erstattungCent = b.erstattungCent === undefined || b.erstattungCent === null ? 0 : b.erstattungCent
if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw ungueltig({ erstattungCent: 'Ungültiger Betrag.' })
const { anfrage, stornoRechnung } = await ablauf.storniere(ctx, a.$id, { grund, erstattungCent })
res.json({ anfrage: await volleAnfrageAnsicht(ctx, anfrage, await sp.ladePreise(anfrage.unterkunftId)), stornoRechnungId: stornoRechnung?.$id ?? null })
}))
// ---- Rechnungen
router.get('/rechnungen', route(async (req, res) => {
const ctx = await mitProjekt(req)
const liste = await sp.listeRechnungen(ctx.featureId)
res.json({
rechnungen: liste.reverse().map((r) => ({
id: r.$id,
nummer: r.nummer ?? '',
art: r.art ?? 'rechnung',
datum: r.datum ?? '',
bruttoCent: r.bruttoCent ?? 0,
anfrageNummer: r.inhalt?.anfrageNummer ?? '',
gastName: r.inhalt?.empfaenger?.firma || r.inhalt?.empfaenger?.name || '',
})),
})
}))
const PDF_RE = /^([A-Za-z0-9_-]{1,36})\.pdf$/
router.get('/rechnungen/:datei', route(async (req, res) => {
const ctx = await mitProjekt(req)
const m = PDF_RE.exec(String(req.params.datei))
if (!m) throw nichtGefunden()
const dok = await sp.ladeRechnung(m[1])
if (!dok || dok.featureId !== ctx.featureId) throw nichtGefunden()
let modul
try {
modul = await import('../services/unterkunft/rechnungPdf.js')
} catch (err) {
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err
return res.status(501).json({ fehler: 'nicht_verfuegbar' })
}
const art = dok.art === 'storno' ? 'storno' : 'rechnung'
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`
res.set('Content-Type', 'application/pdf')
res.set('Content-Disposition', `inline; filename="${dateiname}"`)
res.send(pdf)
}))
// ---- Stripe
const PLATTFORM_FEHLT = /platform profile|signed up for Connect/i
/** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (geloggt wird nur Typ und Code). */
async function ueberStripe(fn) {
try {
return await fn()
} catch (err) {
if (err instanceof UkFehler) throw err
if (err?.status >= 400 && err.status < 500 && !err.type) throw err
if (err?.type === 'StripeInvalidRequestError' && PLATTFORM_FEHLT.test(String(err.message || ''))) {
console.error('[unterkunft] Stripe Connect: Plattformprofil fehlt')
throw stop(503, 'plattform_nicht_bereit', { meldung: MELDUNG.plattform })
}
console.error(`[unterkunft] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim())
throw stop(502, 'stripe_nicht_erreichbar', { meldung: MELDUNG.stripeFern })
}
}
const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#buchungen?stripe=${was}&projekt=${encodeURIComponent(projectId)}`
router.post('/stripe/verbinden', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const accountId = await withLocks(['uk-stripe:' + ctx.featureId], async () => {
const einst = await sp.ladeEinstellungen(ctx.featureId)
if (einst?.stripeAccountId) return einst.stripeAccountId
const customer = await getDocument(config.collections.customers, ctx.customerId).catch(() => null)
const neu = await ueberStripe(() =>
kontoAnlegen({
customer: customer || { $id: ctx.customerId, email: req.session?.email },
project: ctx.project,
webklar: 'unterkunft',
idempotencyKey: 'uk-konto-' + ctx.projectId,
})
)
let status
try {
status = await ueberStripe(() => kontoStatus(neu))
} catch {
status = statusAusKonto({})
}
await sp.speichereEinstellungen(ctx.featureId, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) })
return neu
})
const url = await ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(ctx.projectId, 'zurueck'), refreshUrl: stripeRueckUrl(ctx.projectId, 'neu') }))
res.json({ url })
}))
router.post('/stripe/vorhandenes-konto', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const shop = await shopMitKonto(ctx.customerId, ctx.projectId)
if (!shop) throw new UkFehler(409, 'kein_konto')
const einst = await withLocks(['uk-stripe:' + ctx.featureId], async () => {
const vorher = await sp.ladeEinstellungen(ctx.featureId)
if (vorher?.stripeAccountId && vorher.stripeAccountId !== shop.stripeAccountId) throw new UkFehler(409, 'konto_vorhanden')
return sp.speichereEinstellungen(ctx.featureId, {
stripeAccountId: shop.stripeAccountId,
stripeStatus: typeof shop.stripeStatus === 'string' && shop.stripeStatus ? shop.stripeStatus : JSON.stringify(statusAusKonto({})),
})
})
res.json({ stripe: { status: stripeZustand(einst), details: stripeStatusKnapp(einst.stripeStatus) } })
}))
router.post('/stripe/aktualisieren', ...schreib, route(async (req, res) => {
const ctx = await mitProjekt(req, { schreiben: true })
const einst = await sp.ladeEinstellungen(ctx.featureId)
if (!einst?.stripeAccountId) throw new UkFehler(409, 'kein_konto')
const status = await ueberStripe(() => kontoStatus(einst.stripeAccountId))
const neu = await sp.speichereEinstellungen(ctx.featureId, { stripeStatus: JSON.stringify(status) })
res.json({ stripe: { status: stripeZustand(neu), details: stripeStatusKnapp(neu.stripeStatus) } })
}))
export default router