Compare commits

...
4 Commits
Author SHA1 Message Date
knsoandClaude Opus 5.5 a45f436124 Buchungskalender: Zustimmung per WhatsApp zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:13:33 +00:00
knsoandClaude Opus 5.5 1e0eb9f569 Buchungskalender: Preise je Personenzahl (Portal-Editor)
Neuer Haken "Preis hängt von der Personenzahl ab" im Preis-Editor (nur bei Höchstzahl ab 2 Personen). Angeschaltet zeigt jede Staffel k Euro-Felder (1 Person bis k Personen, k = min(Höchstzahl, 10)) statt eines Preises; "Personen im Preis inklusive" und "Aufpreis je weiterer Person" sind dann ausgeblendet, ihre Werte gehen unverändert mit. Speichern sendet personenCent und preisProNachtCent = personenCent[0]. Beim Laden ist der Haken an, wenn personenCent gespeichert sind. Kundenansicht (nur lesen) sperrt Haken und Felder. Felder brechen auf dem Handy um. Tests im Fake-DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:04 +00:00
knsoandClaude Opus 5.5 02a3732e4a Funktionen: Zustimmung in Textform auch per WhatsApp
Terminplaner, Webshop und Buchungskalender nehmen beim Aktivieren durch das Team den Weg whatsapp an (Nachweis: „per WhatsApp in Textform“). Die Analyse bleibt bei Chat oder E-Mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:02:49 +00:00
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
13 changed files with 684 additions and 29 deletions

No files matched your search

+4 -1
View File
@@ -25,7 +25,10 @@ Antwortformen, die die Portal-Oberfläche erwartet (Geld in Cent, Daten `YYYY-MM
- `PUT /einstellungen` Body = genau diese Felder ohne Wrapper. `rechnungAuftrag` (boolean) leitet der Server aus `rechnungAuftragAt` ab: `true` setzt es, falls leer, `false` widerruft (null). `onlineZahlung` nur `true`, wenn Stripe aktiv ist; der Präfix ist bei vorhandenen Rechnungen nicht änderbar. Feldfehler als `felder.<name>`.
- `GET /unterkuenfte` → `{ unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }] }` (`icalExportUrl` = volle URL mit Token).
- `POST /unterkuenfte`, `PUT /unterkuenfte/:id` Body `{ name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }` → `{ unterkunft }`; Deaktivieren = `PUT` mit `aktiv:false`. `DELETE /unterkuenfte/:id`.
- `GET|PUT /unterkuenfte/:id/preise` → `{ preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }`; PUT sendet genau diese Felder.
- `GET|PUT /unterkuenfte/:id/preise` → `{ preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }`; PUT sendet genau diese Felder.
- Preismodell „pauschal“ (ohne `personenCent`): `preisProNachtCent` gilt für `personenInklusive` Gäste, jede weitere Person kostet `aufpreisPersonNachtCent` je Nacht (Posten „Zusatzperson“).
- Preismodell „je Personenzahl“: jede Staffel trägt `personenCent:[c1,c2,…,ck]` = Preis pro Nacht für die ganze Unterkunft bei 1, 2, …, k Gästen (k = 2 bis 10, Werte ganze Cent 100 bis 100000). Entweder haben alle Staffeln `personenCent` mit gleicher Länge k oder keine; sonst 400 `felder.staffeln`. Der Server setzt `preisProNachtCent = personenCent[0]` (eine mitgeschickte Zahl wird überschrieben). Eine Staffel gilt weiterhin ab ihrer Nächtezahl für alle Nächte. Der Posten heißt „Übernachtung, 7 Nächte, 3 Personen“; `personenInklusive` wird ignoriert. Bei mehr als k Gästen gilt `ck` plus der Posten „Zusatzperson“ (`aufpreisPersonNachtCent`, nur wenn größer 0) für die Gäste über k. Der Direktrabatt gilt auf Übernachtung und Zusatzpersonen.
- Kein neues Schema: `personenCent` liegt im JSON-Text `ukPreise.staffeln` (2000 Zeichen; der Höchstfall 6 Staffeln × 10 Werte braucht rund 800).
- `GET /unterkuenfte/:id/kalender?von&bis` → `{ bereiche:[{ von, bis (Abreisetag, exklusiv), quelle:'gebucht'|'reserviert'|'ical'|'manuell' (auch 'ical:<importId>', 'anfrage:<id>' zählt als gebucht), anfrageNummer?, sperreId?, notiz? }] }`.
- `POST /unterkuenfte/:id/sperren` `{ von, bis, notiz }` → 201, 409 `zeitraum_belegt`; `DELETE /sperren/:id`.
- `POST /unterkuenfte/:id/ical-importe` `{ name, url }` (nur https; Feldfehler `felder.url`) → 201; `DELETE /unterkuenfte/:id/ical-importe/:importId`; `POST /unterkuenfte/:id/ical-export/erneuern`.
+66 -10
View File
@@ -1,9 +1,15 @@
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Preise je Personenzahl, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen). */
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen).
Zwei Preismodelle, umgeschaltet mit dem Haken "Preis hängt von der Personenzahl ab" (#bk-p-personenmodus, nur bei unterkunft.maxPersonen >= 2):
- aus: ein Preis pro Nacht je Staffel; "Personen im Preis inklusive" und "Aufpreis je weiterer Person" gelten wie bisher.
- an: je Staffel k Euro-Felder (k = min(maxPersonen, 10), "1 Person" ... "k Personen") = Preis pro Nacht fuer die ganze Unterkunft; gesendet wird
staffeln:[{abNaechten, preisProNachtCent: personenCent[0], personenCent}]. Die beiden Personenfelder sind dann ausgeblendet, ihre Werte gehen unveraendert mit.
Beim Laden ist der Haken an, wenn die gespeicherten Staffeln personenCent haben (vorhandene Werte eingesetzt, fehlende leer, ueberzaehlige verworfen).
ctx.nurLesen sperrt alle Felder samt Haken. */
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js'
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
// Wortgleich zu docs/funktionen/unterkunft-storno-muster.md, Abschnitt "Stornobedingungen (Muster)" (ohne Markdown-Fettdruck); test/buchungenUi.test.js vergleicht beides.
@@ -30,6 +36,7 @@ export const STORNO_MUSTER = [
].join('\n')
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
const STANDARD = { staffeln: [{ abNaechten: 1, preisProNachtCent: null }], reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: '' }
const PERSONEN_MAX = 10
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
export async function zeichnePreise(ctx, unterkunft) {
@@ -44,13 +51,27 @@ export async function zeichnePreise(ctx, unterkunft) {
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
return wurzel
}
// Preise je Personenzahl: nur sinnvoll, wenn mehr als eine Person in die Unterkunft passt; k Spalten (1 bis k Personen)
const gaeste = Number.isInteger(unterkunft?.maxPersonen) ? Math.min(unterkunft.maxPersonen, PERSONEN_MAX) : 0
const modusMoeglich = gaeste >= 2
const modusBox = h('input', { type: 'checkbox', id: 'bk-p-personenmodus' })
modusBox.checked = modusMoeglich && Array.isArray(preise.staffeln) && preise.staffeln.some((s) => Array.isArray(s?.personenCent) && s.personenCent.length > 0)
const personenText = (n) => `${n} ${n === 1 ? 'Person' : 'Personen'}`
const zeilenEl = h('div', { class: 'bk-staffeln' })
const zeile = (s) => {
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
ab.value = String(s.abNaechten ?? '')
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
preis.dataset.staffel = 'preis'
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ', preis, ' € pro Nacht ',
const spalten = Array.from({ length: modusMoeglich ? gaeste : 0 }, (_, i) => {
const eingabe = euroEingabe({ cent: Array.isArray(s.personenCent) ? s.personenCent[i] : null, label: `Preis pro Nacht in Euro für ${personenText(i + 1)}` })
eingabe.dataset.staffel = 'person'
eingabe.dataset.personen = String(i + 1)
return h('label', { class: 'bk-personpreis' }, h('span', null, personenText(i + 1)), eingabe)
})
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ',
h('span', { class: 'bk-preis-einzeln' }, preis, ' € pro Nacht '),
h('span', { class: 'bk-personenpreise', 'data-personenpreise': true }, spalten),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-weg', 'aria-label': 'Staffel entfernen', onclick: () => { if (zeilenEl.children.length > 1) { el.remove(); halteErste() } } }, '−'))
return el
}
@@ -92,14 +113,33 @@ export async function zeichnePreise(ctx, unterkunft) {
}
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
const modusFeld = hakenFeld('personenmodus', modusBox, 'Preis hängt von der Personenzahl ab', 'Zum Beispiel 55 € für 1 Person, 70 € für 2 Personen und 82 € für 3 Personen pro Nacht.')
modusFeld.hidden = !modusMoeglich
modusBox.hidden = !modusMoeglich
const modusHinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': 'personenpreise' }, 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.')
const inklusiveFeld = feld('personenInklusive', 'Personen im Preis inklusive', inklusive)
const aufpreisFeld = feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis)
/** Haken an: je Staffel die Spalten je Personenzahl statt des einen Preises; die zwei Personenfelder des alten Modells verschwinden (ihre Werte gehen unveraendert mit). */
const zeigeModus = () => {
const an = modusMoeglich && modusBox.checked
for (const el of zeilenEl.querySelectorAll('.bk-preis-einzeln')) el.hidden = an
for (const el of zeilenEl.querySelectorAll('.bk-personenpreise')) el.hidden = !an
modusHinweis.hidden = !an
inklusiveFeld.hidden = an
aufpreisFeld.hidden = an
}
modusBox.addEventListener('change', zeigeModus)
const staffelDazu = () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })); zeigeModus() }
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
h('h4', null, 'Preis pro Nacht (Staffeln)'),
h('p', { class: 'sh-hinweis' }, 'Je länger der Aufenthalt, desto günstiger die Nacht: Die passende Staffel gilt für alle Nächte der Buchung.'),
modusFeld, modusHinweis,
zeilenEl, h('div', { class: 'sh-fehler', 'data-fehler': 'staffeln', role: 'alert' }),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })) } }, '+ Staffel'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: staffelDazu }, '+ Staffel'),
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
inklusiveFeld,
aufpreisFeld,
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
feld('steuerart', 'Steuer', steuerart), ustFelder,
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
@@ -109,6 +149,7 @@ export async function zeichnePreise(ctx, unterkunft) {
h('p', { class: 'sh-hinweis', 'data-hinweis': 'storno-muster' }, STORNO_HINWEIS)),
allgemeinerFehler(),
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
zeigeModus()
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
// Ein Feldfehler (z. B. Platzhalter in den Stornobedingungen) verschwindet, sobald im Feld getippt wird
for (const el of form.querySelectorAll('[data-feld]')) {
@@ -125,8 +166,16 @@ export async function zeichnePreise(ctx, unterkunft) {
const fehler = {}
const staffeln = []
let staffelFehler = ''
const personenModus = modusMoeglich && modusBox.checked
for (const z of zeilenEl.children) {
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
if (personenModus) {
const spalten = [...z.querySelectorAll('[data-staffel="person"]')].map((el) => el.inCent())
if (ab === null || ab < 1 || spalten.some((p) => p === null)) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und für jede Personenzahl einen Preis an.'
else if (spalten.some((p) => p < 100 || p > 100000)) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
else staffeln.push({ abNaechten: ab, preisProNachtCent: spalten[0], personenCent: spalten })
continue
}
const p = z.querySelector('[data-staffel="preis"]').inCent()
if (ab === null || ab < 1 || p === null) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
else if (p < 100 || p > 100000) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
@@ -141,8 +190,15 @@ export async function zeichnePreise(ctx, unterkunft) {
const zahl = (name, steuer, text, min, max) => { const n = ganzzahl(steuer); if (n === null || n < min || n > max) { fehler[name] = text; return null } return n }
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
const aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
// Im Personen-Modus sind "inklusive" und "Aufpreis" ausgeblendet: ihr Wert geht unveraendert mit; Unbrauchbares im unsichtbaren Feld bleibt ohne Fehlermeldung beim gespeicherten Wert
let aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
if (personenModus && (aufpreisCent === null || aufpreisCent > 100000)) aufpreisCent = Number.isInteger(preise.aufpreisPersonNachtCent) ? preise.aufpreisPersonNachtCent : 0
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
const inklusiveAlt = Number.isInteger(preise.personenInklusive) && preise.personenInklusive >= 1 && preise.personenInklusive <= 50 ? preise.personenInklusive : 2
const inklusiveNeu = ganzzahl(inklusive)
const inklusiveWert = personenModus
? (inklusiveNeu !== null && inklusiveNeu >= 1 && inklusiveNeu <= 50 ? inklusiveNeu : inklusiveAlt)
: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000)
const regel = steuerart.value === 'regel'
const ustWert = (name, steuer, alt) => {
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
@@ -155,7 +211,7 @@ export async function zeichnePreise(ctx, unterkunft) {
if (!fehler.stornobedingungen && /\[[^\]]*\]/.test(storn)) fehler.stornobedingungen = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'
const body = {
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000),
personenInklusive: inklusiveWert,
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50),
steuerart: steuerart.value,
ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7),
+5
View File
@@ -52,6 +52,11 @@
.bk-farbe { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--kb-border); display: inline-block; }
.bk-staffel { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.bk-staffel input { width: 90px; }
/* Preise je Personenzahl: ein Euro-Feld je Gaestezahl unter der Beschriftung; bricht auf dem Handy um, kein Seitwaertsscrollen */
.bk-personenpreise { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: flex-end; min-width: 0; max-width: 100%; }
.bk-personenpreise[hidden], .bk-preis-einzeln[hidden] { display: none; }
.bk-personpreis { display: grid; gap: 2px; font-size: 13px; font-weight: 600; }
.bk-personpreis > span { white-space: nowrap; }
.bk-vorschau { font-weight: 700; }
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
.bk-anleitung details { margin: 8px 0; }
+2 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { aboMemo } from '../../services/aboStatus.js'
import { validateTextform } from '../../services/analyse.js'
import { FUNKTION_CHANNELS, validateTextform } from '../../services/analyse.js'
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
import { withLocks } from '../../services/keyedLock.js'
@@ -139,7 +139,7 @@ async function textformFuerAktivierung({ body, customerId, key, doc }) {
if (!textformVerlangt[key](kunde?.email)) return null
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
const text = (wert) => (typeof wert === 'string' ? wert : '')
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) })
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key), channels: FUNKTION_CHANNELS })
}
/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
+5 -3
View File
@@ -19,6 +19,8 @@ const SITE_KEY_RE = /^[a-z0-9]{16,40}$/
const COLOR_RE = /^#[0-9a-f]{6}$/
const DEFAULT_COLOR = '#111111'
const CHANNELS = new Set(['chat', 'email'])
/** Wege der Zustimmung bei Funktionen (Terminplaner, Webshop, Buchungskalender): zusaetzlich WhatsApp (eine Nachricht dort ist Textform). */
export const FUNKTION_CHANNELS = new Set(['chat', 'email', 'whatsapp'])
export const MISSING_PRIVACY_MESSAGE =
'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
@@ -109,9 +111,9 @@ const berlinToday = (now) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europ
/**
* Zustimmung des Kunden in Textform (Mitarbeiter-Weg): Datum JJJJ-MM-TT (echt, nicht nach heute in Berlin,
* nicht vor der Fassung) und Weg chat/email. Wirft 400, sonst { date, channel }.
* nicht vor der Fassung) und Weg chat/email (channels: erlaubte Wege, Funktionen auch whatsapp). Wirft 400, sonst { date, channel }.
*/
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion } = {}) {
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion, channels = CHANNELS } = {}) {
const date = String(textform?.date ?? '')
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
const real = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toISOString().slice(0, 10) === date
@@ -119,7 +121,7 @@ export function validateTextform(textform, { today = berlinToday(new Date()), mi
if (date > today) throw httpError(400, 'Das Datum der Zustimmung liegt in der Zukunft')
if (date < minDate) throw httpError(400, `Das Datum der Zustimmung liegt vor der Fassung ${minDate}`)
const channel = String(textform?.channel ?? '')
if (!CHANNELS.has(channel)) throw httpError(400, 'Weg der Zustimmung fehlt: Chat oder E-Mail')
if (!channels.has(channel)) throw httpError(400, channels.has('whatsapp') ? 'Weg der Zustimmung fehlt: Chat, E-Mail oder WhatsApp' : 'Weg der Zustimmung fehlt: Chat oder E-Mail')
return { date, channel }
}
+2 -2
View File
@@ -614,7 +614,7 @@ async function assertAboLaeuftFuerAktivierung(customerId) {
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
}
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' }
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail', whatsapp: 'WhatsApp' }
/** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */
export const TEXTFORM_KEYS = ['termine', 'shop', 'unterkunft']
@@ -622,7 +622,7 @@ export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung :
/**
* Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im
* Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = textformFassung(key).
* Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' | 'whatsapp' } (validateTextform); fassung = textformFassung(key).
*/
export function textformNachweisText({ date, channel }, fassung, key = 'termine') {
const [jahr, monat, tag] = String(date).split('-')
+51 -6
View File
@@ -1,16 +1,31 @@
// 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]
/** Staffel: hoechste abNaechten <= naechte gilt fuer alle Naechte. */
function staffelPreis(staffeln, n) {
/** 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 ? best.preisProNachtCent : 0
return best
}
export function berechnePreis({ preise, von, bis, personen }) {
@@ -19,10 +34,23 @@ export function berechnePreis({ preise, von, bis, personen }) {
const satzUe = klein ? 0 : preise.ustUebernachtung
const satzRe = klein ? 0 : preise.ustReinigung
const posten = []
const nachtPreis = staffelPreis(preise.staffeln, n)
posten.push({ text: `Übernachtung, ${n} Nächte`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
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) - Number(preise.personenInklusive))
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`,
@@ -96,12 +124,29 @@ export function formatEuro(cent) {
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
+43 -4
View File
@@ -3,6 +3,7 @@ import { config } from '../../config.js'
import { istTag, naechte } from './datum.js'
import { AKTIVE_STATUS, ueberschneidet } from './belegung.js'
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
import { NACHT_CENT_MAX, NACHT_CENT_MIN, PERSONEN_PREISE_MAX, PERSONEN_PREISE_MIN } from './preis.js'
/**
* Buchungskalender, Verwaltung durch den Vermieter: Validierung der Eingaben (reine Funktionen, Rueckgabe { werte } oder
@@ -26,6 +27,8 @@ export const PLATZHALTER_TEXT = 'Bitte ersetze die Platzhalter in eckigen Klamme
export const MAX_UNTERKUENFTE = 20
export const MAX_ICAL_IMPORTE = 5
export const MAX_STAFFELN = 6
/** Groesse des Textfelds ukPreise.staffeln (schema.js); test/unterkunftVerwaltung.test.js vergleicht beide Werte. */
export const STAFFELN_MAX_ZEICHEN = 2000
// ---- Einstellungen
@@ -149,7 +152,12 @@ export function pruefeUnterkunft(eingabe, { partiell = false } = {}) {
// ---- Preise
/** Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6). */
/**
* Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6).
* Jede Staffel darf personenCent tragen (Preis pro Nacht fuer 1..k Personen, k = 2 bis 10): entweder alle Staffeln mit gleich
* vielen Werten oder keine (Modell "pauschal", unveraendert). Im Personen-Modus setzt der Server preisProNachtCent = personenCent[0]
* (eine mitgeschickte Zahl wird ueberschrieben).
*/
export function pruefePreise(eingabe) {
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
const fehler = {}
@@ -160,15 +168,42 @@ export function pruefePreise(eingabe) {
else {
const sauber = []
let letzte = 0
let personenAnzahl // undefined = noch keine Staffel gesehen, 0 = pauschal, sonst Laenge k von personenCent
for (const s of st) {
if (!s || typeof s !== 'object' || !ganz(s.abNaechten) || !ganz(s.preisProNachtCent)) {
if (!s || typeof s !== 'object' || !ganz(s.abNaechten)) {
fehler.staffeln = 'Ungültige Preisstufe.'
break
}
if (s.preisProNachtCent < 100 || s.preisProNachtCent > 100000) {
const hatPersonen = s.personenCent !== undefined && s.personenCent !== null
if (hatPersonen) {
const pc = s.personenCent
if (!Array.isArray(pc)) {
fehler.staffeln = 'Die Preise je Personenzahl sind ungültig.'
break
}
if (pc.length < PERSONEN_PREISE_MIN || pc.length > PERSONEN_PREISE_MAX) {
fehler.staffeln = `Der Preis je Personenzahl braucht ${PERSONEN_PREISE_MIN} bis ${PERSONEN_PREISE_MAX} Werte (für 1 bis ${PERSONEN_PREISE_MAX} Personen).`
break
}
if (!pc.every((c) => ganz(c) && c >= NACHT_CENT_MIN && c <= NACHT_CENT_MAX)) {
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
break
}
} else if (!ganz(s.preisProNachtCent)) {
fehler.staffeln = 'Ungültige Preisstufe.'
break
} else if (s.preisProNachtCent < NACHT_CENT_MIN || s.preisProNachtCent > NACHT_CENT_MAX) {
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
break
}
const anzahl = hatPersonen ? s.personenCent.length : 0
if (personenAnzahl !== undefined && anzahl !== personenAnzahl) {
fehler.staffeln = anzahl === 0 || personenAnzahl === 0
? 'Entweder haben alle Preisstufen Preise je Personenzahl oder keine.'
: 'Alle Preisstufen brauchen gleich viele Preise je Personenzahl.'
break
}
personenAnzahl = anzahl
if (sauber.length === 0 && s.abNaechten !== 1) {
fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.'
break
@@ -178,8 +213,12 @@ export function pruefePreise(eingabe) {
break
}
letzte = s.abNaechten
sauber.push({ abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent })
sauber.push(hatPersonen
? { abNaechten: s.abNaechten, preisProNachtCent: s.personenCent[0], personenCent: [...s.personenCent] }
: { abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent })
}
// Sicherung: das Textfeld ukPreise.staffeln fasst STAFFELN_MAX_ZEICHEN (schema.js); der Hoechstfall (6 x 10 Werte) braucht rund 800
if (!fehler.staffeln && JSON.stringify(sauber).length > STAFFELN_MAX_ZEICHEN) fehler.staffeln = 'Die Preisstufen sind zu umfangreich.'
if (!fehler.staffeln) werte.staffeln = sauber
}
const zahl = (k, min, max, label, std) => {
+4
View File
@@ -326,6 +326,10 @@ test('validateTextform: Datum, Zukunft, Fassung, Weg', () => {
bad(undefined)
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' })
assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' })
// WhatsApp nur, wenn der Aufrufer es erlaubt (Funktionen), nicht bei der Analyse
bad({ date: '2026-10-02', channel: 'whatsapp' })
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'whatsapp' }, { ...ctx, channels: an.FUNKTION_CHANNELS }), { date: '2026-10-02', channel: 'whatsapp' })
assert.throws(() => an.validateTextform({ date: '2026-10-02', channel: 'telefon' }, { ...ctx, channels: an.FUNKTION_CHANNELS }), { status: 400 })
})
test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => {
+203
View File
@@ -868,3 +868,206 @@ test('Preis-Editor: Platzhalter-Feldfehler verschwindet beim Tippen', mit(ukServ
await fx.setze(storno, 'Bis zu 14 Tage vor Anreise kostenfrei stornierbar, danach 100 %.')
assert.equal(fehlerText(fx, panel, 'stornobedingungen'), '')
}))
// ---- Preis-Editor: Preise je Personenzahl (staffeln[].personenCent), Haken #bk-p-personenmodus
const PERSONEN_PREISE = {
...PREISE,
staffeln: [
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
],
}
const PUT_PREISE = { 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }
/** Server mit anderen Preisen und/oder anderer Gaestezahl der Unterkunft. */
const personenServer = (preise, maxPersonen = 3, o = {}) => ukServer({
'GET /api/unterkunft/unterkuenfte': () => ({ body: { unterkuenfte: [{ ...UK, maxPersonen }] } }),
'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise } }),
...PUT_PREISE,
...o,
})
const zeilen = (fx, panel) => fx.alle(panel, '[data-staffel-zeile]')
const spalten = (fx, zeile) => fx.alle(zeile, '[data-staffel="person"]')
const putPreise = (fx) => posts(fx).filter((a) => a.url.includes('/preise')).at(-1)
const modusBox = (fx, panel) => fx.finde(panel, '#bk-p-personenmodus')
const sichtbar = (el) => { for (let e = el; e && e.hidden !== undefined; e = e.parentElement) if (e.hidden) return false; return true }
test('Preis-Editor Personen-Modus: gespeicherte personenCent laden (Haken an, k Felder je Staffel), Speichern sendet sie unverändert', mit(personenServer(PERSONEN_PREISE), async (fx) => {
const panel = await preisEditor(fx)
assert.equal(modusBox(fx, panel).checked, true)
assert.equal(zeilen(fx, panel).length, 2)
for (const [i, erwartet] of [['5500', '7000', '8200'], ['5000', '6500', '7700']].entries()) {
const felder = spalten(fx, zeilen(fx, panel)[i])
assert.equal(felder.length, 3)
assert.deepEqual(felder.map((f) => f.value), erwartet.map((c) => `${c.slice(0, -2)},${c.slice(-2)}`))
assert.deepEqual(felder.map((f) => f.dataset.personen), ['1', '2', '3'])
assert.ok(felder.every(sichtbar))
assert.equal(sichtbar(fx.finde(zeilen(fx, panel)[i], '[data-staffel="preis"]')), false, 'der eine Preis ist ausgeblendet')
}
const beschriftung = fx.text(zeilen(fx, panel)[0])
assert.match(beschriftung, /1 Person(?!en)/)
assert.match(beschriftung, /2 Personen/)
assert.match(beschriftung, /3 Personen/)
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true)
assert.equal(fx.text(fx.finde(panel, '[data-hinweis="personenpreise"]')), 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.')
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false)
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false)
assert.equal(await versuche(fx, panel), 1)
const put = putPreise(fx)
assert.deepEqual(put.body.staffeln, PERSONEN_PREISE.staffeln)
assert.equal(put.body.personenInklusive, 2, 'ausgeblendete Felder gehen unverändert mit')
assert.equal(put.body.aufpreisPersonNachtCent, 1000)
assert.equal(put.body.reinigungCent, 6000)
}))
test('Preis-Editor Personen-Modus: preisProNachtCent im Body ist personenCent[0]; Felder lassen sich ändern', mit(personenServer(PERSONEN_PREISE), async (fx) => {
const panel = await preisEditor(fx)
await fx.setze(spalten(fx, zeilen(fx, panel)[1])[0], '48,50')
await fx.setze(spalten(fx, zeilen(fx, panel)[1])[2], '75')
assert.equal(await versuche(fx, panel), 1)
assert.deepEqual(putPreise(fx).body.staffeln[1], { abNaechten: 7, preisProNachtCent: 4850, personenCent: [4850, 6500, 7500] })
}))
test('Preis-Editor Personen-Modus: Haken schaltet vom alten Modell um (k Felder aus maxPersonen, Hilfsfelder weg) und zurück', mit(personenServer(PREISE, 4), async (fx) => {
const panel = await preisEditor(fx)
const box = modusBox(fx, panel)
assert.equal(box.checked, false)
assert.equal(sichtbar(box), true)
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), true)
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), false)
assert.ok(zeilen(fx, panel).every((z) => sichtbar(fx.finde(z, '[data-staffel="preis"]')) && spalten(fx, z).every((f) => !sichtbar(f))))
await fx.klick(box)
assert.equal(box.checked, true)
for (const z of zeilen(fx, panel)) {
assert.equal(spalten(fx, z).length, 4, 'k = maxPersonen')
assert.ok(spalten(fx, z).every((f) => sichtbar(f) && f.value === ''), 'fehlende Werte bleiben leer')
assert.equal(sichtbar(fx.finde(z, '[data-staffel="preis"]')), false)
}
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false)
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false)
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true)
// neue Staffel hat die Felder ebenfalls
await fx.klick(aktion(fx, panel, 'staffel-dazu'))
assert.equal(spalten(fx, zeilen(fx, panel)[2]).length, 4)
assert.ok(spalten(fx, zeilen(fx, panel)[2]).every(sichtbar))
await fx.klick(fx.finde(zeilen(fx, panel)[2], '[data-aktion="staffel-weg"]'))
// zurück: alter Modus wie bisher, der eine Preis blieb erhalten
await fx.klick(box)
assert.equal(box.checked, false)
assert.equal(fx.finde(zeilen(fx, panel)[0], '[data-staffel="preis"]').value, '55,00')
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
assert.equal(await versuche(fx, panel), 1)
assert.deepEqual(putPreise(fx).body.staffeln, PREISE.staffeln)
assert.ok(putPreise(fx).body.staffeln.every((s) => !('personenCent' in s)))
}))
test('Preis-Editor Personen-Modus: Umschalten, alle Felder füllen, Speichern', mit(personenServer(PREISE, 3), async (fx) => {
const panel = await preisEditor(fx)
await fx.klick(modusBox(fx, panel))
assert.equal(await versuche(fx, panel), 0, 'leere Felder werden nicht gesendet')
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
const werte = [['55', '70', '82'], ['50', '65', '77']]
for (const [i, z] of zeilen(fx, panel).entries()) for (const [j, f] of spalten(fx, z).entries()) await fx.setze(f, werte[i][j])
assert.equal(await versuche(fx, panel), 1)
assert.deepEqual(putPreise(fx).body.staffeln, [
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
])
assert.equal(putPreise(fx).body.personenInklusive, 2)
assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000)
}))
test('Preis-Editor Personen-Modus: Client prüft Preisgrenzen und Staffeln wie der Server', mit(personenServer(PERSONEN_PREISE), async (fx) => {
const panel = await preisEditor(fx)
const zeile = (i) => zeilen(fx, panel)[i]
await fx.setze(spalten(fx, zeile(0))[1], '0,50')
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /1,00 € und 1\.000,00 €/)
await fx.setze(spalten(fx, zeile(0))[1], '1.000,01')
assert.equal(await versuche(fx, panel), 0)
await fx.setze(spalten(fx, zeile(0))[1], 'abc')
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
await fx.setze(spalten(fx, zeile(0))[1], '70')
await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '1')
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/)
await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '7')
assert.equal(await versuche(fx, panel), 1)
}))
test('Preis-Editor Personen-Modus: weniger gespeicherte Werte als Felder bleiben leer, überzählige werden verworfen', async () => {
const gespeichert = { ...PERSONEN_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] }] }
// Unterkunft jetzt für 4 Gäste: vierte Spalte leer
await mit(personenServer(gespeichert, 4), async (fx) => {
const panel = await preisEditor(fx)
assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00', '82,00', ''])
assert.equal(await versuche(fx, panel), 0)
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
})()
// Unterkunft jetzt für 2 Gäste: dritter Wert fällt weg
await mit(personenServer(gespeichert, 2), async (fx) => {
const panel = await preisEditor(fx)
assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00'])
assert.equal(await versuche(fx, panel), 1)
assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }])
})()
// mehr als 10 Gäste: höchstens 10 Felder
await mit(personenServer(gespeichert, 15), async (fx) => {
const panel = await preisEditor(fx)
assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 10)
assert.match(fx.text(zeilen(fx, panel)[0]), /10 Personen/)
})()
})
test('Preis-Editor Personen-Modus: bei einer Person Höchstzahl kein Haken, altes Modell, personenCent fällt weg', mit(personenServer(PERSONEN_PREISE, 1), async (fx) => {
const panel = await preisEditor(fx)
const box = modusBox(fx, panel)
assert.equal(box.checked, false)
assert.equal(sichtbar(box), false)
assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).length === 0 && sichtbar(fx.finde(z, '[data-staffel="preis"]'))))
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
assert.equal(await versuche(fx, panel), 1)
assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 7, preisProNachtCent: 5000 }])
}))
test('Preis-Editor Personen-Modus: nur lesen sperrt Haken und alle Felder', async () => {
await mit(personenServer(PERSONEN_PREISE), async (fx) => {
const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true })
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
assert.ok(modusBox(fx, panel).disabled)
assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 3)
assert.ok(fx.alle(panel, '.bk-preisform input, .bk-preisform select, .bk-preisform textarea, .bk-preisform button').every((el) => el.disabled), 'alles gesperrt')
assert.equal(aktion(fx, panel, 'preise-speichern'), null)
// Klick auf den gesperrten Haken ändert nichts
await fx.klick(modusBox(fx, panel))
assert.equal(modusBox(fx, panel).checked, true)
})()
// auch im alten Modell sperrt nur lesen den Haken
await mit(personenServer(PREISE, 3), async (fx) => {
const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true })
await fx.klick(aktion(fx, panel, 'bearbeiten'))
await fx.ruhe()
assert.ok(modusBox(fx, panel).disabled)
await fx.klick(modusBox(fx, panel))
assert.equal(modusBox(fx, panel).checked, false)
assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).every((f) => f.disabled)))
})()
})
test('Preis-Editor Personen-Modus: unlesbare Reste in den ausgeblendeten Feldern blockieren das Speichern nicht', mit(personenServer(PERSONEN_PREISE), async (fx) => {
const panel = await preisEditor(fx)
await fx.setze(fx.finde(panel, '#bk-p-inklusive'), 'xyz')
await fx.setze(fx.finde(panel, '#bk-p-aufpreis'), 'abc')
assert.equal(await versuche(fx, panel), 1)
assert.equal(putPreise(fx).body.personenInklusive, 2)
assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000)
}))
test('CSS: Felder je Personenzahl brechen um, ausgeblendete Teile bleiben unsichtbar', () => {
const css = readFileSync(new URL('../public/buchungen.css', import.meta.url), 'utf8')
assert.match(css, /\.bk-personenpreise\s*\{[^}]*flex-wrap:\s*wrap/)
assert.match(css, /\.bk-personenpreise\[hidden\][^{]*\{\s*display:\s*none/)
assert.match(css, /\.bk-preis-einzeln\[hidden\]/)
})
+18
View File
@@ -129,6 +129,24 @@ test('unterkunft textform: ohne Angaben 400 ohne Nachweis, mit Angaben 200; Nach
for (const a of k16[0].attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
})
test('unterkunft textform: Weg WhatsApp wird angenommen und im Nachweis genannt; unbekannter Weg 400', async (t) => {
t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails))
ukMit('pilot', ['whatsapp@example.invalid'])
const k = legeKunde({ email: 'whatsapp@example.invalid' })
const v = config.unterkunft.fassung
const telefon = await aktivieren(k, { textformDate: berlinTag(), textformChannel: 'telefon' })
assert.equal(telefon.status, 400)
assert.match(telefon.body.error, /Chat, E-Mail oder WhatsApp/)
assert.equal(consents(k.projectId).length, 0)
const tag = berlinTag()
const ok = await aktivieren(k, { textformDate: tag, textformChannel: 'whatsapp' })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(
leseZustimmung(consents(k.projectId)[0]).texte[0].text,
`Der Kunde hat am ${ttmmjjjj(tag)} per WhatsApp in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${v}) zustimmt.`
)
})
test('unterkunft textform entfällt: Modus off, schon aktiv, Abschalten; ungültige Datumsangaben 400', async (t) => {
t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails))
ukMit('off')
+144
View File
@@ -176,3 +176,147 @@ test('berechnePreis: 20 Personen ueber 548 Naechte besteht pruefePosten', () =>
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)
})
+137 -1
View File
@@ -24,7 +24,7 @@ const zahlung = await import('../server/services/unterkunft/zahlung.js')
const { UkFehler } = await import('../server/services/unterkunft/fehler.js')
const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { pruefeIcalUrl, pruefePreise, pruefeEinstellungen } = await import('../server/services/unterkunft/verwaltung.js')
const { pruefeIcalUrl, pruefePreise, pruefeEinstellungen, STAFFELN_MAX_ZEICHEN } = await import('../server/services/unterkunft/verwaltung.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
@@ -257,6 +257,128 @@ test('Preise: erste Staffel abNaechten:2, absteigend, mehr als 6, Preisgrenzen,
assert.ok(pruefePreise(PREISE).werte)
})
// Preise je Personenzahl (staffeln[].personenCent)
const MIMI_PREISE = {
...PREISE,
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: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer',
}
const mitStaffeln = (staffeln) => pruefePreise({ ...MIMI_PREISE, staffeln })
const einzelne = (personenCent, extra = {}) => mitStaffeln([{ abNaechten: 1, preisProNachtCent: personenCent?.[0] ?? 5500, personenCent, ...extra }])
test('pruefePreise: Preise je Personenzahl gueltig, personenCent wird sauber uebernommen', () => {
const r = pruefePreise(MIMI_PREISE)
assert.deepEqual(r.fehler, undefined)
assert.deepEqual(r.werte.staffeln, MIMI_PREISE.staffeln)
assert.equal(r.werte.staffeln[0].personenCent !== MIMI_PREISE.staffeln[0].personenCent, true, 'Kopie, nicht dasselbe Array')
// Grenzen: 2 und 10 Werte, 100 und 100000 Cent
assert.ok(einzelne([100, 100000]).werte)
assert.ok(einzelne(Array(10).fill(100000)).werte)
// unbekannte Felder in der Staffel fallen weg
const mitMuell = mitStaffeln([{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000], x: 1 }])
assert.deepEqual(mitMuell.werte.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }])
// alter Modus bleibt: kein personenCent im Ergebnis, auch bei null
const alt = pruefePreise(PREISE)
assert.deepEqual(alt.werte.staffeln, PREISE.staffeln)
assert.ok(alt.werte.staffeln.every((s) => !('personenCent' in s)))
const mitNull = mitStaffeln([{ abNaechten: 1, preisProNachtCent: 5500, personenCent: null }])
assert.deepEqual(mitNull.werte.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }])
})
test('pruefePreise: preisProNachtCent wird im Personen-Modus mit personenCent[0] ueberschrieben', () => {
const r = einzelne([5500, 7000, 8200], { preisProNachtCent: 9999 })
assert.equal(r.werte.staffeln[0].preisProNachtCent, 5500)
const ohne = mitStaffeln([{ abNaechten: 1, personenCent: [5500, 7000] }])
assert.equal(ohne.werte.staffeln[0].preisProNachtCent, 5500)
})
test('pruefePreise: personenCent ungueltig -> klare Meldung unter staffeln', () => {
const faelle = {
'Laenge 1': [5500],
'Laenge 11': Array(11).fill(5500),
'leer': [],
'Wert 99': [5500, 99],
'Wert 100001': [5500, 100001],
'Kommazahl': [5500, 7000.5],
'Text': [5500, '7000'],
'null': [5500, null],
'NaN': [5500, NaN],
'kein Array': 'viele',
'Objekt': { 0: 5500, 1: 7000 },
'Zahl': 5500,
}
for (const [name, wert] of Object.entries(faelle)) {
const r = einzelne(wert)
assert.ok(r.fehler?.staffeln, name)
assert.ok(/\S{3,}/.test(r.fehler.staffeln), name)
assert.equal(Object.keys(r.fehler).length, 1, `${name}: nur das Feld staffeln`)
}
assert.match(einzelne([5500]).fehler.staffeln, /2 bis 10/)
assert.match(einzelne([5500, 99]).fehler.staffeln, /1 € und 1\.000 €/)
})
test('pruefePreise: Staffeln mit unterschiedlich vielen oder teils fehlenden personenCent -> Fehler', () => {
const s = (ab, pc) => ({ abNaechten: ab, preisProNachtCent: 5000, ...(pc ? { personenCent: pc } : {}) })
assert.match(mitStaffeln([s(1, [5000, 6000, 7000]), s(7, [5000, 6000])]).fehler.staffeln, /gleich viele/)
assert.match(mitStaffeln([s(1, [5000, 6000, 7000]), s(7)]).fehler.staffeln, /alle Preisstufen/i)
assert.match(mitStaffeln([s(1), s(7, [5000, 6000, 7000])]).fehler.staffeln, /alle Preisstufen/i)
assert.ok(mitStaffeln([s(1, [5000, 6000]), s(7, [4500, 5500])]).werte)
})
test('pruefePreise: bestehende Staffel-Regeln gelten auch im Personen-Modus', () => {
const s = (ab, pc = [5500, 7000]) => ({ abNaechten: ab, preisProNachtCent: pc[0], personenCent: pc })
assert.match(mitStaffeln([s(2)]).fehler.staffeln, /ab 1 Nacht/)
assert.match(mitStaffeln([s(1), s(7), s(3)]).fehler.staffeln, /aufsteigend/)
assert.match(mitStaffeln([s(1), s(1)]).fehler.staffeln, /aufsteigend/)
assert.match(mitStaffeln([1, 2, 3, 4, 5, 6, 7].map((n) => s(n))).fehler.staffeln, /Höchstens 6/)
assert.ok(mitStaffeln([1, 2, 3, 4, 5, 6].map((n) => s(n))).werte)
assert.match(mitStaffeln([{ abNaechten: 1.5, personenCent: [5500, 7000] }]).fehler.staffeln, /Ungültige Preisstufe/)
})
test('pruefePreise: schlimmster Fall (6 Staffeln x 10 Werte x 100000, ab 365) passt in das Textfeld staffeln (2000 Zeichen)', async () => {
const { UNTERKUNFT_COLLECTIONS } = await import('../server/services/unterkunft/schema.js')
const groesse = UNTERKUNFT_COLLECTIONS.ukPreise.attributes.find((a) => a.key === 'staffeln').size
assert.equal(STAFFELN_MAX_ZEICHEN, groesse, 'Konstante und Schema stimmen ueberein')
const staffeln = [360, 361, 362, 363, 364, 365].map((ab, i) => ({ abNaechten: i === 0 ? 1 : ab, preisProNachtCent: 100000, personenCent: Array(10).fill(100000) }))
const r = mitStaffeln(staffeln)
assert.ok(r.werte, JSON.stringify(r.fehler))
assert.ok(JSON.stringify(r.werte.staffeln).length <= groesse, `${JSON.stringify(r.werte.staffeln).length} > ${groesse}`)
assert.ok(JSON.stringify(r.werte.staffeln).length < 1200, 'deutlich unter der Grenze')
})
test('Preise (HTTP): Personen-Modus speichern, lesen, Vorschau; preisProNachtCent wird ueberschrieben', async () => {
const r = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: MIMI_PREISE.staffeln.map((s) => ({ ...s, preisProNachtCent: 1234 })) })
assert.equal(r.status, 200, JSON.stringify(r.body))
assert.deepEqual(r.body.preise.staffeln, MIMI_PREISE.staffeln)
assert.deepEqual((await get('/unterkuenfte/u1/preise')).body.preise.staffeln, MIMI_PREISE.staffeln)
assert.deepEqual(JSON.parse(aw.docs('ukPreise').get('pr1').staffeln), MIMI_PREISE.staffeln)
const v = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-08', personen: 3 })
assert.equal(v.status, 200)
assert.equal(v.body.summen.bruttoCent, 7 * 7700 + 4000)
assert.equal(v.body.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
// zurueck in den alten Modus: ohne personenCent gespeichert
const alt = await put('/unterkuenfte/u1/preise', PREISE)
assert.equal(alt.status, 200)
assert.deepEqual(JSON.parse(aw.docs('ukPreise').get('pr1').staffeln), PREISE.staffeln)
})
test('Preise (HTTP): ungueltige personenCent -> 400 felder.staffeln, gespeicherte Preise bleiben', async () => {
const vorherText = aw.docs('ukPreise').get('pr1').staffeln
for (const pc of [[5500], Array(11).fill(5500), [5500, 99], [5500, 100001], [5500, 'x']]) {
const r = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: pc }] })
assert.equal(r.status, 400, JSON.stringify(pc))
assert.ok(r.body.felder.staffeln, JSON.stringify(pc))
}
const ungleich = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: [MIMI_PREISE.staffeln[0], { ...MIMI_PREISE.staffeln[1], personenCent: [5000, 6500] }] })
assert.equal(ungleich.status, 400)
assert.equal(aw.docs('ukPreise').get('pr1').staffeln, vorherText)
})
test('Preisvorschau: berechnePreis mit gespeicherten Preisen; ohne Preise 409; ungueltige Eingaben 400', async () => {
const r = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 3 })
assert.equal(r.status, 200)
@@ -384,6 +506,20 @@ test('Anfragen: Liste mit Filter und ohne Geheimnisse; Detail mit Vorschlag', as
assert.equal((await get('/anfragen/nix')).status, 404)
})
test('Anfragen im Personen-Modus: Vorschlag und Freigabe ohne Posten rechnen mit der Spalte der Gaestezahl', async () => {
aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...MIMI_PREISE, staffeln: JSON.stringify(MIMI_PREISE.staffeln) })
anfrage('a1', { status: 'neu', von: '2026-11-01', bis: '2026-11-08', personen: 3 })
const d = await get('/anfragen/a1')
assert.equal(d.status, 200)
assert.deepEqual(d.body.vorschlag.posten.map((p) => [p.text, p.menge, p.einzelCent]), [['Übernachtung, 7 Nächte, 3 Personen', 7, 7700], ['Endreinigung', 1, 4000]])
assert.equal(d.body.vorschlag.summen.bruttoCent, 57900)
// Freigabe ohne eigene Posten uebernimmt die berechneten
const f = await post('/anfragen/a1/freigeben', {})
assert.equal(f.status, 200, JSON.stringify(f.body))
assert.equal(f.body.anfrage.summen.bruttoCent, 57900)
assert.equal(f.body.anfrage.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
})
test('Freigeben: Posten + Frist, Antwort mit buchungsLink (Token passt zum Hash), nie tokenHash; Anfrage freigegeben', async () => {
anfrage('a1')
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 5, nachricht: 'Bis bald' })