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>
323 lines
15 KiB
JavaScript
323 lines
15 KiB
JavaScript
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)
|
||
assert.ok(r.posten.every((p) => p.ustProzent === 0))
|
||
assert.ok(pruefePosten(r.posten, 'kleinunternehmer').posten)
|
||
})
|
||
|
||
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, menge: 1, einzelCent: 10_000_000 }, ...Array(10).fill({ ...ok, menge: 1, einzelCent: 10_000_000 })], 'regel').fehler.posten, 'Summe ueber 999.999,99 EUR')
|
||
assert.ok(pruefePosten([{ ...ok, menge: 1, einzelCent: 9_999_999 }], 'regel').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)
|
||
})
|
||
|
||
test('pruefePosten: negative Summe, Grenzen', () => {
|
||
const ok = { text: 'a', menge: 1, einzelCent: 1000, ustProzent: 7 }
|
||
assert.ok(pruefePosten([{ ...ok, einzelCent: -500 }], 'regel').fehler.posten)
|
||
assert.ok(pruefePosten([{ ...ok, menge: 20001 }], 'regel').fehler.posten)
|
||
assert.ok(pruefePosten([{ ...ok, menge: 20000 }], 'regel').posten)
|
||
assert.ok(pruefePosten([{ ...ok, einzelCent: 10_000_001 }], 'regel').fehler.posten)
|
||
assert.ok(pruefePosten([{ ...ok, menge: 2 ** 53 }], 'regel').fehler.posten)
|
||
})
|
||
|
||
test('preiseVollstaendig: strenge Faelle', () => {
|
||
const f = (o) => preiseVollstaendig({ ...PREISE, ...o })
|
||
const st = (ab) => ab.map((a) => ({ abNaechten: a, preisProNachtCent: 100 }))
|
||
assert.equal(f({ staffeln: st([1, 1]) }), false)
|
||
assert.equal(f({ staffeln: st([1, 0]) }), false)
|
||
assert.equal(f({ staffeln: st([1, 2.5]) }), false)
|
||
assert.equal(f({ ustUebernachtung: 5 }), false)
|
||
assert.equal(f({ ustReinigung: 5 }), false)
|
||
assert.equal(f({ reinigungCent: 0, ustReinigung: 5 }), true)
|
||
assert.equal(f({ steuerart: 'kleinunternehmer', ustUebernachtung: 5 }), true)
|
||
assert.equal(f({ reinigungCent: -1 }), false)
|
||
assert.equal(f({ aufpreisPersonNachtCent: 1.5 }), false)
|
||
assert.equal(f({ direktrabattProzent: 51 }), false)
|
||
assert.equal(f({ direktrabattProzent: -1 }), false)
|
||
assert.equal(f({ direktrabattProzent: 2.5 }), false)
|
||
assert.equal(f({ direktrabattProzent: 50 }), true)
|
||
})
|
||
|
||
test('berechnePreis: 20 Personen ueber 548 Naechte besteht pruefePosten', () => {
|
||
const r = berechnePreis({ preise: PREISE, von: '2026-11-01', bis: plusTage('2026-11-01', 548), personen: 20 })
|
||
assert.equal(r.posten[1].menge, 18 * 548)
|
||
assert.ok(pruefePosten(r.posten, 'regel').posten)
|
||
})
|
||
|
||
// ---- Preise je Personenzahl (staffeln[].personenCent): Mimis Monteurwohnung, Preis pro Nacht fuer die ganze Wohnung
|
||
|
||
const MIMI = {
|
||
staffeln: [
|
||
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||
{ abNaechten: 14, preisProNachtCent: 4500, personenCent: [4500, 6000, 7200] },
|
||
{ abNaechten: 28, preisProNachtCent: 4000, personenCent: [4000, 5500, 6700] },
|
||
],
|
||
reinigungCent: 4000,
|
||
personenInklusive: 2,
|
||
aufpreisPersonNachtCent: 1500,
|
||
direktrabattProzent: 0,
|
||
steuerart: 'kleinunternehmer',
|
||
ustUebernachtung: 0,
|
||
ustReinigung: 0,
|
||
}
|
||
const mimi = (n, personen, extra = {}) => berechnePreis({ preise: { ...MIMI, ...extra }, von: '2026-11-01', bis: plusTage('2026-11-01', n), personen })
|
||
|
||
test('Personenpreise: Mimis Matrix (Naechte x Personen) mit Endreinigung 40 EUR', () => {
|
||
const erwartet = [
|
||
// [Naechte, Personen, Preis pro Nacht in Cent]
|
||
[3, 1, 5500], [3, 2, 7000], [3, 3, 8200],
|
||
[6, 3, 8200], [7, 1, 5000], [7, 2, 6500], [7, 3, 7700],
|
||
[13, 3, 7700], [14, 1, 4500], [14, 2, 6000], [14, 3, 7200],
|
||
[27, 3, 7200], [28, 1, 4000], [28, 2, 5500], [28, 3, 6700], [30, 3, 6700],
|
||
]
|
||
for (const [n, p, nacht] of erwartet) {
|
||
const r = mimi(n, p)
|
||
assert.equal(r.posten[0].einzelCent, nacht, `${n} Naechte, ${p} Personen`)
|
||
assert.equal(r.posten[0].menge, n)
|
||
assert.equal(r.summen.bruttoCent, n * nacht + 4000, `${n} Naechte, ${p} Personen`)
|
||
}
|
||
assert.equal(mimi(7, 3).summen.bruttoCent, 57900)
|
||
assert.equal(mimi(3, 2).summen.bruttoCent, 25000)
|
||
assert.equal(mimi(28, 1).summen.bruttoCent, 116000)
|
||
assert.equal(mimi(30, 3).summen.bruttoCent, 205000)
|
||
})
|
||
|
||
test('Personenpreise: Postentexte mit Personenzahl, kein Zusatzperson-Posten', () => {
|
||
const r = mimi(7, 3)
|
||
assert.deepEqual(r.posten, [
|
||
{ text: 'Übernachtung, 7 Nächte, 3 Personen', menge: 7, einzelCent: 7700, ustProzent: 0 },
|
||
{ text: 'Endreinigung', menge: 1, einzelCent: 4000, ustProzent: 0 },
|
||
])
|
||
assert.equal(mimi(7, 1).posten[0].text, 'Übernachtung, 7 Nächte, 1 Person')
|
||
assert.equal(mimi(7, 2).posten[0].text, 'Übernachtung, 7 Nächte, 2 Personen')
|
||
assert.equal(mimi(1, 2).posten[0].text, 'Übernachtung, 1 Nächte, 2 Personen', 'Stil des bisherigen Textes: immer "Nächte"')
|
||
assert.ok(pruefePosten(mimi(30, 3).posten, 'kleinunternehmer').posten)
|
||
})
|
||
|
||
test('Personenpreise: personenInklusive wird ignoriert', () => {
|
||
const a = mimi(7, 3, { personenInklusive: 1 })
|
||
const b = mimi(7, 3, { personenInklusive: 3 })
|
||
assert.deepEqual(a, b)
|
||
assert.equal(a.posten.length, 2)
|
||
})
|
||
|
||
test('Personenpreise: mehr Gaeste als Preisspalten = letzte Spalte + Zusatzperson-Posten', () => {
|
||
const r = mimi(7, 5)
|
||
assert.deepEqual(r.posten, [
|
||
{ text: 'Übernachtung, 7 Nächte, 3 Personen', menge: 7, einzelCent: 7700, ustProzent: 0 },
|
||
{ text: 'Zusatzpersonen (2), 7 Nächte', menge: 14, einzelCent: 1500, ustProzent: 0 },
|
||
{ text: 'Endreinigung', menge: 1, einzelCent: 4000, ustProzent: 0 },
|
||
])
|
||
assert.equal(r.summen.bruttoCent, 7 * 7700 + 14 * 1500 + 4000)
|
||
assert.equal(mimi(7, 4).posten[1].text, 'Zusatzperson (1), 7 Nächte')
|
||
// ohne Aufpreis kein Zusatzperson-Posten
|
||
const ohne = mimi(7, 5, { aufpreisPersonNachtCent: 0 })
|
||
assert.equal(ohne.posten.length, 2)
|
||
assert.equal(ohne.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
|
||
})
|
||
|
||
test('Personenpreise: Direktrabatt auf Uebernachtung und Zusatzpersonen, nicht auf die Reinigung', () => {
|
||
const r = mimi(7, 5, { direktrabattProzent: 10 })
|
||
// Basis 7*7700 + 14*1500 = 74900; 10 % = 7490
|
||
assert.equal(r.posten.at(-1).text, 'Direktbuchungsrabatt 10 %')
|
||
assert.equal(r.posten.at(-1).einzelCent, -7490)
|
||
assert.equal(r.summen.bruttoCent, 74900 + 4000 - 7490)
|
||
const einfach = mimi(7, 2, { direktrabattProzent: 10 })
|
||
assert.equal(einfach.posten.at(-1).einzelCent, -Math.round((7 * 6500 * 10) / 100))
|
||
assert.equal(einfach.summen.bruttoCent, 45500 + 4000 - 4550)
|
||
})
|
||
|
||
test('Personenpreise: Regelbesteuerung 7 % Uebernachtung, 19 % Reinigung', () => {
|
||
const r = mimi(7, 3, { steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 19 })
|
||
assert.deepEqual(r.posten.map((p) => p.ustProzent), [7, 19])
|
||
assert.equal(r.summen.bruttoCent, 57900)
|
||
assert.deepEqual(r.summen.ust, [
|
||
{ satz: 7, nettoCent: Math.round((53900 * 100) / 107), steuerCent: 53900 - Math.round((53900 * 100) / 107) },
|
||
{ satz: 19, nettoCent: Math.round((4000 * 100) / 119), steuerCent: 4000 - Math.round((4000 * 100) / 119) },
|
||
])
|
||
assert.equal(r.summen.ust.reduce((a, u) => a + u.nettoCent + u.steuerCent, 0), 57900)
|
||
})
|
||
|
||
test('Personenpreise: Staffelwahl wie bisher (hoechste abNaechten <= Naechte), ungeordnete Eingabe', () => {
|
||
const gemischt = { ...MIMI, staffeln: [...MIMI.staffeln].reverse() }
|
||
assert.equal(berechnePreis({ preise: gemischt, von: '2026-11-01', bis: plusTage('2026-11-01', 20), personen: 2 }).posten[0].einzelCent, 6000)
|
||
})
|
||
|
||
test('Personenpreise: Personenzahl kleiner 1 oder keine Zahl rechnet wie 1 Person', () => {
|
||
assert.equal(mimi(7, 0).posten[0].einzelCent, 5000)
|
||
assert.equal(mimi(7, NaN).posten[0].einzelCent, 5000)
|
||
})
|
||
|
||
test('Alter Modus ohne personenCent rechnet unveraendert (Text ohne Personenangabe)', () => {
|
||
const r = berechnePreis({ preise: PREISE, von: '2026-11-02', bis: '2026-11-09', personen: 3 })
|
||
assert.deepEqual(r.posten.slice(0, 2), [
|
||
{ text: 'Übernachtung, 7 Nächte', menge: 7, einzelCent: 5500, ustProzent: 7 },
|
||
{ text: 'Zusatzperson (1), 7 Nächte', menge: 7, einzelCent: 1000, ustProzent: 7 },
|
||
])
|
||
// personenCent: null zaehlt als nicht vorhanden
|
||
const mitNull = { ...PREISE, staffeln: PREISE.staffeln.map((s) => ({ ...s, personenCent: null })) }
|
||
assert.deepEqual(berechnePreis({ preise: mitNull, von: '2026-11-02', bis: '2026-11-09', personen: 3 }), r)
|
||
})
|
||
|
||
test('preiseVollstaendig mit personenCent: konsistent ja, sonst nein', () => {
|
||
const f = (staffeln) => preiseVollstaendig({ ...MIMI, staffeln })
|
||
const st = (o = {}) => MIMI.staffeln.map((s, i) => ({ ...s, ...(typeof o === 'function' ? o(s, i) : o) }))
|
||
assert.equal(preiseVollstaendig(MIMI), true)
|
||
assert.equal(f(st()), true)
|
||
// nur eine Staffel hat personenCent
|
||
assert.equal(f(st((s, i) => (i === 2 ? { personenCent: undefined } : {}))), false)
|
||
// ungleiche Laengen
|
||
assert.equal(f(st((s, i) => (i === 1 ? { personenCent: s.personenCent.slice(0, 2) } : {}))), false)
|
||
// Laenge 1 und 11
|
||
assert.equal(f(st((s) => ({ personenCent: [s.preisProNachtCent] }))), false)
|
||
assert.equal(f(st((s) => ({ personenCent: Array(11).fill(s.preisProNachtCent) }))), false)
|
||
assert.equal(f(st((s) => ({ personenCent: Array(10).fill(s.preisProNachtCent) }))), true)
|
||
assert.equal(f(st((s) => ({ personenCent: [s.preisProNachtCent, s.preisProNachtCent] }))), true)
|
||
// ungueltige Werte
|
||
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, 99, 8200] } : {}))), false)
|
||
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, 100001, 8200] } : {}))), false)
|
||
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, 7000.5, 8200] } : {}))), false)
|
||
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, '7000', 8200] } : {}))), false)
|
||
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: 'x' } : {}))), false)
|
||
// preisProNachtCent muss personenCent[0] sein
|
||
assert.equal(f(st((s, i) => (i === 3 ? { preisProNachtCent: 4100 } : {}))), false)
|
||
// null zaehlt als nicht vorhanden
|
||
assert.equal(preiseVollstaendig({ ...PREISE, staffeln: PREISE.staffeln.map((s) => ({ ...s, personenCent: null })) }), true)
|
||
// alter Modus mit personenCent in nur einer Staffel
|
||
assert.equal(preiseVollstaendig({ ...PREISE, staffeln: [{ ...PREISE.staffeln[0], personenCent: [6500, 7500] }, PREISE.staffeln[1], PREISE.staffeln[2]] }), false)
|
||
})
|