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' import { NACHT_CENT_MAX, NACHT_CENT_MIN, PERSONEN_PREISE_MAX, PERSONEN_PREISE_MIN } from './preis.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] /** Unausgefuellte Platzhalter des Mustertexts ("[30]", "[E-Mail-Adresse des Vermieters]") duerfen nicht zum Gast gelangen. */ export const PLATZHALTER_TEXT = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.' export const MAX_UNTERKUENFTE = 20 export const MAX_ICAL_IMPORTE = 5 export const MAX_STAFFELN = 6 /** Groesse des Textfelds ukPreise.staffeln (schema.js); test/unterkunftVerwaltung.test.js vergleicht beide Werte. */ export const STAFFELN_MAX_ZEICHEN = 2000 // ---- Einstellungen const EINSTELLUNG_TEXTE = [ ['firma', 150, 'Firma'], ['inhaber', 150, 'Inhaber'], ['strasse', 150, 'Straße'], ['plz', 10, 'PLZ'], ['ort', 100, 'Ort'], ['email', 200, 'E-Mail'], ['telefon', 50, 'Telefon'], ['steuernummer', 30, 'Steuernummer'], ['ustId', 20, 'USt-IdNr.'], ['iban', 34, 'IBAN'], ['bic', 11, 'BIC'], ['bank', 100, 'Bank'], ['rechnungsPraefix', 8, 'Präfix'], ['akzentfarbe', 7, 'Farbe'], ] /** Felder der Einstellungen, die der Vermieter sieht und aendert (nie Stripe-Konto-ID). */ export const EINSTELLUNG_FELDER = EINSTELLUNG_TEXTE.map(([k]) => k) const PFLICHT = ['firma', 'strasse', 'plz', 'ort', 'email'] /** * Pruefung der Einstellungen. Nur mitgeschickte Felder werden geaendert; die Vollstaendigkeit (Pflichtangaben, Steuernummer * oder USt-IdNr.) gilt fuer das Ergebnis aus vorher + eingabe. onlineZahlung nur mit verbundenem Stripe-Konto (vorher.stripeAccountId). * @returns {{ werte: object } | { fehler: Record }} */ export function pruefeEinstellungen(eingabe, vorher = {}) { const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {} const fehler = {} const werte = {} for (const [key, max, label] of EINSTELLUNG_TEXTE) { if (e[key] === undefined) continue if (!istText(e[key])) { fehler[key] = `${label}: ungültige Eingabe.` continue } let v = text(e[key], key === 'iban' ? 60 : max) if (key === 'iban' || key === 'bic' || key === 'ustId') v = v.replace(/\s+/g, '').toUpperCase() if (v.length > max) { fehler[key] = `${label} darf höchstens ${max} Zeichen lang sein.` continue } if (v === '') { werte[key] = '' continue } if (key === 'email' && !EMAIL_RE.test(v)) fehler[key] = 'Bitte eine gültige E-Mail-Adresse angeben.' else if (key === 'akzentfarbe' && !FARBE_RE.test(v)) fehler[key] = 'Bitte eine Farbe wie #8a6a3f angeben.' else if (key === 'rechnungsPraefix' && !PRAEFIX_RE.test(v)) fehler[key] = 'Das Präfix darf nur aus Buchstaben und Ziffern bestehen (höchstens 8).' else if (key === 'iban' && !IBAN_RE.test(v)) fehler[key] = 'Bitte eine gültige IBAN angeben.' else if (key === 'bic' && !BIC_RE.test(v)) fehler[key] = 'Bitte eine gültige BIC angeben.' else if (key === 'ustId' && !UST_ID_RE.test(v)) fehler[key] = 'Bitte eine gültige USt-IdNr. angeben.' else werte[key] = v } if (e.onlineZahlung !== undefined) { if (typeof e.onlineZahlung !== 'boolean') fehler.onlineZahlung = 'Ungültige Eingabe.' else werte.onlineZahlung = e.onlineZahlung } const gesamt = { ...vorher, ...werte } for (const k of PFLICHT) { if (fehler[k] === undefined && !String(gesamt[k] ?? '').trim()) fehler[k] = 'Pflichtfeld.' } if (fehler.steuernummer === undefined && fehler.ustId === undefined && !String(gesamt.steuernummer ?? '').trim() && !String(gesamt.ustId ?? '').trim()) { fehler.steuernummer = 'Bitte Steuernummer oder USt-IdNr. angeben (Pflicht auf Rechnungen).' } return Object.keys(fehler).length ? { fehler } : { werte } } /** Zaehler leer = noch keine Rechnung ausgestellt; erst dann ist das Praefix frei aenderbar. */ export const zaehlerLeer = (einst) => !einst?.rechnungZaehler || !(Number(einst.rechnungZaehler.n) > 0) // ---- Unterkuenfte /** * Pruefung einer Unterkunft. partiell (PUT): nur mitgeschickte Felder pruefen; sonst name und maxPersonen Pflicht und * Standardwerte fuer den Rest (mindestNaechte 1, Check-in 15:00, Check-out 10:00, sort 0, aktiv). */ export function pruefeUnterkunft(eingabe, { partiell = false } = {}) { const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {} const fehler = {} const werte = {} const da = (k) => e[k] !== undefined if (da('name') || !partiell) { const v = text(e.name, 120) if (!v) fehler.name = 'Bitte einen Namen angeben.' else if (v.length > 120) fehler.name = 'Der Name darf höchstens 120 Zeichen lang sein.' else werte.name = v } if (da('beschreibung')) { const v = istText(e.beschreibung) ? e.beschreibung.trim() : null if (v === null || v.length > 500) fehler.beschreibung = 'Die Beschreibung darf höchstens 500 Zeichen lang sein.' else werte.beschreibung = v } else if (!partiell) werte.beschreibung = '' if (da('maxPersonen') || !partiell) { if (!ganz(e.maxPersonen) || e.maxPersonen < 1 || e.maxPersonen > 50) fehler.maxPersonen = 'Bitte 1 bis 50 Personen angeben.' else werte.maxPersonen = e.maxPersonen } if (da('mindestNaechte')) { if (!ganz(e.mindestNaechte) || e.mindestNaechte < 1 || e.mindestNaechte > 365) fehler.mindestNaechte = 'Bitte 1 bis 365 Nächte angeben.' else werte.mindestNaechte = e.mindestNaechte } else if (!partiell) werte.mindestNaechte = 1 for (const [k, std] of [['checkIn', '15:00'], ['checkOut', '10:00']]) { if (da(k)) { if (!istText(e[k]) || !UHRZEIT_RE.test(e[k].trim())) fehler[k] = 'Bitte eine Uhrzeit wie 15:00 angeben.' else werte[k] = e[k].trim() } else if (!partiell) werte[k] = std } if (da('sort')) { if (!ganz(e.sort) || e.sort < 0 || e.sort > 9999) fehler.sort = 'Ungültige Reihenfolge.' else werte.sort = e.sort } if (da('aktiv')) { if (typeof e.aktiv !== 'boolean') fehler.aktiv = 'Ungültige Eingabe.' else werte.aktiv = e.aktiv } else if (!partiell) werte.aktiv = true return Object.keys(fehler).length ? { fehler } : { werte } } // ---- Preise /** * Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6). * Jede Staffel darf personenCent tragen (Preis pro Nacht fuer 1..k Personen, k = 2 bis 10): entweder alle Staffeln mit gleich * vielen Werten oder keine (Modell "pauschal", unveraendert). Im Personen-Modus setzt der Server preisProNachtCent = personenCent[0] * (eine mitgeschickte Zahl wird ueberschrieben). */ 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 let personenAnzahl // undefined = noch keine Staffel gesehen, 0 = pauschal, sonst Laenge k von personenCent for (const s of st) { if (!s || typeof s !== 'object' || !ganz(s.abNaechten)) { fehler.staffeln = 'Ungültige Preisstufe.' break } const hatPersonen = s.personenCent !== undefined && s.personenCent !== null if (hatPersonen) { const pc = s.personenCent if (!Array.isArray(pc)) { fehler.staffeln = 'Die Preise je Personenzahl sind ungültig.' break } if (pc.length < PERSONEN_PREISE_MIN || pc.length > PERSONEN_PREISE_MAX) { fehler.staffeln = `Der Preis je Personenzahl braucht ${PERSONEN_PREISE_MIN} bis ${PERSONEN_PREISE_MAX} Werte (für 1 bis ${PERSONEN_PREISE_MAX} Personen).` break } if (!pc.every((c) => ganz(c) && c >= NACHT_CENT_MIN && c <= NACHT_CENT_MAX)) { fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.' break } } else if (!ganz(s.preisProNachtCent)) { fehler.staffeln = 'Ungültige Preisstufe.' break } else if (s.preisProNachtCent < NACHT_CENT_MIN || s.preisProNachtCent > NACHT_CENT_MAX) { fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.' break } const anzahl = hatPersonen ? s.personenCent.length : 0 if (personenAnzahl !== undefined && anzahl !== personenAnzahl) { fehler.staffeln = anzahl === 0 || personenAnzahl === 0 ? 'Entweder haben alle Preisstufen Preise je Personenzahl oder keine.' : 'Alle Preisstufen brauchen gleich viele Preise je Personenzahl.' break } personenAnzahl = anzahl 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(hatPersonen ? { abNaechten: s.abNaechten, preisProNachtCent: s.personenCent[0], personenCent: [...s.personenCent] } : { abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent }) } // Sicherung: das Textfeld ukPreise.staffeln fasst STAFFELN_MAX_ZEICHEN (schema.js); der Hoechstfall (6 x 10 Werte) braucht rund 800 if (!fehler.staffeln && JSON.stringify(sauber).length > STAFFELN_MAX_ZEICHEN) fehler.staffeln = 'Die Preisstufen sind zu umfangreich.' 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 if (/\[[^\]]*\]/.test(sb)) fehler.stornobedingungen = PLATZHALTER_TEXT 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)) }