diff --git a/server/routes/unterkunft.js b/server/routes/unterkunft.js index 34bf60e..c94dfb7 100644 --- a/server/routes/unterkunft.js +++ b/server/routes/unterkunft.js @@ -1,13 +1,640 @@ import { Router } from 'express' -import { requireSession } from '../middleware/session.js' +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 { 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=; Vertrag: docs/funktionen/unterkunft-api.md). - * Geruest (Task 5): Task 8 fuellt Status, Einstellungen, Unterkuenfte, Preise, Kalender, Sperren, iCal, Anfragen, Rechnungen - * und Stripe; Task 11 die Anfrage-Funktion (/anfrage-funktion). + * 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-Funktion (/anfrage-funktion) folgt in Task 11. */ const router = Router() -// Jede Route hier braucht eine Sitzung (Task 8 darf das nicht vergessen); Kunden-Pruefung je Route wie in routes/shop.js +// 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 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 limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +export function resetUnterkunftRoutesForTests() { + schreibLimiter.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 (!doc || state === 'off') 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 + for (const u of unterkuenfte) { + const p = await sp.ladePreise(u.$id) + if (p?.steuerart) { + antwort.steuerart = p.steuerart + break + } + } + res.json(antwort) +})) + +// ---- Einstellungen + +function einstellungenAntwort(einst) { + const e = einst ?? {} + const ansicht = Object.fromEntries(vw.EINSTELLUNG_FELDER.map((k) => [k, e[k] ?? ''])) + ansicht.onlineZahlung = e.onlineZahlung === true + ansicht.rechnungsPraefixFrei = vw.zaehlerLeer(e) + ansicht.stripe = { status: stripeZustand(e) } + return { einstellungen: ansicht, vollstaendig: vw.pruefeEinstellungen({}, e).werte !== undefined } +} + +router.get('/einstellungen', route(async (req, res) => { + const ctx = await mitProjekt(req) + res.json(einstellungenAntwort(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], async () => { + const vorher = (await sp.ladeEinstellungen(ctx.featureId)) ?? {} + const r = vw.pruefeEinstellungen(eingabe, vorher) + if (r.fehler) throw ungueltig(r.fehler) + 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.' }) + } + return sp.speichereEinstellungen(ctx.featureId, r.werte) + }) + res.json(einstellungenAntwort(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, + anfrageId: r.anfrageId ?? '', + anfrageNummer: r.inhalt?.anfrageNummer ?? '', + empfaenger: 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 { + 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 diff --git a/server/services/shop/connect.js b/server/services/shop/connect.js index a6c6271..40521e1 100644 --- a/server/services/shop/connect.js +++ b/server/services/shop/connect.js @@ -41,7 +41,7 @@ function liveUrl(project) { } /** Legt das Haendlerkonto an und liefert die Konto-ID. Idempotent je Projekt (Stripe liefert bei gleichem Schluessel dasselbe Konto). */ -export async function kontoAnlegen({ customer, project }) { +export async function kontoAnlegen({ customer, project, webklar = 'shop', idempotencyKey } = {}) { const customerId = String(customer?.$id || customer?.id || '') const projectId = String(project?.$id || project?.id || '') if (!projectId) throw fehler(400, 'Projekt fehlt.') @@ -60,9 +60,9 @@ export async function kontoAnlegen({ customer, project }) { requirement_collection: 'stripe', }, capabilities: { card_payments: { requested: true }, transfers: { requested: true } }, - metadata: { webklar: 'shop', customerId, projectId }, + metadata: { webklar, customerId, projectId }, }, - { idempotencyKey: `shop-konto-${projectId}` } + { idempotencyKey: idempotencyKey || `shop-konto-${projectId}` } ) return account.id } diff --git a/server/services/unterkunft/speicher.js b/server/services/unterkunft/speicher.js index abb59e8..215b33b 100644 --- a/server/services/unterkunft/speicher.js +++ b/server/services/unterkunft/speicher.js @@ -83,6 +83,8 @@ export async function speichereUnterkunft(featureId, felder, id) { return lesen('ukUnterkuenfte', await createDocument(config.collections.ukUnterkuenfte, { ...daten, featureId })) } +export const loescheUnterkunft = (id) => deleteDocument(config.collections.ukUnterkuenfte, id) + // ---- ukPreise (1:1 je Unterkunft) async function preiseDok(unterkunftId) { @@ -99,6 +101,11 @@ export async function speicherePreise(unterkunftId, featureId, felder) { return lesen('ukPreise', await createDocument(config.collections.ukPreise, { ...daten, unterkunftId, featureId })) } +export async function loeschePreise(unterkunftId) { + const vorhanden = await preiseDok(unterkunftId) + if (vorhanden) await deleteDocument(config.collections.ukPreise, vorhanden.$id) +} + // ---- ukSperren export const listeSperren = (unterkunftId) => liste('ukSperren', [Query.equal('unterkunftId', unterkunftId)]) @@ -108,6 +115,7 @@ export async function legeSperreAn(felder) { } export const loescheSperre = (id) => deleteDocument(config.collections.ukSperren, id) +export const ladeSperre = (id) => ladeDok('ukSperren', id) // ---- ukAnfragen diff --git a/server/services/unterkunft/verwaltung.js b/server/services/unterkunft/verwaltung.js new file mode 100644 index 0000000..53af398 --- /dev/null +++ b/server/services/unterkunft/verwaltung.js @@ -0,0 +1,289 @@ +import { randomBytes } from 'node:crypto' +import { config } from '../../config.js' +import { istTag, naechte } from './datum.js' +import { AKTIVE_STATUS, ueberschneidet } from './belegung.js' +import { listeAnfragenNachStatus, listeSperren } from './speicher.js' + +/** + * Buchungskalender, Verwaltung durch den Vermieter: Validierung der Eingaben (reine Funktionen, Rueckgabe { werte } oder + * { fehler: { feld: Text } }) und Aufbereitung von Kalender und Ansichten. Speichern und Rechte liegen in routes/unterkunft.js. + */ + +const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max + 1) : '') +const istText = (v) => typeof v === 'string' +const ganz = (v) => Number.isSafeInteger(v) +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ +const UHRZEIT_RE = /^([01]\d|2[0-3]):[0-5]\d$/ +const FARBE_RE = /^#[0-9a-fA-F]{6}$/ +const PRAEFIX_RE = /^[A-Za-z0-9]{1,8}$/ +const IBAN_RE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/ +const BIC_RE = /^[A-Z0-9]{8}([A-Z0-9]{3})?$/ +const UST_ID_RE = /^[A-Z]{2}[A-Z0-9]{2,18}$/ +const SAETZE = [0, 7, 19] + +export const MAX_UNTERKUENFTE = 20 +export const MAX_ICAL_IMPORTE = 5 +export const MAX_STAFFELN = 6 + +// ---- Einstellungen + +const EINSTELLUNG_TEXTE = [ + ['firma', 150, 'Firma'], + ['inhaber', 150, 'Inhaber'], + ['strasse', 150, 'Straße'], + ['plz', 10, 'PLZ'], + ['ort', 100, 'Ort'], + ['email', 200, 'E-Mail'], + ['telefon', 50, 'Telefon'], + ['steuernummer', 30, 'Steuernummer'], + ['ustId', 20, 'USt-IdNr.'], + ['iban', 34, 'IBAN'], + ['bic', 11, 'BIC'], + ['bank', 100, 'Bank'], + ['rechnungsPraefix', 8, 'Präfix'], + ['akzentfarbe', 7, 'Farbe'], +] + +/** Felder der Einstellungen, die der Vermieter sieht und aendert (nie Stripe-Konto-ID). */ +export const EINSTELLUNG_FELDER = EINSTELLUNG_TEXTE.map(([k]) => k) +const PFLICHT = ['firma', 'strasse', 'plz', 'ort', 'email'] + +/** + * Pruefung der Einstellungen. Nur mitgeschickte Felder werden geaendert; die Vollstaendigkeit (Pflichtangaben, Steuernummer + * oder USt-IdNr.) gilt fuer das Ergebnis aus vorher + eingabe. onlineZahlung nur mit verbundenem Stripe-Konto (vorher.stripeAccountId). + * @returns {{ werte: object } | { fehler: Record }} + */ +export function pruefeEinstellungen(eingabe, vorher = {}) { + const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {} + const fehler = {} + const werte = {} + for (const [key, max, label] of EINSTELLUNG_TEXTE) { + if (e[key] === undefined) continue + if (!istText(e[key])) { + fehler[key] = `${label}: ungültige Eingabe.` + continue + } + let v = text(e[key], key === 'iban' ? 60 : max) + if (key === 'iban' || key === 'bic' || key === 'ustId') v = v.replace(/\s+/g, '').toUpperCase() + if (v.length > max) { + fehler[key] = `${label} darf höchstens ${max} Zeichen lang sein.` + continue + } + if (v === '') { + werte[key] = '' + continue + } + if (key === 'email' && !EMAIL_RE.test(v)) fehler[key] = 'Bitte eine gültige E-Mail-Adresse angeben.' + else if (key === 'akzentfarbe' && !FARBE_RE.test(v)) fehler[key] = 'Bitte eine Farbe wie #8a6a3f angeben.' + else if (key === 'rechnungsPraefix' && !PRAEFIX_RE.test(v)) fehler[key] = 'Das Präfix darf nur aus Buchstaben und Ziffern bestehen (höchstens 8).' + else if (key === 'iban' && !IBAN_RE.test(v)) fehler[key] = 'Bitte eine gültige IBAN angeben.' + else if (key === 'bic' && !BIC_RE.test(v)) fehler[key] = 'Bitte eine gültige BIC angeben.' + else if (key === 'ustId' && !UST_ID_RE.test(v)) fehler[key] = 'Bitte eine gültige USt-IdNr. angeben.' + else werte[key] = v + } + if (e.onlineZahlung !== undefined) { + if (typeof e.onlineZahlung !== 'boolean') fehler.onlineZahlung = 'Ungültige Eingabe.' + else if (e.onlineZahlung && !vorher.stripeAccountId) fehler.onlineZahlung = 'Dafür muss zuerst Stripe verbunden werden.' + else werte.onlineZahlung = e.onlineZahlung + } + const gesamt = { ...vorher, ...werte } + for (const k of PFLICHT) { + if (fehler[k] === undefined && !String(gesamt[k] ?? '').trim()) fehler[k] = 'Pflichtfeld.' + } + if (fehler.steuernummer === undefined && fehler.ustId === undefined && !String(gesamt.steuernummer ?? '').trim() && !String(gesamt.ustId ?? '').trim()) { + fehler.steuernummer = 'Bitte Steuernummer oder USt-IdNr. angeben (Pflicht auf Rechnungen).' + } + return Object.keys(fehler).length ? { fehler } : { werte } +} + +/** Zaehler leer = noch keine Rechnung ausgestellt; erst dann ist das Praefix frei aenderbar. */ +export const zaehlerLeer = (einst) => !einst?.rechnungZaehler || !(Number(einst.rechnungZaehler.n) > 0) + +// ---- Unterkuenfte + +/** + * Pruefung einer Unterkunft. partiell (PUT): nur mitgeschickte Felder pruefen; sonst name und maxPersonen Pflicht und + * Standardwerte fuer den Rest (mindestNaechte 1, Check-in 15:00, Check-out 10:00, sort 0, aktiv). + */ +export function pruefeUnterkunft(eingabe, { partiell = false } = {}) { + const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {} + const fehler = {} + const werte = {} + const da = (k) => e[k] !== undefined + if (da('name') || !partiell) { + const v = text(e.name, 120) + if (!v) fehler.name = 'Bitte einen Namen angeben.' + else if (v.length > 120) fehler.name = 'Der Name darf höchstens 120 Zeichen lang sein.' + else werte.name = v + } + if (da('beschreibung')) { + const v = istText(e.beschreibung) ? e.beschreibung.trim() : null + if (v === null || v.length > 500) fehler.beschreibung = 'Die Beschreibung darf höchstens 500 Zeichen lang sein.' + else werte.beschreibung = v + } else if (!partiell) werte.beschreibung = '' + if (da('maxPersonen') || !partiell) { + if (!ganz(e.maxPersonen) || e.maxPersonen < 1 || e.maxPersonen > 50) fehler.maxPersonen = 'Bitte 1 bis 50 Personen angeben.' + else werte.maxPersonen = e.maxPersonen + } + if (da('mindestNaechte')) { + if (!ganz(e.mindestNaechte) || e.mindestNaechte < 1 || e.mindestNaechte > 365) fehler.mindestNaechte = 'Bitte 1 bis 365 Nächte angeben.' + else werte.mindestNaechte = e.mindestNaechte + } else if (!partiell) werte.mindestNaechte = 1 + for (const [k, std] of [['checkIn', '15:00'], ['checkOut', '10:00']]) { + if (da(k)) { + if (!istText(e[k]) || !UHRZEIT_RE.test(e[k].trim())) fehler[k] = 'Bitte eine Uhrzeit wie 15:00 angeben.' + else werte[k] = e[k].trim() + } else if (!partiell) werte[k] = std + } + if (da('sort')) { + if (!ganz(e.sort) || e.sort < 0 || e.sort > 9999) fehler.sort = 'Ungültige Reihenfolge.' + else werte.sort = e.sort + } + if (da('aktiv')) { + if (typeof e.aktiv !== 'boolean') fehler.aktiv = 'Ungültige Eingabe.' + else werte.aktiv = e.aktiv + } else if (!partiell) werte.aktiv = true + return Object.keys(fehler).length ? { fehler } : { werte } +} + +// ---- Preise + +/** Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6). */ +export function pruefePreise(eingabe) { + const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {} + const fehler = {} + const werte = {} + const st = e.staffeln + if (!Array.isArray(st) || st.length === 0) fehler.staffeln = 'Mindestens eine Preisstufe ist nötig.' + else if (st.length > MAX_STAFFELN) fehler.staffeln = `Höchstens ${MAX_STAFFELN} Preisstufen sind erlaubt.` + else { + const sauber = [] + let letzte = 0 + for (const s of st) { + if (!s || typeof s !== 'object' || !ganz(s.abNaechten) || !ganz(s.preisProNachtCent)) { + fehler.staffeln = 'Ungültige Preisstufe.' + break + } + if (s.preisProNachtCent < 100 || s.preisProNachtCent > 100000) { + fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.' + break + } + if (sauber.length === 0 && s.abNaechten !== 1) { + fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.' + break + } + if (s.abNaechten <= letzte || s.abNaechten > 365) { + fehler.staffeln = 'Die Preisstufen müssen aufsteigend nach Nächten geordnet sein.' + break + } + letzte = s.abNaechten + sauber.push({ abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent }) + } + if (!fehler.staffeln) werte.staffeln = sauber + } + const zahl = (k, min, max, label, std) => { + const v = e[k] === undefined && std !== undefined ? std : e[k] + if (!ganz(v) || v < min || v > max) fehler[k] = `${label}: bitte einen Wert von ${min} bis ${max} angeben.` + else werte[k] = v + } + zahl('reinigungCent', 0, 100000, 'Endreinigung', 0) + zahl('personenInklusive', 1, 50, 'Inklusive Personen') + zahl('aufpreisPersonNachtCent', 0, 100000, 'Aufpreis je Person', 0) + zahl('direktrabattProzent', 0, 50, 'Direktrabatt', 0) + zahl('fristTage', 1, 14, 'Frist') + if (e.steuerart !== 'regel' && e.steuerart !== 'kleinunternehmer') fehler.steuerart = 'Bitte die Steuerart wählen.' + else werte.steuerart = e.steuerart + if (werte.steuerart === 'kleinunternehmer') { + werte.ustUebernachtung = 0 + werte.ustReinigung = 0 + } else { + for (const [k, std] of [['ustUebernachtung', 7], ['ustReinigung', 19]]) { + const v = e[k] === undefined ? std : e[k] + if (!SAETZE.includes(v)) fehler[k] = 'Der Steuersatz muss 0, 7 oder 19 sein.' + else werte[k] = v + } + } + const sb = text(e.stornobedingungen, 5000) + if (sb.length < 20 || sb.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen müssen 20 bis 5000 Zeichen lang sein.' + else werte.stornobedingungen = sb + return Object.keys(fehler).length ? { fehler } : { werte } +} + +// ---- Sperren, iCal + +/** Sperre [von, bis) mit Notiz; bis ist der Abreisetag (exklusiv). */ +export function pruefeSperre(eingabe) { + const e = eingabe && typeof eingabe === 'object' ? eingabe : {} + const fehler = {} + if (!istTag(e.von)) fehler.von = 'Ungültiges Datum.' + if (!istTag(e.bis)) fehler.bis = 'Ungültiges Datum.' + if (!fehler.von && !fehler.bis) { + const n = naechte(e.von, e.bis) + if (n < 1) fehler.bis = 'Das Ende muss nach dem Anfang liegen.' + else if (n > 731) fehler.bis = 'Der Zeitraum ist zu lang.' + } + if (e.notiz !== undefined && !istText(e.notiz)) fehler.notiz = 'Ungültige Eingabe.' + const notiz = istText(e.notiz) ? e.notiz.trim() : '' + if (notiz.length > 200) fehler.notiz = 'Die Notiz darf höchstens 200 Zeichen lang sein.' + return Object.keys(fehler).length ? { fehler } : { werte: { von: e.von, bis: e.bis, notiz } } +} + +/** Nur https, hoechstens 500 Zeichen, ohne Zugangsdaten in der Adresse. DNS-/SSRF-Pruefung folgt beim Abruf (Task 9). */ +export function pruefeIcalUrl(url) { + if (!istText(url) || !url.trim()) return { fehler: { url: 'Bitte eine Adresse angeben.' } } + const u = url.trim() + if (u.length > 500) return { fehler: { url: 'Die Adresse darf höchstens 500 Zeichen lang sein.' } } + let p + try { + p = new URL(u) + } catch { + return { fehler: { url: 'Bitte eine gültige Adresse angeben.' } } + } + if (p.protocol !== 'https:' || !p.hostname) return { fehler: { url: 'Die Adresse muss mit https:// beginnen.' } } + if (p.username || p.password) return { fehler: { url: 'Die Adresse darf keine Zugangsdaten enthalten.' } } + return { werte: { url: u } } +} + +export function pruefeIcalImport(eingabe) { + const e = eingabe && typeof eingabe === 'object' ? eingabe : {} + const u = pruefeIcalUrl(e.url) + const fehler = { ...(u.fehler ?? {}) } + const name = text(e.name, 60) + if (!name) fehler.name = 'Bitte einen Namen angeben (z. B. Booking.com).' + else if (name.length > 60) fehler.name = 'Der Name darf höchstens 60 Zeichen lang sein.' + return Object.keys(fehler).length ? { fehler } : { werte: { name, url: u.werte.url } } +} + +export const neuesExportToken = () => randomBytes(32).toString('base64url') +export const neueImportId = () => randomBytes(6).toString('hex') +export const exportUrl = (token) => (token ? `${config.publicBaseUrl}/unterkunft/ical/${token}.ics` : '') + +// ---- Kalender + +/** + * Belegte Bereiche einer Unterkunft im Fenster [von, bis) mit Quelle, Anfragenummer, Sperr-ID und Notiz. Wie belegung.js + * (gebucht/bezahlt = gebucht, freigegeben mit Frist in der Zukunft = reserviert), aber mit den Angaben fuer die Verwaltung. + */ +export async function kalenderBereiche(unterkunftId, von, bis, { now = Date.now() } = {}) { + const jetzt = now instanceof Date ? now.getTime() : now + const fenster = { von, bis } + const roh = [] + for (const s of await listeSperren(unterkunftId)) { + const quelle = String(s.quelle ?? '') + roh.push({ + von: s.von, + bis: s.bis, + quelle: quelle.startsWith('ical') ? quelle : 'manuell', + sperreId: s.$id, + notiz: s.notiz ?? '', + }) + } + for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'])) { + if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue + roh.push({ von: a.von, bis: a.bis, quelle: a.status === 'freigegeben' ? 'reserviert' : 'gebucht', anfrageNummer: a.nummer }) + } + return roh + .filter((r) => r.von && r.bis && ueberschneidet(r, fenster)) + .map((r) => ({ ...r, von: r.von > von ? r.von : von, bis: r.bis < bis ? r.bis : bis })) + .sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : 0)) +} diff --git a/test/unterkunftVerwaltung.test.js b/test/unterkunftVerwaltung.test.js new file mode 100644 index 0000000..fcce1a5 --- /dev/null +++ b/test/unterkunftVerwaltung.test.js @@ -0,0 +1,554 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 8: Kunden-API fuer Vermieter (routes/unterkunft.js, services/unterkunft/verwaltung.js) +const NAMEN = ['portalFeatures', 'shopConfig', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), + datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const mails = await import('../server/services/unterkunft/mails.js') +const zahlung = await import('../server/services/unterkunft/zahlung.js') +const { UkFehler } = await import('../server/services/unterkunft/fehler.js') +const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { pruefeIcalUrl, pruefePreise, pruefeEinstellungen } = await import('../server/services/unterkunft/verwaltung.js') +const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +const P = 'pu1' +const FEATURE = `f_${P}_unterkunft` +const cookie = customerCookie('c1') +const vorher = { ...config.unterkunft } +const q = `projectId=${P}` +const get = (pfad, opts = { cookie }) => send('GET', `/api/unterkunft${pfad}${pfad.includes('?') ? '&' : '?'}${q}`, opts) +const put = (pfad, body, opts = { cookie }) => send('PUT', `/api/unterkunft${pfad}?${q}`, { ...opts, body }) +const post = (pfad, body = {}, opts = { cookie }) => send('POST', `/api/unterkunft${pfad}?${q}`, { ...opts, body }) +const del = (pfad, opts = { cookie }) => send('DELETE', `/api/unterkunft${pfad}?${q}`, opts) + +const EINST = { + firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Weg 1', plz: '78576', ort: 'Stetten', email: 'mimi@example.com', + telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'DH', akzentfarbe: '#112233', +} +const PREISE = { + staffeln: [{ abNaechten: 1, preisProNachtCent: 10000 }, { abNaechten: 7, preisProNachtCent: 9000 }], + reinigungCent: 5000, personenInklusive: 2, aufpreisPersonNachtCent: 1500, direktrabattProzent: 5, steuerart: 'regel', + ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vor Anreise, danach 50 Prozent.', +} +const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '0151', nachricht: 'Hallo' } +const POSTEN = [{ text: 'Übernachtung, 2 Nächte', menge: 2, einzelCent: 10000, ustProzent: 7 }] + +beforeEach(async () => { + for (const c of [...NAMEN, 'websiteProjects']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + Object.assign(config.unterkunft, vorher, { mode: 'on' }) + resetUnterkunftRoutesForTests() + mails._aufrufe.length = 0 + zahlung._aufrufe.length = 0 + st.calls.length = 0 + aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Donau', liveDomain: 'donau-home.example' }) + aw.seed('websiteProjects', { $id: 'pfremd', customerId: 'c2', projectName: 'Fremd' }) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST }) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: P, name: 'Erste', maxPersonen: 4, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true, icalExportToken: 'tok1' }) + aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, staffeln: JSON.stringify(PREISE.staffeln) }) +}) +afterEach(() => { + Object.assign(config.unterkunft, vorher) +}) + +const anfrage = (id, extra = {}) => + aw.seed('ukAnfragen', { + $id: id, featureId: FEATURE, customerId: 'c1', projectId: P, unterkunftId: 'u1', nummer: `A-${id.toUpperCase()}`.slice(0, 12), von: '2026-11-10', bis: '2026-11-12', personen: 2, + status: 'neu', gast: JSON.stringify(GAST), tokenHash: 'GEHEIMER-HASH', ipHash: 'geheimip', erstattetCent: 0, ...extra, + }) + +// ------------------------------------------------------------------------------------------------ Zugriff + +test('Zugriff: ohne Sitzung 401, fremdes Projekt 403, Funktion aus 409, Kundenansicht und Ansehen-als 403 bei Schreibzugriff', async () => { + assert.equal((await get('/status', {})).status, 401) + assert.equal((await send('GET', '/api/unterkunft/status?projectId=pfremd', { cookie })).status, 403) + assert.equal((await send('GET', '/api/unterkunft/status', { cookie })).status, 400) + assert.equal((await send('PUT', '/api/unterkunft/einstellungen?projectId=pfremd', { cookie, body: EINST })).status, 403) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) + const aus = await get('/unterkuenfte') + assert.equal(aus.status, 409) + assert.equal(aus.body.fehler, 'nicht_aktiv') + assert.equal((await get('/status')).body.state, 'off') + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) + const kansicht = await post('/unterkuenfte', { name: 'X', maxPersonen: 2 }, { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'a'.repeat(32) } }) + assert.equal(kansicht.status, 403) + const als = await post('/unterkuenfte', { name: 'X', maxPersonen: 2 }, { cookie: viewAsCookie() }) + assert.ok([401, 403].includes(als.status)) + assert.equal(aw.docs('ukUnterkuenfte').size, 1) + // Zustand bestellt: lesen ja, schreiben nein + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'ordered', enabled: false }) + assert.equal((await get('/unterkuenfte')).status, 200) + assert.equal((await post('/unterkuenfte', { name: 'X', maxPersonen: 2 })).status, 409) +}) + +test('Zugriff: Schalter off sperrt die Verwaltung', async () => { + config.unterkunft.mode = 'off' + assert.equal((await get('/unterkuenfte')).status, 409) +}) + +test('Zugriff: Schreibgrenze 300 je Stunde (429)', async () => { + let letzter + for (let i = 0; i < 301; i++) letzter = await post('/unterkuenfte/u1/ical-export/erneuern') + assert.equal(letzter.status, 429) +}) + +// ------------------------------------------------------------------------------------------------ Status + +test('Status: Zustand, Stripe, Unterkuenfte, Steuerart, Rechnungen, Shop-Konto', async () => { + const r = await get('/status') + assert.deepEqual(r.body, { state: 'active', steuerart: 'regel', stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 1, rechnungenVorhanden: false }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' }) + aw.seed('portalFeatures', { $id: `f_${P}_shop`, customerId: 'c1', projectId: P, featureKey: 'shop', siteKey: 'shopshopshopshop', state: 'active', enabled: true }) + aw.seed('shopConfig', { $id: `f_${P}_shop`, stripeAccountId: 'acct_shop1', stripeStatus: JSON.stringify({ charges_enabled: true }) }) + const r2 = (await get('/status')).body + assert.equal(r2.rechnungenVorhanden, true) + assert.deepEqual(r2.stripe, { status: 'keins', vorhandenesShopKonto: true }) + assert.ok(!JSON.stringify(r2).includes('acct_')) +}) + +// ------------------------------------------------------------------------------------------------ Einstellungen + +test('Einstellungen: ohne Steuernummer und USt-ID 400 felder.steuernummer', async () => { + const r = await put('/einstellungen', { ...EINST, steuernummer: '', ustId: '' }) + assert.equal(r.status, 400) + assert.equal(r.body.fehler, 'ungueltig') + assert.ok(r.body.felder.steuernummer) + assert.equal(aw.docs('ukEinstellungen').get(FEATURE).steuernummer, EINST.steuernummer) +}) + +test('Einstellungen: speichern, Rueckgabe ohne Stripe-Konto-ID, USt-ID statt Steuernummer genuegt, Validierung je Feld', async () => { + const ok = await put('/einstellungen', { ...EINST, steuernummer: '', ustId: 'de 123456789', iban: 'DE89 3704 0044 0532 0130 00', firma: ' Haus ' }) + assert.equal(ok.status, 200) + assert.equal(ok.body.einstellungen.ustId, 'DE123456789') + assert.equal(ok.body.einstellungen.iban, 'DE89370400440532013000') + assert.equal(ok.body.einstellungen.firma, 'Haus') + assert.equal(ok.body.vollstaendig, true) + assert.equal(ok.body.einstellungen.stripeAccountId, undefined) + const schlecht = await put('/einstellungen', { email: 'kaputt', akzentfarbe: 'rot', iban: 'x', rechnungsPraefix: 'A-B', onlineZahlung: true }) + assert.equal(schlecht.status, 400) + for (const f of ['email', 'akzentfarbe', 'iban', 'rechnungsPraefix', 'onlineZahlung']) assert.ok(schlecht.body.felder[f], f) + const g = await get('/einstellungen') + assert.equal(g.body.einstellungen.firma, 'Haus') + assert.equal(g.body.einstellungen.rechnungsPraefixFrei, true) +}) + +test('Einstellungen: Praefix nach der ersten Rechnung 409, gleicher Wert bleibt erlaubt', async () => { + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungZaehler: JSON.stringify({ jahr: 2026, n: 1 }) }) + const r = await put('/einstellungen', { ...EINST, rechnungsPraefix: 'NEU' }) + assert.equal(r.status, 409) + assert.equal(r.body.fehler, 'praefix_gesperrt') + assert.ok(r.body.felder.rechnungsPraefix) + assert.equal(aw.docs('ukEinstellungen').get(FEATURE).rechnungsPraefix, 'DH') + assert.equal((await put('/einstellungen', { ...EINST, firma: 'Neu GmbH' })).status, 200) + assert.equal((await get('/einstellungen')).body.einstellungen.rechnungsPraefixFrei, false) +}) + +// ------------------------------------------------------------------------------------------------ Unterkuenfte + +test('Unterkunft anlegen, sortieren, deaktivieren; Liste mit Export-URL und Importen', async () => { + const neu = await post('/unterkuenfte', { name: 'Zweite', beschreibung: 'Klein', maxPersonen: 2, mindestNaechte: 2, checkIn: '16:00', checkOut: '11:00' }) + assert.equal(neu.status, 201) + const u = neu.body.unterkunft + assert.equal(u.sort, 2) + assert.equal(u.aktiv, true) + assert.match(u.icalExportUrl, new RegExp(`^${config.publicBaseUrl}/unterkunft/ical/[A-Za-z0-9_-]{43}\\.ics$`)) + assert.deepEqual(u.icalImporte, []) + const sort = await put(`/unterkuenfte/${u.id}`, { sort: 0 }) + assert.equal(sort.body.unterkunft.sort, 0) + assert.equal(sort.body.unterkunft.name, 'Zweite') + const aus = await put(`/unterkuenfte/${u.id}`, { aktiv: false }) + assert.equal(aus.body.unterkunft.aktiv, false) + const liste = (await get('/unterkuenfte')).body.unterkuenfte + assert.deepEqual(liste.map((x) => x.id), [u.id, 'u1']) + assert.equal(liste[1].icalExportUrl, `${config.publicBaseUrl}/unterkunft/ical/tok1.ics`) + assert.equal(aw.docs('ukUnterkuenfte').get(u.id).customerId, 'c1') + assert.equal(aw.docs('ukUnterkuenfte').get(u.id).featureId, FEATURE) +}) + +test('Unterkunft: Validierung (Name, Personen, Uhrzeit), fremde und unsichere IDs 404', async () => { + const r = await post('/unterkuenfte', { name: '', maxPersonen: 0, checkIn: '25:00' }) + assert.equal(r.status, 400) + for (const f of ['name', 'maxPersonen', 'checkIn']) assert.ok(r.body.felder[f], f) + aw.seed('ukUnterkuenfte', { $id: 'ufremd', featureId: 'anderes', name: 'Fremd', aktiv: true }) + assert.equal((await put('/unterkuenfte/ufremd', { name: 'Hack' })).status, 404) + assert.equal(aw.docs('ukUnterkuenfte').get('ufremd').name, 'Fremd') + assert.equal((await put('/unterkuenfte/..%2Fx', { name: 'Hack' })).status, 404) + assert.equal((await get('/unterkuenfte/ufremd/preise')).status, 404) + assert.equal((await del('/unterkuenfte/ufremd')).status, 404) +}) + +test('Unterkunft loeschen: nur ohne Anfragen', async () => { + anfrage('a1') + assert.equal((await del('/unterkuenfte/u1')).status, 409) + await deleteDocument(config.collections.ukAnfragen, 'a1') + assert.equal((await del('/unterkuenfte/u1')).status, 200) + assert.equal(aw.docs('ukUnterkuenfte').size, 0) + assert.equal(aw.docs('ukPreise').size, 0) +}) + +// ------------------------------------------------------------------------------------------------ Preise + +test('Preise: lesen, speichern, null wenn nichts gespeichert', async () => { + aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, name: 'Zweite', maxPersonen: 2, aktiv: true }) + assert.equal((await get('/unterkuenfte/u2/preise')).body.preise, null) + const g = await get('/unterkuenfte/u1/preise') + assert.deepEqual(g.body.preise, PREISE) + const r = await put('/unterkuenfte/u2/preise', { ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 7 }) + assert.equal(r.status, 200) + assert.equal(r.body.preise.steuerart, 'kleinunternehmer') + assert.equal(r.body.preise.ustUebernachtung, 0) + assert.equal(aw.docs('ukPreise').size, 2) +}) + +test('Preise: erste Staffel abNaechten:2, absteigend, mehr als 6, Preisgrenzen, Rabatt, Frist, Stornotext -> 400', async () => { + const fall = (extra) => put('/unterkuenfte/u1/preise', { ...PREISE, ...extra }) + const staffel = (n, p = 10000) => ({ abNaechten: n, preisProNachtCent: p }) + const faelle = [ + [{ staffeln: [staffel(2)] }, 'staffeln'], + [{ staffeln: [staffel(1), staffel(7), staffel(3)] }, 'staffeln'], + [{ staffeln: [1, 2, 3, 4, 5, 6, 7].map((n) => staffel(n)) }, 'staffeln'], + [{ staffeln: [staffel(1, 99)] }, 'staffeln'], + [{ staffeln: [staffel(1, 100001)] }, 'staffeln'], + [{ staffeln: [] }, 'staffeln'], + [{ direktrabattProzent: 51 }, 'direktrabattProzent'], + [{ fristTage: 0 }, 'fristTage'], + [{ fristTage: 15 }, 'fristTage'], + [{ stornobedingungen: 'kurz' }, 'stornobedingungen'], + [{ stornobedingungen: 'x'.repeat(5001) }, 'stornobedingungen'], + [{ steuerart: 'egal' }, 'steuerart'], + [{ ustUebernachtung: 5 }, 'ustUebernachtung'], + ] + for (const [extra, feld] of faelle) { + const r = await fall(extra) + assert.equal(r.status, 400, JSON.stringify(extra).slice(0, 60)) + assert.ok(r.body.felder[feld], feld) + } + assert.deepEqual(aw.docs('ukPreise').get('pr1').staffeln, JSON.stringify(PREISE.staffeln)) + assert.equal((await fall({ staffeln: [1, 2, 3, 4, 5, 6].map((n) => staffel(n)) })).status, 200) + assert.ok(pruefePreise(PREISE).werte) +}) + +test('Preisvorschau: berechnePreis mit gespeicherten Preisen; ohne Preise 409; ungueltige Eingaben 400', async () => { + const r = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 3 }) + assert.equal(r.status, 200) + assert.equal(r.body.naechte, 2) + assert.equal(r.body.posten[0].einzelCent, 10000) + assert.equal(r.body.posten.length, 4) + assert.equal(typeof r.body.summen.bruttoCent, 'number') + assert.equal((await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-03', bis: '2026-12-01', personen: 2 })).status, 400) + assert.equal((await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 0 })).status, 400) + assert.equal((await post('/preisvorschau', { unterkunftId: 'nix', von: '2026-12-01', bis: '2026-12-03', personen: 1 })).status, 404) + aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, name: 'Leer', maxPersonen: 2, aktiv: true }) + const leer = await post('/preisvorschau', { unterkunftId: 'u2', von: '2026-12-01', bis: '2026-12-03', personen: 1 }) + assert.equal(leer.status, 409) + assert.equal(leer.body.fehler, 'preise_unvollstaendig') +}) + +// ------------------------------------------------------------------------------------------------ Kalender, Sperren, iCal + +test('Kalender: Sperren mit notiz/sperreId, Anfragen mit anfrageNummer, iCal-Quelle; zugeschnitten auf das Fenster', async () => { + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-03', bis: '2026-11-06', quelle: 'manuell', notiz: 'Eigennutzung' }) + aw.seed('ukSperren', { $id: 's2', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-20', bis: '2026-11-22', quelle: 'ical:abc123', notiz: '' }) + anfrage('a1', { status: 'gebucht', von: '2026-11-10', bis: '2026-11-12' }) + anfrage('a2', { status: 'bezahlt', von: '2026-11-12', bis: '2026-11-14' }) + anfrage('a3', { status: 'freigegeben', von: '2026-11-15', bis: '2026-11-17', fristAt: new Date(Date.now() + 86400000).toISOString() }) + anfrage('a4', { status: 'freigegeben', von: '2026-11-25', bis: '2026-11-27', fristAt: new Date(Date.now() - 1000).toISOString() }) + anfrage('a5', { status: 'neu', von: '2026-11-28', bis: '2026-11-29' }) + const r = await get('/unterkuenfte/u1/kalender?von=2026-11-01&bis=2026-11-30') + assert.equal(r.status, 200) + const kurz = r.body.bereiche.map((b) => [b.von, b.bis, b.quelle]) + assert.deepEqual(kurz, [ + ['2026-11-03', '2026-11-06', 'manuell'], + ['2026-11-10', '2026-11-12', 'gebucht'], + ['2026-11-12', '2026-11-14', 'gebucht'], + ['2026-11-15', '2026-11-17', 'reserviert'], + ['2026-11-20', '2026-11-22', 'ical:abc123'], + ]) + assert.equal(r.body.bereiche[0].notiz, 'Eigennutzung') + assert.equal(r.body.bereiche[0].sperreId, 's1') + assert.match(r.body.bereiche[1].anfrageNummer, /^A-/) + const klein = await get('/unterkuenfte/u1/kalender?von=2026-11-04&bis=2026-11-05') + assert.deepEqual(klein.body.bereiche.map((b) => [b.von, b.bis]), [['2026-11-04', '2026-11-05']]) + assert.equal((await get('/unterkuenfte/u1/kalender?von=2026-11-05&bis=2026-11-04')).status, 400) + assert.equal((await get('/unterkuenfte/u1/kalender?von=2026-01-01&bis=2026-12-31')).status, 400) +}) + +test('Sperren: anlegen 201 (bis exklusiv), ueber gebuchte Nacht 409 zeitraum_belegt, Grenztag frei, loeschen nur manuell und nur eigene', async () => { + anfrage('a1', { status: 'gebucht', von: '2026-11-10', bis: '2026-11-12' }) + const ok = await post('/unterkuenfte/u1/sperren', { von: '2026-11-05', bis: '2026-11-10', notiz: 'Renovierung' }) + assert.equal(ok.status, 201) + assert.equal(ok.body.sperre.notiz, 'Renovierung') + const belegt = await post('/unterkuenfte/u1/sperren', { von: '2026-11-11', bis: '2026-11-13' }) + assert.equal(belegt.status, 409) + assert.equal(belegt.body.fehler, 'zeitraum_belegt') + assert.equal((await post('/unterkuenfte/u1/sperren', { von: '2026-11-12', bis: '2026-11-13' })).status, 201) + assert.equal((await post('/unterkuenfte/u1/sperren', { von: '2026-11-13', bis: '2026-11-13' })).status, 400) + assert.equal((await post('/unterkuenfte/u1/sperren', { von: 'morgen', bis: '2026-11-13' })).status, 400) + assert.equal((await del(`/sperren/${ok.body.sperre.id}`)).status, 200) + assert.equal(aw.docs('ukSperren').has(ok.body.sperre.id), false) + aw.seed('ukSperren', { $id: 'sfremd', unterkunftId: 'ux', featureId: 'anderes', von: '2026-11-01', bis: '2026-11-02', quelle: 'manuell' }) + assert.equal((await del('/sperren/sfremd')).status, 404) + aw.seed('ukSperren', { $id: 'sical', unterkunftId: 'u1', featureId: FEATURE, von: '2027-01-01', bis: '2027-01-02', quelle: 'ical:x1' }) + assert.equal((await del('/sperren/sical')).status, 409) + assert.equal((await del('/sperren/..%2F..')).status, 404) +}) + +test('iCal-Import: nur https, max 500 Zeichen, Feldfehler felder.url; anlegen 201, Liste, loeschen entfernt dessen Sperren; Export erneuern', async () => { + const schlecht = await post('/unterkuenfte/u1/ical-importe', { name: 'Booking', url: 'http://booking.example/x.ics' }) + assert.equal(schlecht.status, 400) + assert.ok(schlecht.body.felder.url) + assert.ok((await post('/unterkuenfte/u1/ical-importe', { name: 'B', url: `https://x.example/${'a'.repeat(500)}` })).body.felder.url) + assert.ok((await post('/unterkuenfte/u1/ical-importe', { name: 'B', url: 'https://user:pw@x.example/a.ics' })).body.felder.url) + assert.ok((await post('/unterkuenfte/u1/ical-importe', { name: '', url: 'https://x.example/a.ics' })).body.felder.name) + const ok = await post('/unterkuenfte/u1/ical-importe', { name: 'Booking', url: 'https://ical.booking.example/a.ics' }) + assert.equal(ok.status, 201) + const id = ok.body.import.id + const liste = (await get('/unterkuenfte')).body.unterkuenfte[0] + assert.deepEqual(liste.icalImporte, [{ id, name: 'Booking', url: 'https://ical.booking.example/a.ics', letzterAbrufAt: null, letzterErfolgAt: null, fehler: '' }]) + aw.seed('ukSperren', { $id: 'si', unterkunftId: 'u1', featureId: FEATURE, von: '2026-12-01', bis: '2026-12-03', quelle: `ical:${id}` }) + aw.seed('ukSperren', { $id: 'sm', unterkunftId: 'u1', featureId: FEATURE, von: '2026-12-10', bis: '2026-12-13', quelle: 'manuell' }) + assert.equal((await del(`/unterkuenfte/u1/ical-importe/${id}`)).status, 200) + assert.equal(aw.docs('ukSperren').has('si'), false) + assert.equal(aw.docs('ukSperren').has('sm'), true) + assert.equal((await del(`/unterkuenfte/u1/ical-importe/${id}`)).status, 404) + const neu = await post('/unterkuenfte/u1/ical-export/erneuern') + assert.equal(neu.status, 200) + assert.match(neu.body.icalExportUrl, /\/unterkunft\/ical\/[A-Za-z0-9_-]{43}\.ics$/) + assert.notEqual(neu.body.icalExportUrl, `${config.publicBaseUrl}/unterkunft/ical/tok1.ics`) + assert.equal(pruefeIcalUrl('https://x.example/a.ics').werte.url, 'https://x.example/a.ics') + assert.ok(pruefeIcalUrl('ftp://x.example/a').fehler) +}) + +test('iCal-Import: hoechstens 5 je Unterkunft', async () => { + for (let i = 0; i < 5; i++) assert.equal((await post('/unterkuenfte/u1/ical-importe', { name: `K${i}`, url: `https://x.example/${i}.ics` })).status, 201) + const r = await post('/unterkuenfte/u1/ical-importe', { name: 'Zu viel', url: 'https://x.example/6.ics' }) + assert.equal(r.status, 409) +}) + +// ------------------------------------------------------------------------------------------------ Anfragen + +test('Anfragen: Liste mit Filter und ohne Geheimnisse; Detail mit Vorschlag', async () => { + anfrage('a1', { status: 'neu' }) + anfrage('a2', { status: 'gebucht', summen: JSON.stringify({ bruttoCent: 21400, nettoCent: 20000, ust: [] }) }) + aw.seed('ukAnfragen', { $id: 'afremd', featureId: 'anderes', unterkunftId: 'u1', status: 'neu', nummer: 'A-FREMD', gast: JSON.stringify(GAST), von: '2026-11-10', bis: '2026-11-12' }) + const alle = (await get('/anfragen')).body.anfragen + assert.deepEqual(alle.map((a) => a.id).sort(), ['a1', 'a2']) + assert.deepEqual((await get('/anfragen?filter=offen')).body.anfragen.map((a) => a.id), ['a1']) + assert.deepEqual((await get('/anfragen?filter=gebucht')).body.anfragen.map((a) => a.id), ['a2']) + assert.equal((await get('/anfragen?filter=quatsch')).status, 400) + const eintrag = alle.find((a) => a.id === 'a2') + assert.deepEqual(eintrag, { id: 'a2', nummer: 'A-A2', von: '2026-11-10', bis: '2026-11-12', naechte: 2, personen: 2, gast: { name: 'Eva Gast' }, status: 'gebucht', summen: { bruttoCent: 21400 }, unterkunftName: 'Erste' }) + assert.equal(alle.find((a) => a.id === 'a1').summen, null) + const d = await get('/anfragen/a1') + assert.equal(d.status, 200) + assert.equal(d.body.anfrage.unterkunftName, 'Erste') + assert.deepEqual(d.body.anfrage.gast, GAST) + assert.equal(d.body.anfrage.steuerart, 'regel') + assert.equal(d.body.anfrage.rechnungNummer, null) + assert.ok(Array.isArray(d.body.vorschlag.posten) && d.body.vorschlag.posten.length >= 1) + assert.equal(d.body.vorschlag.summen.bruttoCent > 0, true) + for (const antwort of [JSON.stringify(d.body), JSON.stringify(alle)]) { + for (const geheim of ['GEHEIMER-HASH', 'geheimip', 'tokenHash', 'ipHash']) assert.ok(!antwort.includes(geheim), geheim) + } + assert.equal((await get('/anfragen/afremd')).status, 404) + assert.equal((await get('/anfragen/..%2Fx')).status, 404) + assert.equal((await get('/anfragen/nix')).status, 404) +}) + +test('Freigeben: Posten + Frist, Antwort mit buchungsLink (Token passt zum Hash), nie tokenHash; Anfrage freigegeben', async () => { + anfrage('a1') + const r = await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 5, nachricht: 'Bis bald' }) + assert.equal(r.status, 200) + assert.equal(r.body.anfrage.status, 'freigegeben') + assert.equal(r.body.anfrage.nachrichtVermieter, 'Bis bald') + assert.deepEqual(r.body.anfrage.posten, POSTEN) + assert.equal(r.body.anfrage.summen.bruttoCent, 20000) + assert.ok(r.body.anfrage.fristAt) + const m = new RegExp(`^${config.publicBaseUrl}/unterkunft/abcdefghijklmnop/buchung/a1\\?t=([A-Za-z0-9_-]{43})$`).exec(r.body.buchungsLink) + assert.ok(m, r.body.buchungsLink) + const { tokenPasst } = await import('../server/services/unterkunft/token.js') + assert.equal(tokenPasst(aw.docs('ukAnfragen').get('a1').tokenHash, m[1]), true) + const roh = JSON.stringify(r.body) + assert.ok(!roh.includes('tokenHash') && !roh.includes(aw.docs('ukAnfragen').get('a1').tokenHash) && !roh.includes('ipHash')) + assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN })).status, 409) +}) + +test('Freigeben (Review 5): leere Posten, Summe <= 0, Text 201 Zeichen, 31 Posten, ust 7 bei Kleinunternehmer, Frist -> je 400, Anfrage bleibt neu', async () => { + anfrage('a1') + const p = (extra = {}) => [{ ...POSTEN[0], ...extra }] + const bleibtNeu = () => assert.equal(aw.docs('ukAnfragen').get('a1').status, 'neu') + for (const posten of [[], p({ einzelCent: 0 }), p({ einzelCent: -100 }), p({ text: 'x'.repeat(201) }), Array.from({ length: 31 }, () => POSTEN[0]), p({ ustProzent: 5 }), p({ menge: 0 }), 'kaputt']) { + const r = await post('/anfragen/a1/freigeben', { posten }) + assert.equal(r.status, 400, JSON.stringify(posten).slice(0, 50)) + assert.equal(r.body.fehler, 'ungueltig') + assert.ok(r.body.felder.posten) + bleibtNeu() + } + assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 15 })).status, 400) + assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 0 })).status, 400) + assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN, nachricht: 'x'.repeat(2001) })).status, 400) + bleibtNeu() + // Kleinunternehmer: ust 7 abgelehnt + aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 0, ustReinigung: 0, staffeln: JSON.stringify(PREISE.staffeln) }) + const klein = await post('/anfragen/a1/freigeben', { posten: p({ ustProzent: 7 }) }) + assert.equal(klein.status, 400) + assert.ok(klein.body.felder.posten) + bleibtNeu() + // ohne Posten: Vorschlag aus den Preisen + const vorschlag = await post('/anfragen/a1/freigeben', {}) + assert.equal(vorschlag.status, 200) + assert.equal(vorschlag.body.anfrage.status, 'freigegeben') +}) + +test('Freigeben: belegter Zeitraum 409 zeitraum_belegt', async () => { + anfrage('a1') + anfrage('a2', { status: 'gebucht' }) + const r = await post('/anfragen/a1/freigeben', { posten: POSTEN }) + assert.equal(r.status, 409) + assert.equal(r.body.fehler, 'zeitraum_belegt') +}) + +test('Ablehnen, als bezahlt markieren, Stornieren (ohne Erstattung)', async () => { + anfrage('a1') + const ab = await post('/anfragen/a1/ablehnen', { text: 'Leider belegt.' }) + assert.equal(ab.status, 200) + assert.equal(ab.body.anfrage.status, 'abgelehnt') + assert.equal(ab.body.anfrage.nachrichtVermieter, 'Leider belegt.') + assert.equal((await post('/anfragen/a1/ablehnen', { text: 'x'.repeat(2001) })).status, 400) + anfrage('a2', { status: 'gebucht', rechnungId: 'r1', summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 18000, ust: [], steuerart: 'regel' }), posten: JSON.stringify(POSTEN) }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a2', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 20000, inhalt: JSON.stringify({ anfrageNummer: 'A-A2', empfaenger: { name: 'Eva' }, summen: { bruttoCent: 20000, nettoCent: 18000, ust: [] }, posten: POSTEN }) }) + const bez = await post('/anfragen/a2/bezahlt') + assert.equal(bez.status, 200) + assert.equal(bez.body.anfrage.status, 'bezahlt') + assert.equal(bez.body.anfrage.bezahltVia, 'vorort') + assert.equal(bez.body.anfrage.rechnungNummer, 'DH-2026-0001') + const sto = await post('/anfragen/a2/stornieren', { grund: 'Gast krank', erstattungCent: 0 }) + assert.equal(sto.status, 200) + assert.equal(sto.body.anfrage.status, 'storniert') + assert.ok(sto.body.anfrage.stornoRechnungId) + assert.equal(sto.body.stornoRechnungId, sto.body.anfrage.stornoRechnungId) + assert.equal((await post('/anfragen/a2/stornieren', { erstattungCent: -5 })).status, 400) + assert.equal((await post('/anfragen/afremd/stornieren')).status, 404) +}) + +test('Stornieren: Erstattung fehlgeschlagen -> 502 erstattung_fehlgeschlagen mit Hinweis, Storno gilt', async () => { + anfrage('a3', { + status: 'bezahlt', bezahltVia: 'stripe', stripePaymentIntentId: 'pi_x1', rechnungId: 'r3', + summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 18000, ust: [], steuerart: 'regel' }), posten: JSON.stringify(POSTEN), + }) + aw.seed('ukRechnungen', { $id: 'r3', featureId: FEATURE, anfrageId: 'a3', art: 'rechnung', nummer: 'DH-2026-0002', datum: '2026-11-01', bruttoCent: 20000, inhalt: JSON.stringify({ anfrageNummer: 'A-A3' }) }) + zahlung.setzeErstattungFuerTests(async () => { + throw new UkFehler(502, 'zahlung_fehlgeschlagen') + }) + try { + const r = await post('/anfragen/a3/stornieren', { grund: 'x', erstattungCent: 5000 }) + assert.equal(r.status, 502) + assert.equal(r.body.fehler, 'erstattung_fehlgeschlagen') + assert.match(r.body.meldung, /Stornierung wurde durchgeführt/) + assert.equal(aw.docs('ukAnfragen').get('a3').status, 'storniert') + } finally { + zahlung.setzeErstattungFuerTests(null) + } +}) + +// ------------------------------------------------------------------------------------------------ Rechnungen + +test('Rechnungen: Liste nur des Projekts; PDF fremde Rechnung 404, eigene 200 oder 501, unsichere ID 404', async () => { + aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' } }) }) + aw.seed('ukRechnungen', { $id: 'rfremd', featureId: 'anderes', anfrageId: 'a9', art: 'rechnung', nummer: 'XX-1', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' }) + const l = (await get('/rechnungen')).body.rechnungen + assert.deepEqual(l.map((r) => r.id), ['r1']) + assert.equal(l[0].anfrageNummer, 'A-X') + assert.equal(l[0].empfaenger, 'Eva') + assert.equal((await get('/rechnungen/rfremd.pdf')).status, 404) + assert.equal((await get('/rechnungen/nix.pdf')).status, 404) + assert.equal((await get('/rechnungen/r1')).status, 404) + assert.equal((await get('/rechnungen/..%2F..%2Fx.pdf')).status, 404) + const eigene = await get('/rechnungen/r1.pdf') + assert.ok([200, 501].includes(eigene.status), String(eigene.status)) + if (eigene.status === 501) assert.equal(eigene.body.fehler, 'nicht_verfuegbar') +}) + +// ------------------------------------------------------------------------------------------------ Stripe + +test('Stripe verbinden: legt Konto an (Metadaten unterkunft, Key uk-konto-), speichert, Onboarding-Link; zweimal = ein Konto', async () => { + const r = await post('/stripe/verbinden') + assert.equal(r.status, 200) + assert.match(r.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/) + const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts') + assert.equal(anlage.body['metadata[webklar]'], 'unterkunft') + assert.equal(anlage.body['metadata[projectId]'], P) + assert.equal(anlage.idempotencyKey, `uk-konto-${P}`) + const einst = aw.docs('ukEinstellungen').get(FEATURE) + assert.match(einst.stripeAccountId, /^acct_/) + assert.equal(JSON.parse(einst.stripeStatus).charges_enabled, false) + const link = st.calls.find((c) => c.path === '/v1/account_links') + assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#buchungen?stripe=zurueck&projekt=${P}`) + const [a, b] = await Promise.all([post('/stripe/verbinden'), post('/stripe/verbinden')]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1) + const s = (await get('/status')).body + assert.equal(s.stripe.status, 'onboarding') + assert.ok(!JSON.stringify(s).includes(einst.stripeAccountId)) + const eg = await get('/einstellungen') + assert.ok(!JSON.stringify(eg.body).includes(einst.stripeAccountId)) +}) + +test('Stripe aktualisieren: ohne Konto 409; mit Konto neuer Status, aktiv bei charges_enabled', async () => { + assert.equal((await post('/stripe/aktualisieren')).body.fehler, 'kein_konto') + await post('/stripe/verbinden') + const id = aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId + st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true }) + const r = await post('/stripe/aktualisieren') + assert.equal(r.status, 200) + assert.equal(r.body.stripe.status, 'aktiv') + assert.equal(JSON.parse(aw.docs('ukEinstellungen').get(FEATURE).stripeStatus).charges_enabled, true) + assert.equal((await get('/status')).body.stripe.status, 'aktiv') + assert.equal((await put('/einstellungen', { onlineZahlung: true })).status, 200) + assert.equal((await get('/status')).body.onlineZahlung, true) +}) + +test('Stripe vorhandenes-konto: uebernimmt das Shop-Konto desselben Projekts, ohne eins 409, fremdes Konto bleibt unberuehrt', async () => { + const ohne = await post('/stripe/vorhandenes-konto') + assert.equal(ohne.status, 409) + assert.equal(ohne.body.fehler, 'kein_konto') + aw.seed('portalFeatures', { $id: `f_${P}_shop`, customerId: 'c1', projectId: P, featureKey: 'shop', siteKey: 'shopshopshopshop', state: 'active', enabled: true }) + aw.seed('shopConfig', { $id: `f_${P}_shop`, stripeAccountId: 'acct_shop1', stripeStatus: JSON.stringify({ charges_enabled: true }) }) + const r = await post('/stripe/vorhandenes-konto') + assert.equal(r.status, 200) + assert.equal(r.body.stripe.status, 'aktiv') + assert.equal(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, 'acct_shop1') + assert.ok(!JSON.stringify(r.body).includes('acct_shop1')) + assert.equal(st.calls.filter((c) => c.path === '/v1/accounts').length, 0) + // ein anderes Projekt desselben Kunden mit Shop-Konto wird nicht beruehrt + aw.seed('shopConfig', { $id: 'f_pzwei_shop', stripeAccountId: 'acct_andere' }) + assert.equal(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, 'acct_shop1') + assert.equal((await send('POST', '/api/unterkunft/stripe/vorhandenes-konto?projectId=pfremd', { cookie, body: {} })).status, 403) +}) + +// ------------------------------------------------------------------------------------------------ Reine Pruefungen + +test('pruefeEinstellungen: nur gesendete Felder, Vollstaendigkeit aus vorher + eingabe', () => { + assert.ok(pruefeEinstellungen({ firma: 'A' }).fehler.strasse) + const r = pruefeEinstellungen({ telefon: '123' }, { ...EINST }) + assert.deepEqual(r.werte, { telefon: '123' }) + assert.ok(pruefeEinstellungen({ steuernummer: '' }, { ...EINST }).fehler.steuernummer) +})