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