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>
162 lines
8.1 KiB
JavaScript
162 lines
8.1 KiB
JavaScript
// 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
|
||
}
|