diff --git a/server/services/unterkunft/datum.js b/server/services/unterkunft/datum.js new file mode 100644 index 0000000..8081bf4 --- /dev/null +++ b/server/services/unterkunft/datum.js @@ -0,0 +1,39 @@ +// Datumshilfen fuer Buchungskalender: reine Funktionen auf Tagesstrings (YYYY-MM-DD), UTC-Arithmetik, keine I/O. +const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ +const TAG_MS = 86400000 + +function zuMs(tag) { + const [j, m, t] = tag.split('-').map(Number) + return Date.UTC(j, m - 1, t) +} + +/** Gueltiger Kalendertag im Format YYYY-MM-DD. */ +export function istTag(s) { + if (typeof s !== 'string' || !TAG_RE.test(s)) return false + const d = new Date(zuMs(s)) + return d.toISOString().slice(0, 10) === s +} + +/** Anzahl Naechte zwischen Anreise und Abreise. */ +export function naechte(von, bis) { + return Math.round((zuMs(bis) - zuMs(von)) / TAG_MS) +} + +/** Tag um n Tage verschoben. */ +export function plusTage(tag, n) { + return new Date(zuMs(tag) + n * TAG_MS).toISOString().slice(0, 10) +} + +/** Belegte Naechte: von inklusive, bis exklusive. */ +export function tageVon(von, bis) { + const n = naechte(von, bis) + const out = [] + for (let i = 0; i < n; i++) out.push(plusTage(von, i)) + return out +} + +/** '2026-11-12' wird '12.11.2026'. */ +export function formatTag(tag) { + const [j, m, t] = tag.split('-') + return `${t}.${m}.${j}` +} diff --git a/server/services/unterkunft/preis.js b/server/services/unterkunft/preis.js new file mode 100644 index 0000000..72b18ba --- /dev/null +++ b/server/services/unterkunft/preis.js @@ -0,0 +1,97 @@ +// Preisrechnung Buchungskalender: reine Funktionen, keine I/O. Betraege in Cent. +import { config } from '../../config.js' +import { naechte } from './datum.js' + +const SAETZE = [0, 7, 19] + +/** Staffel: hoechste abNaechten <= naechte gilt fuer alle Naechte. */ +function staffelPreis(staffeln, n) { + let best = null + for (const s of staffeln || []) { + if (s.abNaechten <= n && (!best || s.abNaechten > best.abNaechten)) best = s + } + return best ? best.preisProNachtCent : 0 +} + +export function berechnePreis({ preise, von, bis, personen }) { + const n = naechte(von, bis) + const posten = [] + const nachtPreis = staffelPreis(preise.staffeln, n) + posten.push({ text: `Übernachtung, ${n} Nächte`, menge: n, einzelCent: nachtPreis, ustProzent: preise.ustUebernachtung }) + let basis = n * nachtPreis + const extra = Math.max(0, Number(personen) - Number(preise.personenInklusive)) + if (extra > 0 && preise.aufpreisPersonNachtCent > 0) { + posten.push({ + text: `Zusatzperson${extra > 1 ? 'en' : ''} (${extra}), ${n} Nächte`, + menge: extra * n, + einzelCent: preise.aufpreisPersonNachtCent, + ustProzent: preise.ustUebernachtung, + }) + basis += extra * n * preise.aufpreisPersonNachtCent + } + if (preise.reinigungCent > 0) { + posten.push({ text: 'Endreinigung', menge: 1, einzelCent: preise.reinigungCent, ustProzent: preise.ustReinigung }) + } + if (preise.direktrabattProzent > 0) { + const rabatt = -Math.round((basis * preise.direktrabattProzent) / 100) + posten.push({ + text: `Direktbuchungsrabatt ${preise.direktrabattProzent} %`, + menge: 1, + einzelCent: rabatt, + ustProzent: preise.ustUebernachtung, + }) + } + 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 } +} + +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.isInteger(p.menge) || p.menge < 1) return fehler('Die Menge muss eine positive ganze Zahl sein.') + if (!Number.isInteger(p.einzelCent)) 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 }) + } + if (sauber.reduce((a, p) => a + p.menge * p.einzelCent, 0) === 0) return fehler('Die Summe darf nicht 0 sein.') + 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} €` +} + +export function preiseVollstaendig(preise) { + if (!preise || !Array.isArray(preise.staffeln) || preise.staffeln.length === 0) return false + if (!preise.staffeln.some((s) => s.abNaechten === 1)) return false + if (!preise.staffeln.every((s) => Number.isInteger(s.preisProNachtCent) && s.preisProNachtCent > 0)) return false + if (!['regel', 'kleinunternehmer'].includes(preise.steuerart)) return false + return Number.isInteger(preise.personenInklusive) && preise.personenInklusive >= 1 +} diff --git a/test/unterkunftPreis.test.js b/test/unterkunftPreis.test.js new file mode 100644 index 0000000..c398743 --- /dev/null +++ b/test/unterkunftPreis.test.js @@ -0,0 +1,141 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { istTag, naechte, plusTage, tageVon, formatTag } from '../server/services/unterkunft/datum.js' +import { + berechnePreis, + summiere, + pruefePosten, + formatEuro, + preiseVollstaendig, +} from '../server/services/unterkunft/preis.js' + +const PREISE = { + staffeln: [ + { abNaechten: 1, preisProNachtCent: 6500 }, + { abNaechten: 7, preisProNachtCent: 5500 }, + { abNaechten: 28, preisProNachtCent: 4500 }, + ], + reinigungCent: 6000, + personenInklusive: 2, + aufpreisPersonNachtCent: 1000, + direktrabattProzent: 10, + steuerart: 'regel', + ustUebernachtung: 7, + ustReinigung: 7, +} + +test('datum: istTag, naechte, plusTage, tageVon, formatTag', () => { + assert.equal(istTag('2026-10-24'), true) + assert.equal(istTag('2026-02-30'), false) + assert.equal(istTag('2026-1-24'), false) + assert.equal(istTag(null), false) + assert.equal(naechte('2026-10-24', '2026-10-26'), 2) + assert.equal(naechte('2026-12-30', '2027-01-02'), 3) + assert.equal(plusTage('2026-12-31', 1), '2027-01-01') + assert.equal(plusTage('2026-03-01', -1), '2026-02-28') + assert.deepEqual(tageVon('2026-12-30', '2027-01-02'), ['2026-12-30', '2026-12-31', '2027-01-01']) + assert.equal(formatTag('2026-11-12'), '12.11.2026') +}) + +test('berechnePreis: 7 Naechte, 2 Personen', () => { + const r = berechnePreis({ preise: PREISE, von: '2026-11-02', bis: '2026-11-09', personen: 2 }) + assert.equal(r.naechte, 7) + assert.deepEqual(r.posten, [ + { text: 'Übernachtung, 7 Nächte', menge: 7, einzelCent: 5500, ustProzent: 7 }, + { text: 'Endreinigung', menge: 1, einzelCent: 6000, ustProzent: 7 }, + { text: 'Direktbuchungsrabatt 10 %', menge: 1, einzelCent: -3850, ustProzent: 7 }, + ]) + assert.equal(r.summen.bruttoCent, 40650) + assert.deepEqual(r.summen.ust, [{ satz: 7, nettoCent: 37991, steuerCent: 2659 }]) +}) + +test('berechnePreis: 30 Naechte, 3 Personen', () => { + const r = berechnePreis({ preise: PREISE, von: '2026-11-01', bis: '2026-12-01', personen: 3 }) + assert.equal(r.posten[0].menge, 30) + assert.equal(r.posten[0].einzelCent, 4500) + assert.equal(r.posten[1].text, 'Zusatzperson (1), 30 Nächte') + assert.equal(r.posten[1].menge, 30) + assert.equal(r.posten[1].einzelCent, 1000) + assert.equal(r.posten[2].einzelCent, 6000) + assert.equal(r.posten[3].einzelCent, -16500) + assert.equal(r.summen.bruttoCent, 154500) +}) + +test('berechnePreis: 3 Naechte, 2 Personen', () => { + const r = berechnePreis({ preise: PREISE, von: '2026-11-02', bis: '2026-11-05', personen: 2 }) + assert.equal(r.summen.bruttoCent, 23550) +}) + +test('berechnePreis: Mehrzahl Zusatzpersonen', () => { + const r = berechnePreis({ preise: PREISE, von: '2026-11-02', bis: '2026-11-05', personen: 4 }) + assert.equal(r.posten[1].text, 'Zusatzpersonen (2), 3 Nächte') + assert.equal(r.posten[1].menge, 6) +}) + +test('berechnePreis: unterschiedliche Saetze', () => { + const r = berechnePreis({ + preise: { ...PREISE, ustReinigung: 19 }, + von: '2026-11-02', + bis: '2026-11-09', + personen: 2, + }) + assert.equal(r.summen.ust.length, 2) + const summe = r.summen.ust.reduce((a, u) => a + u.nettoCent + u.steuerCent, 0) + assert.equal(summe, r.summen.bruttoCent) +}) + +test('berechnePreis/summiere: Kleinunternehmer', () => { + const r = berechnePreis({ + preise: { ...PREISE, steuerart: 'kleinunternehmer' }, + von: '2026-11-02', + bis: '2026-11-09', + personen: 2, + }) + assert.deepEqual(r.summen.ust, []) + assert.equal(r.summen.nettoCent, r.summen.bruttoCent) +}) + +test('berechnePreis: Rabatt 0 und Reinigung 0 ohne Posten', () => { + const r = berechnePreis({ + preise: { ...PREISE, direktrabattProzent: 0, reinigungCent: 0 }, + von: '2026-11-02', + bis: '2026-11-09', + personen: 2, + }) + assert.equal(r.posten.length, 1) +}) + +test('summiere: direkt', () => { + const s = summiere([{ text: 'a', menge: 2, einzelCent: 500, ustProzent: 19 }], 'regel') + assert.equal(s.bruttoCent, 1000) + assert.deepEqual(s.ust, [{ satz: 19, nettoCent: 840, steuerCent: 160 }]) +}) + +test('pruefePosten', () => { + const ok = { text: ' Miete ', menge: 2, einzelCent: 1000, ustProzent: 7 } + assert.ok(pruefePosten([], 'regel').fehler.posten) + assert.ok(pruefePosten([{ ...ok, einzelCent: 0 }], 'regel').fehler.posten) + assert.ok(pruefePosten([{ ...ok, text: '' }], 'regel').fehler.posten) + assert.ok(pruefePosten([{ ...ok, text: 'x'.repeat(201) }], 'regel').fehler.posten) + assert.ok(pruefePosten(Array(31).fill(ok), 'regel').fehler.posten) + assert.ok(pruefePosten([ok], 'kleinunternehmer').fehler.posten) + assert.ok(pruefePosten([{ ...ok, ustProzent: 5 }], 'regel').fehler.posten) + assert.ok(pruefePosten([{ ...ok, menge: 0 }], 'regel').fehler.posten) + assert.ok(pruefePosten([{ ...ok, menge: 1.5 }], 'regel').fehler.posten) + assert.ok(pruefePosten('nope', 'regel').fehler.posten) + const r = pruefePosten([ok], 'regel') + assert.equal(r.posten[0].text, 'Miete') + assert.equal(pruefePosten([{ ...ok, ustProzent: 0 }], 'kleinunternehmer').posten.length, 1) +}) + +test('formatEuro', () => { + assert.equal(formatEuro(123450), '1.234,50 €') + assert.equal(formatEuro(-3850), '−38,50 €') + assert.equal(formatEuro(5), '0,05 €') +}) + +test('preiseVollstaendig', () => { + assert.equal(preiseVollstaendig(PREISE), true) + assert.equal(preiseVollstaendig({ ...PREISE, staffeln: [] }), false) + assert.equal(preiseVollstaendig(null), false) +})