diff --git a/server/config.js b/server/config.js index e5b7c62..930bec3 100644 --- a/server/config.js +++ b/server/config.js @@ -1098,6 +1098,8 @@ export const config = { unverbindlichMonate: 6, // Offene (neue) Anfragen verfallen nach so vielen Tagen neuVerfallTage: 60, + // Hoechstzahl Schreib-/Loeschvorgaenge der Aufbewahrung je Tageslauf + aufbewahrungDeckel: 500, // Anfragen werden so viele Jahre nach Abschluss anonymisiert; Rechnungen bleiben rechnungJahre Jahre anonymisierenJahre: 3, rechnungJahre: 8, diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index 0fff1a4..203b357 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -1,17 +1,498 @@ +import { randomInt } from 'node:crypto' import express, { Router } from 'express' +import { config } from '../config.js' +import { analyseDomains, analyseHosts } from '../services/analyse.js' +import { bucheVerbindlich, legeAnfrageAn } from '../services/unterkunft/ablauf.js' +import { belegteBereiche } from '../services/unterkunft/belegung.js' +import { istTag, naechte } from '../services/unterkunft/datum.js' +import { UkFehler } from '../services/unterkunft/fehler.js' +import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js' +import { ID_RE, findeRechnung, ladeAnfrage, ladeRechnung, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' +import { checkoutFuer, schliesseAbWennBezahlt } from '../services/unterkunft/zahlung.js' +import { formToken, ipHashVon, pruefeFormToken, tokenPasst } from '../services/unterkunft/token.js' +import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js' +import { heute } from '../services/termine/zeit.js' /** * Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md). * Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js). - * Geruest (Task 5): Task 6 fuellt Konfiguration, Belegung und Anfrage, Task 7 die Buchungsseite. + * Konfiguration, Belegung, Anfrage (Task 6); Buchungsseite GET|POST /:siteKey/buchung/:id, POST …/zahlung, GET …/rechnung.pdf (Task 7). + * + * - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals + * (config.publicBaseUrl) oder gleicher Herkunft; sonst 403. + * - Grenzen: je IP 120 Abrufe je Minute, je Seite 600 je Minute; Anfragen je IP 10 je 10 Minuten und je Seite + * config.unterkunft.anfragenJeSiteStunde je Stunde. + * - Fehlerform: { fehler: code, felder? }. */ const router = Router() -router.use(express.json({ limit: '16kb' })) -// Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage -// Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf +const BODY_MAX = 16 * 1024 +const STUNDE_MS = 60 * 60 * 1000 +const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789' +const STANDARD_AKZENT = '#8a6a3f' +const AKZENT_RE = /^#[0-9a-fA-F]{6}$/ -/** Solange nichts gefuellt ist: jede Seite ist (noch) nicht aktiv, ohne zu verraten, ob der Schluessel existiert. */ -router.get('/:siteKey', (_req, res) => res.json({ aktiv: false })) +let uhr = () => new Date() +export function setzeUhrFuerTests(neueUhr) { + uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() +} + +// ---------------------------------------------------------------- Antworten und Fehler + +const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(fehler), { status, fehlerCode: fehler, ...extra }) +const zuViele = (retryAfter) => fehlerMit(429, 'zu_viele_anfragen', { retryAfter }) +const nichtErlaubt = () => fehlerMit(403, 'nicht_erlaubt') +const nichtGefunden = () => fehlerMit(404, 'nicht_gefunden') +const ungueltig = (felder) => fehlerMit(400, 'ungueltig', felder ? { felder } : {}) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +function fehlerErgebnis(err) { + if (err instanceof UkFehler && err.status >= 400 && err.status < 500) { + const felder = Object.fromEntries(Object.entries(err.felder ?? {}).filter(([, v]) => typeof v === 'string' || typeof v === 'number')) + return { status: err.status, body: { fehler: err.code, ...(Object.keys(felder).length ? { felder } : {}) } } + } + if (typeof err?.fehlerCode === 'string' && err.status >= 400 && err.status < 500) { + const body = { fehler: err.fehlerCode } + if (err.felder) body.felder = err.felder + return { status: err.status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} } + } + console.error(`[publicUnterkunft] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + return { status: 503, body: { fehler: 'nicht_erreichbar' } } +} + +/** Schreibt { status, body, cache, headers }; GET/HEAD tragen immer `*`, POST nur mit geprueftem cors.erlaubt (Meldungen ohne Daten auch `*`). */ +function schreibe(req, res, ergebnis, cors = { erlaubt: null }) { + const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis + const lesen = req.method === 'GET' || req.method === 'HEAD' + if (lesen) res.set('Access-Control-Allow-Origin', '*') + else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' }) + else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*') + res.status(status) + res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers }) + if (status === 204) return res.end() + const inhalt = Buffer.isBuffer(body) || typeof body === 'string' ? body : JSON.stringify(body) + res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) }) + return res.end(inhalt) +} + +function notantwort(res, err) { + try { + console.error(`[publicUnterkunft] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) + if (res.headersSent) return void res.end() + const inhalt = JSON.stringify({ fehler: 'nicht_erreichbar' }) + res.statusCode = 503 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Length', String(inhalt.length)) + return void res.end(inhalt) + } catch { + try { + res.destroy() + } catch { + // nichts mehr zu tun + } + } +} + +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const minAntwortMs = () => (Number.isFinite(config.unterkunft?.minAntwortMs) && config.unterkunft.minAntwortMs >= 0 ? config.unterkunft.minAntwortMs : 300) + +/** + * Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder wirft; + * cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs. + */ +const route = (fn, { mindestdauer = false } = {}) => async (req, res) => { + try { + const cors = { erlaubt: null } + let ergebnis + try { + ergebnis = await fn(req, uhr(), cors) + } catch (err) { + ergebnis = fehlerErgebnis(err) + } + if (mindestdauer) { + const rest = minAntwortMs() - (performance.now() - req.ukStart) + if (rest > 0) await warte(rest) + } + schreibe(req, res, ergebnis, cors) + } catch (err) { + notantwort(res, err) + } +} + +// ---------------------------------------------------------------- Beginn: Zeit, Rumpf + +router.use((req, res, next) => { + req.ukStart = performance.now() + if (Number(req.get('content-length')) > BODY_MAX) { + res.set('Connection', 'close') + return schreibe(req, res, { status: 413, body: { fehler: 'zu_gross' } }) + } + return next() +}) +router.use(express.json({ limit: BODY_MAX })) + +// ---------------------------------------------------------------- Herkunft + +/** + * Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project), Host von config.publicBaseUrl + * oder gleiche Herkunft wie die Anfrage. Sonst null. + */ +function erlaubteHerkunft(req, project) { + const origin = req.get('origin') + if (typeof origin !== 'string' || origin === '') return null + let url + try { + url = new URL(origin) + } catch { + return null + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null + const erlaubt = new Set([String(req.get('host') || '').toLowerCase()]) + try { + erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase()) + } catch { + // Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft + } + if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain) + return erlaubt.has(url.host) ? origin : null +} + +function pruefeHerkunft(req, cors, project) { + const origin = erlaubteHerkunft(req, project) + if (!origin) throw nichtErlaubt() + cors.erlaubt = origin +} + +const vorabantwort = (origin) => ({ + status: 204, + body: '', + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin', + }, +}) + +// Preflight: ohne erreichbare Seite gibt es nichts zu pruefen und nichts zu verraten. +router.options( + '/:siteKey/:was', + route(async (req, now, cors) => { + if (req.params.was !== 'anfrage') throw nichtGefunden() + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return vorabantwort('*') + pruefeHerkunft(req, cors, ctx.project) + return vorabantwort(cors.erlaubt) + }), +) + +router.options( + ['/:siteKey/buchung/:id', '/:siteKey/buchung/:id/zahlung'], + route(async (req, now, cors) => { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return vorabantwort('*') + pruefeHerkunft(req, cors, ctx.project) + return vorabantwort(cors.erlaubt) + }), +) + +// ---------------------------------------------------------------- Konfiguration + +const akzentfarbe = (e) => (AKZENT_RE.test(e?.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT) + +const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' }) + +function datenschutzUrl(project) { + if (!project) return null + const hosts = analyseHosts(project) // [Vorschau-Subdomain, Live-Domain]: die Live-Domain, wenn es eine gibt + const host = hosts[hosts.length - 1] + return host ? `https://${host}/datenschutz` : null +} + +router.get( + '/:siteKey', + route(async (req, now) => { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return aktivFalse() + if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) + const e = ctx.einstellungen ?? {} + const unterkuenfte = await listeUnterkuenfte(ctx.featureId, { nurAktive: true }) + return { + body: { + aktiv: true, + anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) }, + akzentfarbe: akzentfarbe(e), + formToken: formToken(req.params.siteKey, now), + unterkuenfte: unterkuenfte.map((u) => ({ + id: u.$id, + name: u.name, + maxPersonen: u.maxPersonen, + mindestNaechte: u.mindestNaechte, + checkIn: u.checkIn, + checkOut: u.checkOut, + })), + }, + } + }), +) + +// ---------------------------------------------------------------- Belegung + +function monateDazu(tag, n) { + const [j, m, t] = tag.split('-').map(Number) + const d = new Date(Date.UTC(j, m - 1 + n, 1)) + const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate() + d.setUTCDate(Math.min(t, letzter)) + return d.toISOString().slice(0, 10) +} + +/** Fasst sich beruehrende oder ueberlappende Bereiche (nach von sortiert) zusammen; die Quelle wird nicht ausgegeben. */ +function verschmelze(bereiche) { + const aus = [] + for (const b of bereiche) { + const letzter = aus[aus.length - 1] + if (letzter && b.von <= letzter.bis) { + if (b.bis > letzter.bis) letzter.bis = b.bis + } else aus.push({ von: b.von, bis: b.bis }) + } + return aus +} + +router.get( + '/:siteKey/belegung', + route(async (req, now) => { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) return aktivFalse() + if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) + if (typeof req.query.unterkunft === 'string' && !ID_RE.test(req.query.unterkunft)) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) + const { unterkunft, von, bis } = req.query + if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis < von) { + throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' }) + } + const tag = heute(now) + const bisMax = monateDazu(tag, config.unterkunft.horizontMonate) + const bisBis = bis > bisMax ? bisMax : bis // Fenster des Widgets (UTC-Datum des Browsers) darf nie an einem Tag Abweichung scheitern + const u = await ladeUnterkunft(unterkunft) + if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden() + const ab = von < tag ? tag : von + const belegt = ab < bisBis ? verschmelze(await belegteBereiche(u.$id, ab, bisBis, { now })) : [] + return { body: { belegt, heute: tag, bisMax } } + }), +) + +// ---------------------------------------------------------------- Anfrage + +function scheinNummer() { + let n = 'A-' + for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)] + return n +} + +const text = (wert) => (typeof wert === 'string' ? wert : '') + +router.post( + '/:siteKey/anfrage', + route( + async (req, now, cors) => { + const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(siteKey, { now }) + if (!ctx) return { body: { aktiv: false } } + pruefeHerkunft(req, cors, ctx.project) + if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600) + const b = req.body + if (!istObjekt(b)) throw ungueltig() + // Honigtopf gefuellt oder zu schnell abgeschickt: dieselbe Erfolgsantwort, gespeichert und gesendet wird nichts. + if (b.kontakt_url !== undefined && b.kontakt_url !== null && b.kontakt_url !== '') return { status: 201, body: { nummer: scheinNummer() } } + const pruefung = pruefeFormToken(siteKey, b.formToken, now) + if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } } + if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden') + if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600) + if (!ID_RE.test(text(b.unterkunft))) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) + const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen + const anfrage = await legeAnfrageAn( + ctx, + { + unterkunftId: text(b.unterkunft), + von: b.von, + bis: b.bis, + personen, + gast: { name: text(b.name), firma: text(b.firma), email: text(b.email), telefon: text(b.telefon), nachricht: text(b.nachricht) }, + }, + { now, ipHash: ipHashVon(req.ip) }, + ) + return { status: 201, body: { nummer: anfrage.nummer } } + }, + { mindestdauer: true }, + ), +) + +// Rumpf nicht lesbar (kaputtes JSON): 400 in der Fehlerform der Schnittstelle +router.use((err, req, res, next) => { + if (err?.type === 'entity.parse.failed' || err?.type === 'entity.too.large') { + return schreibe(req, res, { status: err.type === 'entity.too.large' ? 413 : 400, body: { fehler: err.type === 'entity.too.large' ? 'zu_gross' : 'ungueltig' } }) + } + return next(err) +}) + +// ---------------------------------------------------------------- Buchungsseite (Task 7) + +const BUCHUNG = { mindestdauer: true } +const ART_RE = /^(rechnung|storno)$/ + +/** Die Anfrage zu Pfad-ID und Token: fuer unbekannte ID, fremde Funktion und falsches Token dieselbe 404-Antwort. */ +async function ladeMitToken(ctx, id, token) { + if (!ID_RE.test(id) || typeof token !== 'string' || token === '' || token.length > 200) throw nichtGefunden() + const a = await ladeAnfrage(id) + if (!a || a.featureId !== ctx.featureId || !tokenPasst(a.tokenHash, token)) throw nichtGefunden() + return a +} + +/** Kontext der Buchungsseite: Lesen je IP begrenzt; Schreiben nur von erlaubter Herkunft und je IP als Buchungsversuch gezaehlt. */ +async function buchungsKontext(req, now, cors, { schreiben }) { + if (!darfAnfrage(req.ip, now)) throw zuViele(60) + const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) + if (!ctx) throw nichtGefunden() + if (schreiben) { + pruefeHerkunft(req, cors, ctx.project) + if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600) + } else if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) + return ctx +} + +const onlineMoeglich = (ctx) => Boolean(ctx.einstellungen?.onlineZahlung && ctx.einstellungen?.stripeAccountId) +const wort = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '') + +async function buchungAntwort(ctx, a) { + const [u, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null]) + const e = ctx.einstellungen ?? {} + const adr = a.rechnungsadresse + return { + status: a.status, + nummer: a.nummer, + unterkunft: { name: u?.name ?? '', checkIn: u?.checkIn ?? '', checkOut: u?.checkOut ?? '' }, + von: a.von, + bis: a.bis, + naechte: naechte(a.von, a.bis), + personen: a.personen, + posten: Array.isArray(a.posten) ? a.posten : [], + summen: a.summen ?? null, + steuerart: a.summen?.steuerart ?? '', + fristAt: a.fristAt ?? '', + stornobedingungen: wort(a.stornobedingungenText, 5000), + anbieter: { + firma: wort(e.firma), + inhaber: wort(e.inhaber), + strasse: wort(e.strasse), + plz: wort(e.plz), + ort: wort(e.ort), + email: wort(e.email), + telefon: wort(e.telefon), + datenschutzUrl: datenschutzUrl(ctx.project), + }, + akzentfarbe: akzentfarbe(e), + gast: { name: wort(a.gast?.name), firma: wort(a.gast?.firma) }, + ...(adr && typeof adr === 'object' + ? { rechnungsadresse: { name: wort(adr.name), firma: wort(adr.firma), strasse: wort(adr.strasse), plz: wort(adr.plz), ort: wort(adr.ort), land: wort(adr.land), ustId: wort(adr.ustId, 20) } } + : {}), + zahlarten: onlineMoeglich(ctx) ? ['vorort', 'online'] : ['vorort'], + ...(a.zahlart ? { zahlart: a.zahlart } : {}), + bezahlt: a.status === 'bezahlt', + ...(rechnung?.nummer ? { rechnungNummer: rechnung.nummer } : {}), + } +} + +router.get( + '/:siteKey/buchung/:id', + route( + async (req, now, cors) => { + const ctx = await buchungsKontext(req, now, cors, { schreiben: false }) + let a = await ladeMitToken(ctx, req.params.id, req.query.t) + // Rueckkehr von Stripe vor dem Webhook (oder Webhook verloren): einmal bei Stripe nachsehen + a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: req.query.wk_zahlung === 'ok' }) + return { body: await buchungAntwort(ctx, a) } + }, + BUCHUNG, + ), +) + +router.post( + '/:siteKey/buchung/:id', + route( + async (req, now, cors) => { + const ctx = await buchungsKontext(req, now, cors, { schreiben: true }) + const a = await ladeMitToken(ctx, req.params.id, req.query.t) + const b = req.body + if (!istObjekt(b)) throw ungueltig() + const { anfrage, rechnung } = await bucheVerbindlich(ctx, a.$id, req.query.t, { + rechnungsadresse: istObjekt(b.rechnungsadresse) ? b.rechnungsadresse : {}, + zahlart: text(b.zahlart), + now, + }) + const body = { status: anfrage.status, rechnungNummer: rechnung?.nummer ?? '' } + if (anfrage.status === 'gebucht' && anfrage.zahlart === 'online') { + try { + body.zahlungUrl = (await checkoutFuer(ctx, anfrage, { now, token: req.query.t })).url + } catch (err) { + // Gebucht ist gebucht: die Seite holt die Zahlung ueber POST .../zahlung nach + console.warn(`[publicUnterkunft] Zahlungssitzung nicht angelegt: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`) + } + } + return { status: 200, body } + }, + BUCHUNG, + ), +) + +router.post( + '/:siteKey/buchung/:id/zahlung', + route( + async (req, now, cors) => { + const ctx = await buchungsKontext(req, now, cors, { schreiben: true }) + let a = await ladeMitToken(ctx, req.params.id, req.query.t) + a = await schliesseAbWennBezahlt(ctx, a, { now, erzwingen: true }) + if (a.status !== 'gebucht' || a.zahlart !== 'online') throw fehlerMit(409, 'status_ungueltig') + try { + return { body: await checkoutFuer(ctx, a, { now, token: req.query.t }) } + } catch (err) { + if (err instanceof UkFehler) throw err + throw new UkFehler(502, 'zahlung_fehlgeschlagen') + } + }, + BUCHUNG, + ), +) + +router.get( + '/:siteKey/buchung/:id/rechnung.pdf', + route( + async (req, now, cors) => { + const ctx = await buchungsKontext(req, now, cors, { schreiben: false }) + const a = await ladeMitToken(ctx, req.params.id, req.query.t) + const art = typeof req.query.art === 'string' ? req.query.art : 'rechnung' + if (!ART_RE.test(art)) throw ungueltig({ art: 'Ungültig.' }) + const dok = await findeRechnung(a.$id, art) + if (!dok) throw nichtGefunden() + let modul + try { + modul = await import('../services/unterkunft/rechnungPdf.js') + } catch (err) { + if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err + return { status: 501, body: { fehler: 'nicht_verfuegbar' } } + } + const pdf = modul.rechnungPdf(dok.inhalt, { art, nummer: dok.nummer, datum: dok.datum }) + const dateiname = `${art === 'storno' ? 'Stornorechnung' : 'Rechnung'}-${String(dok.nummer).replace(/[^A-Za-z0-9_-]/g, '')}.pdf` + return { body: pdf, typ: 'application/pdf', headers: { 'Content-Disposition': `inline; filename="${dateiname}"` } } + }, + BUCHUNG, + ), +) export default router diff --git a/server/routes/team/unterkunft.js b/server/routes/team/unterkunft.js index 37dadfe..3fe9bd6 100644 --- a/server/routes/team/unterkunft.js +++ b/server/routes/team/unterkunft.js @@ -1,10 +1,88 @@ import { Router } from 'express' +import { config } from '../../config.js' +import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js' +import { anfrageAnsichtTeam, entscheideAnfrage } from '../../services/unterkunft/anfrageFunktion.js' +import { ladeEinstellungen, listeUnterkuenfte } from '../../services/unterkunft/speicher.js' +import { listCustomerFeatureDocs } from '../../services/siteFeatures.js' /** - * Buchungskalender im Ticketsystem: GET /api/team/unterkunft/:customerId (Stand je Projekt) und - * POST /api/team/unterkunft/:customerId/anfrage (Entscheidung ueber die Anfrage der Funktion). - * Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt. Geruest (Task 5): Task 11 fuellt es. + * Buchungskalender im Ticketsystem (Kunden-Reiter "Funktionen"): GET /api/team/unterkunft/:customerId (Stand je Projekt) und + * POST /api/team/unterkunft/:customerId/anfrage (Entscheidung ueber die Anfrage der Funktion). Huelle wie routes/team/shop.js. + * Eingehaengt in routes/team/index.js unter /unterkunft, dort hinter requireStaffJwt. */ const router = Router() +const sendError = (res, err) => { + const status = err.status >= 400 && err.status < 600 ? err.status : 500 + if (status >= 500) console.error('[team-unterkunft]', err.message) + return res.status(status).json({ error: status >= 500 ? 'Interner Serverfehler' : err.message, ...(err.body?.code ? { code: err.body.code } : {}) }) +} +const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err)) +const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' }) + +/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */ +async function zaehle(collection, queries, was) { + try { + return await countDocuments(config.collections[collection], queries) + } catch (err) { + console.warn(`[team-unterkunft] ${was} nicht lesbar: ${err.message}`) + return null + } +} + +/** stripeStatus (JSON-Objekt in ukEinstellungen, schon geparst) oder null. */ +const stripeStatus = (roh) => (roh !== null && typeof roh === 'object' && !Array.isArray(roh) ? roh : null) + +async function projektStand(project, feature) { + let einst = null + let unterkuenfte = 0 + if (feature) { + try { + einst = await ladeEinstellungen(feature.$id) + } catch (err) { + console.warn(`[team-unterkunft] Einstellungen ${feature.$id} nicht lesbar: ${err.message}`) + } + try { + unterkuenfte = (await listeUnterkuenfte(feature.$id)).length + } catch (err) { + console.warn(`[team-unterkunft] Unterkuenfte ${feature.$id} nicht lesbar: ${err.message}`) + unterkuenfte = null + } + } + const offeneAnfragen = feature ? await zaehle('ukAnfragen', [Query.equal('featureId', feature.$id), Query.equal('status', 'neu')], 'Anfragenzahl') : 0 + return { + projectId: project.$id, + name: project.projectName || '', + state: feature?.state || 'off', + anfrage: einst?.anfrageStatus ? anfrageAnsichtTeam(einst) : null, + stripe: { accountId: Boolean(einst?.stripeAccountId), status: stripeStatus(einst?.stripeStatus) }, + unterkuenfte, + offeneAnfragen, + } +} + +// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Dokument der Funktion existiert +router.get('/:customerId', route(async (req, res) => { + const customerId = req.params.customerId + const [docs, projects] = await Promise.all([ + listCustomerFeatureDocs(customerId), + listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]), + ]) + const ukDocs = new Map(docs.filter((d) => d.featureKey === 'unterkunft').map((d) => [d.projectId, d])) + const sichtbar = projects.filter((p) => !p.archived || ukDocs.has(p.$id)) + res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, ukDocs.get(p.$id)))) }) +})) + +router.post('/:customerId/anfrage', route(async (req, res) => { + const { projectId, entscheidung, begruendung } = req.body || {} + const ergebnis = await entscheideAnfrage({ + customerId: req.params.customerId, + projectId, + entscheidung, + begruendung, + person: person(req), + }) + res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) }) +})) + export default router diff --git a/server/routes/unterkunft.js b/server/routes/unterkunft.js index 34bf60e..aca1cb9 100644 --- a/server/routes/unterkunft.js +++ b/server/routes/unterkunft.js @@ -1,13 +1,693 @@ 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 { anfrageAnsicht as funktionAnfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/unterkunft/anfrageFunktion.js' +import { UkFehler } from '../services/unterkunft/fehler.js' +import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' +import { berechnePreis, preiseVollstaendig, summiere } from '../services/unterkunft/preis.js' +import { unterkunftFuerKunde } from '../services/unterkunft/schalter.js' +import * as sp from '../services/unterkunft/speicher.js' +import * as vw from '../services/unterkunft/verwaltung.js' /** - * Kunden-API des Buchungskalenders: /api/unterkunft (Session, Projekt per ?projectId=; 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 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 (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 MAX_ANFRAGE_WRITES_PER_HOUR = 20 +const MELDUNG = { + kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.', + zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', + stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', + plattform: 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.', + erstattung: 'Die Stornierung wurde durchgeführt, aber die Erstattung ist fehlgeschlagen. Bitte erstatte den Betrag im Stripe-Dashboard oder versuch es später noch einmal.', +} + +const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR }) +const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) +export function resetUnterkunftRoutesForTests() { + schreibLimiter.reset() + anfrageLimiter.reset() +} + +const nurKunde = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ fehler: 'kundenansicht', error: MELDUNG.kundenansicht }) + return next() +} +const schreib = [nurKunde, limitWrites] + +/** Fachfehler und 403 wie sie sind, alles andere 500 ohne Rohtext. */ +function fail(res, err) { + if (err instanceof UkFehler) { + const body = { fehler: err.code } + if (err.felder && Object.keys(err.felder).length) body.felder = err.felder + if (err.status === 502 && err.code === 'erstattung_fehlgeschlagen') body.meldung = MELDUNG.erstattung + return res.status(err.status).json(body) + } + if (err?.status === 403 && !err.type) return res.status(403).json({ fehler: 'kein_zugriff', error: err.message }) + if (err?.fachlich) return res.status(err.status).json({ fehler: err.fachlich, ...(err.meldung ? { meldung: err.meldung } : {}) }) + console.error(`[unterkunft] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim()) + return res.status(500).json({ fehler: 'intern', error: 'Interner Serverfehler' }) +} + +const route = (fn) => async (req, res) => { + try { + await fn(req, res) + } catch (err) { + fail(res, err) + } +} + +const stop = (status, fachlich, extra = {}) => Object.assign(new Error(fachlich), { status, fachlich, ...extra }) +const ungueltig = (felder) => new UkFehler(400, 'ungueltig', felder) +const nichtGefunden = () => new UkFehler(404, 'nicht_gefunden') + +const projectIdOf = (req) => { + const raw = req.query.projectId ?? req.body?.projectId + return typeof raw === 'string' ? raw : '' +} +const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {}) + +/** Eigenes Projekt (403) und Funktion des Projekts; schreiben verlangt Zustand setup|active. */ +async function mitProjekt(req, { schreiben = false } = {}) { + const projectId = projectIdOf(req) + if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' }) + const customerId = getSessionCustomerId(req) + await assertOwnProject(customerId, projectId) + const ctx = await ladeFuerProjekt(customerId, projectId) + if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) throw new UkFehler(409, 'nicht_aktiv') + if (schreiben && !['setup', 'active'].includes(ctx.state)) throw new UkFehler(409, 'nicht_aktiv') + return ctx +} + +/** Unterkunft aus dem Pfad: ID geprueft, gehoert zum Projekt (sonst 404). */ +async function eigeneUnterkunft(ctx, id) { + if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden() + const u = await sp.ladeUnterkunft(id) + if (!u || u.featureId !== ctx.featureId) throw nichtGefunden() + return u +} + +async function eigeneAnfrage(ctx, id) { + if (typeof id !== 'string' || !sp.ID_RE.test(id)) throw nichtGefunden() + const a = await sp.ladeAnfrage(id) + if (!a || a.featureId !== ctx.featureId) throw nichtGefunden() + return a +} + +const sperreKey = (id) => ['unterkunft:' + id] + +// ---- Ansichten (nie tokenHash, ipHash, Konto-ID, Stripe-IDs) + +const gastAnsicht = (g) => ({ name: g?.name ?? '', firma: g?.firma ?? '', email: g?.email ?? '', telefon: g?.telefon ?? '', nachricht: g?.nachricht ?? '' }) + +function unterkunftAnsicht(u) { + return { + id: u.$id, + name: u.name ?? '', + beschreibung: u.beschreibung ?? '', + maxPersonen: u.maxPersonen ?? 1, + mindestNaechte: u.mindestNaechte ?? 1, + checkIn: u.checkIn ?? '', + checkOut: u.checkOut ?? '', + sort: u.sort ?? 0, + aktiv: u.aktiv === true, + icalImporte: (Array.isArray(u.icalImporte) ? u.icalImporte : []).map((i) => ({ + id: i.id, + name: i.name ?? '', + url: i.url ?? '', + letzterAbrufAt: i.letzterAbrufAt ?? null, + letzterErfolgAt: i.letzterErfolgAt ?? null, + fehler: i.fehler ?? '', + })), + icalExportUrl: vw.exportUrl(u.icalExportToken), + } +} + +function anfrageAnsicht(a, { unterkunftName = '', rechnungNummer = null, stornoRechnungId = null, steuerart = null } = {}) { + return { + id: a.$id, + nummer: a.nummer ?? '', + status: a.status ?? '', + von: a.von, + bis: a.bis, + naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0, + personen: a.personen ?? 0, + unterkunftId: a.unterkunftId ?? '', + unterkunftName, + gast: gastAnsicht(a.gast), + rechnungsadresse: a.rechnungsadresse ?? null, + posten: a.posten ?? null, + summen: a.summen ?? null, + steuerart: a.summen?.steuerart ?? steuerart, + zahlart: a.zahlart || null, + bezahltVia: a.bezahltVia || null, + bezahltAt: a.bezahltAt || null, + erstattetCent: a.erstattetCent ?? 0, + rechnungId: a.rechnungId || null, + rechnungNummer, + stornoRechnungId, + fristAt: a.fristAt || null, + nachrichtVermieter: a.nachrichtVermieter ?? '', + } +} + +/** Anfrage mit Namen der Unterkunft und Rechnungsnummern (nur Rechnungen dieses Projekts). */ +async function volleAnfrageAnsicht(ctx, a, preise) { + const [u, re, storno] = await Promise.all([ + sp.ladeUnterkunft(a.unterkunftId), + a.rechnungId ? sp.ladeRechnung(a.rechnungId) : null, + a.status === 'storniert' ? sp.findeRechnung(a.$id, 'storno') : null, + ]) + return anfrageAnsicht(a, { + unterkunftName: u?.name ?? '', + rechnungNummer: re && re.featureId === ctx.featureId ? re.nummer : null, + stornoRechnungId: storno && storno.featureId === ctx.featureId ? storno.$id : null, + steuerart: preise?.steuerart ?? null, + }) +} + +const preiseAnsicht = (p) => + p + ? { + staffeln: Array.isArray(p.staffeln) ? p.staffeln : [], + reinigungCent: p.reinigungCent ?? 0, + personenInklusive: p.personenInklusive ?? 1, + aufpreisPersonNachtCent: p.aufpreisPersonNachtCent ?? 0, + direktrabattProzent: p.direktrabattProzent ?? 0, + steuerart: p.steuerart ?? 'regel', + ustUebernachtung: p.ustUebernachtung ?? 0, + ustReinigung: p.ustReinigung ?? 0, + fristTage: p.fristTage ?? 3, + stornobedingungen: p.stornobedingungen ?? '', + } + : null + +const STRIPE_FELDER = ['charges_enabled', 'payouts_enabled', 'details_submitted', 'currently_due', 'past_due', 'disabled_reason', 'geprueftAt'] +const stripeStatusKnapp = (s) => (s && typeof s === 'object' ? Object.fromEntries(STRIPE_FELDER.filter((k) => Object.hasOwn(s, k)).map((k) => [k, s[k]])) : null) +const stripeZustand = (einst) => (!einst?.stripeAccountId ? 'keins' : einst.stripeStatus?.charges_enabled === true ? 'aktiv' : 'onboarding') + +/** shopConfig mit Stripe-Konto desselben Projekts (shopConfig hat die ID des Shop-Funktionsdokuments), sonst null. */ +async function shopMitKonto(customerId, projectId) { + const doc = await getFeatureDoc(customerId, projectId, 'shop') + if (!doc) return null + try { + const shop = await getDocument(config.collections.shopConfig, doc.$id) + return typeof shop?.stripeAccountId === 'string' && shop.stripeAccountId ? shop : null + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +// ---- Status + +router.get('/status', route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' }) + const customerId = getSessionCustomerId(req) + await assertOwnProject(customerId, projectId) + const doc = await getFeatureDoc(customerId, projectId, 'unterkunft') + const state = doc?.state ?? 'off' + const antwort = { state, stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 0, rechnungenVorhanden: false } + if (state === 'off') { + // Anfrage der Funktion: offen | abgelehnt | angenommen (zurueckgezogen und keine zaehlen als keine) + let ansicht = null + try { + ansicht = doc ? funktionAnfrageAnsicht(await sp.ladeEinstellungen(doc.$id)) : null + } catch (err) { + console.warn(`[unterkunft] Anfrage im Status nicht lesbar: ${err?.message}`) + } + antwort.anfrage = ansicht && ansicht.status !== 'zurueckgezogen' ? ansicht : null + return res.json(antwort) + } + const [einst, unterkuenfte, rechnungen, shop] = await Promise.all([ + sp.ladeEinstellungen(doc.$id), + sp.listeUnterkuenfte(doc.$id), + sp.listeRechnungen(doc.$id), + shopMitKonto(customerId, projectId), + ]) + antwort.stripe = { status: stripeZustand(einst), vorhandenesShopKonto: Boolean(shop) } + antwort.onlineZahlung = einst?.onlineZahlung === true + antwort.unterkuenfte = unterkuenfte.length + antwort.rechnungenVorhanden = rechnungen.length > 0 + const preise = await Promise.all(unterkuenfte.map((u) => sp.ladePreise(u.$id))) + const mitArt = preise.find((p) => p?.steuerart) + if (mitArt) antwort.steuerart = mitArt.steuerart + res.json(antwort) +})) + +// ---- Anfrage der Funktion (nur solange sie off ist; kein mitProjekt) + +/** Fehler des Anfrage-Dienstes (status, Text, body.code, fehler je Feld) in { fehler: code, felder? }. */ +function anfrageFehler(err) { + if (err instanceof UkFehler || err?.status === 403 || err?.fachlich || !(err?.status >= 400 && err.status < 500)) return err + if (err.fehler) return new UkFehler(400, 'ungueltig', err.fehler) + return new UkFehler(err.status, err.body?.code || (err.status === 400 ? 'ungueltig' : 'anfrage'), err.status === 400 ? { projectId: err.message } : {}) +} +const anfrageKunde = (req) => ({ name: req.session.name || '', email: req.session.email || '' }) + +router.post('/anfrage-funktion', nurKunde, limitAnfrage, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' }) + try { + const anfrage = await stelleAnfrage({ customerId: getSessionCustomerId(req), projectId, eingabe: body(req), person: anfrageKunde(req) }) + res.json({ anfrage }) + } catch (err) { + throw anfrageFehler(err) + } +})) + +router.post('/anfrage-funktion/zurueckziehen', nurKunde, limitAnfrage, route(async (req, res) => { + const projectId = projectIdOf(req) + if (!projectId) throw ungueltig({ projectId: 'Bitte wähle ein Projekt.' }) + try { + res.json({ anfrage: await zieheZurueck({ customerId: getSessionCustomerId(req), projectId, person: anfrageKunde(req) }) }) + } catch (err) { + throw anfrageFehler(err) + } +})) + +// ---- Einstellungen + +async function einstellungenAntwort(ctx, einst) { + const e = einst ?? {} + const ansicht = Object.fromEntries(vw.EINSTELLUNG_FELDER.map((k) => [k, e[k] ?? ''])) + ansicht.onlineZahlung = e.onlineZahlung === true + ansicht.rechnungAuftrag = Boolean(e.rechnungAuftragAt) + const [shop, rechnungen] = await Promise.all([shopMitKonto(ctx.customerId, ctx.projectId), sp.listeRechnungen(ctx.featureId)]) + return { + einstellungen: ansicht, + stripe: { status: stripeZustand(e), vorhandenesShopKonto: Boolean(shop) }, + rechnungenVorhanden: rechnungen.length > 0, + } +} + +router.get('/einstellungen', route(async (req, res) => { + const ctx = await mitProjekt(req) + res.json(await einstellungenAntwort(ctx, await sp.ladeEinstellungen(ctx.featureId))) +})) + +router.put('/einstellungen', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const eingabe = body(req).einstellungen && typeof body(req).einstellungen === 'object' ? body(req).einstellungen : body(req) + const neu = await withLocks(['uk-einst:' + ctx.featureId, 'uk-rechnungsnr:' + ctx.featureId], async () => { + const vorher = (await sp.ladeEinstellungen(ctx.featureId)) ?? {} + const { rechnungAuftrag, ...rest } = eingabe + if (rechnungAuftrag !== undefined && typeof rechnungAuftrag !== 'boolean') throw ungueltig({ rechnungAuftrag: 'Ungültige Eingabe.' }) + const r = vw.pruefeEinstellungen(rest, vorher) + if (r.fehler) throw ungueltig(r.fehler) + if (r.werte.onlineZahlung === true && !vorher.onlineZahlung && stripeZustand(vorher) !== 'aktiv') { + throw new UkFehler(409, 'stripe_nicht_aktiv', { onlineZahlung: 'Online-Zahlung ist erst möglich, wenn dein Stripe-Konto aktiv ist.' }) + } + if (r.werte.rechnungsPraefix !== undefined && r.werte.rechnungsPraefix !== (vorher.rechnungsPraefix ?? '') && !vw.zaehlerLeer(vorher)) { + throw new UkFehler(409, 'praefix_gesperrt', { rechnungsPraefix: 'Das Präfix kann nach der ersten Rechnung nicht mehr geändert werden.' }) + } + const werte = { ...r.werte } + if (rechnungAuftrag === true && !vorher.rechnungAuftragAt) werte.rechnungAuftragAt = new Date().toISOString() + if (rechnungAuftrag === false) werte.rechnungAuftragAt = null + return sp.speichereEinstellungen(ctx.featureId, werte) + }) + res.json(await einstellungenAntwort(ctx, neu)) +})) + +// ---- Unterkuenfte + +router.get('/unterkuenfte', route(async (req, res) => { + const ctx = await mitProjekt(req) + res.json({ unterkuenfte: (await sp.listeUnterkuenfte(ctx.featureId)).map(unterkunftAnsicht) }) +})) + +router.post('/unterkuenfte', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const r = vw.pruefeUnterkunft(body(req)) + if (r.fehler) throw ungueltig(r.fehler) + const u = await withLocks(['uk-neu:' + ctx.featureId], async () => { + const alle = await sp.listeUnterkuenfte(ctx.featureId) + if (alle.length >= vw.MAX_UNTERKUENFTE) throw new UkFehler(409, 'limit_erreicht') + const werte = { ...r.werte } + if (body(req).sort === undefined) werte.sort = alle.reduce((m, x) => Math.max(m, x.sort ?? 0), 0) + 1 + return sp.speichereUnterkunft(ctx.featureId, { + ...werte, + customerId: ctx.customerId, + projectId: ctx.projectId, + icalImporte: [], + icalExportToken: vw.neuesExportToken(), + }) + }) + res.status(201).json({ unterkunft: unterkunftAnsicht(u) }) +})) + +router.put('/unterkuenfte/:id', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u = await eigeneUnterkunft(ctx, req.params.id) + const r = vw.pruefeUnterkunft(body(req), { partiell: true }) + if (r.fehler) throw ungueltig(r.fehler) + const neu = await sp.speichereUnterkunft(ctx.featureId, r.werte, u.$id) + res.json({ unterkunft: unterkunftAnsicht(neu) }) +})) + +// Loeschen nur ohne Anfragen (sonst deaktivieren: aktiv:false); die Oberflaeche deaktiviert nur +router.delete('/unterkuenfte/:id', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u = await eigeneUnterkunft(ctx, req.params.id) + await withLocks(sperreKey(u.$id), async () => { + if ((await sp.listeAnfragen(ctx.featureId, { unterkunftId: u.$id })).length > 0) throw new UkFehler(409, 'hat_anfragen') + for (const s of await sp.listeSperren(u.$id)) await sp.loescheSperre(s.$id) + await sp.loeschePreise(u.$id) + await sp.loescheUnterkunft(u.$id) + }) + res.json({ ok: true }) +})) + +// ---- Preise + +router.get('/unterkuenfte/:id/preise', route(async (req, res) => { + const ctx = await mitProjekt(req) + const u = await eigeneUnterkunft(ctx, req.params.id) + res.json({ preise: preiseAnsicht(await sp.ladePreise(u.$id)) }) +})) + +router.put('/unterkuenfte/:id/preise', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u = await eigeneUnterkunft(ctx, req.params.id) + const eingabe = body(req).preise && typeof body(req).preise === 'object' ? body(req).preise : body(req) + const r = vw.pruefePreise(eingabe) + if (r.fehler) throw ungueltig(r.fehler) + const neu = await sp.speicherePreise(u.$id, ctx.featureId, r.werte) + res.json({ preise: preiseAnsicht(neu) }) +})) + +// ---- Kalender, Sperren + +const FENSTER_MAX_TAGE = 100 + +router.get('/unterkuenfte/:id/kalender', route(async (req, res) => { + const ctx = await mitProjekt(req) + const u = await eigeneUnterkunft(ctx, req.params.id) + const { von, bis } = req.query + if (!istTag(von) || !istTag(bis) || naechte(von, bis) < 1 || naechte(von, bis) > FENSTER_MAX_TAGE) { + throw ungueltig({ zeitraum: 'Bitte einen gültigen Zeitraum von höchstens 100 Tagen angeben.' }) + } + res.json({ bereiche: await vw.kalenderBereiche(u.$id, von, bis) }) +})) + +router.post('/unterkuenfte/:id/sperren', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u = await eigeneUnterkunft(ctx, req.params.id) + const r = vw.pruefeSperre(body(req)) + if (r.fehler) throw ungueltig(r.fehler) + const sperre = await withLocks(sperreKey(u.$id), async () => { + if (!(await istFrei(u.$id, r.werte.von, r.werte.bis))) throw new UkFehler(409, 'zeitraum_belegt') + return sp.legeSperreAn({ unterkunftId: u.$id, featureId: ctx.featureId, von: r.werte.von, bis: r.werte.bis, quelle: 'manuell', notiz: r.werte.notiz }) + }) + res.status(201).json({ sperre: { id: sperre.$id, von: sperre.von, bis: sperre.bis, notiz: sperre.notiz ?? '' } }) +})) + +router.delete('/sperren/:id', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + if (typeof req.params.id !== 'string' || !sp.ID_RE.test(req.params.id)) throw nichtGefunden() + const s = await sp.ladeSperre(req.params.id) + if (!s || s.featureId !== ctx.featureId) throw nichtGefunden() + if (String(s.quelle ?? 'manuell').startsWith('ical')) throw new UkFehler(409, 'sperre_aus_ical') + await sp.loescheSperre(s.$id) + res.json({ ok: true }) +})) + +// ---- iCal + +router.post('/unterkuenfte/:id/ical-importe', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u0 = await eigeneUnterkunft(ctx, req.params.id) + const r = vw.pruefeIcalImport(body(req)) + if (r.fehler) throw ungueltig(r.fehler) + const eintrag = await withLocks(sperreKey(u0.$id), async () => { + const u = await sp.ladeUnterkunft(u0.$id) + const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : [] + if (bisher.length >= vw.MAX_ICAL_IMPORTE) throw new UkFehler(409, 'limit_erreicht') + const neu = { id: vw.neueImportId(), name: r.werte.name, url: r.werte.url, letzterAbrufAt: null, letzterErfolgAt: null, fehler: '' } + await sp.speichereUnterkunft(ctx.featureId, { icalImporte: [...bisher, neu] }, u.$id) + return neu + }) + res.status(201).json({ import: eintrag }) +})) + +router.delete('/unterkuenfte/:id/ical-importe/:importId', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u0 = await eigeneUnterkunft(ctx, req.params.id) + const importId = req.params.importId + if (typeof importId !== 'string' || !sp.ID_RE.test(importId)) throw nichtGefunden() + await withLocks(sperreKey(u0.$id), async () => { + const u = await sp.ladeUnterkunft(u0.$id) + const bisher = Array.isArray(u.icalImporte) ? u.icalImporte : [] + if (!bisher.some((i) => i.id === importId)) throw nichtGefunden() + await sp.speichereUnterkunft(ctx.featureId, { icalImporte: bisher.filter((i) => i.id !== importId) }, u.$id) + for (const s of await sp.listeSperren(u.$id)) if (s.quelle === `ical:${importId}`) await sp.loescheSperre(s.$id) + }) + res.json({ ok: true }) +})) + +router.post('/unterkuenfte/:id/ical-export/erneuern', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const u = await eigeneUnterkunft(ctx, req.params.id) + const neu = await sp.speichereUnterkunft(ctx.featureId, { icalExportToken: vw.neuesExportToken() }, u.$id) + res.json({ icalExportUrl: vw.exportUrl(neu.icalExportToken) }) +})) + +// ---- Preisvorschau (rechnet nur, schreibt nichts) + +router.post('/preisvorschau', route(async (req, res) => { + const ctx = await mitProjekt(req) + const b = body(req) + const u = await eigeneUnterkunft(ctx, b.unterkunftId) + if (!istTag(b.von) || !istTag(b.bis) || naechte(b.von, b.bis) < 1 || naechte(b.von, b.bis) > 365) throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' }) + if (!Number.isInteger(b.personen) || b.personen < 1 || b.personen > 50) throw ungueltig({ personen: 'Ungültige Personenzahl.' }) + const preise = await sp.ladePreise(u.$id) + if (!preiseVollstaendig(preise)) throw new UkFehler(409, 'preise_unvollstaendig') + res.json(berechnePreis({ preise, von: b.von, bis: b.bis, personen: b.personen })) +})) + +// ---- Anfragen + +const FILTER = { offen: ['neu', 'freigegeben'], gebucht: ['gebucht', 'bezahlt'] } + +router.get('/anfragen', route(async (req, res) => { + const ctx = await mitProjekt(req) + const filter = req.query.filter === undefined ? 'alle' : String(req.query.filter) + if (filter !== 'alle' && !FILTER[filter]) throw ungueltig({ filter: 'Ungültiger Filter.' }) + const [liste, unterkuenfte] = await Promise.all([sp.listeAnfragen(ctx.featureId, { status: FILTER[filter] }), sp.listeUnterkuenfte(ctx.featureId)]) + const namen = new Map(unterkuenfte.map((u) => [u.$id, u.name ?? ''])) + liste.sort((a, b) => String(b.$createdAt ?? '').localeCompare(String(a.$createdAt ?? '')) || String(b.nummer).localeCompare(String(a.nummer))) + res.json({ + anfragen: liste.map((a) => ({ + id: a.$id, + nummer: a.nummer ?? '', + von: a.von, + bis: a.bis, + naechte: istTag(a.von) && istTag(a.bis) ? naechte(a.von, a.bis) : 0, + personen: a.personen ?? 0, + gast: { name: a.gast?.name ?? '' }, + status: a.status ?? '', + summen: a.summen && typeof a.summen.bruttoCent === 'number' ? { bruttoCent: a.summen.bruttoCent } : null, + unterkunftName: namen.get(a.unterkunftId) ?? '', + })), + }) +})) + +function vorschlagFuer(preise, a) { + const steuerart = preise?.steuerart ?? 'regel' + if (preiseVollstaendig(preise) && istTag(a.von) && istTag(a.bis) && naechte(a.von, a.bis) >= 1) { + return { ...berechnePreis({ preise, von: a.von, bis: a.bis, personen: a.personen }), steuerart } + } + return { posten: [], summen: summiere([], steuerart), steuerart } +} + +router.get('/anfragen/:id', route(async (req, res) => { + const ctx = await mitProjekt(req) + const a = await eigeneAnfrage(ctx, req.params.id) + const preise = await sp.ladePreise(a.unterkunftId) + res.json({ anfrage: await volleAnfrageAnsicht(ctx, a, preise), vorschlag: vorschlagFuer(preise, a) }) +})) + +router.post('/anfragen/:id/freigeben', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const a = await eigeneAnfrage(ctx, req.params.id) + const b = body(req) + if (b.fristTage !== undefined && b.fristTage !== null && (!Number.isInteger(b.fristTage) || b.fristTage < 1 || b.fristTage > 14)) { + throw ungueltig({ fristTage: 'Die Frist muss 1 bis 14 Tage betragen.' }) + } + if (b.nachricht !== undefined && b.nachricht !== null && (typeof b.nachricht !== 'string' || b.nachricht.length > 2000)) { + throw ungueltig({ nachricht: 'Die Nachricht darf höchstens 2000 Zeichen lang sein.' }) + } + const { anfrage, token } = await ablauf.gibFrei(ctx, a.$id, { posten: b.posten, fristTage: b.fristTage ?? undefined, nachricht: b.nachricht ?? '' }) + const preise = await sp.ladePreise(anfrage.unterkunftId) + res.json({ + anfrage: await volleAnfrageAnsicht(ctx, anfrage, preise), + buchungsLink: `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrage.$id}?t=${token}`, + }) +})) + +router.post('/anfragen/:id/ablehnen', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const a = await eigeneAnfrage(ctx, req.params.id) + const t = body(req).text + if (t !== undefined && t !== null && (typeof t !== 'string' || t.length > 2000)) throw ungueltig({ text: 'Der Text darf höchstens 2000 Zeichen lang sein.' }) + const neu = await ablauf.lehneAb(ctx, a.$id, { text: t ?? '' }) + res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) }) +})) + +router.post('/anfragen/:id/bezahlt', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const a = await eigeneAnfrage(ctx, req.params.id) + const neu = await ablauf.markiereBezahlt(ctx, a.$id, { via: 'vorort' }) + res.json({ anfrage: await volleAnfrageAnsicht(ctx, neu, await sp.ladePreise(neu.unterkunftId)) }) +})) + +router.post('/anfragen/:id/stornieren', ...schreib, route(async (req, res) => { + const ctx = await mitProjekt(req, { schreiben: true }) + const a = await eigeneAnfrage(ctx, req.params.id) + const b = body(req) + const grund = b.grund === undefined || b.grund === null ? '' : b.grund + if (typeof grund !== 'string' || grund.length > 500) throw ungueltig({ grund: 'Der Grund darf höchstens 500 Zeichen lang sein.' }) + const erstattungCent = b.erstattungCent === undefined || b.erstattungCent === null ? 0 : b.erstattungCent + if (!Number.isInteger(erstattungCent) || erstattungCent < 0) throw ungueltig({ erstattungCent: 'Ungültiger Betrag.' }) + const { anfrage, stornoRechnung } = await ablauf.storniere(ctx, a.$id, { grund, erstattungCent }) + res.json({ anfrage: await volleAnfrageAnsicht(ctx, anfrage, await sp.ladePreise(anfrage.unterkunftId)), stornoRechnungId: stornoRechnung?.$id ?? null }) +})) + +// ---- Rechnungen + +router.get('/rechnungen', route(async (req, res) => { + const ctx = await mitProjekt(req) + const liste = await sp.listeRechnungen(ctx.featureId) + res.json({ + rechnungen: liste.reverse().map((r) => ({ + id: r.$id, + nummer: r.nummer ?? '', + art: r.art ?? 'rechnung', + datum: r.datum ?? '', + bruttoCent: r.bruttoCent ?? 0, + anfrageNummer: r.inhalt?.anfrageNummer ?? '', + gastName: r.inhalt?.empfaenger?.firma || r.inhalt?.empfaenger?.name || '', + })), + }) +})) + +const PDF_RE = /^([A-Za-z0-9_-]{1,36})\.pdf$/ + +router.get('/rechnungen/:datei', route(async (req, res) => { + const ctx = await mitProjekt(req) + const m = PDF_RE.exec(String(req.params.datei)) + if (!m) throw nichtGefunden() + const dok = await sp.ladeRechnung(m[1]) + if (!dok || dok.featureId !== ctx.featureId) throw nichtGefunden() + let modul + try { + modul = await import('../services/unterkunft/rechnungPdf.js') + } catch { + 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/routes/unterkunftSeite.js b/server/routes/unterkunftSeite.js index 3d78e25..76c790c 100644 --- a/server/routes/unterkunftSeite.js +++ b/server/routes/unterkunftSeite.js @@ -1,11 +1,54 @@ import { Router } from 'express' +import { config } from '../config.js' +import { listDocuments, Query } from '../services/appwriteAdmin.js' +import { baueIcs } from '../services/unterkunft/ical.js' +import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' +import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js' +import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js' /** * Oeffentliche Standalone-Seiten des Buchungskalenders: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id * (Vorbild: shopSeite.js). Geruest (Task 5): Task 7 und Strang 3 fuellen die Seiten. + * Davor steht der iCal-Export /unterkunft/ical/.ics (Task 9): die Adresse ist das Geheimnis (Token, 43 Zeichen). */ + +const DATEI_RE = /^[A-Za-z0-9_-]{20,64}\.ics$/ +const nicht = (res) => res.status(404).type('text/plain').set('Cache-Control', 'no-store').send('Not found') + +async function exportEreignisse(unterkunft, jetzt) { + const out = [] + for (const s of await listeSperren(unterkunft.$id)) { + const quelle = String(s.quelle ?? 'manuell') + if (quelle.startsWith('ical') || !s.von || !s.bis) continue // nie zurueckspielen, was importiert wurde + out.push({ uid: `${s.$id}@project.webklar.com`, von: s.von, bis: s.bis }) + } + for (const a of await listeAnfragenNachStatus(unterkunft.$id, ['gebucht', 'bezahlt', 'freigegeben'])) { + if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue + if (!a.von || !a.bis) continue + out.push({ uid: `${a.$id}@project.webklar.com`, von: a.von, bis: a.bis }) + } + return out.sort((x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.uid < y.uid ? -1 : 1)) +} + export function unterkunftSeiteRouter() { const router = Router() + + router.get('/unterkunft/ical/:datei', async (req, res, next) => { + try { + if (unterkunftModus() === 'off' || !DATEI_RE.test(req.params.datei)) return nicht(res) + const token = req.params.datei.slice(0, -4) + const [u] = await listDocuments(config.collections.ukUnterkuenfte, [Query.equal('icalExportToken', token), Query.limit(1)]) + if (!u || u.icalExportToken !== token) return nicht(res) + const ctx = await ladeFuerProjekt(u.customerId, u.projectId) + if (!ctx || ctx.featureId !== u.featureId || ctx.state !== 'active' || !unterkunftFuerKunde(ctx.besitzerEmail)) return nicht(res) + const jetzt = new Date() + const text = baueIcs({ name: u.name, ereignisse: await exportEreignisse(u, jetzt.getTime()), jetzt }) + res.set({ 'Content-Type': 'text/calendar; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }).send(text) + } catch (err) { + next(err) + } + }) + return router } diff --git a/server/routes/unterkunftWebhook.js b/server/routes/unterkunftWebhook.js index 8c9f892..24ef671 100644 --- a/server/routes/unterkunftWebhook.js +++ b/server/routes/unterkunftWebhook.js @@ -3,7 +3,10 @@ import { config } from '../config.js' import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' import { withLocks } from '../services/keyedLock.js' import { statusAusKonto } from '../services/shop/connect.js' +import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' import { unterkunftModus } from '../services/unterkunft/schalter.js' +import { aktualisiereAnfrage, ladeAnfrage, ladeEinstellungen } from '../services/unterkunft/speicher.js' +import { verbucheZahlung } from '../services/unterkunft/zahlung.js' /** * Stripe-Ereignisse des Buchungskalenders. Kein eigener Endpunkt: routes/shopWebhook.js (Connect-Webhook, ein Endpunkt je Konto) @@ -13,9 +16,63 @@ import { unterkunftModus } from '../services/unterkunft/schalter.js' const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/ +const text = (v) => (typeof v === 'string' ? v : '') +const ID_OK = /^[A-Za-z0-9_-]{1,36}$/ + +/** Kontext fuer ablauf.js: voll, solange die Funktion existiert; sonst der Mindestkontext (eine eingegangene Zahlung geht nie verloren). */ +async function kontextFuer(anfrage) { + const voll = await ladeFuerProjekt(anfrage.customerId, anfrage.projectId).catch(() => null) + if (voll && voll.featureId === anfrage.featureId) return voll + return { + featureId: anfrage.featureId, + customerId: anfrage.customerId ?? '', + projectId: anfrage.projectId ?? '', + siteKey: '', + besitzerEmail: '', + einstellungen: await ladeEinstellungen(anfrage.featureId), + project: null, + } +} + +/** Anfrage zum Ereignis: nur wenn das Konto des Ereignisses das Konto der Funktion ist (sonst Fremdereignis, ohne Wirkung). */ +async function anfrageZu(event, anfrageId) { + if (!ID_OK.test(text(anfrageId))) return null + const anfrage = await ladeAnfrage(anfrageId) + if (!anfrage) return null + const ctx = await kontextFuer(anfrage) + if (!ctx.einstellungen?.stripeAccountId || ctx.einstellungen.stripeAccountId !== event.account) return null + return { anfrage, ctx } +} + +async function zahlungEingegangen(event, session, now) { + const treffer = await anfrageZu(event, session.metadata?.anfrageId) + if (!treffer) return + const { anfrage, ctx } = treffer + const brutto = anfrage.summen?.bruttoCent + if (Number.isInteger(session.amount_total) && Number.isInteger(brutto) && session.amount_total !== brutto) { + console.warn(`[unterkunft] Webhook: Betrag weicht ab (Buchung ${anfrage.nummer})`) + } + const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id + await verbucheZahlung(ctx, anfrage.$id, pi, { now }) +} + +async function erstattungGebucht(event, charge) { + const treffer = await anfrageZu(event, charge.metadata?.anfrageId) + if (!treffer) return + const { anfrage } = treffer + if (anfrage.stripePaymentIntentId && typeof charge.payment_intent === 'string' && charge.payment_intent !== anfrage.stripePaymentIntentId) return + const stand = charge.amount_refunded + if (!Number.isInteger(stand) || stand <= 0) return + await withLocks(['unterkunft:' + anfrage.unterkunftId], async () => { + const frisch = await ladeAnfrage(anfrage.$id) + if (frisch && stand > (frisch.erstattetCent || 0)) await aktualisiereAnfrage(frisch.$id, { erstattetCent: stand }) + }) +} + /** - * Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten. Geruest (Task 5): Task 8/9 fuellen es (Zahlung eingegangen, - * Sitzung abgelaufen, Erstattung gebucht). Bis dahin ohne Wirkung. + * Checkout- und Erstattungsereignisse des Buchungskalenders verarbeiten (idempotent; Fehler => 500, Stripe wiederholt). + * checkout.session.completed (bezahlt) und async_payment_succeeded: Buchung als bezahlt markieren. charge.refunded: erstattetCent auf + * mindestens den Stand der Zahlung heben (heilt einen Absturz zwischen Stripe-Erfolg und Schreiben). * @param {object} event Stripe-Ereignis (signiert und geprueft) * @param {Date} [now] */ @@ -23,10 +80,27 @@ export async function verarbeiteUkEreignis(event, now = new Date()) { return ereignisHandler(event, now) } -// Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen); Task 8/9 ersetzen den Standard durch die echte Verarbeitung -let ereignisHandler = async () => {} +async function verarbeite(event, now) { + const objekt = event?.data?.object + if (!objekt || typeof objekt !== 'object') return + switch (event.type) { + case 'checkout.session.completed': + if (objekt.metadata?.wkUk === '1' && objekt.payment_status === 'paid') await zahlungEingegangen(event, objekt, now) + break + case 'checkout.session.async_payment_succeeded': + if (objekt.metadata?.wkUk === '1') await zahlungEingegangen(event, objekt, now) + break + case 'charge.refunded': + await erstattungGebucht(event, objekt) + break + default: + } +} + +let ereignisHandler = verarbeite +// Austauschbar nur fuer Tests (Weiche in shopWebhook.js pruefen) export function setzeUkEreignisHandlerFuerTests(fn) { - ereignisHandler = fn ?? (async () => {}) + ereignisHandler = fn ?? verarbeite } /** diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index 7b04f43..d3b549c 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -307,6 +307,21 @@ export async function deleteCustomerWithPortalAccess(customerId) { console.error(`[customerAdmin] Webshop von Kunde ${customerId} nicht geschlossen:`, err.message) } + // Buchungskalender abschalten: Gaeste koennen keine Anfragen mehr stellen, die Daten loescht der Tagesjob 90 Tage nach disabledAt. Neue + // Anfragen schliessen wir sofort (Unterkuenfte inaktiv, Speicher der oeffentlichen Schnittstelle leeren). Jeder Teilschritt fuer sich abgesichert. + try { + const { disableCustomerFeatures } = await import('./siteFeatures.js') + await disableCustomerFeatures(customerId, 'unterkunft', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' }) + } catch (err) { + console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht abgeschaltet:`, err.message) + } + try { + const { schliesseUkVonKunde } = await import('./unterkunft/aufbewahrung.js') + await schliesseUkVonKunde(customerId) + } catch (err) { + console.error(`[customerAdmin] Buchungskalender von Kunde ${customerId} nicht geschlossen:`, err.message) + } + // Oeffnungszeiten live: wie der Terminplaner abschalten (die Antwort der Website ist sofort aktiv false: Kunde und Funktion fehlen/sind // disabled), danach Zeiten und Erinnerungsmerker sofort loeschen (Spec 5) und den Speicher der Antworten leeren. Beides fuer sich in // try/catch: ein Fehler haelt weder das Loeschen des Kunden noch den jeweils anderen Schritt auf. Geloggt wird nur die Kunden-ID. diff --git a/server/services/retention.js b/server/services/retention.js index 7e2f30c..064c0f8 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -4,6 +4,7 @@ import { config } from '../config.js' import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' import { loescheShopDaten } from './shop/loeschen.js' +import { hatUkDaten, loescheUkDaten } from './unterkunft/aufbewahrung.js' import { disable } from './siteFeatures.js' import { getStripe } from './stripeBilling.js' import { filesOf, STATUS } from './takeoverCredits.js' @@ -37,6 +38,9 @@ import { filesOf, STATUS } from './takeoverCredits.js' * Abschalten die Zeiten und Erinnerungsmerker des Kunden (loescheZeitenVonKunde, per dynamischem Import wie bei siteFeatures). * Scheitert das, werden diese Funktionen nicht abgeschaltet (solange Daten da sind, soll nichts "aus" aussehen); der Fehler steht unter * "Fehlgeschlagen" im Bericht. + * - Buchungskalender: Fuer faellige Kunden loescht derselbe Lauf auch alle uk*-Daten ihrer Funktionen 'unterkunft' (plan.news[].ukIds, nur wenn es welche gibt; + * Deckel UK_LOESCH_DECKEL je Kunde, Rest = Kunde gehalten). Die eigenen Fristen (ipHash, Anonymisierung, Rechnungen 8 Jahre, 90 Tage nach dem + * Abschalten) laufen im Tagesjob des Buchungskalenders (unterkunft/aufbewahrung.js). * Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr. * Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden * weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine @@ -51,6 +55,7 @@ const FEATURES = () => config.collections.portalFeatures const CONSENTS = () => config.collections.featureConsents const SHOP_CONFIG = () => config.collections.shopConfig const SHOP_LOESCH_DECKEL = 2000 +const UK_LOESCH_DECKEL = 2000 /** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */ const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt'] @@ -154,7 +159,10 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } const withFields = allFeatures.filter(hasConsentFields) const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)]) const shops = await shopsOf(customerId, list) - if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length) continue + // Buchungskalender: Funktionen 'unterkunft' mit uk*-Daten (auch abgeschaltete); ohne Collections (Setup nicht gelaufen) gibt es keine + const ukIds = [] + for (const f of allFeatures.filter((x) => x.featureKey === 'unterkunft')) if (await hatUkDaten(f.$id)) ukIds.push(f.$id) + if (!posts.length && !features.length && !consents.length && !withFields.length && !shops.length && !ukIds.length) continue news.push({ customerId, posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })), @@ -162,6 +170,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT } consentIds: consents.map((c) => c.$id), featureFieldIds: withFields.map((f) => f.$id), ...(shops.length ? { shops } : {}), + ...(ukIds.length ? { ukIds } : {}), zeitenIds: features.filter((f) => f.featureKey === 'zeiten').map((f) => f.$id), }) } @@ -216,12 +225,12 @@ async function deleteQuietly(collection, id) { * die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen. * @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number, * newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number, shops: number, shopDocs: number, - * zeiten: number }, + * zeiten: number, unterkuenfte: number, ukDocs: number }, * manual: { customerId: string, subscriptionId: string, reason: string }[], * failed: { what: string, id: string, reason: string }[], held: string[] }>} */ export async function applyDeletions(plan) { - const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0 } + const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0, shops: 0, shopDocs: 0, zeiten: 0, unterkuenfte: 0, ukDocs: 0 } const manual = [] const failed = [] const held = [] @@ -286,6 +295,18 @@ export async function applyDeletions(plan) { for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 } + // Buchungskalender: alle uk*-Daten der Funktion (Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte, Einstellungen); gemeinsamer Deckel je Kunde + let ukRest = UK_LOESCH_DECKEL + for (const featureId of entry.ukIds || []) { + await attempt('unterkunft', featureId, async () => { + const weg = await loescheUkDaten({ featureId, deckel: ukRest }) + ukRest -= weg.geloescht + deleted.ukDocs += weg.geloescht + if (!weg.vollstaendig) throw new Error('Buchungsdaten nicht vollständig gelöscht (Deckel je Lauf), der nächste Lauf macht weiter') + deleted.unterkuenfte += 1 + return true + }) + } // Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten) let shopRest = SHOP_LOESCH_DECKEL for (const shop of entry.shops || []) { @@ -366,9 +387,10 @@ export function formatReport(plan, result = null) { const consents = n.consentIds?.length || 0 const fields = n.featureFieldIds?.length || 0 const shops = n.shops?.length || 0 + const uk = n.ukIds?.length ? `; ${n.ukIds.length} Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen` : '' const zeiten = n.zeitenIds?.length ? `; Öffnungszeiten-Daten von ${n.zeitenIds.length} Funktion(en) zu löschen` : '' lines.push( - `Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${zeiten}` + `Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren${shops ? `, ${shops} Webshop(s) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen` : ''}${uk}${zeiten}` ) } const del = plan.takeover.filter((t) => t.action === 'loeschen') @@ -391,6 +413,7 @@ export function formatReport(plan, result = null) { `Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`, ...(d.shops || d.shopDocs ? [`Webshop: ${d.shops} Shop(s) gelöscht (${d.shopDocs} Dokument(e) in Bestellungen, Artikeln und Widerrufen).`] : []) ) + if (d.unterkuenfte || d.ukDocs) lines.push(`Buchungskalender: ${d.unterkuenfte} Funktion(en) gelöscht (${d.ukDocs} Dokument(e)).`) if (d.zeiten) lines.push(`Öffnungszeiten: ${d.zeiten} Datensatz/Datensätze gelöscht.`) if (result.failed?.length) { lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) diff --git a/server/services/shop/connect.js b/server/services/shop/connect.js index 6e1e4e1..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 } @@ -139,7 +139,7 @@ export async function sessionAblaufen(accountId, id) { } /** Erstattung auf dem Haendlerkonto; ohne betragCent wird voll erstattet. */ -export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey } = {}) { +export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey, webklar = 'shop' } = {}) { pruefeKonto(accountId) if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.') if (betragCent !== undefined && (!Number.isInteger(betragCent) || betragCent <= 0)) throw fehler(400, 'Ungültiger Erstattungsbetrag.') @@ -149,7 +149,7 @@ export async function erstatten(accountId, { paymentIntent, betragCent, grund, i payment_intent: paymentIntent, ...(betragCent !== undefined ? { amount: betragCent } : {}), reason: 'requested_by_customer', - metadata: { webklar: 'shop', ...(text ? { grund: text } : {}) }, + metadata: { webklar, ...(text ? { grund: text } : {}) }, }, { stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}) } ) diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 998ceb5..48db4a3 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -24,6 +24,7 @@ import { kontextPerFeature, ladeEinstellung } from './kontext.js' import { anGast } from './mails.js' import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js' import { shopModus } from '../shop/schalter.js' +import { unterkunftModus } from '../unterkunft/schalter.js' import { termineAus } from './schalter.js' import { buchungsNummer } from './token.js' import { pruefeNow, zeitMs } from './util.js' @@ -812,11 +813,11 @@ async function gibtBezahlteTerminplaner() { /** * Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes - * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen). + * Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop oder der Buchungskalender ist aktiv (Warteschlange aufraeumen). * Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher. */ async function tagesjobFreigabe() { - const mails = shopModus() !== 'off' + const mails = shopModus() !== 'off' || unterkunftModus() !== 'off' let termine = false try { termine = await gibtBezahlteTerminplaner() diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index 38223f1..66548f2 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -49,6 +49,11 @@ function fuerFeature(ctx, a) { return a } +/** Ohne Rechnungsauftrag (Zustimmung des Vermieters) darf nichts freigegeben oder verbindlich gebucht werden. */ +function brauchtZustimmung(ctx) { + if (!ctx?.einstellungen?.rechnungAuftragAt) throw new UkFehler(409, 'zustimmung_fehlt') +} + const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '') const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ @@ -115,6 +120,7 @@ export async function legeAnfrageAn(ctx, eingabe, { now = new Date(), ipHash = ' export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, now = new Date() } = {}) { const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId)) + brauchtZustimmung(ctx) const ergebnis = await withLocks(withLocksKey(pre.unterkunftId), async () => { const a = fuerFeature(ctx, await ladeAnfrage(anfrageId)) if (a.status !== 'neu') throw new UkFehler(409, 'status_ungueltig') @@ -192,6 +198,7 @@ export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse if (a.status === 'gebucht' || a.status === 'bezahlt') { return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false } } + brauchtZustimmung(ctx) if (a.status === 'abgelaufen') throw new UkFehler(410, 'frist_abgelaufen') if (a.status !== 'freigegeben') throw new UkFehler(409, 'status_ungueltig') if (!(Date.parse(a.fristAt) > ms(now))) throw new UkFehler(410, 'frist_abgelaufen') @@ -283,6 +290,7 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0 const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) }) return { anfrage, stornoRechnung, neu: true } }) + if (r.neu) await sende(() => zahlung.beendeOffeneZahlung(ctx, r.anfrage)) if (r.neu) await sende(() => mails.anGast('G22', ctx, r.anfrage, { stornoRechnung: r.stornoRechnung, erstattetCent: erstattungCent, grund: text(grund, 500) })) let anfrage = r.anfrage if (erstattungCent > 0) { diff --git a/server/services/unterkunft/anfrageFunktion.js b/server/services/unterkunft/anfrageFunktion.js index 93c4931..9163e4a 100644 --- a/server/services/unterkunft/anfrageFunktion.js +++ b/server/services/unterkunft/anfrageFunktion.js @@ -1,7 +1,293 @@ -// Stub (Task 5): Anfrage-Funktion des Buchungskalenders. Task 11 ersetzt dieses Modul. -export const ANFRAGE_OFFEN_TEXT = 'Anfrage noch nicht freigegeben' +import { config } from '../../config.js' +import { withLocks } from '../keyedLock.js' +import { sendAdminNotice, sendCustomerMail } from '../mailer.js' +import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' +import { unterkunftFuerKunde } from './schalter.js' +import { ladeEinstellungen, speichereEinstellungen } from './speicher.js' -/** Bestellpruefung-Haken: offene Punkte der Bestellung (Stub: immer offen). */ -export async function anfrageOffenePunkte() { - return [ANFRAGE_OFFEN_TEXT] +/** + * Anfrage der Funktion "Buchungskalender" (Muster services/shop/anfrage.js, Spezifikation 2026-10-07-buchungskalender-design.md, + * Abschnitt 9). Der Kunde stellt eine Anfrage, das Team entscheidet (annehmen oder ablehnen); erst eine angenommene Anfrage laesst + * die Bestellung zu (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht in ukEinstellungen (Dokument-ID = ID des + * portalFeatures-Dokuments): anfrage (JSON), anfrageStatus ('' | offen | angenommen | abgelehnt | zurueckgezogen), anfrageAt, + * anfrageEntschiedenAt, anfrageEntschiedenVon, anfrageBegruendung. Sperre je Projekt: uk-anfrage:. + * Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code und err.fehler (je Feld). + * Mails: T22 ans Team (Anfrage eingegangen), K34 an den Kunden (Entscheidung); die Vorlagen kommen aus Task 20. + */ + +export const ANFRAGE_OFFEN_TEXT = 'Deine Anfrage für den Buchungskalender ist noch nicht freigegeben.' + +export const ARTEN = ['ferienwohnung', 'monteurzimmer', 'pension', 'sonstiges'] +export const ART_NAMEN = { ferienwohnung: 'Ferienwohnung oder Ferienhaus', monteurzimmer: 'Monteurwohnung', pension: 'Pension oder Gästezimmer', sonstiges: 'Sonstiges' } +/** Das Portal sendet und liest 'monteur' (ui-erwartungen.md); gespeichert wird 'monteurzimmer'. */ +const ART_ALIAS = { monteur: 'monteurzimmer' } +export const PORTALE = ['booking', 'airbnb'] +export const PORTAL_NAMEN = { booking: 'Booking.com', airbnb: 'Airbnb', andere: 'Portale (ohne Angabe)' } + +const EINHEITEN_MAX = 999 +const NACHRICHT_MAX = 1000 +const BEGRUENDUNG_MAX = 1000 + +const MELDUNG = { + projekt: 'Bitte wähle ein Projekt.', + nichtVerfuegbar: 'Der Buchungskalender ist noch nicht verfügbar.', + vorhanden: 'Für dieses Projekt gibt es schon einen Buchungskalender.', + laeuft: 'Für dieses Projekt gibt es schon eine Anfrage für den Buchungskalender.', + nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.', + nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.', + entscheidung: 'Bitte wähle annehmen oder ablehnen.', + begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).', + ungueltig: 'Ungültige Eingabe.', +} + +const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) }) +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Versand (nur fuer Tests austauschbar: die Vorlagen K34 und T22 liegen auf einem anderen Strang). */ +export const _versand = { + admin: (nachricht) => sendAdminNotice(nachricht), + kunde: (an, nachricht) => sendCustomerMail(an, nachricht), +} + +/** [] wenn die Anfrage des Projekts vom Team angenommen ist, sonst ein Punkt. Lesefehler werden weitergereicht. */ +export async function anfrageOffenePunkte({ customerId, projectId }) { + const doc = await getFeatureDoc(customerId, projectId, 'unterkunft') + if (!doc) return [ANFRAGE_OFFEN_TEXT] + const einst = await ladeEinstellungen(doc.$id) + return einst?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] +} + +/** + * Eingabe pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }. + * art (ARTEN, auch 'monteur'), einheiten (ganze Zahl 1-999), portale (Liste aus booking|airbnb, leer erlaubt; ein boolean wird + * angenommen: true = 'andere'), nachricht (optional, bis 1000 Zeichen). + */ +export function normalisiereAnfrage(eingabe) { + const fehler = {} + const e = istObjekt(eingabe) ? eingabe : {} + + const artRoh = typeof e.art === 'string' ? (Object.hasOwn(ART_ALIAS, e.art) ? ART_ALIAS[e.art] : e.art) : '' + const art = ARTEN.includes(artRoh) ? artRoh : '' + if (!art) fehler.art = 'Bitte wähle die Art deiner Unterkunft.' + + const einheiten = e.einheiten + if (!Number.isInteger(einheiten) || einheiten < 1 || einheiten > EINHEITEN_MAX) fehler.einheiten = `Bitte gib eine ganze Zahl von 1 bis ${EINHEITEN_MAX} an.` + + let portale = [] + if (e.portale === undefined || e.portale === null) portale = [] + else if (typeof e.portale === 'boolean') portale = e.portale ? ['andere'] : [] + else if (Array.isArray(e.portale) && e.portale.every((p) => PORTALE.includes(p))) portale = PORTALE.filter((p) => e.portale.includes(p)) + else fehler.portale = MELDUNG.ungueltig + + let nachricht = '' + if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') { + if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig + else { + nachricht = e.nachricht.toWellFormed().trim() + if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.` + } + } + + if (Object.keys(fehler).length) return { fehler } + return { anfrage: { art, einheiten, portale, nachricht } } +} + +/** Gespeicherte Anfrage (aus ukEinstellungen.anfrage, schon geparst) bereinigt oder null. */ +function leseAnfrage(einst) { + const roh = einst?.anfrage + if (!istObjekt(roh)) return null + return { + art: ARTEN.includes(roh.art) ? roh.art : '', + einheiten: Number.isInteger(roh.einheiten) ? roh.einheiten : null, + portale: Array.isArray(roh.portale) ? roh.portale.filter((p) => typeof p === 'string') : [], + nachricht: typeof roh.nachricht === 'string' ? roh.nachricht : '', + } +} + +/** + * Anfrage fuer das Portal des Kunden: { status, anfrage: { art ('monteur' statt 'monteurzimmer'), einheiten, portale (Liste), + * nachricht }, at, entschiedenAt, begruendung } oder null, solange es keine gab. + */ +export function anfrageAnsicht(einst) { + const status = einst?.anfrageStatus || '' + if (!status) return null + const a = leseAnfrage(einst) + return { + status, + anfrage: a ? { ...a, art: a.art === 'monteurzimmer' ? 'monteur' : a.art } : null, + at: einst.anfrageAt || null, + entschiedenAt: einst.anfrageEntschiedenAt || null, + begruendung: einst.anfrageBegruendung || '', + } +} + +/** Anfrage fuer das Team (WOMS): flach, art wie gespeichert, portale als boolean; ohne Anfrage status ''. */ +export function anfrageAnsichtTeam(einst) { + const a = leseAnfrage(einst) + return { + status: einst?.anfrageStatus || '', + at: einst?.anfrageAt || null, + art: a?.art || '', + einheiten: a?.einheiten ?? null, + portale: (a?.portale.length ?? 0) > 0, + nachricht: a?.nachricht || '', + entschiedenAt: einst?.anfrageEntschiedenAt || null, + entschiedenVon: einst?.anfrageEntschiedenVon || '', + begruendung: einst?.anfrageBegruendung || '', + } +} + +/** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */ +async function pruefeZugang({ customerId, projectId, person, neu }) { + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + let project + try { + project = await assertOwnProject(customerId, projectId, { forNewOrder: neu }) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + if (!unterkunftFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') + return project +} + +/** + * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre je + * Projekt: Funktion muss `off` sein (409), Status '' | abgelehnt | zurueckgezogen (409 sonst). Speichert, loescht eine fruehere + * Entscheidung und schickt T22 ans Team (ein Fehler dabei wird nur geloggt). + * @returns {Promise} anfrageAnsicht des neuen Standes + */ +export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) { + const project = await pruefeZugang({ customerId, projectId, person, neu: true }) + const { anfrage, fehler } = normalisiereAnfrage(eingabe) + if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler }) + + const gespeichert = await withLocks([`uk-anfrage:${projectId}`], async () => { + const feature = await ensureFeatureDoc(customerId, projectId, 'unterkunft') + if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden') + return withLocks([`uk-einst:${feature.$id}`], async () => { + const einst = await ladeEinstellungen(feature.$id) + if (!['', 'abgelehnt', 'zurueckgezogen'].includes(einst?.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft') + return speichereEinstellungen(feature.$id, { + anfrage, + anfrageStatus: 'offen', + anfrageAt: now.toISOString(), + anfrageEntschiedenAt: null, + anfrageEntschiedenVon: '', + anfrageBegruendung: '', + }) + }) + }) + + await meldeTeam({ customerId, projectId, project, anfrage, person }) + return anfrageAnsicht(gespeichert) +} + +/** T22 "Neue Anfrage Buchungskalender" ans Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log. */ +async function meldeTeam({ customerId, projectId, project, anfrage, person }) { + try { + let kundeName = String(person?.name || '').trim() + if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim() + const result = await _versand.admin({ + template: 'T22', + values: { + kunde_name: kundeName || 'Kunde', + email: person?.email || '', + projekt: project?.projectName || projectId, + art: ART_NAMEN[anfrage.art], + einheiten: String(anfrage.einheiten), + portale: anfrage.portale.length ? anfrage.portale.map((p) => PORTAL_NAMEN[p]).join(', ') : 'keine', + nachricht: anfrage.nachricht || '–', + woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`, + }, + }) + if (!result?.sent) console.warn(`[unterkunft] Team-Mail T22 nicht gesendet: ${result?.reason || 'unbekannt'}`) + } catch (err) { + console.warn(`[unterkunft] Team-Mail T22 nicht gesendet: ${err?.message}`) + } +} + +/** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise} anfrageAnsicht */ +export async function zieheZurueck({ customerId, projectId, person = {} }) { + await pruefeZugang({ customerId, projectId, person, neu: false }) + const gespeichert = await withLocks([`uk-anfrage:${projectId}`], async () => { + const feature = await getFeatureDoc(customerId, projectId, 'unterkunft') + if (!feature) throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') + return withLocks([`uk-einst:${feature.$id}`], async () => { + const einst = await ladeEinstellungen(feature.$id) + if (!einst || einst.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') + return speichereEinstellungen(feature.$id, { anfrageStatus: 'zurueckgezogen' }) + }) + }) + return anfrageAnsicht(gespeichert) +} + +/** 'Name ' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */ +const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin') + +/** + * Das Team entscheidet eine offene Anfrage: annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je + * Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Danach geht K34 an den Kunden; scheitert + * die Mail, bleibt die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`. + * @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes + */ +export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) { + if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung) + let grund = '' + if (entscheidung === 'ablehnen') { + grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : '' + if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung) + } + if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) + + const { gespeichert, project } = await withLocks([`uk-anfrage:${projectId}`], async () => { + let besitz + try { + besitz = await assertOwnProject(customerId, projectId) + } catch (err) { + throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err + } + const feature = await getFeatureDoc(customerId, projectId, 'unterkunft') + if (!feature || feature.state !== 'off') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen') + const neu = await withLocks([`uk-einst:${feature.$id}`], async () => { + const einst = await ladeEinstellungen(feature.$id) + if (!einst || einst.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen') + return speichereEinstellungen(feature.$id, { + anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt', + anfrageEntschiedenAt: now.toISOString(), + anfrageEntschiedenVon: personLabel(person).slice(0, 120), + anfrageBegruendung: grund, + }) + }) + return { gespeichert: neu, project: besitz } + }) + + const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund })) + return { anfrage: anfrageAnsicht(gespeichert), mailFehler } +} + +/** K34 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */ +async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) { + try { + const kunde = await customerOf(customerId) + if (!kunde?.email) { + console.warn(`[unterkunft] K34 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`) + return false + } + await _versand.kunde(kunde.email, { + template: 'K34', + values: { + kunde_name: String(kunde.name || '').trim() || 'Kunde', + projekt: project?.projectName || projectId, + preis: config.features.unterkunft.priceLabel, + ust_hinweis: config.ustHinweis, + begruendung, + portal_url: `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(projectId)}`, + }, + blocks: { angenommen, abgelehnt: !angenommen }, + }) + return true + } catch (err) { + console.warn(`[unterkunft] K34 nicht gesendet: ${err?.message}`) + return false + } } diff --git a/server/services/unterkunft/aufbewahrung.js b/server/services/unterkunft/aufbewahrung.js new file mode 100644 index 0000000..8968399 --- /dev/null +++ b/server/services/unterkunft/aufbewahrung.js @@ -0,0 +1,372 @@ +import { config } from '../../config.js' +import { deleteDocument, listAllDocuments, listDocuments, Query, updateDocument, getDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { heute } from '../termine/zeit.js' +import { aktualisiereAnfrage, ladeAnfrage } from './speicher.js' +import { leereCache } from './kontext.js' + +/** + * Buchungskalender: Aufbewahrung und Loeschfristen (AVV Nr. 9, bindend): + * - ipHash: spaetestens config.unterkunft.ipHashTage (30) Tage nach Anlage der Anfrage weg (bei Freigabe/Ablehnung schon in ablauf.js). + * - Status `neu` ohne Entscheidung nach neuVerfallTage (60) Tagen: `abgelaufen`, ohne Mail. + * - Anfragen, die nie eine Buchung wurden (abgelehnt, abgelaufen, storniert ohne Rechnung): unverbindlichMonate (6) nach Anlage + * anonymisiert (gast {}, nachrichtVermieter '', rechnungsadresse null, ipHash '', anonymisiertAt). + * - Gebuchte Aufenthalte (gebucht, bezahlt, storniert mit Rechnung): Gastdaten 3 Jahre nach Ende des Kalenderjahres der Abreise (Berlin) + * anonymisiert; rechnungsadresse und ukRechnungen bleiben (der Rechnungs-Schnappschuss ist der Beleg). + * - ukRechnungen: 8 Jahre nach Ende des Kalenderjahres des Rechnungsdatums geloescht; zugleich wird die rechnungsadresse der Anfrage + * anonymisiert (sobald keine Rechnung der Anfrage mehr uebrig ist). + * - Abgeschaltete Funktion: 90 Tage nach disabledAt werden alle uk*-Daten der Funktion geloescht (Marker in portalFeatures.adminNote). + * Je Lauf hoechstens `deckel` (500) Schreib-/Loeschvorgaenge; idempotent; Logs ohne Personendaten (nur Zaehler, IDs, Fehlername). + * Leichtgewichtig gehalten (retention.js importiert dieses Modul): keine Mail-, Stripe- oder Ablauf-Importe. + */ + +const TAG_MS = 24 * 60 * 60 * 1000 +const STANDARD_DECKEL = 500 +const SEITE = 100 +const ANONYM_JAHRE = 3 +const RECHNUNG_JAHRE = 8 +const MARKER = 'Buchungsdaten gelöscht am ' +const MARKER_RE = /Buchungsdaten gelöscht am (\d{4}-\d{2}-\d{2})/ +const ANFRAGE_ANONYM = { gast: {}, nachrichtVermieter: '', ipHash: '' } + +const zeitMs = (v) => { + const t = Date.parse(v) + return Number.isNaN(t) ? NaN : t +} +const jahrVon = (tag) => Number(String(tag).slice(0, 4)) +const gueltigerTag = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}/.test(tag) +/** Nur Name, Status und Code: die Meldung kann Personendaten enthalten und kommt nie ins Log. */ +const fehlerText = (err) => [err?.name, err?.status, err?.code ?? err?.type].filter((x) => x !== undefined && x !== null && x !== '').join(' ') +const sammlungFehlt = (err) => err?.type === 'collection_not_found' || err?.code === 'collection_not_found' + +/** now minus n Kalendermonate (UTC, Tag wird auf den Monatsletzten gekuerzt). */ +function minusMonate(ms, n) { + const d = new Date(ms) + const ziel = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - n, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds())) + const letzter = new Date(Date.UTC(ziel.getUTCFullYear(), ziel.getUTCMonth() + 1, 0)).getUTCDate() + ziel.setUTCDate(Math.min(d.getUTCDate(), letzter)) + return ziel.getTime() +} + +const nowMsVon = (now) => { + const ms = now instanceof Date ? now.getTime() : Number(now) + if (!Number.isFinite(ms)) throw new TypeError('aufbewahrung: now fehlt') + return ms +} + +/** Liest seitenweise (nach $id, 100 je Seite, nur die genannten Felder) und sammelt Treffer von `passt`, hoechstens max. */ +async function sammle(sammlung, filter, felder, passt, max) { + const treffer = [] + if (max <= 0) return { treffer, gedeckelt: true } + let cursor = null + for (;;) { + const seite = await listDocuments(sammlung, [ + ...filter, + Query.select(['$id', ...felder]), + Query.orderAsc('$id'), + Query.limit(SEITE), + ...(cursor ? [Query.cursorAfter(cursor)] : []), + ]) + for (const doc of seite) { + if (!passt(doc)) continue + if (treffer.length >= max) return { treffer, gedeckelt: true } + treffer.push(doc) + } + if (seite.length < SEITE) return { treffer, gedeckelt: false } + cursor = seite[seite.length - 1].$id + } +} + +/** Anfragen mit Rechnung: Menge der anfrageIds aller ukRechnungen (nur ein Feld gelesen). */ +async function anfragenMitRechnung() { + const alle = await listAllDocuments(config.collections.ukRechnungen, [Query.select(['$id', 'anfrageId'])]) + return new Set(alle.map((r) => r.anfrageId).filter(Boolean)) +} + +/** + * Tagesarbeit der Aufbewahrung. + * @param {Date} now + * @param {{ deckel?: number }} [opt] hoechstens so viele Schreib-/Loeschvorgaenge (Standard 500) + * @returns {Promise<{ verbraucht: number (genutzter Deckel), anfragen: number, gaeste: number, ipHashes: number, abgelaufen: number, rechnungen: number, adressen: number, fehler: number, gedeckelt: boolean }>} + * anfragen = unverbindliche Anfragen anonymisiert, gaeste = Gastdaten gebuchter Aufenthalte anonymisiert + */ +export async function anonymisiere(now, { deckel = config.unterkunft.aufbewahrungDeckel ?? STANDARD_DECKEL } = {}) { + const nowMs = nowMsVon(now) + const erg = { anfragen: 0, gaeste: 0, ipHashes: 0, abgelaufen: 0, rechnungen: 0, adressen: 0, fehler: 0, gedeckelt: false, verbraucht: 0 } + let rest = Number.isInteger(deckel) && deckel > 0 ? deckel : STANDARD_DECKEL + const start = rest + const jetztIso = new Date(nowMs).toISOString() + const AN = config.collections.ukAnfragen + const c = config.unterkunft + + const bearbeite = async (name, { treffer, gedeckelt }, arbeit) => { + if (gedeckelt) erg.gedeckelt = true + for (const doc of treffer) { + if (rest <= 0) { + erg.gedeckelt = true + return + } + try { + if (await arbeit(doc)) rest -= 1 + } catch (err) { + erg.fehler += 1 + console.warn(`[unterkunft-aufbewahrung] ${name} ${doc.$id} fehlgeschlagen: ${fehlerText(err)}`) + } + } + } + const schritt = async (name, arbeit) => { + try { + await arbeit() + } catch (err) { + erg.fehler += 1 + console.warn(`[unterkunft-aufbewahrung] ${name} fehlgeschlagen: ${fehlerText(err)}`) + } + } + + // 1. ipHash leeren (30 Tage nach Anlage) + await schritt('ipHash', async () => { + const grenze = nowMs - c.ipHashTage * TAG_MS + const gefunden = await sammle(AN, [Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['ipHash', '$createdAt'], (d) => d.ipHash && zeitMs(d.$createdAt) < grenze, rest) + await bearbeite('ipHash', gefunden, async (d) => { + await aktualisiereAnfrage(d.$id, { ipHash: '' }) + erg.ipHashes += 1 + return true + }) + }) + + // 2. neu, nach neuVerfallTage nicht entschieden: abgelaufen (ohne Mail) + await schritt('neu verfallen', async () => { + const grenze = nowMs - c.neuVerfallTage * TAG_MS + const gefunden = await sammle(AN, [Query.equal('status', 'neu'), Query.lessThan('$createdAt', new Date(grenze).toISOString())], ['status', 'unterkunftId', '$createdAt'], (d) => zeitMs(d.$createdAt) < grenze, rest) + await bearbeite('neu verfallen', gefunden, async (d) => { + const neu = await withLocks(['unterkunft:' + d.unterkunftId], async () => { // derselbe Schluessel wie ablauf.js (gibFrei/lehneAb/Fristen) + const a = await ladeAnfrage(d.$id) + if (!a || a.status !== 'neu') return null + return aktualisiereAnfrage(a.$id, { status: 'abgelaufen', abgelaufenAt: jetztIso, ipHash: '' }) + }) + if (!neu) return false + erg.abgelaufen += 1 + return true + }) + }) + + // 3. + 4. Anonymisieren + await schritt('anonymisieren', async () => { + const mitRechnung = await anfragenMitRechnung() + const hatRechnung = (d) => Boolean(d.rechnungId) || mitRechnung.has(d.$id) + const unverbindlichGrenze = minusMonate(nowMs, c.unverbindlichMonate) + const gebuchtJahr = jahrVon(heute(new Date(nowMs))) + + const unv = await sammle( + AN, + [Query.equal('status', ['abgelehnt', 'abgelaufen', 'storniert']), Query.lessThan('$createdAt', new Date(unverbindlichGrenze).toISOString())], + ['status', 'rechnungId', 'anonymisiertAt', '$createdAt'], + (d) => !d.anonymisiertAt && zeitMs(d.$createdAt) < unverbindlichGrenze && !(d.status === 'storniert' && hatRechnung(d)), + rest, + ) + await bearbeite('anonymisieren', unv, async (d) => { + await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, rechnungsadresse: null, anonymisiertAt: jetztIso }) + erg.anfragen += 1 + return true + }) + + const gebucht = await sammle( + AN, + [Query.equal('status', ['gebucht', 'bezahlt', 'storniert'])], + ['status', 'rechnungId', 'anonymisiertAt', 'bis'], + // Abreisejahr Y: faellig, sobald das Berliner Jahr >= Y + 3 + 1 ist (3 Jahre nach Ende des Jahres Y) + (d) => !d.anonymisiertAt && gueltigerTag(d.bis) && gebuchtJahr >= jahrVon(d.bis) + ANONYM_JAHRE + 1 && (d.status !== 'storniert' || hatRechnung(d)), + rest, + ) + await bearbeite('anonymisieren', gebucht, async (d) => { + // ohne Rechnung gibt es keinen Beleg, der die Rechnungsadresse schuetzt: sie geht mit den Gastdaten + await aktualisiereAnfrage(d.$id, { ...ANFRAGE_ANONYM, ...(hatRechnung(d) ? {} : { rechnungsadresse: null }), anonymisiertAt: jetztIso }) + erg.gaeste += 1 + return true + }) + }) + + // 5. Rechnungen nach 8 Jahren loeschen, vorher die Rechnungsadresse der Anfrage anonymisieren + await schritt('rechnungen', async () => { + const jahr = jahrVon(heute(new Date(nowMs))) + const RE = config.collections.ukRechnungen + const faellig = (r) => gueltigerTag(r.datum) && jahr >= jahrVon(r.datum) + RECHNUNG_JAHRE + 1 + const gefunden = await sammle(RE, [], ['anfrageId', 'datum'], faellig, rest) + if (gefunden.gedeckelt) erg.gedeckelt = true + const erledigt = new Set() + for (const r of gefunden.treffer) { + if (rest <= 0) { + erg.gedeckelt = true + return + } + try { + // Adresse erst, wenn keine nicht faellige Rechnung der Anfrage uebrig ist (sonst braucht die Anfrage sie noch) + if (r.anfrageId && !erledigt.has(r.anfrageId)) { + const alle = await listAllDocuments(RE, [Query.equal('anfrageId', r.anfrageId), Query.select(['$id', 'datum'])]) + if (alle.every(faellig)) { + const a = await ladeAnfrage(r.anfrageId) + if (a && (a.rechnungsadresse !== null || !a.anonymisiertAt)) { + await aktualisiereAnfrage(a.$id, { rechnungsadresse: null, ...(a.anonymisiertAt ? {} : { ...ANFRAGE_ANONYM, anonymisiertAt: jetztIso }) }) + erg.adressen += 1 + rest -= 1 + } + erledigt.add(r.anfrageId) + } + } + try { + await deleteDocument(RE, r.$id) + } catch (err) { + if (err?.status !== 404) throw err + } + erg.rechnungen += 1 + rest -= 1 + } catch (err) { + erg.fehler += 1 + console.warn(`[unterkunft-aufbewahrung] rechnungen ${r.$id} fehlgeschlagen: ${fehlerText(err)}`) + } + } + }) + + erg.verbraucht = start - rest + return erg +} + +const GRUPPEN = ['ukAnfragen', 'ukRechnungen', 'ukSperren', 'ukPreise', 'ukUnterkuenfte'] + +/** + * Alle uk*-Daten EINER Funktion (featureId) loeschen: Anfragen, Rechnungen, Sperren, Preise, Unterkuenfte und zuletzt die Einstellungen + * (ukEinstellungen, Dokument-ID = featureId). Seitenweise (immer die erste Seite neu), am Deckel endet der Lauf (vollstaendig: false). + * @returns {Promise<{ geloescht: number, vollstaendig: boolean, abgebrochen: boolean }>} + */ +export async function loescheUkDaten({ featureId, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true } = {}) { + if (typeof featureId !== 'string' || featureId === '') throw new TypeError('loescheUkDaten: featureId fehlt') + const erg = { geloescht: 0, vollstaendig: false, abgebrochen: false } + for (const name of GRUPPEN) { + const sammlung = config.collections[name] + for (;;) { + if (!(await nochGueltig())) return { ...erg, abgebrochen: true } + const rest = deckel - erg.geloescht + if (rest <= 0) return erg + let seite + try { + seite = await listDocuments(sammlung, [Query.equal('featureId', featureId), Query.select(['$id']), Query.limit(Math.min(SEITE, rest))]) + } catch (err) { + if (sammlungFehlt(err)) break + throw err + } + if (seite.length === 0) break + for (const doc of seite) { + try { + await deleteDocument(sammlung, doc.$id) + } catch (err) { + if (err?.status !== 404) throw err + } + erg.geloescht += 1 + } + } + } + if (!(await nochGueltig())) return { ...erg, abgebrochen: true } + try { + await deleteDocument(config.collections.ukEinstellungen, featureId) + erg.geloescht += 1 + } catch (err) { + if (err?.status !== 404 && !sammlungFehlt(err)) throw err + } + erg.vollstaendig = true + return erg +} + +/** Hat die Funktion (featureId) noch uk*-Daten? (fuer die Kundenloeschung in retention.js; fehlende Collections zaehlen als leer) */ +export async function hatUkDaten(featureId) { + for (const name of ['ukUnterkuenfte', 'ukAnfragen', 'ukRechnungen', 'ukSperren', 'ukPreise', 'ukEinstellungen']) { + try { + const treffer = await listDocuments(config.collections[name], [Query.equal('featureId', featureId), Query.select(['$id']), Query.limit(1)]) + if (treffer.length) return true + } catch (err) { + if (!sammlungFehlt(err)) throw err + } + } + return false +} + +/** Marker an die bisherige Notiz haengen (eine alte Marke wird ersetzt, der Rest bleibt; hoechstens 300 Zeichen, der Marker bleibt ganz). */ +function markiere(notiz, tag) { + const marker = `${MARKER}${tag}` + const alt = String(notiz ?? '').replace(/\s*(?:[-–|]\s*)?Buchungsdaten gelöscht am \d{4}-\d{2}-\d{2}/g, '').trim() + if (!alt) return marker + return `${alt.slice(0, Math.max(0, 300 - marker.length - 3))} – ${marker}` +} + +/** + * Abgeschaltete Buchungskalender (portalFeatures.state disabled, disabledAt aelter als loeschTageNachAbschalten = 90 Tage): alle uk*-Daten + * loeschen, danach Marker in adminNote (wie beim Shop; ein spaeteres Abschalten setzt disabledAt neu und gilt wieder). + * @returns {Promise<{ funktionen: number, dokumente: number, fehler: number, gedeckelt: boolean }>} + */ +export async function loescheAbgeschaltete(now, { deckel = STANDARD_DECKEL } = {}) { + const nowMs = nowMsVon(now) + const tage = config.unterkunft.loeschTageNachAbschalten ?? 90 + const grenze = nowMs - tage * TAG_MS + const FEATURES = config.collections.portalFeatures + const erg = { funktionen: 0, dokumente: 0, fehler: 0, gedeckelt: false } + let rest = deckel + const funktionen = await listAllDocuments(FEATURES, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'disabled')]) + for (const f of funktionen) { + if (!(zeitMs(f.disabledAt) < grenze)) continue + const marker = MARKER_RE.exec(String(f.adminNote ?? '')) + if (marker && marker[1] >= String(f.disabledAt).slice(0, 10)) continue + if (rest <= 0) { + erg.gedeckelt = true + return erg + } + try { + // vor jeder Seite frisch lesen: das Team kann die Funktion zwischenzeitlich wieder einschalten + const nochAus = async () => { + const frisch = await getDocument(FEATURES, f.$id) + return frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze + } + const weg = await loescheUkDaten({ featureId: f.$id, deckel: rest, nochGueltig: nochAus }) + rest -= weg.geloescht + erg.dokumente += weg.geloescht + if (f.siteKey) leereCache(f.siteKey) + if (weg.vollstaendig) { + erg.funktionen += 1 + const frisch = await getDocument(FEATURES, f.$id) + if (frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) { + await updateDocument(FEATURES, f.$id, { adminNote: markiere(frisch.adminNote, heute(new Date(nowMs))) }) + } + } else if (!weg.abgebrochen) { + erg.gedeckelt = true + } + } catch (err) { + erg.fehler += 1 + console.warn(`[unterkunft-aufbewahrung] Funktion ${f.$id} nicht vollständig gelöscht: ${fehlerText(err)}`) + } + } + return erg +} + +/** + * Kundenloeschung (customerAdmin.js): schliesst die Buchungskalender des Kunden fuer neue Anfragen (alle Unterkuenfte aktiv=false) und + * leert den Speicher der oeffentlichen Schnittstelle. Die Funktion selbst schaltet disableCustomerFeatures ab; die Daten loescht + * loescheAbgeschaltete 90 Tage nach dem Abschalten. Scheitert eines, werden die uebrigen trotzdem geschlossen, der erste Fehler wird weitergereicht. + * @returns {Promise} Zahl der geschlossenen Unterkuenfte + */ +export async function schliesseUkVonKunde(customerId) { + const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.equal('featureKey', 'unterkunft')]) + let geschlossen = 0 + let erster = null + for (const f of funktionen) { + try { + const unterkuenfte = await listAllDocuments(config.collections.ukUnterkuenfte, [Query.equal('featureId', f.$id)]) + for (const u of unterkuenfte) { + if (u.aktiv !== false) await updateDocument(config.collections.ukUnterkuenfte, u.$id, { aktiv: false }) + geschlossen += 1 + } + } catch (err) { + if (!sammlungFehlt(err)) erster ||= err + } + if (f.siteKey) leereCache(f.siteKey) + } + if (erster) throw erster + return geschlossen +} diff --git a/server/services/unterkunft/freischaltung.js b/server/services/unterkunft/freischaltung.js index cf66a75..47a21f3 100644 --- a/server/services/unterkunft/freischaltung.js +++ b/server/services/unterkunft/freischaltung.js @@ -1,6 +1,33 @@ -// Stub (Task 5): Freischaltpruefung des Buchungskalenders. Task 11 ersetzt dieses Modul. +import { preiseVollstaendig } from './preis.js' +import { ladeEinstellungen, ladePreise, listeUnterkuenfte } from './speicher.js' -/** freischaltbar-Haken (Stub, fail-closed: nie freigeben). */ -export async function freischaltbar() { - return false +/** + * freischaltbar-Haken des Buchungskalenders (featureRegistry, Zustand 'setup', Ticket geschlossen): darf die Funktion jetzt aktiv + * werden? Ja, wenn der Kunde alles hinterlegt hat, was Rechnungen und Buchungen brauchen: + * - Einstellungen: firma, strasse, plz, ort, email und (steuernummer oder ustId), dazu rechnungAuftragAt (Zustimmung zur + * Rechnungsstellung im Namen des Vermieters); + * - mindestens eine aktive Unterkunft mit vollstaendigen Preisen (preiseVollstaendig) und Stornobedingungen. + * Fail-closed: bei jedem Lesefehler false. + * @param {{ $id: string }} doc portalFeatures-Dokument + * @returns {Promise} + */ +export async function freischaltbar(doc) { + try { + const featureId = doc?.$id + if (typeof featureId !== 'string' || !featureId) return false + const einst = await ladeEinstellungen(featureId) + if (!einst) return false + const text = (wert) => typeof wert === 'string' && wert.trim() !== '' + if (!['firma', 'strasse', 'plz', 'ort', 'email'].every((k) => text(einst[k]))) return false + if (!text(einst.steuernummer) && !text(einst.ustId)) return false + if (!einst.rechnungAuftragAt) return false + const unterkuenfte = await listeUnterkuenfte(featureId, { nurAktive: true }) + for (const u of unterkuenfte) { + const preise = await ladePreise(u.$id) + if (preiseVollstaendig(preise) && text(preise.stornobedingungen)) return true + } + return false + } catch { + return false + } } diff --git a/server/services/unterkunft/ical.js b/server/services/unterkunft/ical.js new file mode 100644 index 0000000..ed1dc05 --- /dev/null +++ b/server/services/unterkunft/ical.js @@ -0,0 +1,161 @@ +import { heute as berlinHeute } from '../termine/zeit.js' +import { istTag, naechte, plusTage } from './datum.js' +import { config } from '../../config.js' + +/** + * Buchungskalender: iCalendar lesen und schreiben (reine Funktionen, keine I/O). Gelesen werden nur VEVENT mit Anfangs- und + * Enddatum (DATE, lokale Zeit, TZID oder UTC); mehr braucht die Belegung nicht. Ergebnis sind Berliner Kalendertage [von, bis) + * wie bei allen Sperren. Wiederholungsregeln (RRULE) werden nicht aufgeloest: Booking.com und Airbnb liefern Einzeltermine. + */ + +const MAX_EREIGNISSE = 2000 +const MAX_NAECHTE = 731 + +/** Zeilenumbrueche entfalten: eine Zeile, die mit Leerzeichen oder Tab beginnt, setzt die vorige fort. */ +function entfalte(text) { + const out = [] + for (const z of text.split(/\r\n|\n|\r/)) { + if ((z.startsWith(' ') || z.startsWith('\t')) && out.length) out[out.length - 1] += z.slice(1) + else out.push(z) + } + return out +} + +/** 'NAME;PARAM=x:Wert' => { name (gross), params, wert }. Doppelpunkte in Anfuehrungszeichen der Parameter zaehlen nicht. */ +function zeile(z) { + let inAnf = false + let doppelpunkt = -1 + for (let i = 0; i < z.length; i++) { + const c = z[i] + if (c === '"') inAnf = !inAnf + else if (c === ':' && !inAnf) { + doppelpunkt = i + break + } + } + if (doppelpunkt < 1) return null + const kopf = z.slice(0, doppelpunkt).split(';') + const params = {} + for (const p of kopf.slice(1)) { + const i = p.indexOf('=') + if (i > 0) params[p.slice(0, i).toUpperCase()] = p.slice(i + 1).replace(/^"|"$/g, '') + } + return { name: kopf[0].trim().toUpperCase(), params, wert: z.slice(doppelpunkt + 1).trim() } +} + +const DATUM_RE = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/ + +/** iCal-Datum oder -Zeitpunkt => Berliner Kalendertag oder null. UTC (Z) wird nach Berlin umgerechnet, alles andere gilt als lokal. */ +function tagVon(wert) { + const m = DATUM_RE.exec(wert) + if (!m) return null + const [, j, mo, t, h, mi, s, z] = m + if (z && h !== undefined) { + const d = new Date(Date.UTC(+j, +mo - 1, +t, +h, +mi, +(s ?? 0))) + return Number.isNaN(d.getTime()) ? null : berlinHeute(d) + } + const tag = `${j}-${mo}-${t}` + return istTag(tag) ? tag : null +} + +function dauerTage(wert) { + const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T.*)?$/.exec(wert) + if (!m) return null + const tage = (m[1] ? +m[1] * 7 : 0) + (m[2] ? +m[2] : 0) + return tage > 0 ? tage : null +} + +/** + * ICS-Text lesen. Ereignisse mit STATUS:CANCELLED, ohne lesbaren Anfang, die vor gestern enden oder nach dem Horizont beginnen + * fallen weg. Fehlt das Ende, ist es eine Nacht. Kein VCALENDAR (z. B. HTML-Fehlerseite) => Error('kein_ics'). + * @param {string} text + * @param {{ heute?: string, horizontTage?: number }} [opt] heute: Berliner Tag (Vorgabe: jetzt); horizontTage: Vorgabe 31 x horizontMonate + */ +export function parseIcs(text, { heute = berlinHeute(), horizontTage = config.unterkunft.horizontMonate * 31 } = {}) { + if (typeof text !== 'string' || !/^\s*BEGIN:VCALENDAR/i.test(text.replace(/^\uFEFF/, ''))) throw new Error('kein_ics') + const gestern = plusTage(heute, -1) + const horizont = plusTage(heute, horizontTage) + const ereignisse = [] + const gesehen = new Set() + let ev = null + let tiefe = 0 + let nr = 0 + for (const roh of entfalte(text.replace(/^\uFEFF/, ''))) { + const p = zeile(roh) + if (!p) continue + if (p.name === 'BEGIN') { + if (p.wert.toUpperCase() === 'VEVENT') { + ev = { uid: '', start: null, ende: null, dauer: null, status: '' } + tiefe = 0 + } else if (ev) tiefe++ + continue + } + if (p.name === 'END') { + if (p.wert.toUpperCase() === 'VEVENT' && ev) { + const e = ev + ev = null + nr++ + if (e.status === 'CANCELLED' || !e.start) continue + let bis = e.ende + if (!bis && e.dauer) bis = plusTage(e.start, e.dauer) + if (!bis || bis <= e.start) bis = plusTage(e.start, 1) + if (naechte(e.start, bis) > MAX_NAECHTE) bis = plusTage(e.start, MAX_NAECHTE) + if (bis < gestern || e.start > horizont) continue + let uid = e.uid || `ohne-uid-${nr}` + if (gesehen.has(uid)) uid = `${uid}#${nr}` + gesehen.add(uid) + if (ereignisse.length < MAX_EREIGNISSE) ereignisse.push({ von: e.start, bis, uid }) + } else if (ev && tiefe > 0) tiefe-- + continue + } + if (!ev || tiefe > 0) continue + if (p.name === 'UID') ev.uid = p.wert.slice(0, 200) + else if (p.name === 'STATUS') ev.status = p.wert.toUpperCase() + else if (p.name === 'DTSTART') ev.start = tagVon(p.wert) + else if (p.name === 'DTEND') ev.ende = tagVon(p.wert) + else if (p.name === 'DURATION') ev.dauer = dauerTage(p.wert) + } + return { ereignisse } +} + +// ---- Schreiben + +/** Zeile auf hoechstens 75 Oktette falten (Fortsetzungszeilen beginnen mit einem Leerzeichen); schneidet nie mitten in ein Zeichen. */ +function falte(z) { + const teile = [] + let aktuell = '' + let bytes = 0 + let limit = 75 + for (const zeichen of z) { + const b = Buffer.byteLength(zeichen) + if (bytes + b > limit) { + teile.push(aktuell) + aktuell = ' ' + bytes = 1 + limit = 75 + } + aktuell += zeichen + bytes += b + } + teile.push(aktuell) + return teile +} + +const maskiere = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n') +const ohneTrenner = (tag) => tag.replace(/-/g, '') +const zeitstempel = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') + +/** + * Kalender mit ganztaegigen Ereignissen "Belegt" bauen (CRLF, gefaltet). Absichtlich keine Gastdaten. + * @param {{ name: string, ereignisse: {uid: string, von: string, bis: string}[], jetzt?: Date }} eingabe + */ +export function baueIcs({ name, ereignisse, jetzt = new Date() }) { + const stempel = zeitstempel(jetzt) + const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//WEBklar//Buchungskalender//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH'] + if (name) z.push(`X-WR-CALNAME:${maskiere(name)}`) + for (const e of ereignisse) { + z.push('BEGIN:VEVENT', `UID:${e.uid}`, `DTSTAMP:${stempel}`, `DTSTART;VALUE=DATE:${ohneTrenner(e.von)}`, `DTEND;VALUE=DATE:${ohneTrenner(e.bis)}`, 'SUMMARY:Belegt', 'TRANSP:OPAQUE', 'END:VEVENT') + } + z.push('END:VCALENDAR') + return z.flatMap(falte).join('\r\n') + '\r\n' +} diff --git a/server/services/unterkunft/icalAbgleich.js b/server/services/unterkunft/icalAbgleich.js new file mode 100644 index 0000000..2b1e22e --- /dev/null +++ b/server/services/unterkunft/icalAbgleich.js @@ -0,0 +1,367 @@ +import dns from 'node:dns/promises' +import https from 'node:https' +import net from 'node:net' +import { Readable } from 'node:stream' +import { config } from '../../config.js' +import { listAllDocuments, Query } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { heute as berlinHeute } from '../termine/zeit.js' +import { parseIcs } from './ical.js' +import { ladeFuerProjekt } from './kontext.js' +import * as mails from './mails.js' +import { unterkunftFuerKunde, unterkunftModus } from './schalter.js' +import { ladeEinstellungen, ladeUnterkunft, legeSperreAn, listeSperren, listeUnterkuenfte, loescheSperre, speichereEinstellungen, speichereUnterkunft } from './speicher.js' + +/** + * Buchungskalender: iCal-Import. holeIcs ruft eine fremde Adresse ab (Booking.com, Airbnb, ...) und ist deshalb gegen SSRF + * gehaertet: nur https auf Port 443, keine Zugangsdaten, der Host wird selbst aufgeloest, jede Adresse muss oeffentlich sein, + * und der Abruf verbindet sich genau mit der geprueften Adresse (kein zweites DNS, also kein Rebinding). Weiterleitungen + * (hoechstens 3) werden jeweils neu geprueft. Fehler tragen einen Code (err.code) und nie die Adresse: Booking- und + * Airbnb-Adressen enthalten ein Geheimnis, deshalb wird nirgends eine volle Adresse protokolliert (nur der Host). + */ + +const MAX_WEITERLEITUNGEN = 3 +const TAG_MS = 24 * 3600 * 1000 +const MAX_IMPORTE_JE_LAUF = 200 + +class IcalFehler extends Error { + constructor(code) { + super(code) + this.code = code + } +} + +// ---- Adresspruefung + +function v4Privat(a) { + const [o1, o2, o3] = a.split('.').map(Number) + return ( + o1 === 0 || // "dieses Netz" inkl. 0.0.0.0 + o1 === 10 || + (o1 === 100 && o2 >= 64 && o2 <= 127) || // CGNAT + o1 === 127 || + (o1 === 169 && o2 === 254) || // Link-Local, Cloud-Metadaten + (o1 === 172 && o2 >= 16 && o2 <= 31) || + (o1 === 192 && o2 === 0 && o3 === 0) || + (o1 === 192 && o2 === 0 && o3 === 2) || + (o1 === 192 && o2 === 168) || + (o1 === 198 && (o2 === 18 || o2 === 19)) || + (o1 === 198 && o2 === 51 && o3 === 100) || + (o1 === 203 && o2 === 0 && o3 === 113) || + o1 >= 224 // Multicast, reserviert, Broadcast + ) +} + +function v6Gruppen(adresse) { + let a = adresse.toLowerCase().split('%')[0] + const m = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(a) + if (m) { + const b = m[2].split('.').map(Number) + a = `${m[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}` + } + const teile = a.split('::') + if (teile.length > 2) return null + const kopf = teile[0] ? teile[0].split(':') : [] + const rest = teile.length === 2 && teile[1] ? teile[1].split(':') : [] + let g + if (teile.length === 2) { + const luecke = 8 - kopf.length - rest.length + if (luecke < 1) return null + g = [...kopf, ...Array(luecke).fill('0'), ...rest] + } else g = kopf + if (g.length !== 8 || g.some((x) => !/^[0-9a-f]{1,4}$/.test(x))) return null + return g.map((x) => parseInt(x, 16)) +} + +const v4Aus = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}` + +function v6Privat(adresse) { + const g = v6Gruppen(adresse) + if (!g) return true + const nullBis = (n) => g.slice(0, n).every((x) => x === 0) + if (nullBis(5) && g[5] === 0xffff) return v4Privat(v4Aus(g[6], g[7])) // IPv4-mapped + if (nullBis(6)) return true // ::, ::1, IPv4-kompatibel + if (g[0] === 0x64 && g[1] === 0xff9b) return true // NAT64 + if (g[0] === 0x2002) return v4Privat(v4Aus(g[1], g[2])) // 6to4 + if (g[0] === 0x2001 && g[1] === 0) return true // Teredo + if (g[0] === 0x2001 && g[1] === 0xdb8) return true // Dokumentation + if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true // Discard + return (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xff00) === 0xff00 +} + +/** true fuer alles, was nicht eindeutig eine oeffentliche IPv4-/IPv6-Adresse ist. */ +export function istPrivateAdresse(adresse) { + if (net.isIPv4(adresse)) return v4Privat(adresse) + if (net.isIPv6(adresse)) return v6Privat(adresse) + return true +} + +// ---- Abruf + +/** Standard-Abruf: https.request mit fester Adresse (lookup-Override), SNI und Zertifikatspruefung gegen den Hostnamen. */ +function festerAbruf(url, { adresse, signal }) { + return new Promise((resolve, reject) => { + const u = new URL(url) + const host = u.hostname.replace(/^\[|\]$/g, '') + const req = https.request( + { + hostname: host, + port: 443, + path: `${u.pathname}${u.search}`, + method: 'GET', + headers: { 'user-agent': 'WEBklar-Buchungskalender/1.0', accept: 'text/calendar, text/plain;q=0.9, */*;q=0.1', 'accept-encoding': 'identity' }, + lookup: (_h, opts, cb) => (opts?.all ? cb(null, [{ address: adresse.address, family: adresse.family }]) : cb(null, adresse.address, adresse.family)), + signal, + }, + (res) => { + const headers = new Headers() + for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v) + const ohneKoerper = [204, 205, 304].includes(res.statusCode) + if (ohneKoerper) res.resume() + resolve(new Response(ohneKoerper ? null : Readable.toWeb(res), { status: res.statusCode, headers })) + }, + ) + req.on('error', reject) + req.end() + }) +} + +async function pruefeZiel(url, lookup) { + let u + try { + u = new URL(url) + } catch { + throw new IcalFehler('ungueltige_adresse') + } + if (u.protocol !== 'https:' || !u.hostname || u.username || u.password || (u.port && u.port !== '443')) throw new IcalFehler('ungueltige_adresse') + const host = u.hostname.replace(/^\[|\]$/g, '') + let adressen + if (net.isIP(host)) adressen = [{ address: host, family: net.isIPv6(host) ? 6 : 4 }] + else { + try { + adressen = await lookup(host, { all: true }) + } catch { + throw new IcalFehler('netzwerk') + } + } + if (!adressen?.length) throw new IcalFehler('netzwerk') + if (adressen.some((a) => istPrivateAdresse(a.address))) throw new IcalFehler('private_adresse') + return { adresse: adressen[0] } +} + +async function liesBegrenzt(res, max) { + const laenge = Number(res.headers.get('content-length')) + if (Number.isFinite(laenge) && laenge > max) { + await res.body?.cancel().catch(() => {}) + throw new IcalFehler('zu_gross') + } + if (!res.body) return '' + const reader = res.body.getReader() + const teile = [] + let summe = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + summe += value.byteLength + if (summe > max) { + await reader.cancel().catch(() => {}) + throw new IcalFehler('zu_gross') + } + teile.push(value) + } + return Buffer.concat(teile).toString('utf8') +} + +/** + * ICS-Text von einer Adresse holen. Fehler: Error mit code ungueltige_adresse | private_adresse | netzwerk | timeout | zu_gross | + * zu_viele_weiterleitungen | http_. + * @param {string} url + * @param {{ fetch?: Function, lookup?: Function }} [opt] fetch(url, { adresse, signal, redirect: 'manual' }) => Response; lookup(host, { all: true }) (Tests) + */ +export async function holeIcs(url, { fetch: abruf = festerAbruf, lookup = dns.lookup } = {}) { + const ac = new AbortController() + const timer = setTimeout(() => ac.abort(), config.unterkunft.icalTimeoutMs) + try { + let aktuell = url + for (let sprung = 0; ; sprung++) { + const { adresse } = await pruefeZiel(aktuell, lookup) + const res = await abruf(aktuell, { adresse, signal: ac.signal, redirect: 'manual' }) + if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { + await res.body?.cancel().catch(() => {}) + if (sprung >= MAX_WEITERLEITUNGEN) throw new IcalFehler('zu_viele_weiterleitungen') + try { + aktuell = new URL(res.headers.get('location'), aktuell).href + } catch { + throw new IcalFehler('ungueltige_adresse') + } + continue + } + if (res.status !== 200) { + await res.body?.cancel().catch(() => {}) + throw new IcalFehler(`http_${res.status}`) + } + return await liesBegrenzt(res, config.unterkunft.icalMaxBytes) + } + } catch (err) { + if (err instanceof IcalFehler) throw err + throw new IcalFehler(ac.signal.aborted ? 'timeout' : 'netzwerk') + } finally { + clearTimeout(timer) + } +} + +// ---- Abgleich + +const ms = (now) => (now instanceof Date ? now.getTime() : Number(now)) +const iso = (t) => new Date(t).toISOString() +const lockKey = (id) => ['unterkunft:' + id] +const hostVon = (url) => { + try { + return new URL(url).hostname + } catch { + return '?' + } +} + +/** '07.11.2026, 06:00' in Berliner Zeit. */ +function berlinZeit(t) { + const p = Object.fromEntries( + new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + .formatToParts(new Date(t)) + .map((x) => [x.type, x.value]), + ) + return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute}` +} + +async function ersetzeEintrag(unterkunft, importId, aenderung) { + const u = await ladeUnterkunft(unterkunft.$id) + const liste = Array.isArray(u?.icalImporte) ? u.icalImporte : [] + const i = liste.findIndex((x) => x.id === importId) + if (i < 0) return null + const neu = aenderung(liste[i]) + const kopie = [...liste] + kopie[i] = neu + await speichereUnterkunft(u.featureId, { icalImporte: kopie }, u.$id) + return neu +} + +/** Fehlermail K33, wenn der Import seit icalFehlerStunden nicht mehr gelang, hoechstens einmal je 24 h und Funktion. Wirft nie. */ +async function pruefeFehlerMail(unterkunft, eintrag, jetzt, ctxVorgabe) { + try { + const seit = Date.parse(eintrag.letzterErfolgAt) || Date.parse(eintrag.fehlerSeit) + if (!Number.isFinite(seit) || jetzt - seit < config.unterkunft.icalFehlerStunden * 3600e3) return + const einst = await ladeEinstellungen(unterkunft.featureId) + const letzte = Date.parse(einst?.icalFehlerMailAt) + if (Number.isFinite(letzte) && jetzt - letzte < TAG_MS) return + const ctx = ctxVorgabe ?? (await ladeFuerProjekt(unterkunft.customerId, unterkunft.projectId)) ?? { featureId: unterkunft.featureId, customerId: unterkunft.customerId, projectId: unterkunft.projectId, einstellungen: einst ?? {} } + await mails.anVermieter('K33', ctx, { unterkunft: unterkunft.name, quelle: eintrag.name, seit: berlinZeit(seit) }) + await speichereEinstellungen(unterkunft.featureId, { icalFehlerMailAt: iso(jetzt) }) + } catch (err) { + console.warn(`[unterkunft-ical] Fehlermail fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } +} + +/** + * Einen Import abgleichen: Feed holen, lesen, die Sperren 'ical:' angleichen (fehlende zuerst anlegen, dann + * ueberzaehlige loeschen) und den Eintrag in icalImporte nachfuehren. Bei einem Fehler bleiben die alten Sperren stehen. + * @returns {Promise<{ok: true, anzahl: number} | {ok: false, fehler: string}>} + */ +export async function gleicheAb(unterkunft, importEintrag, { now = new Date(), fetch, lookup, ctx } = {}) { + const jetzt = ms(now) + const importId = importEintrag.id + const quelle = `ical:${importId}` + let ereignisse = null + let code = null + try { + const text = await holeIcs(importEintrag.url, { fetch, lookup }) + ereignisse = parseIcs(text, { heute: berlinHeute(new Date(jetzt)) }).ereignisse + } catch (err) { + code = err?.code || (err?.message === 'kein_ics' ? 'kein_ics' : 'netzwerk') + } + + const ergebnis = await withLocks(lockKey(unterkunft.$id), async () => { + const frisch = await ladeUnterkunft(unterkunft.$id) + const aktuellerEintrag = (Array.isArray(frisch?.icalImporte) ? frisch.icalImporte : []).find((x) => x.id === importId) + if (!aktuellerEintrag) return { ok: false, fehler: 'import_entfernt' } + if (code) { + const neu = await ersetzeEintrag(frisch, importId, (e) => ({ ...e, letzterAbrufAt: iso(jetzt), fehler: code, fehlerSeit: e.fehlerSeit || iso(jetzt) })) + return { ok: false, fehler: code, eintrag: neu } + } + const alt = (await listeSperren(frisch.$id)).filter((s) => s.quelle === quelle) + const behalten = new Set() + const vorhanden = new Map() + const ueberzaehlig = [] + for (const s of alt) { + const k = `${s.von}|${s.bis}` + if (vorhanden.has(k)) ueberzaehlig.push(s) + else vorhanden.set(k, s) + } + const wuensche = new Map() + for (const e of ereignisse) wuensche.set(`${e.von}|${e.bis}`, e) + for (const [k, e] of wuensche) { + if (vorhanden.has(k)) behalten.add(k) + else await legeSperreAn({ unterkunftId: frisch.$id, featureId: frisch.featureId, von: e.von, bis: e.bis, quelle, notiz: String(importEintrag.name ?? '').slice(0, 200) }) + } + for (const [k, s] of vorhanden) if (!behalten.has(k)) ueberzaehlig.push(s) + for (const s of ueberzaehlig) await loescheSperre(s.$id) + await ersetzeEintrag(frisch, importId, (e) => { + const { fehlerSeit, ...rest } = e + void fehlerSeit + return { ...rest, letzterAbrufAt: iso(jetzt), letzterErfolgAt: iso(jetzt), fehler: '' } + }) + return { ok: true, anzahl: wuensche.size } + }) + + if (!ergebnis.ok && ergebnis.eintrag) { + await pruefeFehlerMail(unterkunft, ergebnis.eintrag, jetzt, ctx) + return { ok: false, fehler: ergebnis.fehler } + } + return ergebnis.ok ? { ok: true, anzahl: ergebnis.anzahl } : { ok: false, fehler: ergebnis.fehler } +} + +/** + * Alle faelligen Importe aller aktiven Buchungskalender nacheinander abgleichen (Viertelstundenjob). Hoechstens maxImporte je Lauf; + * ein Fehler stoppt die anderen nicht. Protokoll nur mit Host, nie mit der vollen Adresse. + * @returns {Promise<{importe: number, erfolg: number, fehler: number}>} + */ +export async function gleicheAlleAb(now = new Date(), { fetch, lookup, maxImporte = MAX_IMPORTE_JE_LAUF } = {}) { + const jetzt = ms(now) + const zaehler = { importe: 0, erfolg: 0, fehler: 0 } + const faellig = (e) => { + const letzter = Date.parse(e.letzterAbrufAt) + return !Number.isFinite(letzter) || jetzt - letzter >= (config.unterkunft.icalIntervallMin - 1) * 60e3 + } + const features = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'active')]) + for (const feature of features) { + if (zaehler.importe >= maxImporte) break + try { + if (unterkunftModus() === 'off') break + let ctx + if (unterkunftModus() === 'pilot') { + ctx = await ladeFuerProjekt(feature.customerId, feature.projectId) + if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) continue + } + for (const u of await listeUnterkuenfte(feature.$id, { nurAktive: true })) { + for (const eintrag of Array.isArray(u.icalImporte) ? u.icalImporte : []) { + if (zaehler.importe >= maxImporte) break + if (!eintrag?.url || !faellig(eintrag)) continue + zaehler.importe++ + try { + const r = await gleicheAb(u, eintrag, { now, fetch, lookup, ctx }) + if (r.ok) zaehler.erfolg++ + else { + zaehler.fehler++ + console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${r.fehler}`) + } + } catch (err) { + zaehler.fehler++ + console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${err?.name}`) + } + } + } + } catch (err) { + console.warn(`[unterkunft-ical] Funktion ${feature.$id} uebersprungen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } + } + return zaehler +} diff --git a/server/services/unterkunft/jobs.js b/server/services/unterkunft/jobs.js index 71d1548..bc4a7b0 100644 --- a/server/services/unterkunft/jobs.js +++ b/server/services/unterkunft/jobs.js @@ -1,10 +1,14 @@ import { laufeFristenAb } from './ablauf.js' +import { config } from '../../config.js' +import { listDocuments, Query } from '../appwriteAdmin.js' +import { anonymisiere, loescheAbgeschaltete } from './aufbewahrung.js' +import { gleicheAlleAb } from './icalAbgleich.js' import { unterkunftModus } from './schalter.js' /** * Jobs des Buchungskalenders (Registrierung in server/index.js: Minuten 80 s Start / 60 s, Viertelstunden 140 s / 15 min, * taeglich 210 s / 24 h). Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten - * Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 9/10 fuellen Viertelstunden- und Tagesjob. + * Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck (der Tagesjob nur, solange es keine Buchungsdaten mehr gibt). */ const laufend = new Set() @@ -18,7 +22,7 @@ async function einzeln(name, arbeit) { try { return await arbeit() } catch (err) { - console.error(`[unterkunft-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + console.error(`[unterkunft-jobs] ${name} fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) return { fehler: true } } finally { laufend.delete(name) @@ -31,16 +35,47 @@ export async function minutenJob(now = new Date()) { return einzeln('minutenJob', () => laufeFristenAb(now)) } -/** Viertelstundenjob (Task 9: iCal-Import). Bei UNTERKUNFT_ENABLED=off aus. */ +/** Viertelstundenjob: iCal-Importe abgleichen. Bei UNTERKUNFT_ENABLED=off aus. */ export async function viertelstundenJob(now = new Date()) { - void now if (unterkunftModus() === 'off') return { aus: true } - return einzeln('viertelstundenJob', async () => ({})) + return einzeln('viertelstundenJob', () => gleicheAlleAb(now)) } -/** Tagesjob (Task 10: Aufbewahrung, Loeschen). Bei UNTERKUNFT_ENABLED=off aus. */ +/** Gibt es irgendwelche Buchungsdaten? Die Fristen gelten auch bei UNTERKUNFT_ENABLED=off (fehlende Collections = nein). */ +async function gibtDaten() { + for (const name of ['ukEinstellungen', 'ukAnfragen']) { + try { + if ((await listDocuments(config.collections[name], [Query.select(['$id']), Query.limit(1)])).length > 0) return true + } catch (err) { + if (err?.type !== 'collection_not_found' && err?.code !== 'collection_not_found') throw err + } + } + return false +} + +/** + * Tagesjob: Aufbewahrung (ipHash, Verfall von `neu`, Anonymisierung, Rechnungen nach 8 Jahren) und Loeschung abgeschalteter Funktionen + * (90 Tage). Gemeinsamer Deckel config.unterkunft.aufbewahrungDeckel (500) je Lauf (die Loeschung bekommt, was die Aufbewahrung uebrig laesst). Bei UNTERKUNFT_ENABLED=off nur, wenn es noch + * Buchungsdaten gibt (die Fristen gelten auch dann). Ein Teil, der scheitert, stoppt den anderen nicht. + */ export async function taeglicherJob(now = new Date()) { - void now - if (unterkunftModus() === 'off') return { aus: true } - return einzeln('taeglicherJob', async () => ({})) + return einzeln('taeglicherJob', async () => { + if (unterkunftModus() === 'off' && !(await gibtDaten())) return { aus: true } + const deckel = config.unterkunft.aufbewahrungDeckel ?? 500 + let anon = { anfragen: 0, gaeste: 0, fehler: 1 } + let weg = { funktionen: 0, dokumente: 0, fehler: 1, gedeckelt: false } + let rest = deckel + try { + anon = await anonymisiere(now, { deckel }) + rest = Math.max(0, deckel - (anon.verbraucht ?? 0)) + } catch (err) { + console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) + } + try { + weg = await loescheAbgeschaltete(now, { deckel: rest }) + } catch (err) { + console.error(`[unterkunft-jobs] taeglicherJob: Löschung abgeschalteter Funktionen fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) + } + return { aufbewahrung: anon, abgeschaltet: weg } + }) } diff --git a/server/services/unterkunft/schema.js b/server/services/unterkunft/schema.js index 7fabd85..efdd5de 100644 --- a/server/services/unterkunft/schema.js +++ b/server/services/unterkunft/schema.js @@ -32,7 +32,7 @@ export const UNTERKUNFT_COLLECTIONS = { str('icalImporte', 4000), str('icalExportToken', 64), ], - indexes: [index('idx_feature', 'featureId')], + indexes: [index('idx_feature', 'featureId'), index('idx_export_token', 'icalExportToken')], }, ukPreise: { attributes: [ diff --git a/server/services/unterkunft/speicher.js b/server/services/unterkunft/speicher.js index 7e95ef3..215b33b 100644 --- a/server/services/unterkunft/speicher.js +++ b/server/services/unterkunft/speicher.js @@ -41,8 +41,11 @@ function schreiben(name, felder) { const nichtGefunden = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' +/** Dokument-IDs landen roh im URL-Pfad: nur harmlose Zeichen durchlassen. */ +export const ID_RE = /^[A-Za-z0-9_-]{1,36}$/ + async function ladeDok(name, id) { - if (!id) return null + if (typeof id !== 'string' || !ID_RE.test(id)) return null try { return lesen(name, await getDocument(config.collections[name], id)) } catch (err) { @@ -80,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) { @@ -96,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)]) @@ -105,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/ticketDetails.js b/server/services/unterkunft/ticketDetails.js new file mode 100644 index 0000000..8352c9f --- /dev/null +++ b/server/services/unterkunft/ticketDetails.js @@ -0,0 +1,62 @@ +import { TICKET_DETAILS_MAX, anleitungsLink, kuerze, minimaleTicketDetails } from '../featureRegistry.js' +import { ART_NAMEN, PORTAL_NAMEN } from './anfrageFunktion.js' +import { ladeEinstellungen } from './speicher.js' + +/** + * Details des Einrichtungs-Tickets fuer den Buchungskalender (config.features.unterkunft.ticket.details, Spezifikation Abschnitt 9). + * ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt (doc = portalFeatures-Dokument). Deterministischer Text: + * Kopfzeilen, Kurzfassung der Anfrage (best effort aus ukEinstellungen; Lesefehler => ohne), Snippet und Schritte. Wirft nie. + */ + +/** Kurzfassung der Anfrage als Zeile, oder ''. */ +async function anfrageZeile(doc) { + try { + const a = (await ladeEinstellungen(doc.$id))?.anfrage + if (a === null || typeof a !== 'object' || Array.isArray(a)) return '' + const teile = [] + if (Object.hasOwn(ART_NAMEN, a.art)) teile.push(`Art: ${ART_NAMEN[a.art]}`) + if (Number.isInteger(a.einheiten)) teile.push(`Einheiten: ${a.einheiten}`) + const portale = (Array.isArray(a.portale) ? a.portale : []).filter((p) => Object.hasOwn(PORTAL_NAMEN, p)).map((p) => PORTAL_NAMEN[p]) + teile.push(`Portale: ${portale.length ? portale.join(', ') : 'keine'}`) + return `Anfrage: ${teile.join(', ')}` + } catch { + return '' + } +} + +export async function unterkunftTicketDetails(ctx) { + try { + const { doc } = ctx + const siteKey = doc?.siteKey || '' + const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Buchungskalender' } }).split('\n').filter((zeile) => !zeile.startsWith('Verweis: ')) + const anfrage = doc?.$id ? await anfrageZeile(doc) : '' + const zeilen = [ + ...kopf, + ...(anfrage ? [anfrage] : []), + '', + 'Snippet (an der Stelle des Kalender-Bereichs einsetzen):', + `
`, + '', + '', + 'Schritte:', + '1. Seite oder Bereich für den Kalender anlegen und Menüpunkt setzen, Snippet einsetzen.', + '2. CSS-Variablen (--wk-uk-akzent, --wk-uk-text, --wk-uk-flaeche, --wk-uk-linie, --wk-uk-radius, --wk-uk-schrift) an die Seite anpassen.', + '3. Datenschutz-Absatz Buchungskalender in die Datenschutzerklärung einfügen.', + '4. Impressum prüfen.', + '5. Testanfrage über den Kalender stellen und im Portal (Buchungen) prüfen.', + '6. Ticket schließen: das schaltet die Funktion frei (sobald der Kunde Unterkunft, Preise, Stornobedingungen und Firmendaten hinterlegt hat).', + '', + 'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.', + 'Einbauanleitung: docs/funktionen/unterkunft-einbau.md', + `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, + ] + return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true }) + } catch (err) { + console.error(`[unterkunft-ticket] Details nicht erzeugbar, Notfall-Details: ${err?.message}`) + try { + return kuerze(minimaleTicketDetails({ ...ctx, feature: { key: 'Buchungskalender' } }).toWellFormed(), TICKET_DETAILS_MAX) + } catch { + return 'Funktion: Buchungskalender\nDetails konnten nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).' + } + } +} diff --git a/server/services/unterkunft/verwaltung.js b/server/services/unterkunft/verwaltung.js new file mode 100644 index 0000000..23a6cb9 --- /dev/null +++ b/server/services/unterkunft/verwaltung.js @@ -0,0 +1,288 @@ +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 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/server/services/unterkunft/zahlung.js b/server/services/unterkunft/zahlung.js index d73ed70..4bc54cd 100644 --- a/server/services/unterkunft/zahlung.js +++ b/server/services/unterkunft/zahlung.js @@ -1,10 +1,181 @@ -// Stub: Task 7 ersetzt den Inhalt. Erstattung ueber Stripe Connect. +import { config } from '../../config.js' +import { withLocks } from '../keyedLock.js' +import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from '../shop/connect.js' +import { formatTag } from './datum.js' import { UkFehler } from './fehler.js' +import { aktualisiereAnfrage, ladeAnfrage, ladeRechnung, ladeUnterkunft } from './speicher.js' +import { markiereBezahlt } from './ablauf.js' + +/** + * Stripe-Zahlung und Erstattung des Buchungskalenders (Direct Charges auf dem Konto des Vermieters, wie der Webshop). + * Die Sitzung traegt metadata { wkUk:'1', anfrageId }; dieselben Metadaten (plus webklar:'unterkunft') stehen an der Zahlung, + * damit charge.refunded dem Buchungskalender zugeordnet werden kann (routes/shopWebhook.js). + */ export const _aufrufe = [] +const SITZUNG_MINUTEN = 31 // Stripe verlangt mindestens 30 Minuten Restlaufzeit beim Anlegen +const KOERPER_MAX = 250 + +const sekunden = (now) => Math.floor((now instanceof Date ? now.getTime() : Number(now)) / 1000) + +/** Buchungsseite des Gastes (Rueckkehr von Stripe); das Token liegt nur beim Gast, nicht in der Datenbank. */ +export function buchungsUrl(ctx, anfrageId, token, zusatz = '') { + return `${config.publicBaseUrl}/unterkunft/${ctx.siteKey}/buchung/${anfrageId}?t=${encodeURIComponent(token)}${zusatz}` +} + +const lockKey = (a) => ['unterkunft:' + a.unterkunftId] + +/** + * Checkout-Sitzung fuer eine gebuchte Online-Zahlung. Unter der Sperre der Unterkunft wird die Anfrage frisch gelesen (nach einem Storno + * entsteht keine Sitzung mehr). Eine noch offene, nicht abgelaufene Sitzung der Anfrage wird wiederverwendet (Doppelklick); sonst entsteht + * eine neue mit auf die volle Minute gerundetem Ablauf, sodass Wiederholungen derselben Minute mit dem Idempotency-Key identisch sind. + * @param {object} ctx Kontext mit einstellungen.stripeAccountId und siteKey + * @param {object} anfrage Anfrage im Status gebucht (zahlart online) + * @param {{ now?: Date, token: string }} opt token: das Buchungs-Token des Gastes (fuer die Rueckkehr-Links) + * @returns {Promise<{ url: string }>} + */ +export async function checkoutFuer(ctx, anfrage, { now = new Date(), token } = {}) { + const einst = ctx?.einstellungen ?? {} + const accountId = einst.stripeAccountId + if (!einst.onlineZahlung || !accountId) throw new UkFehler(409, 'online_aus') + if (typeof token !== 'string' || token === '') throw new UkFehler(400, 'ungueltig') + if (!anfrage?.$id) throw new UkFehler(409, 'status_ungueltig') + const nowMs = now instanceof Date ? now.getTime() : Number(now) + return withLocks(lockKey(anfrage), async () => { + const a = await ladeAnfrage(anfrage.$id) + if (!a || a.featureId !== ctx.featureId || a.status !== 'gebucht' || a.zahlart !== 'online') throw new UkFehler(409, 'status_ungueltig') + const brutto = a.summen?.bruttoCent + if (!Number.isInteger(brutto) || brutto <= 0) throw new UkFehler(409, 'status_ungueltig') + if (a.stripeSessionId) { + try { + const alt = await sessionHolen(accountId, a.stripeSessionId) + if (alt?.status === 'open' && alt.url && !(Number(alt.expires_at) * 1000 <= nowMs)) return { url: alt.url } + } catch (err) { + console.warn(`[unterkunft] Alte Zahlungssitzung nicht lesbar: ${err?.name ?? 'Fehler'}`) + } + } + const [unterkunft, rechnung] = await Promise.all([ladeUnterkunft(a.unterkunftId), a.rechnungId ? ladeRechnung(a.rechnungId) : null]) + const name = `Aufenthalt ${unterkunft?.name ?? ''} ${formatTag(a.von)}–${formatTag(a.bis)}${rechnung?.nummer ? `, Rechnung ${rechnung.nummer}` : ''}`.replace(/\s+/g, ' ').slice(0, KOERPER_MAX) + const metadata = { wkUk: '1', anfrageId: a.$id } + const minute = Math.floor(nowMs / 60000) + const params = { + mode: 'payment', + payment_method_types: ['card'], + locale: 'de', + line_items: [{ quantity: 1, price_data: { currency: 'eur', unit_amount: brutto, product_data: { name } } }], + expires_at: minute * 60 + SITZUNG_MINUTEN * 60, + metadata, + payment_intent_data: { metadata: { ...metadata, webklar: 'unterkunft' } }, + success_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=ok'), + cancel_url: buchungsUrl(ctx, a.$id, token, '&wk_zahlung=abbruch'), + ...(typeof a.gast?.email === 'string' && a.gast.email ? { customer_email: a.gast.email } : {}), + } + const session = await checkoutAnlegen(accountId, params, { idempotencyKey: `uk-zahlung-${a.$id}-${minute}` }) + if (!session.url) throw new UkFehler(502, 'zahlung_fehlgeschlagen') + await aktualisiereAnfrage(a.$id, { stripeSessionId: session.id }) + return { url: session.url } + }) +} + +/** Nach dem Storno: eine noch offene, unbezahlte Checkout-Sitzung beenden (Fehler nur loggen: sie laeuft ohnehin in 31 Minuten ab). */ +export async function beendeOffeneZahlung(ctx, anfrage) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (!anfrage?.stripeSessionId || !accountId || anfrage.bezahltAt) return + try { + await sessionAblaufen(accountId, anfrage.stripeSessionId) + } catch (err) { + console.warn(`[unterkunft] Offene Zahlungssitzung nicht beendet: ${err?.name ?? 'Fehler'}`) + } +} + +/** Volle Rueckerstattung einer Zahlung, die zu keiner offenen Buchung mehr passt (nach Storno, Ablauf oder doppelt gezahlt). */ +export async function erstatteUeberzaehligeZahlung(ctx, paymentIntentId) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (!accountId || typeof paymentIntentId !== 'string' || paymentIntentId === '') return + await erstatten(accountId, { paymentIntent: paymentIntentId, grund: 'Zahlung ohne offene Buchung', idempotencyKey: `uk-rueck-${paymentIntentId}`, webklar: 'unterkunft' }) + console.warn('[unterkunft] Zahlung ohne offene Buchung automatisch erstattet') +} + +/** + * Eingegangene Zahlung buchen. Passt sie nicht (Buchung storniert/abgelaufen oder schon mit anderer Zahlung bezahlt), wird sie voll erstattet; + * der Buchungsstatus bleibt dann unveraendert. Idempotent. Gibt die aktuelle Anfrage zurueck. + */ +export async function verbucheZahlung(ctx, anfrageId, paymentIntentId, { now = new Date() } = {}) { + const pi = typeof paymentIntentId === 'string' && paymentIntentId !== '' ? paymentIntentId : undefined + const vorher = await ladeAnfrage(anfrageId) + if (vorher?.status === 'bezahlt') { + if (pi && vorher.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi) + return vorher + } + try { + return await markiereBezahlt(ctx, anfrageId, { via: 'stripe', paymentIntentId: pi, now }) + } catch (err) { + if (!(err instanceof UkFehler) || err.status !== 409) throw err + const jetzt = await ladeAnfrage(anfrageId) + // dieselbe Zahlung, die schon gebucht wurde (Wiederholung des Ereignisses nach spaeterem Storno): nichts erstatten + if (pi && jetzt?.stripePaymentIntentId !== pi) await erstatteUeberzaehligeZahlung(ctx, pi) + return jetzt ?? vorher + } +} + +// Abgleich mit Stripe hoechstens alle 30 Sekunden je Sitzung; abgelaufene Sitzungen nie wieder +const ABGLEICH_MS = 30 * 1000 +const ABGLEICH_MAX = 5000 +const abgleich = new Map() +export const leereAbgleichFuerTests = () => abgleich.clear() +function merkeAbgleich(schluessel, eintrag) { + abgleich.delete(schluessel) + if (abgleich.size >= ABGLEICH_MAX) abgleich.delete(abgleich.keys().next().value) + abgleich.set(schluessel, eintrag) +} + +/** + * Hat der Gast bezahlt, ohne dass der Webhook schon da war? Sitzung bei Stripe nachsehen und bei complete+paid als bezahlt buchen. + * Gibt die (ggf. neue) Anfrage zurueck; Stripe-Fehler werden geloggt und lassen die Anfrage unveraendert. erzwingen: ohne Pause (Rueckkehr von Stripe). + */ +export async function schliesseAbWennBezahlt(ctx, anfrage, { now = new Date(), erzwingen = false } = {}) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (anfrage?.status !== 'gebucht' || anfrage.zahlart !== 'online' || !anfrage.stripeSessionId || !accountId) return anfrage + const schluessel = `${anfrage.$id}:${anfrage.stripeSessionId}` + const nowMs = now instanceof Date ? now.getTime() : Number(now) + const bekannt = abgleich.get(schluessel) + if (bekannt?.abgelaufen) return anfrage + if (!erzwingen && bekannt && Math.abs(nowMs - bekannt.at) < ABGLEICH_MS) return anfrage + try { + const session = await sessionHolen(accountId, anfrage.stripeSessionId) + merkeAbgleich(schluessel, { at: nowMs, abgelaufen: session?.status === 'expired' }) + if (session?.status === 'complete' && session.payment_status === 'paid') { + const pi = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id + return await verbucheZahlung(ctx, anfrage.$id, pi, { now }) + } + } catch (err) { + console.warn(`[unterkunft] Zahlungsabgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`) + } + return anfrage +} + +async function erstatteEcht(ctx, anfrage, betragCent, { idempotencyKey } = {}) { + const accountId = ctx?.einstellungen?.stripeAccountId + if (!accountId) throw new UkFehler(409, 'online_aus') + if (!anfrage?.stripePaymentIntentId) throw new UkFehler(409, 'status_ungueltig') + const refund = await erstatten(accountId, { + paymentIntent: anfrage.stripePaymentIntentId, + betragCent, + grund: `Stornierung Buchung ${anfrage.nummer ?? ''}`.trim(), + idempotencyKey, + webklar: 'unterkunft', + }) + return { erstattetCent: Number.isInteger(refund?.amount) ? refund.amount : betragCent } +} + +let erstatter = erstatteEcht +/** Nur fuer Tests: ersetzt die Erstattung (null stellt die echte wieder her). */ +export function setzeErstattungFuerTests(fn) { + erstatter = typeof fn === 'function' ? fn : erstatteEcht +} + export async function erstatte(ctx, anfrage, betragCent, { now, idempotencyKey } = {}) { _aufrufe.push({ anfrageId: anfrage?.$id, betragCent, idempotencyKey }) - if (!ctx?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus') - return { erstattetCent: betragCent } + return erstatter(ctx, anfrage, betragCent, { now, idempotencyKey }) } diff --git a/test/fixtures/ical/airbnb.ics b/test/fixtures/ical/airbnb.ics new file mode 100644 index 0000000..ef58654 --- /dev/null +++ b/test/fixtures/ical/airbnb.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Airbnb Inc//Hosting Calendar 1.0//EN +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261120 +DTEND;VALUE=DATE:20261125 +DTSTAMP:20261001T120000Z +UID:a1-xyz@airbnb.com +SUMMARY:Reserved +DESCRIPTION:Reservation URL: https://www.airbnb.com/hosting/reservations/details/HM123\nPhone Number (Last 4 Digits): 1234 +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261201 +DTEND;VALUE=DATE:20261202 +UID:a2@airbnb.com +SUMMARY:Airbnb (Not available) +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/ical/booking.ics b/test/fixtures/ical/booking.ics new file mode 100644 index 0000000..8b95db4 --- /dev/null +++ b/test/fixtures/ical/booking.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Booking.com//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261110 +DTEND;VALUE=DATE:20261113 +UID:b1@booking.com +SUMMARY:CLOSED - Not available +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261201 +DTEND;VALUE=DATE:20261203 +UID:b2@booking.com +SUMMARY:CLOSED - Not available +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/ical/fehlerseite.html b/test/fixtures/ical/fehlerseite.html new file mode 100644 index 0000000..679fcec --- /dev/null +++ b/test/fixtures/ical/fehlerseite.html @@ -0,0 +1,2 @@ + +Anmelden

Bitte melden Sie sich an

diff --git a/test/fixtures/ical/gefaltet.ics b/test/fixtures/ical/gefaltet.ics new file mode 100644 index 0000000..17a4bf4 --- /dev/null +++ b/test/fixtures/ical/gefaltet.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:g1 +SUMMARY:Ein sehr langer Titel, der ueber mehrere Zeilen gefaltet + wurde und DTEND:20990101 enthaelt + + und weiter +DTSTART;VALUE=DATE:20261110 +DTEND;VALUE=DATE: + 20261112 +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/ical/tzid.ics b/test/fixtures/ical/tzid.ics new file mode 100644 index 0000000..590231f --- /dev/null +++ b/test/fixtures/ical/tzid.ics @@ -0,0 +1,39 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:z1 +DTSTART:20261110T140000Z +DTEND:20261112T100000Z +END:VEVENT +BEGIN:VEVENT +UID:z2 +DTSTART;TZID=Europe/Berlin:20261110T000000 +DTEND;TZID=Europe/Berlin:20261112T000000 +END:VEVENT +BEGIN:VEVENT +UID:z3 +DTSTART:20261130T233000Z +DTEND:20261202T100000Z +END:VEVENT +BEGIN:VEVENT +UID:z4 +DTSTART;VALUE=DATE:20261215 +END:VEVENT +BEGIN:VEVENT +UID:z5 +DTSTART;VALUE=DATE:20261220 +DTEND;VALUE=DATE:20261222 +STATUS:CANCELLED +END:VEVENT +BEGIN:VEVENT +UID:z6 +DTSTART;VALUE=DATE:20200101 +DTEND;VALUE=DATE:20200105 +END:VEVENT +BEGIN:VEVENT +UID:z7 +DTSTART;VALUE=DATE:20300101 +DTEND;VALUE=DATE:20300105 +END:VEVENT +END:VCALENDAR diff --git a/test/retention.test.js b/test/retention.test.js index 8570c1c..dcac6a9 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -23,6 +23,12 @@ const aw = await startAppwriteMock({ shopOrders: schemaKeys('shopOrders'), shopWiderrufe: schemaKeys('shopWiderrufe'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), + ukUnterkuenfte: schemaKeys('ukUnterkuenfte'), + ukPreise: schemaKeys('ukPreise'), + ukSperren: schemaKeys('ukSperren'), + ukAnfragen: schemaKeys('ukAnfragen'), + ukRechnungen: schemaKeys('ukRechnungen'), + ukEinstellungen: schemaKeys('ukEinstellungen'), }, }) const st = await startFakeStripe() @@ -673,3 +679,28 @@ test('Webshop: scheitert das Löschen der Shopdaten, bleiben die Abo-Dokumente s assert.deepEqual(nochmal.held, []) assert.ok(!aw.docs('shopConfig').has('f_sh4_shop') && !aw.docs('subscriptions').has('sh4_sub')) }) + +// ------------------------------------------------------------------------------------------------ Buchungskalender (Task 10) + +test('Buchungskalender: fällige Kunden verlieren alle uk*-Daten ihrer Funktion; andere Kunden bleiben; Bericht nennt es', async () => { + for (const [kunde, faellig] of [['uk1', true], ['uk2', false]]) { + sub(`${kunde}_sub`, kunde, faellig ? { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' } : { status: 'active' }) + if (faellig) stripeSub(`${kunde}_sub`, kunde) + const f = `f_${kunde}` + aw.seed('portalFeatures', { $id: f, customerId: kunde, projectId: `${kunde}p`, featureKey: 'unterkunft', siteKey: 'defghijklmnopqrs', state: 'disabled', enabled: false }) + aw.seed('ukEinstellungen', { $id: f, featureId: f, firma: 'Haus' }) + aw.seed('ukUnterkuenfte', { $id: `${f}_u`, featureId: f, customerId: kunde, name: 'W', aktiv: false }) + aw.seed('ukAnfragen', { $id: `${f}_a`, featureId: f, customerId: kunde, unterkunftId: `${f}_u`, nummer: 'A-X', status: 'bezahlt', gast: '{}' }) + aw.seed('ukRechnungen', { $id: `${f}_r`, featureId: f, anfrageId: `${f}_a`, art: 'rechnung', nummer: 'R', datum: '2022-01-01', bruttoCent: 1, inhalt: '{}' }) + } + const plan = await findDueDeletions(JAN_2026) + assert.deepEqual(plan.news.find((n) => n.customerId === 'uk1').ukIds, ['f_uk1']) + assert.ok(!plan.news.some((n) => n.customerId === 'uk2')) + assert.match(formatReport(plan), /Kunde uk1: .*1 Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen/) + const result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'uk1'), news: plan.news.filter((n) => n.customerId === 'uk1'), takeover: [] }) + assert.equal(result.deleted.unterkuenfte, 1) + assert.equal(result.deleted.ukDocs, 4) + for (const c of ['ukEinstellungen', 'ukUnterkuenfte', 'ukAnfragen', 'ukRechnungen']) assert.equal([...aw.docs(c).values()].filter((d) => d.$id.startsWith('f_uk1')).length, 0, c) + assert.ok(aw.docs('ukAnfragen').has('f_uk2_a')) + assert.match(formatReport(plan, result), /Buchungskalender: 1 Funktion\(en\) gelöscht \(4 Dokument/) +}) diff --git a/test/unterkunftAblauf.test.js b/test/unterkunftAblauf.test.js index fc9cc55..9ee19e4 100644 --- a/test/unterkunftAblauf.test.js +++ b/test/unterkunftAblauf.test.js @@ -21,7 +21,7 @@ const { istFrei } = await import('../server/services/unterkunft/belegung.js') after(() => aw.close()) const NOW = new Date('2026-11-01T12:00:00Z') -const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', strasse: 'Str 1', plz: '10115', ort: 'Berlin', steuernummer: '12/345', onlineZahlung: false } } +const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', strasse: 'Str 1', plz: '10115', ort: 'Berlin', steuernummer: '12/345', onlineZahlung: false, rechnungAuftragAt: '2026-10-01T00:00:00.000Z' } } const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo' } const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } @@ -29,6 +29,11 @@ beforeEach(async () => { for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) mails._aufrufe.length = 0 zahlung._aufrufe.length = 0 + // Erstattung ohne Stripe: wie der fruehere Stub (die echte Erstattung testet unterkunftBuchung.test.js) + zahlung.setzeErstattungFuerTests(async (c, a, betragCent) => { + if (!c?.einstellungen?.stripeAccountId) throw new UkFehler(409, 'online_aus') + return { erstattetCent: betragCent } + }) aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung', maxPersonen: 4, mindestNaechte: 2, aktiv: true }) aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: 'f1', staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), diff --git a/test/unterkunftAnfrageFunktion.test.js b/test/unterkunftAnfrageFunktion.test.js new file mode 100644 index 0000000..23cccb5 --- /dev/null +++ b/test/unterkunftAnfrageFunktion.test.js @@ -0,0 +1,309 @@ +import { test, after, beforeEach } 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 11: Funktion bestellen "auf Anfrage", Freischaltung, Ticket-Details, Team-API +const NAMEN = ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukAnfragen', 'ukSperren', 'ukRechnungen', 'featureConsents'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), + datetimes: { ukEinstellungen: ['rechnungAuftragAt', 'anfrageAt', 'anfrageEntschiedenAt', 'icalFehlerMailAt'] }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send, rawGet, post: rawPost } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const af = await import('../server/services/unterkunft/anfrageFunktion.js') +const { freischaltbar } = await import('../server/services/unterkunft/freischaltung.js') +const { unterkunftTicketDetails } = await import('../server/services/unterkunft/ticketDetails.js') +const { featureHaken } = await import('../server/services/featureRegistry.js') +const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes, '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +const P = 'pu1' +const FEATURE = `f_${P}_unterkunft` +const cookie = customerCookie('c1') +const staff = { headers: { authorization: 'Bearer jwt-staff' } } +const person = { name: 'Kunde Eins', email: customers.c1.email } +const vorher = { ...config.unterkunft } +const GUELTIG = { art: 'ferienwohnung', einheiten: 3, portale: ['booking', 'airbnb'], nachricht: 'Hallo' } +const stelle = (b = {}, opts = { cookie }) => send('POST', `/api/unterkunft/anfrage-funktion?projectId=${P}`, { ...opts, body: { ...GUELTIG, ...b } }) +const zurueck = (opts = { cookie }) => send('POST', `/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${P}`, { ...opts, body: {} }) +const status = (opts = { cookie }) => send('GET', `/api/unterkunft/status?projectId=${P}`, opts) +const entscheide = (b, opts = staff) => rawPost(`/api/team/unterkunft/c1/anfrage`, { projectId: P, ...b }, opts) +const einst = () => aw.docs('ukEinstellungen').get(FEATURE) ?? null + +// Mails: Vorlagen K34/T22 liegen auf einem anderen Strang, der Test prueft nur den Aufruf +const aufrufe = { admin: [], kunde: [], adminFehler: false, kundeFehler: false } +af._versand.admin = async (m) => { + aufrufe.admin.push(m) + if (aufrufe.adminFehler) throw new Error('SMTP') + return { sent: true } +} +af._versand.kunde = async (an, m) => { + aufrufe.kunde.push({ an, ...m }) + if (aufrufe.kundeFehler) throw new Error('SMTP') +} + +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: 'pilot', pilotEmails: [customers.c1.email] }) + resetUnterkunftRoutesForTests() + aufrufe.admin.length = 0 + aufrufe.kunde.length = 0 + aufrufe.adminFehler = aufrufe.kundeFehler = false + aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Donau', archived: false }) + aw.seed('websiteProjects', { $id: 'pfremd', customerId: 'c2', projectName: 'Fremd' }) +}) + +test('normalisiereAnfrage: gueltig, Alias monteur, Fehler je Feld', () => { + const ok = af.normalisiereAnfrage({ ...GUELTIG, art: 'monteur', x: 1 }) + assert.deepEqual(ok.anfrage, { art: 'monteurzimmer', einheiten: 3, portale: ['booking', 'airbnb'], nachricht: 'Hallo' }) + const f = (a) => af.normalisiereAnfrage({ ...GUELTIG, ...a }).fehler + assert.ok(f({ art: 'villa' }).art) + assert.ok(f({ art: 'constructor' }).art) + assert.ok(f({ einheiten: 0 }).einheiten) + assert.ok(f({ einheiten: '3' }).einheiten) + assert.ok(f({ einheiten: 1000 }).einheiten) + assert.equal(f({ einheiten: 999 }), undefined) + assert.ok(f({ portale: ['ebay'] }).portale) + assert.ok(f({ portale: 'booking' }).portale) + assert.deepEqual(af.normalisiereAnfrage({ ...GUELTIG, portale: [] }).anfrage.portale, []) + assert.deepEqual(af.normalisiereAnfrage({ ...GUELTIG, portale: true }).anfrage.portale, ['andere']) + assert.ok(f({ nachricht: 'n'.repeat(1001) }).nachricht) + assert.equal(af.normalisiereAnfrage({ ...GUELTIG, nachricht: undefined }).anfrage.nachricht, '') + assert.ok(af.normalisiereAnfrage(null).fehler.art) +}) + +test('Anfrage stellen: gespeichert, T22 ans Team, Status-Block fuer state off', async () => { + const leer = await status() + assert.equal(leer.status, 200) + assert.equal(leer.body.state, 'off') + assert.equal(leer.body.anfrage, null) + const res = await stelle({ art: 'monteur' }) + assert.equal(res.status, 200) + assert.equal(res.body.anfrage.status, 'offen') + assert.equal(res.body.anfrage.anfrage.art, 'monteur', 'das Portal liest monteur') + assert.deepEqual(res.body.anfrage.anfrage.portale, ['booking', 'airbnb']) + assert.equal(einst().anfrageStatus, 'offen') + assert.equal(JSON.parse(einst().anfrage).art, 'monteurzimmer') + assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off') + assert.equal(aufrufe.admin.length, 1) + assert.equal(aufrufe.admin[0].template, 'T22') + assert.equal(aufrufe.admin[0].values.projekt, 'Donau') + assert.equal(aufrufe.admin[0].values.einheiten, '3') + assert.equal(aufrufe.admin[0].values.art, 'Monteurwohnung') + assert.equal(aufrufe.admin[0].values.portale, 'Booking.com, Airbnb') + assert.equal(aufrufe.admin[0].values.woms_url, `${config.womsBaseUrl}/customers/c1?tab=funktionen`) + assert.equal(aufrufe.kunde.length, 0) + const s = (await status()).body.anfrage + assert.equal(s.status, 'offen') + assert.ok(s.at) + assert.deepEqual(Object.keys(s.anfrage).sort(), ['art', 'einheiten', 'nachricht', 'portale']) +}) + +test('Anfrage: doppelt 409, nur bei state off, Eingabefehler, Schalter, fremdes Projekt, Kundenansicht', async () => { + assert.equal((await stelle()).status, 200) + const doppelt = await stelle() + assert.equal(doppelt.status, 409) + assert.equal(doppelt.body.fehler, 'anfrage_laeuft') + assert.equal(aufrufe.admin.length, 1) + + await deleteDocument(config.collections.ukEinstellungen, FEATURE) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) + const aktiv = await stelle() + assert.equal(aktiv.status, 409) + assert.equal(aktiv.body.fehler, 'vorhanden') + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) + + const schlecht = await stelle({ einheiten: 0, art: 'x' }) + assert.equal(schlecht.status, 400) + assert.equal(schlecht.body.fehler, 'ungueltig') + assert.ok(schlecht.body.felder.einheiten && schlecht.body.felder.art) + assert.equal(einst(), null) + + assert.equal((await send('POST', '/api/unterkunft/anfrage-funktion?projectId=pfremd', { cookie, body: GUELTIG })).status, 403) + assert.equal((await send('POST', '/api/unterkunft/anfrage-funktion', { cookie, body: GUELTIG })).status, 400) + assert.equal((await stelle({}, {})).status, 401) + assert.equal((await stelle({}, { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'a'.repeat(32) } })).status, 403) + config.unterkunft.mode = 'off' + const aus = await stelle() + assert.equal(aus.status, 409) + assert.equal(aus.body.fehler, 'nicht_verfuegbar') + assert.equal(einst(), null) +}) + +test('Anfrage: Mail-Fehler bei T22 verhindert nichts; Grenze 20 je Stunde', async () => { + aufrufe.adminFehler = true + assert.equal((await stelle()).status, 200) + assert.equal(einst().anfrageStatus, 'offen') + let letzter + for (let i = 0; i < 20; i++) letzter = await zurueck() + assert.equal(letzter.status, 429) +}) + +test('zurueckziehen: nur offen; danach neue Anfrage moeglich; Status-Block zeigt keine zurueckgezogene', async () => { + assert.equal((await zurueck()).status, 409) + await stelle() + const z = await zurueck() + assert.equal(z.status, 200) + assert.equal(z.body.anfrage.status, 'zurueckgezogen') + assert.equal((await status()).body.anfrage, null) + assert.equal((await zurueck()).status, 409) + assert.equal((await stelle({ einheiten: 5 })).status, 200) + assert.equal(JSON.parse(einst().anfrage).einheiten, 5) +}) + +test('Team: Uebersicht je Projekt und Entscheidung annehmen/ablehnen mit K34', async () => { + assert.equal((await rawGet('/api/team/unterkunft/c1', {})).status, 401) + const leer = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects + assert.equal(leer.length, 1) + assert.deepEqual(leer[0], { projectId: P, name: 'Donau', state: 'off', unterkuenfte: 0, offeneAnfragen: 0, stripe: { accountId: false, status: null }, anfrage: null }) + await stelle({ art: 'monteur', portale: [] }) + const p = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects[0] + assert.equal(p.anfrage.status, 'offen') + assert.equal(p.anfrage.art, 'monteurzimmer') + assert.equal(p.anfrage.einheiten, 3) + assert.equal(p.anfrage.portale, false) + + assert.equal((await entscheide({ entscheidung: 'vielleicht' })).status, 400) + const ohneGrund = await entscheide({ entscheidung: 'ablehnen', begruendung: ' ' }) + assert.equal(ohneGrund.status, 400) + assert.equal(einst().anfrageStatus, 'offen') + + assert.deepEqual(await featureHaken('unterkunft').bestellpruefung({ customerId: 'c1', projectId: P }), [af.ANFRAGE_OFFEN_TEXT]) + const ab = await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' }) + assert.equal(ab.status, 200) + assert.equal(ab.body.anfrage.status, 'abgelehnt') + assert.equal(aufrufe.kunde.length, 1) + assert.equal(aufrufe.kunde[0].template, 'K34') + assert.equal(aufrufe.kunde[0].an, customers.c1.email) + assert.equal(aufrufe.kunde[0].values.begruendung, 'Passt nicht.') + assert.equal(aufrufe.kunde[0].values.portal_url, `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${P}`) + assert.deepEqual(aufrufe.kunde[0].blocks, { angenommen: false, abgelehnt: true }) + assert.equal((await status()).body.anfrage.begruendung, 'Passt nicht.') + assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409, 'nicht mehr offen') + + await stelle() + assert.equal(einst().anfrageBegruendung, '', 'neue Anfrage loescht die Entscheidung') + aufrufe.kundeFehler = true + const an = await entscheide({ entscheidung: 'annehmen' }) + assert.equal(an.status, 200) + assert.equal(an.body.mailFehler, true) + assert.equal(an.body.anfrage.status, 'angenommen') + assert.equal(einst().anfrageEntschiedenVon, 'Andrej ') + assert.deepEqual(aufrufe.kunde.at(-1).blocks, { angenommen: true, abgelehnt: false }) + assert.deepEqual(await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P }), []) + assert.deepEqual(await featureHaken('unterkunft').bestellpruefung({ customerId: 'c1', projectId: P }), []) + assert.equal((await status()).body.anfrage.status, 'angenommen') + // fremdes Projekt des Kunden + assert.equal((await rawPost('/api/team/unterkunft/c1/anfrage', { projectId: 'pfremd', entscheidung: 'annehmen' }, staff)).status, 403) +}) + +test('Team: Stripe-Form wie Webshop, Entscheidung nur bei state off, Status ohne Lesefehler-500', async () => { + await stelle() + aw.seed('ukEinstellungen', { ...einst(), stripeAccountId: 'acct_1', stripeStatus: JSON.stringify({ charges_enabled: true }) }) + const p = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects[0] + assert.deepEqual(p.stripe, { accountId: true, status: { charges_enabled: true } }) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(FEATURE), state: 'setup' }) + assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(FEATURE), state: 'off' }) + const echt = config.collections.ukEinstellungen + config.collections.ukEinstellungen = 'gibt_es_nicht' + try { + const s = await status() + assert.equal(s.status, 200) + assert.equal(s.body.anfrage, null) + } finally { + config.collections.ukEinstellungen = echt + } +}) + +test('anfrageOffenePunkte: ohne Dokumente offen, Lesefehler wird weitergereicht', async () => { + assert.equal((await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P })).length, 1) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) + assert.equal((await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P })).length, 1) +}) + +// ------------------------------------------------------------------------------------------------ Freischaltung + +const EINST = { + firma: 'Donau Home', strasse: 'Weg 1', plz: '78576', ort: 'Stetten', email: 'mimi@example.com', steuernummer: '12/345/67890', ustId: '', + rechnungAuftragAt: '2026-10-01T00:00:00.000Z', +} +const PREISE = { + staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), reinigungCent: 5000, personenInklusive: 2, aufpreisPersonNachtCent: 1500, + direktrabattProzent: 0, steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vor Anreise.', +} +function seedFrei({ einst = {}, aktiv = true, preise = {} } = {}) { + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false }) + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, ...einst }) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: P, name: 'Erste', maxPersonen: 4, aktiv, sort: 1 }) + aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, ...preise }) +} +const doc = () => aw.docs('portalFeatures').get(FEATURE) + +test('freischaltbar: false ohne Daten, true mit vollstaendigen Daten, je Pflicht eine Gegenprobe', async () => { + assert.equal(await freischaltbar({ $id: FEATURE }), false) + assert.equal(await freischaltbar(null), false) + seedFrei() + assert.equal(await freischaltbar(doc()), true) + assert.equal(await featureHaken('unterkunft').freischaltbar(doc()), true) + const ust = { steuernummer: '', ustId: 'DE123456789' } + for (const [name, kaputt] of [ + ['Firma', { einst: { firma: '' } }], ['Strasse', { einst: { strasse: ' ' } }], ['PLZ', { einst: { plz: '' } }], ['Ort', { einst: { ort: '' } }], + ['E-Mail', { einst: { email: '' } }], ['Steuer', { einst: { steuernummer: '', ustId: '' } }], ['Auftrag', { einst: { rechnungAuftragAt: null } }], + ['Unterkunft inaktiv', { aktiv: false }], ['Preise leer', { preise: { staffeln: '[]' } }], ['Storno leer', { preise: { stornobedingungen: ' ' } }], + ]) { + await beforeEachClear() + seedFrei(kaputt) + assert.equal(await freischaltbar(doc()), false, name) + } + await beforeEachClear() + seedFrei({ einst: ust }) + assert.equal(await freischaltbar(doc()), true, 'ustId statt Steuernummer') +}) + +async function beforeEachClear() { + for (const c of ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) +} + +test('freischaltbar: Lesefehler => false (fail-closed)', async () => { + seedFrei() + const d = doc() + const echt = config.collections.ukPreise + config.collections.ukPreise = 'gibt_es_nicht' + try { + assert.equal(await freischaltbar(d), false) + } finally { + config.collections.ukPreise = echt + } +}) + +test('ticketDetails: siteKey, Snippet, Schritte, Verweis, Anfrage; wirft nie', async () => { + seedFrei() + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, anfrage: JSON.stringify({ art: 'pension', einheiten: 4, portale: ['airbnb'], nachricht: 'x' }), anfrageStatus: 'angenommen' }) + const text = await unterkunftTicketDetails({ doc: doc(), project: { projectName: 'Donau' }, customer: { name: 'Mimi' } }) + assert.match(text, /siteKey: abcdefghijklmnop/) + assert.ok(text.includes('
')) + assert.ok(text.includes('')) + assert.match(text, /docs\/funktionen\/unterkunft-einbau\.md/) + assert.match(text, /Art: Pension oder Gästezimmer, Einheiten: 4, Portale: Airbnb/) + assert.match(text, /Funktion: Buchungskalender/) + assert.match(text, /CSS-Variablen/) + assert.match(text, /Verweis: /) + const notfall = await unterkunftTicketDetails(null) + assert.match(notfall, /Buchungskalender/) + const viaConfig = await config.features.unterkunft.ticket.details({ doc: doc(), project: {}, customer: null, feature: { key: 'Buchungskalender' } }) + assert.ok(viaConfig.includes('abcdefghijklmnop')) +}) diff --git a/test/unterkunftAufbewahrung.test.js b/test/unterkunftAufbewahrung.test.js new file mode 100644 index 0000000..dace5fd --- /dev/null +++ b/test/unterkunftAufbewahrung.test.js @@ -0,0 +1,306 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 10: Aufbewahrung, Fristen, Loeschen (aufbewahrung.js, Tagesjob, Kundenloeschung). Die Uhr ist immer ein fester Zeitpunkt. +const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen'] +const aw = await startAppwriteMock({ + schema: { ...Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), portalFeatures: schemaKeys('portalFeatures'), termineOutbox: schemaKeys('termineOutbox') }, + datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt', 'anonymisiertAt'], termineOutbox: ['naechsterVersuch', 'versendetAt'] }, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { anonymisiere, loescheAbgeschaltete, loescheUkDaten, schliesseUkVonKunde } = await import('../server/services/unterkunft/aufbewahrung.js') +const jobs = await import('../server/services/unterkunft/jobs.js') +const mails = await import('../server/services/unterkunft/mails.js') +const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { raeumeAuf } = await import('../server/services/termine/outbox.js') + +const TAG = 24 * 3600 * 1000 +const JETZT = new Date('2026-10-07T10:00:00.000Z') +const vor = (tage, von = JETZT) => new Date(von.getTime() - tage * TAG).toISOString() +const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '0123', nachricht: 'Hallo' } +const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } +let n = 0 +const anfrage = (over = {}) => { + const $id = over.$id ?? `a${++n}` + aw.seed('ukAnfragen', { $id, featureId: 'f1', customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', nummer: 'A-' + $id, von: '2026-09-01', bis: '2026-09-05', personen: 2, status: 'neu', gast: JSON.stringify(GAST), nachrichtVermieter: 'Willkommen', ipHash: 'abcd', erstattetCent: 0, ...over }) + return $id +} +const holen = (id) => aw.docs('ukAnfragen').get(id) +const gast = (id) => JSON.parse(holen(id).gast) + +beforeEach(async () => { + for (const c of [...NAMEN, 'termineOutbox', 'portalFeatures', 'customers', 'websiteProjects']) for (const id of aw.docs(c).keys()) await deleteDocument(c, id) + mails._aufrufe.length = 0 +}) + +test('ipHash: nach 29 Tagen noch da, nach 31 Tagen leer; idempotent', async () => { + const jung = anfrage({ $createdAt: vor(29) }) + const alt = anfrage({ $createdAt: vor(31) }) + const r = await anonymisiere(JETZT) + assert.equal(r.ipHashes, 1) + assert.equal(holen(jung).ipHash, 'abcd') + assert.equal(holen(alt).ipHash, '') + assert.equal((await anonymisiere(JETZT)).ipHashes, 0) +}) + +test('neu: nach 59 Tagen bleibt neu, nach 61 Tagen abgelaufen ohne Mail', async () => { + const jung = anfrage({ $createdAt: vor(59) }) + const alt = anfrage({ $createdAt: vor(61) }) + const entschieden = anfrage({ $createdAt: vor(61), status: 'freigegeben' }) + const r = await anonymisiere(JETZT) + assert.equal(r.abgelaufen, 1) + assert.equal(holen(jung).status, 'neu') + assert.equal(holen(alt).status, 'abgelaufen') + assert.ok(Math.abs(Date.parse(holen(alt).abgelaufenAt) - JETZT.getTime()) < 1000) + assert.equal(holen(entschieden).status, 'freigegeben') + assert.equal(mails._aufrufe.length, 0) +}) + +test('Unverbindliche Anfragen: nach 6 Monaten anonymisiert, knapp davor nicht; storniert ohne Rechnung auch, mit Rechnung nicht', async () => { + const sechs = new Date('2026-04-07T10:00:00.000Z') + const davor = anfrage({ status: 'abgelehnt', $createdAt: new Date(sechs.getTime() + 3600e3).toISOString(), rechnungsadresse: JSON.stringify(ADRESSE) }) + const danach = anfrage({ status: 'abgelehnt', $createdAt: new Date(sechs.getTime() - 3600e3).toISOString(), rechnungsadresse: JSON.stringify(ADRESSE) }) + const verfallen = anfrage({ status: 'abgelaufen', $createdAt: vor(200) }) + const stornoOhne = anfrage({ status: 'storniert', $createdAt: vor(200) }) + const stornoMit = anfrage({ status: 'storniert', $createdAt: vor(200), rechnungId: 'r1', rechnungsadresse: JSON.stringify(ADRESSE), bis: '2026-09-05' }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: stornoMit, art: 'rechnung', nummer: 'R-1', datum: '2026-09-01', bruttoCent: 100, inhalt: '{"x":1}' }) + const r = await anonymisiere(JETZT) + assert.equal(r.anfragen, 3) + assert.equal(r.gaeste, 0) + assert.deepEqual(gast(davor), GAST) + for (const id of [danach, verfallen, stornoOhne]) { + const d = holen(id) + assert.deepEqual(JSON.parse(d.gast), {}) + assert.equal(d.nachrichtVermieter, '') + assert.equal(d.rechnungsadresse, null) + assert.equal(d.ipHash, '') + assert.ok(d.anonymisiertAt) + } + assert.deepEqual(gast(stornoMit), GAST) + assert.deepEqual(JSON.parse(holen(stornoMit).rechnungsadresse), ADRESSE) + assert.equal((await anonymisiere(JETZT)).anfragen, 0) +}) + +test('Gebuchte Aufenthalte: Gast weg 3 Jahre nach Jahresende der Abreise (Berlin); Rechnungsadresse und Rechnung bleiben unveraendert', async () => { + const inhalt = JSON.stringify({ gast: ADRESSE, summe: 12345 }) + const a = anfrage({ status: 'bezahlt', bis: '2022-12-31', von: '2022-12-28', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE), rechnungId: 'r1' }) + const b = anfrage({ status: 'gebucht', bis: '2023-01-02', von: '2022-12-30', $createdAt: vor(1500), rechnungsadresse: JSON.stringify(ADRESSE) }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: a, art: 'rechnung', nummer: 'R-1', datum: '2022-12-20', bruttoCent: 12345, inhalt }) + // 31.12.2025 23:30 Berlin: noch Jahr 2025 -> nichts + assert.equal((await anonymisiere(new Date('2025-12-31T22:30:00Z'))).gaeste, 0) + assert.deepEqual(gast(a), GAST) + // 01.01.2026 00:30 Berlin -> a (Abreise 2022), b (Abreise 2023) noch nicht + const r = await anonymisiere(new Date('2025-12-31T23:30:00Z')) + assert.equal(r.gaeste, 1) + assert.deepEqual(gast(a), {}) + assert.ok(holen(a).anonymisiertAt) + assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE) + assert.deepEqual(gast(b), GAST) + const re = aw.docs('ukRechnungen').get('r1') + assert.equal(re.inhalt, inhalt, 'PDF-Schnappschuss unveraendert') + assert.equal(re.nummer, 'R-1') + assert.equal((await anonymisiere(new Date('2025-12-31T23:30:00Z'))).gaeste, 0) +}) + +test('Rechnungen: 8 Jahre nach Ende des Rechnungsjahres geloescht, zugleich Rechnungsadresse der Anfrage weg', async () => { + const a = anfrage({ status: 'bezahlt', bis: '2018-05-05', anonymisiertAt: vor(1000), gast: '{}', rechnungsadresse: JSON.stringify(ADRESSE), rechnungId: 'r1' }) + aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f1', anfrageId: a, art: 'rechnung', nummer: 'R-1', datum: '2018-05-01', bruttoCent: 1, inhalt: '{}' }) + aw.seed('ukRechnungen', { $id: 'r2', featureId: 'f1', anfrageId: a, art: 'storno', nummer: 'S-1', datum: '2019-01-10', bruttoCent: -1, inhalt: '{}' }) + // Ende 2026 -> r1 faellig am 01.01.2027; r2 (2019) erst 01.01.2028 + assert.equal((await anonymisiere(new Date('2026-12-31T22:30:00Z'))).rechnungen, 0) + const r = await anonymisiere(new Date('2026-12-31T23:30:00Z')) + assert.equal(r.rechnungen, 1) + assert.equal(aw.docs('ukRechnungen').has('r1'), false) + assert.equal(aw.docs('ukRechnungen').has('r2'), true) + assert.deepEqual(JSON.parse(holen(a).rechnungsadresse), ADRESSE, 'noch eine Rechnung der Anfrage uebrig') + const r2 = await anonymisiere(new Date('2027-12-31T23:30:00Z')) + assert.equal(r2.rechnungen, 1) + assert.equal(r2.adressen, 1) + assert.equal(aw.docs('ukRechnungen').size, 0) + assert.equal(holen(a).rechnungsadresse, null) +}) + +test('Deckel begrenzt die Zahl je Lauf, der Rest folgt am naechsten', async () => { + for (let i = 0; i < 5; i++) anfrage({ $createdAt: vor(40) }) + const r1 = await anonymisiere(JETZT, { deckel: 2 }) + assert.equal(r1.ipHashes, 2) + assert.equal(r1.gedeckelt, true) + assert.equal((await anonymisiere(JETZT, { deckel: 2 })).ipHashes, 2) + const r3 = await anonymisiere(JETZT, { deckel: 2 }) + assert.equal(r3.ipHashes, 1) + assert.equal(r3.gedeckelt, false) +}) + +test('Logs ohne Personendaten: ein Fehler wird gezaehlt, der Lauf macht weiter', async (t) => { + const log = t.mock.method(console, 'warn', () => {}) + const a = anfrage({ $createdAt: vor(40) }) + const b = anfrage({ $createdAt: vor(40) }) + aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500, message: 'kaputt eva@example.com' }) + const r = await anonymisiere(JETZT) + assert.equal(r.fehler, 1) + assert.equal(r.ipHashes, 1) + assert.deepEqual([holen(a).ipHash, holen(b).ipHash].sort(), ['', 'abcd']) + assert.ok(log.mock.calls.length >= 1) + assert.ok(!JSON.stringify(log.mock.calls.map((c) => c.arguments)).includes('eva@example.com'), 'keine Personendaten im Log') +}) + +const funktion = (over = {}) => aw.seed('portalFeatures', { $id: 'f1', customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, ...over }) +const datenFuer = (f) => { + aw.seed('ukEinstellungen', { $id: f, featureId: f, firma: 'Haus' }) + aw.seed('ukUnterkuenfte', { $id: 'u_' + f, featureId: f, customerId: 'c1', name: 'W', aktiv: true }) + aw.seed('ukPreise', { $id: 'p_' + f, unterkunftId: 'u_' + f, featureId: f }) + aw.seed('ukSperren', { $id: 's_' + f, unterkunftId: 'u_' + f, featureId: f, von: '2026-12-01', bis: '2026-12-02' }) + anfrage({ $id: 'an_' + f, featureId: f }) + aw.seed('ukRechnungen', { $id: 're_' + f, featureId: f, anfrageId: 'an_' + f, art: 'rechnung', nummer: 'R', datum: '2026-09-01', bruttoCent: 1, inhalt: '{}' }) +} +const anzahl = (f) => NAMEN.reduce((s, c) => s + [...aw.docs(c).values()].filter((d) => d.featureId === f).length, 0) + +test('Abgeschaltete Funktion: nach 89 Tagen alles da, nach 91 Tagen alle uk*-Daten weg, Marker gesetzt, Rest unberuehrt, idempotent', async () => { + funktion({ state: 'disabled', enabled: false, disabledAt: vor(89) }) + funktion({ $id: 'f2', state: 'disabled', enabled: false, disabledAt: vor(100), siteKey: 'bcdefghijklmnopq' }) + funktion({ $id: 'f3', state: 'active' }) + for (const f of ['f1', 'f2', 'f3']) datenFuer(f) + let r = await loescheAbgeschaltete(JETZT) + assert.equal(r.funktionen, 1) + assert.equal(anzahl('f1'), 6) + assert.equal(anzahl('f2'), 0) + assert.equal(anzahl('f3'), 6) + assert.match(aw.docs('portalFeatures').get('f2').adminNote, /^Buchungsdaten gelöscht am 2026-10-07/) + r = await loescheAbgeschaltete(JETZT) + assert.equal(r.funktionen, 0) + r = await loescheAbgeschaltete(new Date(JETZT.getTime() + 3 * TAG)) + assert.equal(r.funktionen, 1) + assert.equal(anzahl('f1'), 0) + assert.equal(anzahl('f3'), 6) +}) + +test('loescheUkDaten mit Deckel: unvollstaendig, Fortsetzung loescht den Rest', async () => { + datenFuer('f1') + const a = await loescheUkDaten({ featureId: 'f1', deckel: 2 }) + assert.equal(a.vollstaendig, false) + assert.equal(a.geloescht, 2) + const b = await loescheUkDaten({ featureId: 'f1', deckel: 100 }) + assert.equal(b.vollstaendig, true) + assert.equal(anzahl('f1'), 0) +}) + +test('Tagesjob: off ohne Daten = aus, mit Daten laeuft er; on fuehrt Aufbewahrung und Loeschung aus', async () => { + const modus = config.unterkunft.mode + try { + config.unterkunft.mode = 'off' + assert.deepEqual(await jobs.taeglicherJob(JETZT), { aus: true }) + anfrage({ $createdAt: vor(40) }) + const r = await jobs.taeglicherJob(JETZT) + assert.equal(r.aufbewahrung.ipHashes, 1, 'die Fristen gelten auch bei off') + config.unterkunft.mode = 'on' + funktion({ state: 'disabled', enabled: false, disabledAt: vor(120) }) + datenFuer('f1') + const r2 = await jobs.taeglicherJob(JETZT) + assert.equal(r2.abgeschaltet.funktionen, 1) + assert.equal(anzahl('f1'), 0) + } finally { + config.unterkunft.mode = modus + } +}) + +test('Warteschlange: raeumeAuf loescht Eintraege jeder art (auch uk-*) 7 Tage nach Versand bzw. 48 Stunden nach Fehler', async () => { + const eintrag = (id, status, wann) => aw.seed('termineOutbox', { $id: id, featureId: 'f1', art: 'G16', bookingId: 'x', status, naechsterVersuch: wann }) + eintrag('o1', 'gesendet', vor(8)) + eintrag('o2', 'gesendet', vor(-6)) + eintrag('o3', 'fehler', vor(3)) + eintrag('o4', 'wartet', vor(10)) + assert.equal(await raeumeAuf(JETZT), 2) + assert.deepEqual([...aw.docs('termineOutbox').keys()].sort(), ['o2', 'o4']) +}) + +test('Kundenloeschung schaltet die Funktion ab, schliesst die Unterkuenfte, Daten bleiben bis zur Frist', async () => { + aw.seed('customers', { $id: 'c1', name: 'Weg', email: 'weg@example.invalid' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Haus' }) + funktion() + datenFuer('f1') + aw.seed('ukUnterkuenfte', { $id: 'u_fremd', featureId: 'f9', customerId: 'c9', name: 'X', aktiv: true }) + await deleteCustomerWithPortalAccess('c1') + const f = aw.docs('portalFeatures').get('f1') + assert.deepEqual([f.state, f.enabled, f.adminNote, f.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht']) + assert.equal(aw.docs('ukUnterkuenfte').get('u_f1').aktiv, false) + assert.equal(aw.docs('ukUnterkuenfte').get('u_fremd').aktiv, true) + assert.equal(anzahl('f1'), 6) + assert.equal((await loescheAbgeschaltete(new Date(Date.parse(f.disabledAt) + 91 * TAG))).funktionen, 1) + assert.equal(anzahl('f1'), 0) + assert.equal(await schliesseUkVonKunde('c1'), 0) +}) + +test('Sperre wie ablauf.js: ein Wechsel auf freigegeben unter der Unterkunfts-Sperre wird nicht ueberschrieben', async () => { + const id = anfrage({ $createdAt: vor(61), unterkunftId: 'u1' }) + let lauf + await withLocks(['unterkunft:u1'], async () => { + // der Job laeuft an und wartet auf die Sperre; waehrenddessen gibt der Vermieter frei + lauf = anonymisiere(JETZT) + await new Promise((r) => setTimeout(r, 150)) + await (await import('../server/services/appwriteAdmin.js')).updateDocument(config.collections.ukAnfragen, id, { status: 'freigegeben' }) + }) + const r = await lauf + assert.equal(r.abgelaufen, 0) + assert.equal(holen(id).status, 'freigegeben') +}) + +test('Gebucht ohne jede Rechnung: Rechnungsadresse geht mit den Gastdaten', async () => { + const a = anfrage({ status: 'gebucht', bis: '2022-06-01', rechnungsadresse: JSON.stringify(ADRESSE) }) + const r = await anonymisiere(new Date('2026-01-02T10:00:00Z')) + assert.equal(r.gaeste, 1) + assert.deepEqual(gast(a), {}) + assert.equal(holen(a).rechnungsadresse, null) +}) + +test('adminNote: bisheriger Text bleibt vor dem Marker; Wiedereinschalten bricht ab; erneutes Abschalten loescht nach neuen 90 Tagen; aktive Funktion mit altem Marker unberuehrt', async () => { + const sp = await import('../server/services/appwriteAdmin.js') + funktion({ state: 'disabled', enabled: false, disabledAt: vor(100), adminNote: 'Kunde gelöscht' }) + datenFuer('f1') + assert.equal((await loescheAbgeschaltete(JETZT)).funktionen, 1) + assert.equal(aw.docs('portalFeatures').get('f1').adminNote, 'Kunde gelöscht – Buchungsdaten gelöscht am 2026-10-07') + // wieder eingeschaltet: nichts wird geloescht + funktion({ $id: 'f4', state: 'disabled', enabled: false, disabledAt: vor(91) }) + datenFuer('f4') + await sp.updateDocument(config.collections.portalFeatures, 'f4', { state: 'active', enabled: true }) + assert.equal((await loescheAbgeschaltete(JETZT)).funktionen, 0) + assert.equal(anzahl('f4'), 6) + // erneut abgeschaltet (alter Marker steht noch): erst nach neuen 90 Tagen + await sp.updateDocument(config.collections.portalFeatures, 'f4', { adminNote: 'Buchungsdaten gelöscht am 2026-01-01', state: 'disabled', enabled: false, disabledAt: vor(10) }) + assert.equal((await loescheAbgeschaltete(JETZT)).funktionen, 0) + assert.equal(anzahl('f4'), 6) + assert.equal((await loescheAbgeschaltete(new Date(JETZT.getTime() + 81 * TAG))).funktionen, 1) + assert.equal(anzahl('f4'), 0) + // aktive Funktion mit altem Marker: unberuehrt + funktion({ $id: 'f5', state: 'active', adminNote: 'Buchungsdaten gelöscht am 2026-01-01' }) + datenFuer('f5') + await loescheAbgeschaltete(new Date(JETZT.getTime() + 500 * TAG)) + assert.equal(anzahl('f5'), 6) +}) + +test('Gemeinsamer Deckel: der Tagesjob gibt der Loeschung nur den Rest', async () => { + const modus = config.unterkunft.mode + const alt = config.unterkunft.aufbewahrungDeckel + try { + config.unterkunft.mode = 'on' + config.unterkunft.aufbewahrungDeckel = 3 + for (let i = 0; i < 3; i++) anfrage({ $createdAt: vor(40) }) + funktion({ state: 'disabled', enabled: false, disabledAt: vor(120) }) + datenFuer('f1') + const r = await jobs.taeglicherJob(JETZT) + assert.equal(r.aufbewahrung.verbraucht, 3) + assert.equal(r.abgeschaltet.dokumente, 0) + assert.equal(r.abgeschaltet.gedeckelt, true) + } finally { + config.unterkunft.mode = modus + config.unterkunft.aufbewahrungDeckel = alt + } +}) diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js new file mode 100644 index 0000000..f15201e --- /dev/null +++ b/test/unterkunftBuchung.test.js @@ -0,0 +1,426 @@ +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 7: Buchungsseite, Stripe-Zahlung, Webhook (Stripe ist der Nachbau) +const NAMEN = ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.filter((n) => !['websiteProjects', 'customers'].includes(n)).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 { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const K = await import('../server/services/unterkunft/kontext.js') +const mails = await import('../server/services/unterkunft/mails.js') +const ab = await import('../server/services/unterkunft/ablauf.js') +const sp = await import('../server/services/unterkunft/speicher.js') +const zahlung = await import('../server/services/unterkunft/zahlung.js') +const W = await import('../server/routes/unterkunftWebhook.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js') +const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes }) +after(async () => { + await Promise.all([app.close(), aw.close(), st.close()]) +}) + +const SITE = 'abcdefghijklmnop' +const FEATURE = 'f_uk' +const ACCOUNT = 'acct_haendler1' +const NOW = new Date('2026-11-01T12:00:00.000Z') +const BASE = `/api/public/unterkunft/v1/${SITE}` +const ORIGIN = 'https://donau-home.example' +const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } +const vorher = { ...config.unterkunft } + +const einstellungen = (extra = {}) => ({ + $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Str 1', plz: '78576', ort: 'Stetten', email: 'x@y.de', + steuernummer: '12/345', iban: 'DE00', akzentfarbe: '#112233', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', ...extra, +}) + +async function seedBasis(einstExtra = {}) { + for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) + aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' }) + aw.seed('ukEinstellungen', einstellungen(einstExtra)) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', maxPersonen: 4, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true }) + aw.seed('ukPreise', { + $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), + reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'regel', + ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vorher.', + }) + K.leereCache() +} + +beforeEach(async () => { + Object.assign(config.unterkunft, vorher, { mode: 'on', minAntwortMs: 0 }) + resetTermineLimitsForTests() + mails._aufrufe.length = 0 + zahlung._aufrufe.length = 0 + zahlung.setzeErstattungFuerTests(null) + zahlung.leereAbgleichFuerTests() + W.setzeUkEreignisHandlerFuerTests(null) + st.calls.length = 0 + setzeUhrFuerTests(() => NOW) + await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT }) +}) +afterEach(() => Object.assign(config.unterkunft, vorher)) + +/** Freigegebene Anfrage mit Token (wie der Vermieter sie freigibt). */ +async function freigegeben(von = '2026-11-10', bis = '2026-11-12') { + const ctx = await K.unterkunftPerSiteKey(SITE, { now: NOW }) + const a = await ab.legeAnfrageAn(ctx, { unterkunftId: 'u1', von, bis, personen: 2, gast: { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: '' } }, { now: NOW, ipHash: 'x' }) + const { token } = await ab.gibFrei(ctx, a.$id, { now: NOW }) + mails._aufrufe.length = 0 + return { id: a.$id, token, ctx } +} +const url = (id, t, rest = '') => `${BASE}/buchung/${id}${rest}?t=${encodeURIComponent(t)}` +const post = (pfad, body, headers = { origin: ORIGIN }) => send('POST', pfad, { body, headers }) +const arten = () => mails._aufrufe.map((m) => m.art || m.template) + +test('GET buchung: falsches Token und unbekannte ID sind identisch 404', async () => { + const { id, token } = await freigegeben() + const falsch = await send('GET', url(id, 'falsch')) + const fremd = await send('GET', url('gibtsnicht', token)) + const kaputt = await send('GET', url('a.b', token)) + const ohne = await send('GET', `${BASE}/buchung/${id}`) + for (const r of [falsch, fremd, kaputt, ohne]) { + assert.equal(r.status, 404) + assert.deepEqual(r.body, { fehler: 'nicht_gefunden' }) + } +}) + +test('GET buchung: Vertrag, ohne interne Felder, zahlarten ohne online wenn Stripe fehlt', async () => { + await seedBasis({}) + const { id, token } = await freigegeben() + const r = await send('GET', url(id, token)) + assert.equal(r.status, 200) + assert.equal(r.body.status, 'freigegeben') + assert.equal(r.body.unterkunft.name, 'Erste') + assert.equal(r.body.naechte, 2) + assert.equal(r.body.summen.bruttoCent, 20000) + assert.equal(r.body.akzentfarbe, '#112233') + assert.equal(r.body.anbieter.datenschutzUrl, 'https://donau-home.example/datenschutz') + assert.equal(r.body.anbieter.firma, 'Donau Home') + assert.deepEqual(r.body.zahlarten, ['vorort']) + assert.equal(r.body.bezahlt, false) + assert.equal(r.body.rechnungNummer, undefined) + assert.equal(r.body.stornobedingungen, 'Kostenlos bis 7 Tage vorher.') + const roh = JSON.stringify(r.body) + for (const verboten of ['tokenHash', 'ipHash', 'steuernummer', 'iban', 'featureId', 'customerId', 'eva@example.com']) assert.ok(!roh.includes(verboten), verboten) + const mitOnline = await (async () => { + await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT }) + const f = await freigegeben() + return send('GET', url(f.id, f.token)) + })() + assert.deepEqual(mitOnline.body.zahlarten, ['vorort', 'online']) +}) + +test('POST buchung vor Ort: gebucht, Rechnung; zweites POST gleiche Antwort ohne zweite Rechnung', async () => { + const { id, token } = await freigegeben() + const r1 = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) + assert.equal(r1.status, 200) + assert.deepEqual(r1.body, { status: 'gebucht', rechnungNummer: 'RE-2026-0001' }) + assert.equal(r1.headers.get('access-control-allow-origin'), ORIGIN) + const r2 = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) + assert.deepEqual(r2.body, r1.body) + assert.equal(aw.docs('ukRechnungen').size, 1) + assert.deepEqual(arten(), ['G19', 'K32']) + const g = await send('GET', url(id, token)) + assert.equal(g.body.status, 'gebucht') + assert.equal(g.body.rechnungNummer, 'RE-2026-0001') + assert.equal(g.body.zahlart, 'vorort') + assert.equal(g.body.rechnungsadresse.ort, 'Berlin') +}) + +test('POST buchung: falsches Token 404, fremde Herkunft 403, Adresse fehlt 400, Frist abgelaufen 410', async () => { + const { id, token } = await freigegeben() + const falsch = await post(url(id, 'falsch'), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) + assert.equal(falsch.status, 404) + assert.deepEqual(falsch.body, { fehler: 'nicht_gefunden' }) + assert.equal((await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }, { origin: 'https://boese.example' })).status, 403) + const luecke = await post(url(id, token), { rechnungsadresse: { name: 'x' }, zahlart: 'vorort' }) + assert.equal(luecke.status, 400) + assert.equal(luecke.body.fehler, 'ungueltig') + setzeUhrFuerTests(() => new Date(NOW.getTime() + 4 * 86400000)) + const spaet = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) + assert.equal(spaet.status, 410) + assert.deepEqual(spaet.body, { fehler: 'frist_abgelaufen' }) + assert.equal(aw.docs('ukRechnungen').size, 0) +}) + +test('POST buchung online: zahlungUrl vom Stripe-Nachbau, Sitzung auf dem Konto des Vermieters', async () => { + const { id, token } = await freigegeben() + const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + assert.equal(r.status, 200) + assert.equal(r.body.status, 'gebucht') + assert.match(r.body.zahlungUrl, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/) + const [call] = st.calls.filter((c) => c.path === '/v1/checkout/sessions') + assert.equal(call.account, ACCOUNT) + assert.equal(call.body.mode, 'payment') + assert.equal(call.body['metadata[wkUk]'], '1') + assert.equal(call.body['metadata[anfrageId]'], id) + assert.equal(call.body['payment_intent_data[metadata][anfrageId]'], id) + assert.equal(call.body['payment_intent_data[metadata][webklar]'], 'unterkunft') + assert.equal(call.body['line_items[0][price_data][unit_amount]'], '20000') + assert.match(call.body['line_items[0][price_data][product_data][name]'], /^Aufenthalt Erste 10\.11\.2026–12\.11\.2026, Rechnung RE-2026-0001$/) + assert.equal(call.body['payment_method_types[0]'], 'card') + assert.equal(call.body['payment_method_types[1]'], undefined) + assert.equal(call.body.locale, 'de') + assert.match(call.body.success_url, new RegExp(`/unterkunft/${SITE}/buchung/${id}\\?t=.*&wk_zahlung=ok$`)) + assert.match(call.body.cancel_url, /&wk_zahlung=abbruch$/) + assert.equal(Number(call.body.expires_at), Math.floor(NOW.getTime() / 1000) + 31 * 60) + assert.match(call.idempotencyKey, new RegExp(`^uk-zahlung-${id}-\\d+$`)) + assert.ok((await sp.ladeAnfrage(id)).stripeSessionId.startsWith('cs_')) +}) + +test('Webhook checkout.session.completed: bezahlt, G20, zweimal nur ein Uebergang', async () => { + const { id, token } = await freigegeben() + const r = await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const sid = (await sp.ladeAnfrage(id)).stripeSessionId + mails._aufrufe.length = 0 + const session = st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_1' }) + const event = { type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } } + await W.verarbeiteUkEreignis(event, NOW) + await W.verarbeiteUkEreignis(event, NOW) + const a = await sp.ladeAnfrage(id) + assert.equal(a.status, 'bezahlt') + assert.equal(a.bezahltVia, 'stripe') + assert.equal(a.stripePaymentIntentId, 'pi_1') + assert.deepEqual(arten(), ['G20']) + assert.ok(r.body.zahlungUrl) +}) + +test('Webhook: unbezahlt, fremdes Konto, unbekannte Anfrage ohne Wirkung; async_payment_succeeded bucht', async () => { + const { id, token } = await freigegeben() + await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const sid = (await sp.ladeAnfrage(id)).stripeSessionId + const unbezahlt = { ...st.completeSession(ACCOUNT, sid, { payment_status: 'unpaid', payment_intent: 'pi_2' }) } + await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: unbezahlt } }, NOW) + assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht') + const bezahlt = { ...unbezahlt, payment_status: 'paid' } + await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: 'acct_fremd', data: { object: bezahlt } }, NOW) + assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht') + await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: { ...bezahlt, metadata: { wkUk: '1', anfrageId: 'gibtsnicht' } } } }, NOW) + await W.verarbeiteUkEreignis({ type: 'checkout.session.async_payment_succeeded', account: ACCOUNT, data: { object: bezahlt } }, NOW) + assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt') +}) + +test('Rueckkehr mit wk_zahlung=ok schliesst ohne Webhook selbst ab', async () => { + const { id, token } = await freigegeben() + await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const sid = (await sp.ladeAnfrage(id)).stripeSessionId + const offen = await send('GET', url(id, token)) + assert.equal(offen.body.status, 'gebucht') + mails._aufrufe.length = 0 + st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_3' }) + const r = await send('GET', url(id, token) + '&wk_zahlung=ok') + assert.equal(r.body.status, 'bezahlt') + assert.equal(r.body.bezahlt, true) + assert.deepEqual(arten(), ['G20']) + assert.equal((await sp.ladeAnfrage(id)).stripePaymentIntentId, 'pi_3') +}) + +test('POST zahlung: neue Sitzung fuer gebuchte Online-Zahlung; vor Ort oder bezahlt 409', async () => { + const { id, token } = await freigegeben() + await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const r = await post(url(id, token, '/zahlung'), {}) + assert.equal(r.status, 200) + assert.match(r.body.url, /^https:\/\/checkout\.stripe\.test\//) + assert.equal((await post(url(id, 'falsch', '/zahlung'), {})).status, 404) + const sid = (await sp.ladeAnfrage(id)).stripeSessionId + st.completeSession(ACCOUNT, sid, { payment_status: 'paid', payment_intent: 'pi_4' }) + const nach = await post(url(id, token, '/zahlung'), {}) + assert.equal(nach.status, 409) + assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt') + const g = await freigegeben('2026-12-10', '2026-12-12') + await post(url(g.id, g.token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) + assert.equal((await post(url(g.id, g.token, '/zahlung'), {})).status, 409) +}) + +async function bezahlt(pi = 'pi_5') { + const { id, token, ctx } = await freigegeben() + await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + const sid = (await sp.ladeAnfrage(id)).stripeSessionId + const session = st.completeSession(ACCOUNT, sid, { payment_status: 'paid', amount_total: 20000, payment_intent: pi }) + await W.verarbeiteUkEreignis({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } }, NOW) + return { id, token, ctx } +} + +test('Erstattung echt: Stripe-Aufruf mit Metadaten unterkunft und Idempotency-Key, erstattetCent aus Stripe', async () => { + const { id, ctx } = await bezahlt('pi_6') + st.setPaymentIntent(ACCOUNT, 'pi_6', { amount: 20000 }) + st.calls.length = 0 + const r = await ab.storniere(ctx, id, { grund: 'Krank', erstattungCent: 12000, now: NOW }) + assert.equal(r.anfrage.erstattetCent, 12000) + const [call] = st.calls.filter((c) => c.path === '/v1/refunds') + assert.equal(call.account, ACCOUNT) + assert.equal(call.body.payment_intent, 'pi_6') + assert.equal(call.body.amount, '12000') + assert.equal(call.body['metadata[webklar]'], 'unterkunft') + assert.equal(call.idempotencyKey, `uk-erstattung-${id}-12000`) + assert.equal(zahlung._aufrufe.length, 1) +}) + +test('Erstattung: Stripe scheitert -> 502, Storno bleibt, erstattetCent unveraendert', async () => { + const { id, ctx } = await bezahlt('pi_7') + st.setPaymentIntent(ACCOUNT, 'pi_7', { amount: 20000 }) + st.failNext({ status: 400, path: '/v1/refunds' }) + await assert.rejects(ab.storniere(ctx, id, { erstattungCent: 5000, now: NOW }), (e) => e.status === 502 && e.code === 'erstattung_fehlgeschlagen') + const a = await sp.ladeAnfrage(id) + assert.equal(a.status, 'storniert') + assert.equal(a.erstattetCent ?? 0, 0) +}) + +test('Webhook charge.refunded: erstattetCent = Maximum, nie zurueck', async () => { + const { id, ctx } = await bezahlt('pi_8') + await ab.storniere(ctx, id, { now: NOW }) + const charge = (betrag) => ({ type: 'charge.refunded', account: ACCOUNT, data: { object: { id: 'ch_1', payment_intent: 'pi_8', amount_refunded: betrag, metadata: { wkUk: '1', webklar: 'unterkunft', anfrageId: id } } } }) + await W.verarbeiteUkEreignis(charge(7000), NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) + await W.verarbeiteUkEreignis(charge(3000), NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) + await W.verarbeiteUkEreignis(charge(7000), NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) + await W.verarbeiteUkEreignis({ ...charge(9000), account: 'acct_fremd' }, NOW) + assert.equal((await sp.ladeAnfrage(id)).erstattetCent, 7000) +}) + +test('rechnung.pdf: 200 mit PDF oder 501 ohne Erzeuger; falsches Token 404', async () => { + const { id, token } = await freigegeben() + await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }) + const r = await send('GET', url(id, token, '/rechnung.pdf')) + assert.ok([200, 501].includes(r.status), String(r.status)) + if (r.status === 200) assert.equal(r.headers.get('content-type'), 'application/pdf') + assert.equal((await send('GET', url(id, 'falsch', '/rechnung.pdf'))).status, 404) + assert.equal((await send('GET', url(id, token, '/rechnung.pdf') + '&art=storno')).status, 404) + assert.equal((await send('GET', url(id, token, '/rechnung.pdf') + '&art=x')).status, 400) +}) + +const sitzungen = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST') +const sekunden = (n) => new Date(NOW.getTime() + n * 1000) + +test('Doppelklick: zwei Aufrufe in einer Minute -> dieselbe URL, eine Sitzung; nach Ablauf eine neue', async () => { + const { id, token, ctx } = await freigegeben() + await post(url(id, token), { rechnungsadresse: ADRESSE, zahlart: 'vorort' }).catch(() => {}) + await seedBasis({ onlineZahlung: true, stripeAccountId: ACCOUNT }) + const f = await freigegeben('2026-12-10', '2026-12-12') + const b = await ab.bucheVerbindlich(f.ctx, f.id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW }) + st.calls.length = 0 + const x = await zahlung.checkoutFuer(f.ctx, b.anfrage, { now: sekunden(5), token: f.token }) + const y = await zahlung.checkoutFuer(f.ctx, await sp.ladeAnfrage(f.id), { now: sekunden(40), token: f.token }) + assert.equal(x.url, y.url) + assert.equal(sitzungen().length, 1) + assert.equal(Number(sitzungen()[0].body.expires_at) % 60, 0) + const sid = (await sp.ladeAnfrage(f.id)).stripeSessionId + st.accountSessions(ACCOUNT).set(sid, { ...st.accountSessions(ACCOUNT).get(sid), status: 'expired' }) + const z = await zahlung.checkoutFuer(f.ctx, await sp.ladeAnfrage(f.id), { now: sekunden(150), token: f.token }) + assert.notEqual(z.url, x.url) + assert.equal(sitzungen().length, 2) + assert.ok(ctx) +}) + +async function onlineGebucht(von, bis) { + const f = await freigegeben(von, bis) + const r = await post(url(f.id, f.token), { rechnungsadresse: ADRESSE, zahlart: 'online' }) + assert.ok(r.body.zahlungUrl) + return { ...f, sid: (await sp.ladeAnfrage(f.id)).stripeSessionId } +} +const erstattungen = (pi) => [...st.refunds(ACCOUNT).values()].filter((r) => r.payment_intent === pi) +const refunds = () => st.calls.filter((c) => c.path === '/v1/refunds' && c.method === 'POST') + +test('Storno beendet die offene Sitzung; danach keine neue Sitzung (409), auch bei abgelaufener Buchung', async () => { + const f = await onlineGebucht('2026-11-10', '2026-11-12') + await ab.storniere(f.ctx, f.id, { now: NOW }) + assert.equal(st.accountSessions(ACCOUNT).get(f.sid).status, 'expired') + const r = await post(url(f.id, f.token, '/zahlung'), {}) + assert.equal(r.status, 409) + assert.equal(sitzungen().length, 1) + // checkoutFuer liest frisch: eine veraltete Anfrage-Kopie (gebucht) erzeugt trotzdem keine Sitzung + await assert.rejects(zahlung.checkoutFuer(f.ctx, { ...(await sp.ladeAnfrage(f.id)), status: 'gebucht' }, { now: NOW, token: f.token }), (e) => e.status === 409) + assert.equal(sitzungen().length, 1) + // abgelaufen (Frist): freigegeben, nie gebucht + const g = await freigegeben('2026-12-10', '2026-12-12') + await ab.laufeFristenAb(new Date(NOW.getTime() + 4 * 86400000)) + assert.equal((await sp.ladeAnfrage(g.id)).status, 'abgelaufen') + assert.equal((await post(url(g.id, g.token, '/zahlung'), {})).status, 409) +}) + +test('Zahlung nach Storno: automatisch voll erstattet, Status bleibt, Wiederholung ohne zweite Erstattung', async () => { + const f = await onlineGebucht('2026-11-10', '2026-11-12') + await ab.storniere(f.ctx, f.id, { now: NOW }) + st.setPaymentIntent(ACCOUNT, 'pi_spaet', { amount: 20000 }) + const session = st.completeSession(ACCOUNT, f.sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_spaet' }) + const event = { type: 'checkout.session.completed', account: ACCOUNT, data: { object: session } } + st.calls.length = 0 + await W.verarbeiteUkEreignis(event, NOW) + await W.verarbeiteUkEreignis(event, NOW) + const [call] = refunds() + assert.equal(call.body.payment_intent, 'pi_spaet') + assert.equal(call.body['metadata[webklar]'], 'unterkunft') + assert.equal(call.idempotencyKey, 'uk-rueck-pi_spaet') + assert.equal(call.account, ACCOUNT) + assert.equal(erstattungen('pi_spaet').length, 1) + assert.equal((await sp.ladeAnfrage(f.id)).status, 'storniert') +}) + +test('Zahlung nach Frist-Ablauf der Sitzung/Storno ueber Rueckkehr-Abgleich: ebenfalls erstattet', async () => { + const f = await onlineGebucht('2026-11-10', '2026-11-12') + st.setPaymentIntent(ACCOUNT, 'pi_sp2', { amount: 20000 }) + st.completeSession(ACCOUNT, f.sid, { payment_status: 'paid', amount_total: 20000, payment_intent: 'pi_sp2' }) + // Storno zwischen Abgleich-Lesen und Buchen: markiereBezahlt wird abgelehnt + await ab.storniere(f.ctx, f.id, { now: NOW }) + const a = await zahlung.verbucheZahlung(f.ctx, f.id, 'pi_sp2', { now: NOW }) + assert.equal(a.status, 'storniert') + assert.equal(erstattungen('pi_sp2').length, 1) +}) + +test('Zweite Zahlung auf bereits bezahlte Buchung (andere Zahlung): automatisch erstattet; dieselbe Zahlung nicht', async () => { + const f = await onlineGebucht('2026-11-10', '2026-11-12') + st.setPaymentIntent(ACCOUNT, 'pi_a', { amount: 20000 }) + st.setPaymentIntent(ACCOUNT, 'pi_b', { amount: 20000 }) + const ev = (pi) => ({ type: 'checkout.session.completed', account: ACCOUNT, data: { object: st.completeSession(ACCOUNT, f.sid, { payment_status: 'paid', amount_total: 20000, payment_intent: pi }) } }) + await W.verarbeiteUkEreignis(ev('pi_a'), NOW) + assert.equal((await sp.ladeAnfrage(f.id)).status, 'bezahlt') + await W.verarbeiteUkEreignis(ev('pi_a'), NOW) + assert.equal(erstattungen('pi_a').length, 0) + await W.verarbeiteUkEreignis(ev('pi_b'), NOW) + await W.verarbeiteUkEreignis(ev('pi_b'), NOW) + assert.equal(erstattungen('pi_b').length, 1) + const a = await sp.ladeAnfrage(f.id) + assert.equal(a.status, 'bezahlt') + assert.equal(a.stripePaymentIntentId, 'pi_a') + // spaeterer Storno + Wiederholung der echten Zahlung erstattet nichts + await ab.storniere(f.ctx, f.id, { now: NOW }) + await W.verarbeiteUkEreignis(ev('pi_a'), NOW) + assert.equal(erstattungen('pi_a').length, 0) +}) + +test('GET: Stripe-Abgleich hoechstens alle 30 s je Sitzung, abgelaufene Sitzung nie wieder', async () => { + zahlung.leereAbgleichFuerTests() + const f = await onlineGebucht('2026-11-10', '2026-11-12') + const abrufe = () => st.calls.filter((c) => c.method === 'GET' && c.path === `/v1/checkout/sessions/${f.sid}`).length + const vorherAbrufe = abrufe() + await send('GET', url(f.id, f.token)) + await send('GET', url(f.id, f.token)) + assert.equal(abrufe() - vorherAbrufe, 1) + await send('GET', url(f.id, f.token) + '&wk_zahlung=ok') + assert.equal(abrufe() - vorherAbrufe, 2) + setzeUhrFuerTests(() => sekunden(31)) + await send('GET', url(f.id, f.token)) + assert.equal(abrufe() - vorherAbrufe, 3) + st.accountSessions(ACCOUNT).set(f.sid, { ...st.accountSessions(ACCOUNT).get(f.sid), status: 'expired' }) + setzeUhrFuerTests(() => sekunden(70)) + await send('GET', url(f.id, f.token)) + setzeUhrFuerTests(() => sekunden(200)) + await send('GET', url(f.id, f.token)) + assert.equal(abrufe() - vorherAbrufe, 4) +}) diff --git a/test/unterkunftIcal.test.js b/test/unterkunftIcal.test.js new file mode 100644 index 0000000..b1f0eef --- /dev/null +++ b/test/unterkunftIcal.test.js @@ -0,0 +1,326 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +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 9: iCal-Parser/-Export (ical.js), Abruf + Abgleich (icalAbgleich.js), Export-Route (unterkunftSeite.js) +const NAMEN = ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukSperren', 'ukAnfragen', 'customers', 'websiteProjects'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.filter((n) => !['customers', 'websiteProjects'].includes(n)).map((n) => [n, schemaKeys(n)])), + datetimes: { ukAnfragen: ['fristAt'], ukEinstellungen: ['icalFehlerMailAt'] }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, rawGet } = await import('./support/testApp.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const mails = await import('../server/services/unterkunft/mails.js') +const { parseIcs, baueIcs } = await import('../server/services/unterkunft/ical.js') +const { holeIcs, gleicheAb, gleicheAlleAb, istPrivateAdresse } = await import('../server/services/unterkunft/icalAbgleich.js') +const { viertelstundenJob } = await import('../server/services/unterkunft/jobs.js') +const { unterkunftSeiteRouter } = await import('../server/routes/unterkunftSeite.js') +const { ladeUnterkunft, listeSperren, ladeEinstellungen } = await import('../server/services/unterkunft/speicher.js') +const app = await startTestApp({ '/': unterkunftSeiteRouter() }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +const fx = (n) => readFileSync(new URL(`./fixtures/ical/${n}`, import.meta.url), 'utf8') +const FEATURE = 'f_p1_unterkunft' +const vorher = { ...config.unterkunft } +const OPT = { heute: '2026-11-07', horizontTage: 540 } +const antwort = (text, status = 200, headers = {}) => new Response(text, { status, headers }) +const oeffentlich = async () => [{ address: '93.184.216.34', family: 4 }] + +beforeEach(async () => { + for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + Object.assign(config.unterkunft, vorher, { mode: 'on' }) + mails._aufrufe.length = 0 + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) + aw.seed('customers', { $id: 'c1', email: 'mimi@example.com' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau' }) +}) +afterEach(() => Object.assign(config.unterkunft, vorher)) + +// ------------------------------------------------------------------------------------------------ Parser + +test('parseIcs: Booking-Fixture liefert DATE-Ereignisse, Airbnb ebenso', () => { + const b = parseIcs(fx('booking.ics'), OPT) + assert.deepEqual(b.ereignisse.map((e) => [e.von, e.bis, e.uid]), [['2026-11-10', '2026-11-13', 'b1@booking.com'], ['2026-12-01', '2026-12-03', 'b2@booking.com']]) + const a = parseIcs(fx('airbnb.ics'), OPT) + assert.deepEqual(a.ereignisse.map((e) => [e.von, e.bis]), [['2026-11-20', '2026-11-25'], ['2026-12-01', '2026-12-02']]) +}) + +test('parseIcs: UTC-, TZID- und Datumswerte, fehlendes DTEND = 1 Nacht, CANCELLED, Fenster', () => { + const t = parseIcs(fx('tzid.ics'), OPT).ereignisse + const nach = Object.fromEntries(t.map((e) => [e.uid, [e.von, e.bis]])) + assert.deepEqual(nach.z1, ['2026-11-10', '2026-11-12']) + assert.deepEqual(nach.z2, ['2026-11-10', '2026-11-12']) + assert.deepEqual(nach.z3, ['2026-12-01', '2026-12-02'], '23:30 UTC am 30.11. ist in Berlin schon der 1.12.') + assert.deepEqual(nach.z4, ['2026-12-15', '2026-12-16']) + assert.equal(nach.z5, undefined, 'CANCELLED ignoriert') + assert.equal(nach.z6, undefined, 'vergangen') + assert.equal(nach.z7, undefined, 'hinter dem Horizont') +}) + +test('parseIcs: gefaltete Zeilen stoeren nicht, DTEND-Text in SUMMARY wird nicht gelesen', () => { + const g = parseIcs(fx('gefaltet.ics'), OPT).ereignisse + assert.deepEqual(g.map((e) => [e.von, e.bis]), [['2026-11-10', '2026-11-12']]) +}) + +test('parseIcs: HTML oder Unsinn => kein_ics', () => { + assert.throws(() => parseIcs(fx('fehlerseite.html'), OPT), { message: 'kein_ics' }) + assert.throws(() => parseIcs('', OPT), { message: 'kein_ics' }) + assert.throws(() => parseIcs(null, OPT), { message: 'kein_ics' }) + assert.deepEqual(parseIcs('BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n', OPT).ereignisse, []) +}) + +test('baueIcs: CRLF, gefaltete Zeilen <= 75 Oktette, Pflichtfelder', () => { + const s = baueIcs({ name: 'Wohnung mit sehr langem Namen äöü '.repeat(4), ereignisse: [{ uid: 'a1@project.webklar.com', von: '2026-11-10', bis: '2026-11-12' }], jetzt: new Date('2026-11-07T10:00:00Z') }) + assert.ok(s.startsWith('BEGIN:VCALENDAR\r\n') && s.endsWith('END:VCALENDAR\r\n')) + assert.ok(!/(^|[^\r])\n/.test(s), 'nur CRLF') + for (const z of s.split('\r\n')) assert.ok(Buffer.byteLength(z) <= 75, z) + assert.match(s, /PRODID:-\/\/WEBklar\/\/Buchungskalender\/\/DE/) + assert.match(s, /SUMMARY:Belegt/) + assert.match(s, /DTSTART;VALUE=DATE:20261110/) + assert.match(s, /DTEND;VALUE=DATE:20261112/) + assert.match(s, /UID:a1@project\.webklar\.com/) + assert.match(s, /DTSTAMP:20261107T100000Z/) + assert.equal(parseIcs(s, OPT).ereignisse.length, 1, 'Rundreise') +}) + +// ------------------------------------------------------------------------------------------------ Abruf, SSRF + +test('istPrivateAdresse: private, Loopback, Link-Local, CGNAT, ULA, Multicast, unspecified, IPv4-mapped', () => { + for (const a of ['127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '255.255.255.255', '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1', '::ffff:7f00:1', '64:ff9b::a00:1', '2002:7f00:1::1']) { + assert.equal(istPrivateAdresse(a), true, a) + } + for (const a of ['93.184.216.34', '8.8.8.8', '172.32.0.1', '2606:2800:220:1:248:1893:25c8:1946', '::ffff:8.8.8.8']) assert.equal(istPrivateAdresse(a), false, a) + assert.equal(istPrivateAdresse('kein-ip'), true, 'Unklares gilt als privat') +}) + +test('holeIcs: nur https, keine privaten Ziele, kein Abruf bei Ablehnung', async () => { + let aufrufe = 0 + const fetch = async () => (aufrufe++, antwort(fx('booking.ics'))) + await assert.rejects(holeIcs('http://example.com/a.ics', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' }) + for (const u of ['https://127.0.0.1/a.ics', 'https://10.0.0.5/', 'https://[::1]/', 'https://[::ffff:127.0.0.1]/', 'https://2130706433/', 'https://169.254.169.254/latest']) { + await assert.rejects(holeIcs(u, { fetch, lookup: oeffentlich }), { code: 'private_adresse' }, u) + } + await assert.rejects(holeIcs('https://intern.example/a.ics', { fetch, lookup: async () => [{ address: '93.184.216.34', family: 4 }, { address: '10.1.2.3', family: 4 }] }), { code: 'private_adresse' }, 'eine private Antwort genuegt') + await assert.rejects(holeIcs('https://u:p@example.com/a.ics', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' }) + assert.equal(aufrufe, 0) +}) + +test('holeIcs: Erfolg, Adresse wird dem Abruf mitgegeben (Pinning), Fehlerstatus, zu gross, Timeout', async () => { + let gesehen + const text = await holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async (u, o) => ((gesehen = o), antwort(fx('booking.ics'))) }) + assert.match(text, /BEGIN:VCALENDAR/) + assert.deepEqual(gesehen.adresse, { address: '93.184.216.34', family: 4 }) + assert.equal(gesehen.redirect, 'manual') + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x', 503) }), { code: 'http_503' }) + config.unterkunft.icalMaxBytes = 100 + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x'.repeat(5000)) }), { code: 'zu_gross' }) + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x'.repeat(50), 200, { 'content-length': '5000' }) }), { code: 'zu_gross' }) + config.unterkunft.icalMaxBytes = 1_000_000 + config.unterkunft.icalTimeoutMs = 50 + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: (u, o) => new Promise((_, rej) => o.signal.addEventListener('abort', () => rej(new Error('abort')))) }), { code: 'timeout' }) +}) + +test('holeIcs: Weiterleitungen werden je erneut geprueft, hoechstens 3', async () => { + const ziele = [] + const weiter = (nach) => antwort('', 302, { location: nach }) + let fetch = async (u) => (ziele.push(u), u.endsWith('/ziel.ics') ? antwort(fx('booking.ics')) : weiter(u.replace(/\/\d$/, '') + '/ziel.ics')) + assert.match(await holeIcs('https://example.com/start/1', { fetch, lookup: oeffentlich }), /VCALENDAR/) + fetch = async () => weiter('http://example.com/x') + await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' }) + fetch = async () => weiter('https://127.0.0.1/geheim') + await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'private_adresse' }) + // relativer Pfad, Rebinding auf zweiten Host: der zweite Host loest privat auf + fetch = async () => weiter('https://zweiter.example/x') + await assert.rejects( + holeIcs('https://example.com/a', { fetch, lookup: async (h) => [{ address: h === 'example.com' ? '93.184.216.34' : '10.0.0.9', family: 4 }] }), + { code: 'private_adresse' }, + ) + let n = 0 + fetch = async () => (n++, weiter(`https://example.com/${n}`)) + await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'zu_viele_weiterleitungen' }) + assert.equal(n, 4, 'erster Abruf + 3 Weiterleitungen') +}) + +// ------------------------------------------------------------------------------------------------ Abgleich + +const imp = (extra = {}) => ({ id: 'imp1', name: 'Booking.com', url: 'https://example.com/geheim-token/cal.ics', letzterAbrufAt: null, letzterErfolgAt: null, fehler: '', ...extra }) +function seedU(importe = [imp()], extra = {}) { + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', aktiv: true, sort: 1, icalExportToken: 'T'.repeat(43), icalImporte: JSON.stringify(importe), ...extra }) +} +const sperren = async () => (await listeSperren('u1')).map((s) => [s.von, s.bis, s.quelle]).sort() +const NOW = new Date('2026-11-07T10:00:00Z') + +test('gleicheAb: Erfolg legt Sperren an (quelle ical:), ersetzt alte, laesst manuelle stehen', async () => { + seedU() + aw.seed('ukSperren', { $id: 's_alt', unterkunftId: 'u1', featureId: FEATURE, von: '2026-10-01', bis: '2026-10-03', quelle: 'ical:imp1' }) + aw.seed('ukSperren', { $id: 's_fremd', unterkunftId: 'u1', featureId: FEATURE, von: '2026-10-01', bis: '2026-10-03', quelle: 'ical:imp2' }) + aw.seed('ukSperren', { $id: 's_man', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-01', bis: '2026-11-02', quelle: 'manuell' }) + const u = await ladeUnterkunft('u1') + const r = await gleicheAb(u, u.icalImporte[0], { now: NOW, lookup: oeffentlich, fetch: async () => antwort(fx('booking.ics')) }) + assert.deepEqual(r, { ok: true, anzahl: 2 }) + assert.deepEqual(await sperren(), [['2026-10-01', '2026-10-03', 'ical:imp2'], ['2026-11-01', '2026-11-02', 'manuell'], ['2026-11-10', '2026-11-13', 'ical:imp1'], ['2026-12-01', '2026-12-03', 'ical:imp1']]) + const i = (await ladeUnterkunft('u1')).icalImporte[0] + assert.equal(i.letzterErfolgAt, NOW.toISOString()) + assert.equal(i.letzterAbrufAt, NOW.toISOString()) + assert.equal(i.fehler, '') + // zweiter Lauf mit gleichem Feed: unveraendert + const vorIds = (await listeSperren('u1')).map((s) => s.$id).sort() + await gleicheAb(await ladeUnterkunft('u1'), i, { now: NOW, lookup: oeffentlich, fetch: async () => antwort(fx('booking.ics')) }) + assert.deepEqual((await listeSperren('u1')).map((s) => s.$id).sort(), vorIds) +}) + +test('gleicheAb: Fehler laesst alte Sperren stehen, setzt fehler, URL steht nie im Fehlertext', async () => { + seedU([imp({ letzterErfolgAt: '2026-11-07T08:00:00.000Z' })]) + aw.seed('ukSperren', { $id: 's_alt', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-13', quelle: 'ical:imp1' }) + const u = await ladeUnterkunft('u1') + for (const [fetch, code] of [[async () => antwort(fx('fehlerseite.html')), 'kein_ics'], [async () => antwort('x', 500), 'http_500'], [async () => { throw new Error('ECONNRESET https://example.com/geheim-token/cal.ics') }, 'netzwerk']]) { + const r = await gleicheAb(u, u.icalImporte[0], { now: NOW, lookup: oeffentlich, fetch }) + assert.deepEqual(r, { ok: false, fehler: code }) + } + assert.deepEqual(await sperren(), [['2026-11-10', '2026-11-13', 'ical:imp1']]) + const i = (await ladeUnterkunft('u1')).icalImporte[0] + assert.equal(i.fehler, 'netzwerk') + assert.equal(i.letzterErfolgAt, '2026-11-07T08:00:00.000Z', 'letzter Erfolg bleibt') + assert.equal(i.letzterAbrufAt, NOW.toISOString()) + assert.ok(!JSON.stringify(i.fehler).includes('geheim')) +}) + +test('gleicheAb: wurde der Import waehrend des Abrufs entfernt, bleiben keine Sperren zurueck', async () => { + seedU() + const u = await ladeUnterkunft('u1') + const r = await gleicheAb(u, u.icalImporte[0], { + now: NOW, lookup: oeffentlich, + fetch: async () => { + await updateDocument(config.collections.ukUnterkuenfte, 'u1', { icalImporte: '[]' }) + return antwort(fx('booking.ics')) + }, + }) + assert.equal(r.ok, false) + assert.deepEqual(await sperren(), []) +}) + +test('K33: erst nach icalFehlerStunden ohne Erfolg, danach hoechstens 1x je 24 h', async () => { + const fetch = async () => antwort('x', 500) + const lauf = async (now) => { + const u = await ladeUnterkunft('u1') + return gleicheAb(u, u.icalImporte[0], { now, lookup: oeffentlich, fetch }) + } + const K33 = () => mails._aufrufe.filter((a) => a.template === 'K33') + seedU([imp({ letzterErfolgAt: new Date(NOW.getTime() - 5 * 3600e3).toISOString() })]) + await lauf(NOW) + assert.equal(K33().length, 0, 'nach 5 h noch keine Mail') + await lauf(new Date(NOW.getTime() + 2 * 3600e3)) + assert.equal(K33().length, 1, 'nach 7 h Mail') + assert.deepEqual(Object.keys(K33()[0].values).sort(), ['quelle', 'seit', 'unterkunft']) + assert.equal(K33()[0].values.quelle, 'Booking.com') + assert.equal(K33()[0].values.unterkunft, 'Erste') + assert.match(K33()[0].values.seit, /^07\.11\.2026, 06:00$/) + assert.ok(!JSON.stringify(K33()[0]).includes('geheim')) + assert.ok((await ladeEinstellungen(FEATURE)).icalFehlerMailAt) + await lauf(new Date(NOW.getTime() + 10 * 3600e3)) + assert.equal(K33().length, 1, 'binnen 24 h keine zweite') + await lauf(new Date(NOW.getTime() + 27 * 3600e3)) + assert.equal(K33().length, 2, 'nach 24 h wieder') +}) + +test('K33: Import ohne jeden Erfolg zaehlt ab dem ersten Fehler', async () => { + seedU([imp()]) + const fetch = async () => antwort('x', 404) + const lauf = async (now) => { const u = await ladeUnterkunft('u1'); return gleicheAb(u, u.icalImporte[0], { now, lookup: oeffentlich, fetch }) } + await lauf(NOW) + await lauf(new Date(NOW.getTime() + 3 * 3600e3)) + assert.equal(mails._aufrufe.length, 0) + await lauf(new Date(NOW.getTime() + 7 * 3600e3)) + assert.equal(mails._aufrufe.length, 1) +}) + +test('gleicheAlleAb: nur aktive Funktionen, nur faellige Importe, Deckel, Fehler eines Imports stoppt die anderen nicht', async () => { + seedU([imp({ id: 'a1', url: 'https://example.com/a' }), imp({ id: 'a2', url: 'https://example.com/b' }), imp({ id: 'a3', url: 'https://example.com/c', letzterAbrufAt: new Date(NOW.getTime() - 2 * 60e3).toISOString() })]) + aw.seed('portalFeatures', { $id: 'f_aus', customerId: 'c2', projectId: 'p2', featureKey: 'unterkunft', state: 'off' }) + aw.seed('ukUnterkuenfte', { $id: 'u_aus', featureId: 'f_aus', customerId: 'c2', projectId: 'p2', name: 'Aus', aktiv: true, icalImporte: JSON.stringify([imp({ id: 'x', url: 'https://example.com/x' })]) }) + const abgerufen = [] + const fetch = async (u) => { + abgerufen.push(u) + if (u.endsWith('/a')) throw new Error('boom') + return antwort(fx('booking.ics')) + } + const r = await gleicheAlleAb(NOW, { fetch, lookup: oeffentlich }) + assert.deepEqual(abgerufen.sort(), ['https://example.com/a', 'https://example.com/b']) + assert.equal(r.importe, 2) + assert.equal(r.fehler, 1) + const zweiter = await gleicheAlleAb(NOW, { fetch, lookup: oeffentlich, maxImporte: 0 }) + assert.equal(zweiter.importe, 0) +}) + +test('gleicheAlleAb: Log enthaelt nie die volle URL', async () => { + seedU([imp({ url: 'https://ical.example.com/SECRET123/cal.ics' })]) + const zeilen = [] + const orig = { log: console.log, warn: console.warn, error: console.error } + console.log = console.warn = console.error = (...a) => zeilen.push(a.join(' ')) + try { + await gleicheAlleAb(NOW, { lookup: oeffentlich, fetch: async () => { throw new Error('kaputt https://ical.example.com/SECRET123/cal.ics') } }) + } finally { + Object.assign(console, orig) + } + assert.ok(zeilen.length > 0) + assert.ok(!zeilen.join('\n').includes('SECRET123'), zeilen.join('\n')) + assert.ok(zeilen.join('\n').includes('ical.example.com')) +}) + +test('viertelstundenJob: aus bei off, laeuft bei on', async () => { + seedU([]) + config.unterkunft.mode = 'off' + assert.deepEqual(await viertelstundenJob(NOW), { aus: true }) + config.unterkunft.mode = 'on' + const r = await viertelstundenJob(NOW) + assert.equal(r.importe, 0) +}) + +// ------------------------------------------------------------------------------------------------ Export + +const TOKEN = 'ExportTokenAbcdefghijklmnopqrstuvwxyz0123456' +test('Export: Buchung + manuelle Sperre + Reservierung mit Frist, nie ical, nie Gastname; CRLF; Header', async () => { + seedU([imp()], { icalExportToken: TOKEN }) + const basis = { featureId: FEATURE, customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', gast: JSON.stringify({ name: 'Eva Gast', email: 'eva@example.com' }), tokenHash: 'h', ipHash: 'i', personen: 2 } + aw.seed('ukAnfragen', { $id: 'a_geb', ...basis, status: 'gebucht', von: '2027-01-10', bis: '2027-01-12', nummer: 'A-1' }) + aw.seed('ukAnfragen', { $id: 'a_bez', ...basis, status: 'bezahlt', von: '2027-02-10', bis: '2027-02-12', nummer: 'A-2' }) + aw.seed('ukAnfragen', { $id: 'a_res', ...basis, status: 'freigegeben', fristAt: new Date(Date.now() + 86400e3).toISOString(), von: '2027-03-10', bis: '2027-03-12', nummer: 'A-3' }) + aw.seed('ukAnfragen', { $id: 'a_alt', ...basis, status: 'freigegeben', fristAt: new Date(Date.now() - 86400e3).toISOString(), von: '2027-04-10', bis: '2027-04-12', nummer: 'A-4' }) + aw.seed('ukAnfragen', { $id: 'a_neu', ...basis, status: 'neu', von: '2027-05-10', bis: '2027-05-12', nummer: 'A-5' }) + aw.seed('ukAnfragen', { $id: 'a_sto', ...basis, status: 'storniert', von: '2027-06-10', bis: '2027-06-12', nummer: 'A-6' }) + aw.seed('ukSperren', { $id: 's_man', unterkunftId: 'u1', featureId: FEATURE, von: '2027-07-01', bis: '2027-07-03', quelle: 'manuell', notiz: 'Eigenbedarf privat' }) + aw.seed('ukSperren', { $id: 's_ical', unterkunftId: 'u1', featureId: FEATURE, von: '2027-08-01', bis: '2027-08-03', quelle: 'ical:imp1', notiz: 'Booking' }) + const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`) + assert.equal(r.status, 200) + assert.match(r.headers.get('content-type'), /^text\/calendar; charset=utf-8/) + assert.equal(r.headers.get('cache-control'), 'no-store') + const body = r.body + assert.ok(!/(^|[^\r])\n/.test(body)) + const e = parseIcs(body, { heute: '2026-11-07', horizontTage: 900 }).ereignisse + assert.deepEqual(e.map((x) => x.von).sort(), ['2027-01-10', '2027-02-10', '2027-03-10', '2027-07-01']) + assert.ok(body.includes('UID:a_geb@project.webklar.com') && body.includes('UID:s_man@project.webklar.com')) + assert.ok(!body.includes('Eva') && !body.includes('eva@') && !body.includes('Eigenbedarf') && !body.includes('Booking')) +}) + +test('Export: unbekanntes Token, falsches Format, Funktion aus/inaktiv => 404', async () => { + seedU([], { icalExportToken: TOKEN }) + assert.equal((await rawGet(`/unterkunft/ical/${'Z'.repeat(43)}.ics`)).status, 404) + assert.equal((await rawGet('/unterkunft/ical/kurz.ics')).status, 404) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}`)).status, 404) + assert.equal((await rawGet(`/unterkunft/ical/..%2f${TOKEN}.ics`)).status, 404) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 200) + config.unterkunft.mode = 'off' + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404) + config.unterkunft.mode = 'on' + await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'off' }) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404) +}) diff --git a/test/unterkunftPublic.test.js b/test/unterkunftPublic.test.js new file mode 100644 index 0000000..ae8b105 --- /dev/null +++ b/test/unterkunftPublic.test.js @@ -0,0 +1,262 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 6: oeffentliche API (Konfiguration, Belegung, Anfrage) +const NAMEN = ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.filter((n) => !['websiteProjects', 'customers'].includes(n)).map((n) => [n, schemaKeys(n)])), + datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] }, + defaultLimit: 25, +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) + +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const K = await import('../server/services/unterkunft/kontext.js') +const T = await import('../server/services/unterkunft/token.js') +const mails = await import('../server/services/unterkunft/mails.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js') +const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes }) +after(async () => { + await app.close() + await aw.close() +}) + +const SITE = 'abcdefghijklmnop' +const FEATURE = 'f_uk' +const NOW = new Date('2026-11-01T12:00:00.000Z') +const BASE = `/api/public/unterkunft/v1/${SITE}` +const ORIGIN = 'https://donau-home.example' +const vorher = { ...config.unterkunft } + +beforeEach(async () => { + for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + Object.assign(config.unterkunft, vorher, { mode: 'on', minAntwortMs: 0, formMinSekunden: 3 }) + K.leereCache() + resetTermineLimitsForTests() + mails._aufrufe.length = 0 + setzeUhrFuerTests(() => NOW) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) + aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' }) + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', ort: 'Stetten', email: 'x@y.de', steuernummer: 'GEHEIM', iban: 'DE00', akzentfarbe: '#112233' }) + aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Zweite', maxPersonen: 2, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 2, aktiv: true, icalExportToken: 'geheim' }) + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', maxPersonen: 4, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true }) + aw.seed('ukUnterkuenfte', { $id: 'u3', featureId: FEATURE, name: 'Aus', maxPersonen: 4, mindestNaechte: 1, sort: 0, aktiv: false }) + aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: FEATURE, staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), personenInklusive: 2, steuerart: 'regel' }) +}) +afterEach(() => { + Object.assign(config.unterkunft, vorher) +}) + +const formToken = (alter = 60_000) => T.formToken(SITE, new Date(NOW.getTime() - alter)) +const anfrage = (extra = {}) => ({ unterkunft: 'u1', von: '2026-11-10', bis: '2026-11-12', personen: 2, name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo', formToken: formToken(), kontakt_url: '', ...extra }) +const post = (body, headers = { origin: ORIGIN }) => send('POST', `${BASE}/anfrage`, { body, headers }) + +test('Konfiguration: unbekannter oder inaktiver siteKey und Modus off -> {aktiv:false}', async () => { + assert.deepEqual((await send('GET', '/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz')).body, { aktiv: false }) + assert.deepEqual((await send('GET', '/api/public/unterkunft/v1/kurz')).body, { aktiv: false }) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'off', enabled: true }) + K.leereCache() + assert.deepEqual((await send('GET', BASE)).body, { aktiv: false }) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true }) + config.unterkunft.mode = 'off' + K.leereCache() + const r = await send('GET', BASE) + assert.deepEqual(r.body, { aktiv: false }) + assert.equal(r.headers.get('access-control-allow-origin'), '*') +}) + +test('Konfiguration: aktiv, nur aktive Unterkuenfte sortiert, keine internen Felder', async () => { + const r = await send('GET', BASE) + assert.equal(r.status, 200) + assert.equal(r.headers.get('access-control-allow-origin'), '*') + assert.equal(r.body.aktiv, true) + assert.deepEqual(r.body.anbieter, { name: 'Donau Home', ort: 'Stetten', datenschutzUrl: 'https://donau-home.example/datenschutz' }) + assert.equal(r.body.akzentfarbe, '#112233') + assert.equal(T.pruefeFormToken(SITE, r.body.formToken, new Date(NOW.getTime() + 10_000)), 'ok') + assert.deepEqual(r.body.unterkuenfte.map((u) => u.id), ['u1', 'u2']) + assert.deepEqual(Object.keys(r.body.unterkuenfte[0]).sort(), ['checkIn', 'checkOut', 'id', 'maxPersonen', 'mindestNaechte', 'name']) + const roh = JSON.stringify(r.body) + for (const geheim of ['c1', 'p1', 'GEHEIM', 'DE00', 'mimi@example.com', FEATURE, 'geheim']) assert.ok(!roh.includes(geheim), geheim) +}) + +test('Konfiguration: ohne Einstellungen und Domain -> Standardfarbe, datenschutzUrl null', async () => { + await deleteDocument(config.collections.ukEinstellungen, FEATURE) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau' }) + K.leereCache() + const r = await send('GET', BASE) + assert.equal(r.body.akzentfarbe, '#8a6a3f') + assert.equal(r.body.anbieter.datenschutzUrl, null) + assert.equal(r.body.anbieter.name, '') +}) + +test('Belegung: fasst angrenzende Bereiche zusammen, ohne Quelle, auf das Fenster zugeschnitten', async () => { + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-05', bis: '2026-11-08', quelle: 'manuell' }) + aw.seed('ukSperren', { $id: 's2', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-08', bis: '2026-11-10', quelle: 'ical:abc', notiz: 'x' }) + aw.seed('ukAnfragen', { $id: 'a1', featureId: FEATURE, unterkunftId: 'u1', nummer: 'A-AAAA', von: '2026-11-20', bis: '2026-11-25', status: 'gebucht', personen: 2 }) + aw.seed('ukSperren', { $id: 's3', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-12', bis: '2026-11-14', quelle: 'manuell' }) + const r = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-22`) + assert.equal(r.status, 200) + assert.deepEqual(r.body.belegt, [ + { von: '2026-11-05', bis: '2026-11-10' }, + { von: '2026-11-12', bis: '2026-11-14' }, + { von: '2026-11-20', bis: '2026-11-22' }, + ]) + assert.equal(r.body.heute, '2026-11-01') + assert.equal(r.body.bisMax, '2028-05-01') + assert.equal(r.headers.get('access-control-allow-origin'), '*') +}) + +test('Belegung: ueber 18 Monate hinaus, kaputte Daten, fremde oder inaktive Unterkunft', async () => { + aw.seed('ukSperren', { $id: 'sz', unterkunftId: 'u1', featureId: FEATURE, von: '2028-04-28', bis: '2028-06-10', quelle: 'manuell' }) + const geklemmt = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2028-04-01&bis=2030-01-01`) + assert.equal(geklemmt.status, 200) + assert.equal(geklemmt.body.bisMax, '2028-05-01') + assert.deepEqual(geklemmt.body.belegt, [{ von: '2028-04-28', bis: '2028-05-01' }]) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-02`)).status, 200) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-05&bis=2026-11-04`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-01`)).status, 200) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=morgen&bis=2026-11-05`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?von=2026-11-01&bis=2026-11-05`)).status, 400) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u3&von=2026-11-01&bis=2026-11-05`)).status, 404) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=nix&von=2026-11-01&bis=2026-11-05`)).status, 404) + assert.deepEqual((await send('GET', `/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-05`)).body, { aktiv: false }) +}) + +test('Anfrage: gueltig -> 201 {nummer} + Dokument neu, CORS-Origin gespiegelt', async () => { + const r = await post(anfrage()) + assert.equal(r.status, 201) + assert.match(r.body.nummer, /^A-[A-Z2-9]{4}$/) + assert.equal(r.headers.get('access-control-allow-origin'), ORIGIN) + const docs = [...aw.docs('ukAnfragen').values()] + assert.equal(docs.length, 1) + assert.equal(docs[0].status, 'neu') + assert.equal(docs[0].nummer, r.body.nummer) + assert.equal(docs[0].personen, 2) + assert.deepEqual(mails._aufrufe.map((m) => m.art || m.template), ['G16', 'K31']) +}) + +test('Anfrage: Honigtopf und zu schnelles Token -> 201 mit Schein-Nummer, nichts gespeichert', async () => { + const a = await post(anfrage({ kontakt_url: 'http://spam.example' })) + assert.equal(a.status, 201) + assert.match(a.body.nummer, /^A-[A-Z2-9]{4}$/) + const b = await post(anfrage({ formToken: formToken(500) })) + assert.equal(b.status, 201) + assert.match(b.body.nummer, /^A-[A-Z2-9]{4}$/) + assert.equal(aw.docs('ukAnfragen').size, 0) + assert.equal(mails._aufrufe.length, 0) +}) + +test('Anfrage: fehlende E-Mail, ungueltiges Token, Fachfehler', async () => { + const a = await post(anfrage({ email: '' })) + assert.equal(a.status, 400) + assert.equal(a.body.fehler, 'ungueltig') + assert.ok(a.body.felder.email) + const b = await post(anfrage({ formToken: 'quatsch' })) + assert.equal(b.status, 400) + assert.equal(b.body.fehler, 'neu_laden') + const c = await post(anfrage({ personen: 9 })) + assert.equal(c.status, 400) + assert.equal(c.body.fehler, 'zu_viele_personen') + assert.equal(c.body.felder.maxPersonen, 4) + const d = await post(anfrage({ personen: '2', von: '2026-11-10', bis: '2026-11-11' })) + assert.equal(d.body.fehler, 'mindestnaechte') + aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }) + const e = await post(anfrage()) + assert.equal(e.status, 409) + assert.equal(e.body.fehler, 'zeitraum_belegt') + const f = await send('POST', `${BASE}/anfrage`, { body: '{kaputt', headers: { origin: ORIGIN, 'content-type': 'application/json' } }) + assert.equal(f.status, 400) + assert.equal(aw.docs('ukAnfragen').size, 0) +}) + +test('Anfrage: mehr als anfragenJeSiteStunde je Seite -> 429', async () => { + config.unterkunft.anfragenJeSiteStunde = 2 + assert.equal((await post(anfrage({ von: '2026-11-10', bis: '2026-11-12' }))).status, 201) + assert.equal((await post(anfrage({ von: '2026-12-10', bis: '2026-12-12' }))).status, 201) + const r = await post(anfrage({ von: '2027-01-10', bis: '2027-01-12' })) + assert.equal(r.status, 429) + assert.equal(r.body.fehler, 'zu_viele_anfragen') + assert.ok(r.headers.get('retry-after')) + assert.equal(aw.docs('ukAnfragen').size, 2) +}) + +test('CORS: Preflight erlaubt/fremd, POST von fremder oder fehlender Origin -> 403', async () => { + const ok = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: ORIGIN, 'access-control-request-method': 'POST' } }) + assert.equal(ok.status, 204) + assert.equal(ok.headers.get('access-control-allow-origin'), ORIGIN) + const sub = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: 'https://donau.project.webklar.com' } }) + assert.equal(sub.headers.get('access-control-allow-origin'), 'https://donau.project.webklar.com') + const fremd = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: 'https://boese.example' } }) + assert.equal(fremd.status, 403) + assert.equal(fremd.headers.get('access-control-allow-origin'), null) + const p = await post(anfrage(), { origin: 'https://boese.example' }) + assert.equal(p.status, 403) + assert.equal((await post(anfrage(), {})).status, 403) + assert.equal(aw.docs('ukAnfragen').size, 0) + // unbekannte Seite: Preflight verraet nichts + const unbekannt = await send('OPTIONS', '/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz/anfrage', { headers: { origin: 'https://boese.example' } }) + assert.equal(unbekannt.status, 204) + assert.equal(unbekannt.headers.get('access-control-allow-origin'), '*') +}) + +test('Fix: manipulierte Unterkunfts-IDs -> 400 (Belegung, Anfrage); Speicher fragt nicht nach', async () => { + for (const id of ['../x', 'a?b', 'a%23b', 'x'.repeat(37)]) { + const r = await send('GET', `${BASE}/belegung?unterkunft=${encodeURIComponent(id)}&von=2026-11-01&bis=2026-11-05`) + assert.equal(r.status, 400, id) + assert.ok(r.body.felder.unterkunft) + const p = await post(anfrage({ unterkunft: id })) + assert.equal(p.status, 400, id) + assert.ok(p.body.felder.unterkunft) + } + assert.equal(aw.docs('ukAnfragen').size, 0) + const sp = await import('../server/services/unterkunft/speicher.js') + const echtesFetch = globalThis.fetch + let aufrufe = 0 + globalThis.fetch = (...a) => (aufrufe++, echtesFetch(...a)) + try { + for (const id of ['../x', 'a?b', '', 'x'.repeat(37), 5, null]) assert.equal(await sp.ladeUnterkunft(id), null) + assert.equal(await sp.ladeAnfrage('../../x'), null) + } finally { + globalThis.fetch = echtesFetch + } + assert.equal(aufrufe, 0) +}) + +test('Fix: POST prueft die IP-Grenze vor dem siteKey-Lookup', async () => { + const echtesFetch = globalThis.fetch + let aufrufe = 0 + globalThis.fetch = (u, ...a) => (String(u?.url ?? u).startsWith(aw.url) && aufrufe++, echtesFetch(u, ...a)) + try { + const stati = [] + for (let i = 0; i < 125; i++) { + const key = ('a'.repeat(15) + String.fromCharCode(97 + (i % 26))).slice(0, 16) + stati.push((await send('POST', `/api/public/unterkunft/v1/${key}/anfrage`, { body: anfrage(), headers: { origin: ORIGIN } })).status) + } + assert.ok(aufrufe > 0, 'unter der Grenze wird nachgeschlagen') + assert.equal(stati[119], 200) + assert.deepEqual(stati.slice(120), [429, 429, 429, 429, 429]) + const nachher = aufrufe + await send('POST', `${BASE}/anfrage`, { body: anfrage(), headers: { origin: ORIGIN } }) + assert.equal(aufrufe, nachher, 'ueber der IP-Grenze keine Lookups mehr') + } finally { + globalThis.fetch = echtesFetch + } +}) + +test('Fix: Pilotmodus mit fremdem Besitzer -> {aktiv:false}; zu grosser Rumpf -> 413', async () => { + config.unterkunft.mode = 'pilot' + config.unterkunft.pilotEmails = ['andere@example.com'] + K.leereCache() + assert.deepEqual((await send('GET', BASE)).body, { aktiv: false }) + config.unterkunft.pilotEmails = ['mimi@example.com'] + assert.equal((await send('GET', BASE)).body.aktiv, true) + const gross = await post(anfrage({ nachricht: 'x'.repeat(20000) })) + assert.equal(gross.status, 413) +}) diff --git a/test/unterkunftSetup.test.js b/test/unterkunftSetup.test.js index 5eed07b..5b082b4 100644 --- a/test/unterkunftSetup.test.js +++ b/test/unterkunftSetup.test.js @@ -143,7 +143,7 @@ test('Schema: jedes Attribut ist optional, hat gueltige Form und Schluessel (kei test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attributen', () => { const idx = (name) => UNTERKUNFT_COLLECTIONS[name].indexes.map((i) => i.attributes.join('+')) - assert.deepEqual(idx('ukUnterkuenfte'), ['featureId']) + assert.deepEqual(idx('ukUnterkuenfte'), ['featureId', 'icalExportToken']) assert.deepEqual(idx('ukPreise'), ['unterkunftId']) assert.deepEqual(idx('ukSperren'), ['unterkunftId', 'unterkunftId+quelle']) assert.deepEqual(idx('ukAnfragen'), ['featureId', 'unterkunftId', 'unterkunftId+status', 'status']) diff --git a/test/unterkunftVerwaltung.test.js b/test/unterkunftVerwaltung.test.js new file mode 100644 index 0000000..6b26ae6 --- /dev/null +++ b/test/unterkunftVerwaltung.test.js @@ -0,0 +1,613 @@ +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: '', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', 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.einstellungen.stripeAccountId, undefined) + assert.equal(ok.body.einstellungen.rechnungAuftrag, true) + const schlecht = await put('/einstellungen', { email: 'kaputt', akzentfarbe: 'rot', iban: 'x', rechnungsPraefix: 'A-B' }) + assert.equal(schlecht.status, 400) + for (const f of ['email', 'akzentfarbe', 'iban', 'rechnungsPraefix']) assert.ok(schlecht.body.felder[f], f) + const g = await get('/einstellungen') + assert.equal(g.body.einstellungen.firma, 'Haus') + assert.equal(g.body.rechnungenVorhanden, false) + assert.deepEqual(g.body.stripe, { status: 'keins', vorhandenesShopKonto: false }) + assert.deepEqual(Object.keys(g.body).sort(), ['einstellungen', 'rechnungenVorhanden', 'stripe']) +}) + +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) + aw.seed('ukRechnungen', { $id: 'r9', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 1, inhalt: '{}' }) + assert.equal((await get('/einstellungen')).body.rechnungenVorhanden, true) +}) + +// ------------------------------------------------------------------------------------------------ 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].gastName, 'Eva') + assert.equal(l[0].empfaenger, undefined) + assert.equal(l[0].anfrageId, undefined) + 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) +}) + +test('Einstellungen: rechnungAuftrag setzt/widerruft rechnungAuftragAt', async () => { + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungAuftragAt: undefined }) + assert.equal((await get('/einstellungen')).body.einstellungen.rechnungAuftrag, false) + const an = await put('/einstellungen', { ...EINST, rechnungAuftrag: true }) + assert.equal(an.body.einstellungen.rechnungAuftrag, true) + const zeit = aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt + assert.ok(zeit) + await put('/einstellungen', { ...EINST, rechnungAuftrag: true }) + assert.equal(new Date(aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt).getTime(), new Date(zeit).getTime()) + const aus = await put('/einstellungen', { ...EINST, rechnungAuftrag: false }) + assert.equal(aus.body.einstellungen.rechnungAuftrag, false) + assert.ok(!aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt) + assert.equal((await put('/einstellungen', { rechnungAuftrag: 'ja' })).status, 400) +}) + +test('Zustimmung: ohne rechnungAuftrag verweigern freigeben und bucheVerbindlich mit 409 zustimmung_fehlt', async () => { + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungAuftragAt: undefined }) + anfrage('a1') + const r = await post('/anfragen/a1/freigeben', { posten: POSTEN }) + assert.equal(r.status, 409) + assert.equal(r.body.fehler, 'zustimmung_fehlt') + assert.equal(aw.docs('ukAnfragen').get('a1').status, 'neu') + const ab = await import('../server/services/unterkunft/ablauf.js') + const { token, hash } = (await import('../server/services/unterkunft/token.js')).neuesToken() + anfrage('a2', { status: 'freigegeben', tokenHash: hash, fristAt: new Date(Date.now() + 86400000).toISOString(), posten: JSON.stringify(POSTEN), summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 20000, ust: [] }) }) + const ctx = { featureId: FEATURE, einstellungen: { ...EINST, rechnungAuftragAt: null } } + await assert.rejects( + ab.bucheVerbindlich(ctx, 'a2', token, { rechnungsadresse: { name: 'Eva', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' }, zahlart: 'vorort' }), + (e) => e instanceof UkFehler && e.status === 409 && e.code === 'zustimmung_fehlt', + ) + assert.equal(aw.docs('ukAnfragen').get('a2').status, 'freigegeben') + assert.equal(aw.docs('ukRechnungen').size, 0) +}) + +test('Online-Zahlung: nur mit aktivem Stripe-Konto, sonst 409 mit felder.onlineZahlung', async () => { + await post('/stripe/verbinden') + st.setAccount(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, { charges_enabled: false }) + await post('/stripe/aktualisieren') + assert.equal((await get('/status')).body.stripe.status, 'onboarding') + const r = await put('/einstellungen', { onlineZahlung: true }) + assert.equal(r.status, 409) + assert.ok(r.body.felder.onlineZahlung) + assert.notEqual(aw.docs('ukEinstellungen').get(FEATURE).onlineZahlung, true) + // ohne Konto ebenfalls 409 + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST }) + const ohne = await put('/einstellungen', { onlineZahlung: true }) + assert.equal(ohne.status, 409) + assert.ok(ohne.body.felder.onlineZahlung) + // bereits eingeschaltet: andere Aenderungen scheitern nicht, auch wenn Stripe inaktiv ist + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, onlineZahlung: true }) + assert.equal((await put('/einstellungen', { telefon: '123' })).status, 200) + assert.equal((await put('/einstellungen', { onlineZahlung: true })).status, 200) +}) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 46b24ab..5e94142 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -85,7 +85,7 @@ test('Haken: featureHaken(unterkunft) hat alle fünf, Stubs und Schalter wirken' assert.equal(h.verfuegbar('a@b.de'), false) config.unterkunft.mode = 'on' assert.equal(h.verfuegbar('a@b.de'), true) - assert.deepEqual(await h.bestellpruefung({ customerId: KUNDE, projectId: PROJEKT }), ['Anfrage noch nicht freigegeben']) + assert.deepEqual(await h.bestellpruefung({ customerId: KUNDE, projectId: PROJEKT }), [(await import('../server/services/unterkunft/anfrageFunktion.js')).ANFRAGE_OFFEN_TEXT]) assert.equal(await h.freischaltbar({ $id: FEATURE }), false) }) @@ -150,7 +150,7 @@ test('API-Vertrag: docs/funktionen/unterkunft-api.md nennt alle Pfade', () => { }) // -------------------------------------------------------------------------------------------------- Öffentliche Schnittstelle -test('Öffentlich: GET /api/public/unterkunft/v1/ liefert {aktiv:false} (Gerüst)', async () => { +test('Öffentlich: GET /api/public/unterkunft/v1/ liefert für unbekannte Seiten {aktiv:false}', async () => { const r = await rawGet('/api/public/unterkunft/v1/aaaaaaaaaaaaaaaa') assert.equal(r.status, 200) assert.deepEqual(r.body, { aktiv: false }) @@ -227,14 +227,20 @@ test('Jobs: im Modus off {aus:true}; Minutenjob ruft die Fristen ab, die anderen config.unterkunft.mode = 'off' assert.deepEqual(await jobs.minutenJob(T0), { aus: true }) assert.deepEqual(await jobs.viertelstundenJob(T0), { aus: true }) - assert.deepEqual(await jobs.taeglicherJob(T0), { aus: true }) + // Tagesjob: die Fristen gelten auch bei off; ohne Buchungsdaten {aus:true}, mit Daten laeuft er + const tagAus = await jobs.taeglicherJob(T0) + const hatDaten = aw.docs('ukEinstellungen').size > 0 || aw.docs('ukAnfragen').size > 0 + if (hatDaten) assert.equal(tagAus.aufbewahrung?.fehler, 0, JSON.stringify(tagAus)) + else assert.deepEqual(tagAus, { aus: true }) await leere() config.unterkunft.mode = 'on' const r = await jobs.minutenJob(T0) assert.notEqual(r?.aus, true) assert.notEqual(r?.fehler, true, JSON.stringify(r)) - assert.deepEqual(await jobs.viertelstundenJob(T0), {}) - assert.deepEqual(await jobs.taeglicherJob(T0), {}) + assert.deepEqual(await jobs.viertelstundenJob(T0), { importe: 0, erfolg: 0, fehler: 0 }) + const tag = await jobs.taeglicherJob(T0) + assert.equal(tag.aufbewahrung.fehler, 0, JSON.stringify(tag)) + assert.equal(tag.abgeschaltet.fehler, 0) }) // -------------------------------------------------------------------------------------------------- Webhook