Buchungskalender: Verwaltungs-API für Vermieter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a94b50fbc1
commit
d0ab92658a
5 files changed
+1486
-8
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ export async function speichereUnterkunft(featureId, felder, id) {
|
||||
return lesen('ukUnterkuenfte', await createDocument(config.collections.ukUnterkuenfte, { ...daten, featureId }))
|
||||
}
|
||||
|
||||
export const loescheUnterkunft = (id) => deleteDocument(config.collections.ukUnterkuenfte, id)
|
||||
|
||||
// ---- ukPreise (1:1 je Unterkunft)
|
||||
|
||||
async function preiseDok(unterkunftId) {
|
||||
@@ -99,6 +101,11 @@ export async function speicherePreise(unterkunftId, featureId, felder) {
|
||||
return lesen('ukPreise', await createDocument(config.collections.ukPreise, { ...daten, unterkunftId, featureId }))
|
||||
}
|
||||
|
||||
export async function loeschePreise(unterkunftId) {
|
||||
const vorhanden = await preiseDok(unterkunftId)
|
||||
if (vorhanden) await deleteDocument(config.collections.ukPreise, vorhanden.$id)
|
||||
}
|
||||
|
||||
// ---- ukSperren
|
||||
|
||||
export const listeSperren = (unterkunftId) => liste('ukSperren', [Query.equal('unterkunftId', unterkunftId)])
|
||||
@@ -108,6 +115,7 @@ export async function legeSperreAn(felder) {
|
||||
}
|
||||
|
||||
export const loescheSperre = (id) => deleteDocument(config.collections.ukSperren, id)
|
||||
export const ladeSperre = (id) => ladeDok('ukSperren', id)
|
||||
|
||||
// ---- ukAnfragen
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
import { istTag, naechte } from './datum.js'
|
||||
import { AKTIVE_STATUS, ueberschneidet } from './belegung.js'
|
||||
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
|
||||
|
||||
/**
|
||||
* Buchungskalender, Verwaltung durch den Vermieter: Validierung der Eingaben (reine Funktionen, Rueckgabe { werte } oder
|
||||
* { fehler: { feld: Text } }) und Aufbereitung von Kalender und Ansichten. Speichern und Rechte liegen in routes/unterkunft.js.
|
||||
*/
|
||||
|
||||
const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max + 1) : '')
|
||||
const istText = (v) => typeof v === 'string'
|
||||
const ganz = (v) => Number.isSafeInteger(v)
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
||||
const UHRZEIT_RE = /^([01]\d|2[0-3]):[0-5]\d$/
|
||||
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
|
||||
const PRAEFIX_RE = /^[A-Za-z0-9]{1,8}$/
|
||||
const IBAN_RE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/
|
||||
const BIC_RE = /^[A-Z0-9]{8}([A-Z0-9]{3})?$/
|
||||
const UST_ID_RE = /^[A-Z]{2}[A-Z0-9]{2,18}$/
|
||||
const SAETZE = [0, 7, 19]
|
||||
|
||||
export const MAX_UNTERKUENFTE = 20
|
||||
export const MAX_ICAL_IMPORTE = 5
|
||||
export const MAX_STAFFELN = 6
|
||||
|
||||
// ---- Einstellungen
|
||||
|
||||
const EINSTELLUNG_TEXTE = [
|
||||
['firma', 150, 'Firma'],
|
||||
['inhaber', 150, 'Inhaber'],
|
||||
['strasse', 150, 'Straße'],
|
||||
['plz', 10, 'PLZ'],
|
||||
['ort', 100, 'Ort'],
|
||||
['email', 200, 'E-Mail'],
|
||||
['telefon', 50, 'Telefon'],
|
||||
['steuernummer', 30, 'Steuernummer'],
|
||||
['ustId', 20, 'USt-IdNr.'],
|
||||
['iban', 34, 'IBAN'],
|
||||
['bic', 11, 'BIC'],
|
||||
['bank', 100, 'Bank'],
|
||||
['rechnungsPraefix', 8, 'Präfix'],
|
||||
['akzentfarbe', 7, 'Farbe'],
|
||||
]
|
||||
|
||||
/** Felder der Einstellungen, die der Vermieter sieht und aendert (nie Stripe-Konto-ID). */
|
||||
export const EINSTELLUNG_FELDER = EINSTELLUNG_TEXTE.map(([k]) => k)
|
||||
const PFLICHT = ['firma', 'strasse', 'plz', 'ort', 'email']
|
||||
|
||||
/**
|
||||
* Pruefung der Einstellungen. Nur mitgeschickte Felder werden geaendert; die Vollstaendigkeit (Pflichtangaben, Steuernummer
|
||||
* oder USt-IdNr.) gilt fuer das Ergebnis aus vorher + eingabe. onlineZahlung nur mit verbundenem Stripe-Konto (vorher.stripeAccountId).
|
||||
* @returns {{ werte: object } | { fehler: Record<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 if (e.onlineZahlung && !vorher.stripeAccountId) fehler.onlineZahlung = 'Dafür muss zuerst Stripe verbunden werden.'
|
||||
else werte.onlineZahlung = e.onlineZahlung
|
||||
}
|
||||
const gesamt = { ...vorher, ...werte }
|
||||
for (const k of PFLICHT) {
|
||||
if (fehler[k] === undefined && !String(gesamt[k] ?? '').trim()) fehler[k] = 'Pflichtfeld.'
|
||||
}
|
||||
if (fehler.steuernummer === undefined && fehler.ustId === undefined && !String(gesamt.steuernummer ?? '').trim() && !String(gesamt.ustId ?? '').trim()) {
|
||||
fehler.steuernummer = 'Bitte Steuernummer oder USt-IdNr. angeben (Pflicht auf Rechnungen).'
|
||||
}
|
||||
return Object.keys(fehler).length ? { fehler } : { werte }
|
||||
}
|
||||
|
||||
/** Zaehler leer = noch keine Rechnung ausgestellt; erst dann ist das Praefix frei aenderbar. */
|
||||
export const zaehlerLeer = (einst) => !einst?.rechnungZaehler || !(Number(einst.rechnungZaehler.n) > 0)
|
||||
|
||||
// ---- Unterkuenfte
|
||||
|
||||
/**
|
||||
* Pruefung einer Unterkunft. partiell (PUT): nur mitgeschickte Felder pruefen; sonst name und maxPersonen Pflicht und
|
||||
* Standardwerte fuer den Rest (mindestNaechte 1, Check-in 15:00, Check-out 10:00, sort 0, aktiv).
|
||||
*/
|
||||
export function pruefeUnterkunft(eingabe, { partiell = false } = {}) {
|
||||
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
|
||||
const fehler = {}
|
||||
const werte = {}
|
||||
const da = (k) => e[k] !== undefined
|
||||
if (da('name') || !partiell) {
|
||||
const v = text(e.name, 120)
|
||||
if (!v) fehler.name = 'Bitte einen Namen angeben.'
|
||||
else if (v.length > 120) fehler.name = 'Der Name darf höchstens 120 Zeichen lang sein.'
|
||||
else werte.name = v
|
||||
}
|
||||
if (da('beschreibung')) {
|
||||
const v = istText(e.beschreibung) ? e.beschreibung.trim() : null
|
||||
if (v === null || v.length > 500) fehler.beschreibung = 'Die Beschreibung darf höchstens 500 Zeichen lang sein.'
|
||||
else werte.beschreibung = v
|
||||
} else if (!partiell) werte.beschreibung = ''
|
||||
if (da('maxPersonen') || !partiell) {
|
||||
if (!ganz(e.maxPersonen) || e.maxPersonen < 1 || e.maxPersonen > 50) fehler.maxPersonen = 'Bitte 1 bis 50 Personen angeben.'
|
||||
else werte.maxPersonen = e.maxPersonen
|
||||
}
|
||||
if (da('mindestNaechte')) {
|
||||
if (!ganz(e.mindestNaechte) || e.mindestNaechte < 1 || e.mindestNaechte > 365) fehler.mindestNaechte = 'Bitte 1 bis 365 Nächte angeben.'
|
||||
else werte.mindestNaechte = e.mindestNaechte
|
||||
} else if (!partiell) werte.mindestNaechte = 1
|
||||
for (const [k, std] of [['checkIn', '15:00'], ['checkOut', '10:00']]) {
|
||||
if (da(k)) {
|
||||
if (!istText(e[k]) || !UHRZEIT_RE.test(e[k].trim())) fehler[k] = 'Bitte eine Uhrzeit wie 15:00 angeben.'
|
||||
else werte[k] = e[k].trim()
|
||||
} else if (!partiell) werte[k] = std
|
||||
}
|
||||
if (da('sort')) {
|
||||
if (!ganz(e.sort) || e.sort < 0 || e.sort > 9999) fehler.sort = 'Ungültige Reihenfolge.'
|
||||
else werte.sort = e.sort
|
||||
}
|
||||
if (da('aktiv')) {
|
||||
if (typeof e.aktiv !== 'boolean') fehler.aktiv = 'Ungültige Eingabe.'
|
||||
else werte.aktiv = e.aktiv
|
||||
} else if (!partiell) werte.aktiv = true
|
||||
return Object.keys(fehler).length ? { fehler } : { werte }
|
||||
}
|
||||
|
||||
// ---- Preise
|
||||
|
||||
/** Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6). */
|
||||
export function pruefePreise(eingabe) {
|
||||
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
|
||||
const fehler = {}
|
||||
const werte = {}
|
||||
const st = e.staffeln
|
||||
if (!Array.isArray(st) || st.length === 0) fehler.staffeln = 'Mindestens eine Preisstufe ist nötig.'
|
||||
else if (st.length > MAX_STAFFELN) fehler.staffeln = `Höchstens ${MAX_STAFFELN} Preisstufen sind erlaubt.`
|
||||
else {
|
||||
const sauber = []
|
||||
let letzte = 0
|
||||
for (const s of st) {
|
||||
if (!s || typeof s !== 'object' || !ganz(s.abNaechten) || !ganz(s.preisProNachtCent)) {
|
||||
fehler.staffeln = 'Ungültige Preisstufe.'
|
||||
break
|
||||
}
|
||||
if (s.preisProNachtCent < 100 || s.preisProNachtCent > 100000) {
|
||||
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
|
||||
break
|
||||
}
|
||||
if (sauber.length === 0 && s.abNaechten !== 1) {
|
||||
fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.'
|
||||
break
|
||||
}
|
||||
if (s.abNaechten <= letzte || s.abNaechten > 365) {
|
||||
fehler.staffeln = 'Die Preisstufen müssen aufsteigend nach Nächten geordnet sein.'
|
||||
break
|
||||
}
|
||||
letzte = s.abNaechten
|
||||
sauber.push({ abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent })
|
||||
}
|
||||
if (!fehler.staffeln) werte.staffeln = sauber
|
||||
}
|
||||
const zahl = (k, min, max, label, std) => {
|
||||
const v = e[k] === undefined && std !== undefined ? std : e[k]
|
||||
if (!ganz(v) || v < min || v > max) fehler[k] = `${label}: bitte einen Wert von ${min} bis ${max} angeben.`
|
||||
else werte[k] = v
|
||||
}
|
||||
zahl('reinigungCent', 0, 100000, 'Endreinigung', 0)
|
||||
zahl('personenInklusive', 1, 50, 'Inklusive Personen')
|
||||
zahl('aufpreisPersonNachtCent', 0, 100000, 'Aufpreis je Person', 0)
|
||||
zahl('direktrabattProzent', 0, 50, 'Direktrabatt', 0)
|
||||
zahl('fristTage', 1, 14, 'Frist')
|
||||
if (e.steuerart !== 'regel' && e.steuerart !== 'kleinunternehmer') fehler.steuerart = 'Bitte die Steuerart wählen.'
|
||||
else werte.steuerart = e.steuerart
|
||||
if (werte.steuerart === 'kleinunternehmer') {
|
||||
werte.ustUebernachtung = 0
|
||||
werte.ustReinigung = 0
|
||||
} else {
|
||||
for (const [k, std] of [['ustUebernachtung', 7], ['ustReinigung', 19]]) {
|
||||
const v = e[k] === undefined ? std : e[k]
|
||||
if (!SAETZE.includes(v)) fehler[k] = 'Der Steuersatz muss 0, 7 oder 19 sein.'
|
||||
else werte[k] = v
|
||||
}
|
||||
}
|
||||
const sb = text(e.stornobedingungen, 5000)
|
||||
if (sb.length < 20 || sb.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen müssen 20 bis 5000 Zeichen lang sein.'
|
||||
else werte.stornobedingungen = sb
|
||||
return Object.keys(fehler).length ? { fehler } : { werte }
|
||||
}
|
||||
|
||||
// ---- Sperren, iCal
|
||||
|
||||
/** Sperre [von, bis) mit Notiz; bis ist der Abreisetag (exklusiv). */
|
||||
export function pruefeSperre(eingabe) {
|
||||
const e = eingabe && typeof eingabe === 'object' ? eingabe : {}
|
||||
const fehler = {}
|
||||
if (!istTag(e.von)) fehler.von = 'Ungültiges Datum.'
|
||||
if (!istTag(e.bis)) fehler.bis = 'Ungültiges Datum.'
|
||||
if (!fehler.von && !fehler.bis) {
|
||||
const n = naechte(e.von, e.bis)
|
||||
if (n < 1) fehler.bis = 'Das Ende muss nach dem Anfang liegen.'
|
||||
else if (n > 731) fehler.bis = 'Der Zeitraum ist zu lang.'
|
||||
}
|
||||
if (e.notiz !== undefined && !istText(e.notiz)) fehler.notiz = 'Ungültige Eingabe.'
|
||||
const notiz = istText(e.notiz) ? e.notiz.trim() : ''
|
||||
if (notiz.length > 200) fehler.notiz = 'Die Notiz darf höchstens 200 Zeichen lang sein.'
|
||||
return Object.keys(fehler).length ? { fehler } : { werte: { von: e.von, bis: e.bis, notiz } }
|
||||
}
|
||||
|
||||
/** Nur https, hoechstens 500 Zeichen, ohne Zugangsdaten in der Adresse. DNS-/SSRF-Pruefung folgt beim Abruf (Task 9). */
|
||||
export function pruefeIcalUrl(url) {
|
||||
if (!istText(url) || !url.trim()) return { fehler: { url: 'Bitte eine Adresse angeben.' } }
|
||||
const u = url.trim()
|
||||
if (u.length > 500) return { fehler: { url: 'Die Adresse darf höchstens 500 Zeichen lang sein.' } }
|
||||
let p
|
||||
try {
|
||||
p = new URL(u)
|
||||
} catch {
|
||||
return { fehler: { url: 'Bitte eine gültige Adresse angeben.' } }
|
||||
}
|
||||
if (p.protocol !== 'https:' || !p.hostname) return { fehler: { url: 'Die Adresse muss mit https:// beginnen.' } }
|
||||
if (p.username || p.password) return { fehler: { url: 'Die Adresse darf keine Zugangsdaten enthalten.' } }
|
||||
return { werte: { url: u } }
|
||||
}
|
||||
|
||||
export function pruefeIcalImport(eingabe) {
|
||||
const e = eingabe && typeof eingabe === 'object' ? eingabe : {}
|
||||
const u = pruefeIcalUrl(e.url)
|
||||
const fehler = { ...(u.fehler ?? {}) }
|
||||
const name = text(e.name, 60)
|
||||
if (!name) fehler.name = 'Bitte einen Namen angeben (z. B. Booking.com).'
|
||||
else if (name.length > 60) fehler.name = 'Der Name darf höchstens 60 Zeichen lang sein.'
|
||||
return Object.keys(fehler).length ? { fehler } : { werte: { name, url: u.werte.url } }
|
||||
}
|
||||
|
||||
export const neuesExportToken = () => randomBytes(32).toString('base64url')
|
||||
export const neueImportId = () => randomBytes(6).toString('hex')
|
||||
export const exportUrl = (token) => (token ? `${config.publicBaseUrl}/unterkunft/ical/${token}.ics` : '')
|
||||
|
||||
// ---- Kalender
|
||||
|
||||
/**
|
||||
* Belegte Bereiche einer Unterkunft im Fenster [von, bis) mit Quelle, Anfragenummer, Sperr-ID und Notiz. Wie belegung.js
|
||||
* (gebucht/bezahlt = gebucht, freigegeben mit Frist in der Zukunft = reserviert), aber mit den Angaben fuer die Verwaltung.
|
||||
*/
|
||||
export async function kalenderBereiche(unterkunftId, von, bis, { now = Date.now() } = {}) {
|
||||
const jetzt = now instanceof Date ? now.getTime() : now
|
||||
const fenster = { von, bis }
|
||||
const roh = []
|
||||
for (const s of await listeSperren(unterkunftId)) {
|
||||
const quelle = String(s.quelle ?? '')
|
||||
roh.push({
|
||||
von: s.von,
|
||||
bis: s.bis,
|
||||
quelle: quelle.startsWith('ical') ? quelle : 'manuell',
|
||||
sperreId: s.$id,
|
||||
notiz: s.notiz ?? '',
|
||||
})
|
||||
}
|
||||
for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'])) {
|
||||
if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue
|
||||
roh.push({ von: a.von, bis: a.bis, quelle: a.status === 'freigegeben' ? 'reserviert' : 'gebucht', anfrageNummer: a.nummer })
|
||||
}
|
||||
return roh
|
||||
.filter((r) => r.von && r.bis && ueberschneidet(r, fenster))
|
||||
.map((r) => ({ ...r, von: r.von > von ? r.von : von, bis: r.bis < bis ? r.bis : bis }))
|
||||
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : 0))
|
||||
}
|
||||
Reference in new issue
Block a user