Buchungskalender: Strang server zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7b978ad2e4
37 files changed
+5617
-59
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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 } : {}) }
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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).'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
Vendored
+18
@@ -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
|
||||
Vendored
+17
@@ -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
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Anmelden</title></head><body><h1>Bitte melden Sie sich an</h1></body></html>
|
||||
Vendored
+13
@@ -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
|
||||
Vendored
+39
@@ -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
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 }]),
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user