695 lines
32 KiB
JavaScript
695 lines
32 KiB
JavaScript
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 } 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)
|
||
})
|
||
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
|