Buchungskalender: Strang server zusammenführen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:49:27 +00:00
commit 7b978ad2e4
37 files changed
+5618 -60

No files matched your search

+2
View File
@@ -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,
+487 -6
View File
@@ -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
+81 -3
View File
@@ -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
+685 -5
View File
@@ -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
+43
View File
@@ -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/<token>.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
}
+79 -5
View File
@@ -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
}
/**
+15
View File
@@ -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.
+27 -4
View File
@@ -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}`))
+5 -5
View File
@@ -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 } : {}) }
)
+3 -2
View File
@@ -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()
+8
View File
@@ -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) {
+291 -5
View File
@@ -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:<projectId>.
* 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<object>} 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<object>} 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 <email>' (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
}
}
+372
View File
@@ -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<number>} 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
}
+31 -4
View File
@@ -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<boolean>}
*/
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
}
}
+161
View File
@@ -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'
}
+367
View File
@@ -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_<status>.
* @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:<importId>' 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
}
+44 -9
View File
@@ -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 }
})
}
+1 -1
View File
@@ -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: [
+12 -1
View File
@@ -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
@@ -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):',
`<div data-webklar-unterkunft="${siteKey}" data-api="https://project.webklar.com"></div>`,
'<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>',
'',
'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).'
}
}
}
+288
View File
@@ -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<string,string> }}
*/
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))
}
+174 -3
View File
@@ -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 })
}
+18
View File
@@ -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
+17
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><head><title>Anmelden</title></head><body><h1>Bitte melden Sie sich an</h1></body></html>
+13
View File
@@ -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
+39
View File
@@ -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
+31
View File
@@ -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/)
})
+6 -1
View File
@@ -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 }]),
+309
View File
@@ -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 <andrej@webklar.com>')
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('<div data-webklar-unterkunft="abcdefghijklmnop" data-api="https://project.webklar.com"></div>'))
assert.ok(text.includes('<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>'))
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'))
})
+306
View File
@@ -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
}
})
+426
View File
@@ -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)
})
+326
View File
@@ -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:<id>), 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)
})
+262
View File
@@ -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)
})
+1 -1
View File
@@ -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'])
+613
View File
@@ -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)
})
+11 -5
View File
@@ -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/<siteKey> liefert {aktiv:false} (Gerüst)', async () => {
test('Öffentlich: GET /api/public/unterkunft/v1/<siteKey> 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