Files
Webklar-Kundenbereich/server/services/unterkunft/preis.js
T
knsoandClaude Opus 5.5 a4ade5f649 Buchungskalender: Preise je Personenzahl (Server)
Jede Staffel darf personenCent tragen (Preis pro Nacht für 1 bis k Personen, k = 2 bis 10), ohne Schemaänderung im JSON-Feld staffeln. pruefePreise prüft Länge, Werte und Gleichheit über alle Staffeln und setzt preisProNachtCent = personenCent[0]. berechnePreis wählt die Spalte der Gästezahl (Posten mit Personenzahl), bei mehr Gästen als Spalten gilt die letzte plus Zusatzperson-Aufpreis. preiseVollstaendig prüft die Konsistenz. Ohne personenCent rechnet alles wie bisher. Tests mit Mimis Preismatrix, Doku in unterkunft-api.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:02:45 +00:00

162 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Preisrechnung Buchungskalender: reine Funktionen, keine I/O. Betraege in Cent.
//
// Preismodelle (preise.staffeln, Staffel = hoechste abNaechten <= Naechte, sie gilt fuer alle Naechte der Buchung):
// - pauschal (ohne personenCent): preisProNachtCent gilt fuer die ersten personenInklusive Gaeste, jede weitere Person
// kostet aufpreisPersonNachtCent je Nacht (eigener Posten "Zusatzperson").
// - je Personenzahl (alle Staffeln mit personenCent gleicher Laenge k, 2 bis 10): personenCent[p - 1] ist der Preis pro
// Nacht fuer die ganze Unterkunft bei p Gaesten. personenInklusive wird dann ignoriert; bei mehr als k Gaesten gilt
// personenCent[k - 1] plus der Posten "Zusatzperson" (aufpreisPersonNachtCent) fuer die Gaeste ueber k. Der Server
// haelt preisProNachtCent === personenCent[0] (pruefePreise), damit aeltere Leser weiter rechnen koennen.
// Der Direktrabatt gilt in beiden Modellen auf Uebernachtung und Zusatzpersonen, nicht auf die Reinigung.
import { config } from '../../config.js'
import { naechte } from './datum.js'
const SAETZE = [0, 7, 19]
/** Grenzen der Preise je Personenzahl: Laenge k von personenCent (1 bis k Gaeste) und Wert in Cent. */
export const PERSONEN_PREISE_MIN = 2
export const PERSONEN_PREISE_MAX = 10
export const NACHT_CENT_MIN = 100
export const NACHT_CENT_MAX = 100000
/** Die Staffel, die fuer n Naechte gilt (hoechste abNaechten <= n), sonst null. */
function waehleStaffel(staffeln, n) {
let best = null
for (const s of staffeln || []) {
if (s.abNaechten <= n && (!best || s.abNaechten > best.abNaechten)) best = s
}
return best
}
export function berechnePreis({ preise, von, bis, personen }) {
const n = naechte(von, bis)
const klein = preise.steuerart === 'kleinunternehmer'
const satzUe = klein ? 0 : preise.ustUebernachtung
const satzRe = klein ? 0 : preise.ustReinigung
const posten = []
const staffel = waehleStaffel(preise.staffeln, n)
const pc = staffel && Array.isArray(staffel.personenCent) && staffel.personenCent.length > 0 ? staffel.personenCent : null
let nachtPreis
let inklusive
if (pc) {
// je Personenzahl: die Spalte der Gaestezahl (hoechstens die letzte), der Rest ist Zusatzperson
const gaeste = Number.isFinite(Number(personen)) && Number(personen) >= 1 ? Math.floor(Number(personen)) : 1
inklusive = Math.min(gaeste, pc.length)
nachtPreis = pc[inklusive - 1]
posten.push({ text: `Übernachtung, ${n} Nächte, ${inklusive} ${inklusive === 1 ? 'Person' : 'Personen'}`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
} else {
nachtPreis = staffel ? staffel.preisProNachtCent : 0
inklusive = Number(preise.personenInklusive)
posten.push({ text: `Übernachtung, ${n} Nächte`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
}
let basis = n * nachtPreis
const extra = Math.max(0, Number(personen) - inklusive)
if (extra > 0 && preise.aufpreisPersonNachtCent > 0) {
posten.push({
text: `Zusatzperson${extra > 1 ? 'en' : ''} (${extra}), ${n} Nächte`,
menge: extra * n,
einzelCent: preise.aufpreisPersonNachtCent,
ustProzent: satzUe,
})
basis += extra * n * preise.aufpreisPersonNachtCent
}
if (preise.reinigungCent > 0) {
posten.push({ text: 'Endreinigung', menge: 1, einzelCent: preise.reinigungCent, ustProzent: satzRe })
}
if (preise.direktrabattProzent > 0) {
const rabatt = -Math.round((basis * preise.direktrabattProzent) / 100)
posten.push({
text: `Direktbuchungsrabatt ${preise.direktrabattProzent} %`,
menge: 1,
einzelCent: rabatt,
ustProzent: satzUe,
})
}
return { naechte: n, posten, summen: summiere(posten, preise.steuerart) }
}
export function summiere(posten, steuerart) {
const bruttoCent = posten.reduce((a, p) => a + p.menge * p.einzelCent, 0)
if (steuerart === 'kleinunternehmer') return { bruttoCent, nettoCent: bruttoCent, ust: [] }
const proSatz = new Map()
for (const p of posten) proSatz.set(p.ustProzent, (proSatz.get(p.ustProzent) || 0) + p.menge * p.einzelCent)
const ust = [...proSatz.entries()]
.sort((a, b) => a[0] - b[0])
.map(([satz, brutto]) => {
const nettoCent = Math.round((brutto * 100) / (100 + satz))
return { satz, nettoCent, steuerCent: brutto - nettoCent }
})
const nettoCent = ust.reduce((a, u) => a + u.nettoCent, 0)
return { bruttoCent, nettoCent, ust }
}
/** Obergrenze der Bruttosumme einer Anfrage (999.999,99 EUR): darunter bleiben Stripe-Betrag und Rechnung sicher darstellbar. */
export const BRUTTO_MAX_CENT = 99_999_999
export function pruefePosten(posten, steuerart) {
const fehler = (m) => ({ fehler: { posten: m } })
const maxPosten = config.unterkunft?.maxPosten ?? 30
const textMax = config.unterkunft?.postenTextMax ?? 200
if (!Array.isArray(posten) || posten.length === 0) return fehler('Mindestens ein Posten ist nötig.')
if (posten.length > maxPosten) return fehler(`Höchstens ${maxPosten} Posten sind erlaubt.`)
const sauber = []
for (const p of posten) {
if (!p || typeof p !== 'object') return fehler('Ungültiger Posten.')
const text = typeof p.text === 'string' ? p.text.trim() : ''
if (!text || text.length > textMax) return fehler(`Der Text eines Postens muss 1 bis ${textMax} Zeichen lang sein.`)
if (!Number.isSafeInteger(p.menge) || p.menge < 1 || p.menge > 20000) return fehler('Die Menge muss eine positive ganze Zahl sein.')
if (!Number.isSafeInteger(p.einzelCent) || Math.abs(p.einzelCent) > 10_000_000) return fehler('Der Einzelpreis ist ungültig.')
if (!SAETZE.includes(p.ustProzent)) return fehler('Der Steuersatz muss 0, 7 oder 19 sein.')
if (steuerart === 'kleinunternehmer' && p.ustProzent !== 0) return fehler('Bei Kleinunternehmern ist keine Umsatzsteuer erlaubt.')
sauber.push({ text, menge: p.menge, einzelCent: p.einzelCent, ustProzent: p.ustProzent })
}
const summe = sauber.reduce((a, p) => a + p.menge * p.einzelCent, 0)
if (summe <= 0) return fehler('Die Summe muss größer als 0 sein.')
if (summe > BRUTTO_MAX_CENT) return fehler('Die Summe ist zu hoch.')
return { posten: sauber }
}
export function formatEuro(cent) {
const neg = cent < 0
const abs = Math.abs(Math.round(cent))
const euro = String(Math.floor(abs / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
const rest = String(abs % 100).padStart(2, '0')
return `${neg ? '−' : ''}${euro},${rest} €`
}
/** Preise je Personenzahl: entweder keine Staffel hat personenCent, oder alle gleich lang (2 bis 10) mit gueltigen Werten und preisProNachtCent === personenCent[0]. */
function personenPreiseKonsistent(staffeln) {
const hat = (s) => s.personenCent !== undefined && s.personenCent !== null
if (!staffeln.some(hat)) return true
const k = staffeln[0].personenCent?.length
return staffeln.every(
(s) =>
Array.isArray(s.personenCent) &&
s.personenCent.length === k &&
k >= PERSONEN_PREISE_MIN &&
k <= PERSONEN_PREISE_MAX &&
s.personenCent.every((c) => Number.isInteger(c) && c >= NACHT_CENT_MIN && c <= NACHT_CENT_MAX) &&
s.preisProNachtCent === s.personenCent[0],
)
}
export function preiseVollstaendig(preise) {
if (!preise || !Array.isArray(preise.staffeln) || preise.staffeln.length === 0) return false
const ab = preise.staffeln.map((s) => s && s.abNaechten)
if (!ab.every((a) => Number.isInteger(a) && a > 0)) return false
if (new Set(ab).size !== ab.length || !ab.includes(1)) return false
if (!preise.staffeln.every((s) => Number.isInteger(s.preisProNachtCent) && s.preisProNachtCent > 0)) return false
if (!personenPreiseKonsistent(preise.staffeln)) return false
if (!['regel', 'kleinunternehmer'].includes(preise.steuerart)) return false
if (!Number.isInteger(preise.personenInklusive) || preise.personenInklusive < 1) return false
const nn = (v) => Number.isInteger(v) && v >= 0
if (!nn(preise.reinigungCent) || !nn(preise.aufpreisPersonNachtCent)) return false
const r = preise.direktrabattProzent
if (!Number.isInteger(r) || r < 0 || r > 50) return false
if (preise.steuerart === 'regel') {
if (!SAETZE.includes(preise.ustUebernachtung)) return false
if (preise.reinigungCent > 0 && !SAETZE.includes(preise.ustReinigung)) return false
}
return true
}