186 lines
14 KiB
JavaScript
186 lines
14 KiB
JavaScript
/* 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,
|
||
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). */
|
||
|
||
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, 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.
|
||
export const STORNO_MUSTER = [
|
||
"1. Stornierung durch den Gast. Eine Stornierung der Buchung ist nur in Textform möglich (E-Mail an [E-Mail-Adresse des Vermieters]) und wird erst mit unserer Bestätigung wirksam; wir stornieren die Buchung in unserem System und senden Ihnen eine Stornorechnung. Maßgeblich für die Frist ist der Eingang Ihrer Nachricht bei uns.",
|
||
"",
|
||
"2. Kosten der Stornierung. Bei einer Stornierung berechnen wir folgenden Anteil des Gesamtpreises (ohne Endreinigung, soweit der Aufenthalt nicht angetreten wird):",
|
||
"- bis [30] Tage vor der Anreise: kostenfrei,",
|
||
"- [29] bis [14] Tage vor der Anreise: [30] Prozent,",
|
||
"- [13] bis [7] Tage vor der Anreise: [60] Prozent,",
|
||
"- ab [6] Tage vor der Anreise und bei Nichtanreise: [90] Prozent.",
|
||
"",
|
||
"Ihnen bleibt der Nachweis gestattet, dass uns kein oder ein geringerer Schaden entstanden ist. Uns bleibt unser Anspruch unberührt, wenn wir die Unterkunft anderweitig vermieten können: In diesem Fall berechnen wir nur den tatsächlichen Ausfall.",
|
||
"",
|
||
"3. Erstattung. Haben Sie bereits online bezahlt, erstatten wir den Teil des Preises, der nach Nr. 2 nicht anfällt, auf das Zahlungsmittel, mit dem Sie bezahlt haben. Die Erstattung geht über unser Stripe-Konto; die Gutschrift auf Ihrem Konto kann einige Tage dauern. Haben Sie noch nicht bezahlt, zahlen Sie den nach Nr. 2 anfallenden Betrag vor Ort oder nach Rechnung.",
|
||
"",
|
||
"4. Verspätete Anreise und vorzeitige Abreise. Reisen Sie später an oder früher ab, besteht kein Anspruch auf Erstattung des Preises für die nicht genutzten Nächte, es sei denn, wir können die Unterkunft anderweitig vermieten.",
|
||
"",
|
||
"5. Stornierung durch uns. Können wir die Unterkunft aus einem Grund, den wir zu vertreten haben, nicht bereitstellen, stornieren wir die Buchung, bieten nach Möglichkeit eine gleichwertige Unterkunft an und erstatten bereits gezahlte Beträge vollständig.",
|
||
"",
|
||
"6. Kein Widerrufsrecht. Für die Buchung zu einem bestimmten Zeitraum besteht kein gesetzliches Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Diese Stornobedingungen gelten stattdessen.",
|
||
"",
|
||
"7. Reiserücktrittsversicherung. Wir empfehlen Ihnen eine Reiserücktrittsversicherung.",
|
||
].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 ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
|
||
|
||
export async function zeichnePreise(ctx, unterkunft) {
|
||
const basis = `/api/unterkunft/unterkuenfte/${enc(unterkunft.id)}`
|
||
const q = `projectId=${enc(ctx.projectId)}`
|
||
const wurzel = h('div', { class: 'bk-preise' }, h('p', { class: 'kb-empty' }, 'Lade…'))
|
||
let preise = STANDARD
|
||
try {
|
||
const daten = await ctx.request(`${basis}/preise?${q}`)
|
||
if (daten?.preise) preise = { ...STANDARD, ...daten.preise }
|
||
} catch (err) {
|
||
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
|
||
return wurzel
|
||
}
|
||
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 ',
|
||
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
|
||
}
|
||
const halteErste = () => { const ab = zeilenEl.querySelector('[data-staffel="ab"]'); if (ab) { ab.value = '1'; ab.readOnly = true; ab.setAttribute('readonly', '') } }
|
||
zeilenEl.append(...(preise.staffeln?.length ? preise.staffeln : STANDARD.staffeln).map(zeile))
|
||
halteErste()
|
||
const reinigung = euroEingabe({ cent: preise.reinigungCent, id: 'bk-p-reinigung' })
|
||
const inklusive = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-inklusive' }); inklusive.value = String(preise.personenInklusive ?? 2)
|
||
const aufpreis = euroEingabe({ cent: preise.aufpreisPersonNachtCent, id: 'bk-p-aufpreis' })
|
||
const rabatt = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-rabatt' }); rabatt.value = String(preise.direktrabattProzent ?? 0)
|
||
const steuerart = h('select', { id: 'bk-p-steuerart' }, [['kleinunternehmer', 'Kleinunternehmer (keine Umsatzsteuer)'], ['regel', 'Regelbesteuerung (mit Umsatzsteuer)']].map(([w, t]) => h('option', { value: w }, t)))
|
||
steuerart.value = preise.steuerart === 'regel' ? 'regel' : 'kleinunternehmer'
|
||
const ustUe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-ue' }); ustUe.value = String(preise.ustUebernachtung ?? 7)
|
||
const ustRe = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-ust-re' }); ustRe.value = String(preise.ustReinigung ?? 7)
|
||
const frist = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-frist' }); frist.value = String(preise.fristTage ?? 3)
|
||
const storno = h('textarea', { id: 'bk-p-storno', rows: '6', maxlength: '5000' }); storno.value = zeichen(preise.stornobedingungen)
|
||
const ustFelder = h('div', { class: 'bk-ust' }, feld('ustUebernachtung', 'USt Übernachtung in %', ustUe), feld('ustReinigung', 'USt Endreinigung in %', ustRe))
|
||
const zeigeUst = () => { ustFelder.hidden = steuerart.value !== 'regel' }
|
||
steuerart.addEventListener('change', zeigeUst)
|
||
zeigeUst()
|
||
|
||
const vorschauText = h('p', { class: 'bk-vorschau', 'data-vorschau': true, role: 'status' })
|
||
const naechteEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vn' }); naechteEl.value = '7'
|
||
const personenEl = h('input', { type: 'text', inputmode: 'numeric', id: 'bk-p-vp' }); personenEl.value = '2'
|
||
async function vorschau() {
|
||
const naechte = ganzzahl(naechteEl)
|
||
const personen = ganzzahl(personenEl)
|
||
if (!naechte || !personen) { vorschauText.textContent = 'Gib Nächte und Personen als ganze Zahlen an.'; return }
|
||
const von = plusTage(heuteBerlin(), 60)
|
||
try {
|
||
const antwort = await ctx.request(`/api/unterkunft/preisvorschau?${q}`, { method: 'POST', body: { unterkunftId: unterkunft.id, von, bis: plusTage(von, naechte), personen } })
|
||
if (!ctx.aktuell()) return
|
||
vorschauText.textContent = Number.isInteger(antwort?.summen?.bruttoCent)
|
||
? `Beispiel: ${antwort.naechte ?? naechte} ${(antwort.naechte ?? naechte) === 1 ? 'Nacht' : 'Nächte'}, ${personen} ${personen === 1 ? 'Person' : 'Personen'} = ${euroText(antwort.summen.bruttoCent)}`
|
||
: 'Für diese Angaben gibt es noch keine Beispielrechnung.'
|
||
} catch (err) {
|
||
vorschauText.textContent = Object.values(err.body?.felder || {})[0] || 'Die Beispielrechnung klappt erst, wenn die Preise gespeichert sind.'
|
||
}
|
||
}
|
||
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
|
||
|
||
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.'),
|
||
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'),
|
||
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
|
||
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
|
||
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
|
||
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.'),
|
||
feld('stornobedingungen', 'Stornobedingungen', storno, 'Pflicht, bevor du den Buchungskalender freischaltest. Sie stehen auf der Buchungsseite.'),
|
||
ctx.nurLesen ? null : h('div', { class: 'bk-aktionen' },
|
||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster', onclick: () => { storno.value = STORNO_MUSTER } }, 'Mustertext übernehmen'),
|
||
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')))
|
||
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]')) {
|
||
el.addEventListener('input', () => {
|
||
const name = el.getAttribute('data-feld')
|
||
const ziel = form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
|
||
if (ziel) ziel.textContent = ''
|
||
el.removeAttribute('aria-invalid')
|
||
})
|
||
}
|
||
|
||
async function speichern(ev) {
|
||
const knopf = ev.currentTarget
|
||
const fehler = {}
|
||
const staffeln = []
|
||
let staffelFehler = ''
|
||
for (const z of zeilenEl.children) {
|
||
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
|
||
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.'
|
||
else staffeln.push({ abNaechten: ab, preisProNachtCent: p })
|
||
}
|
||
if (!staffelFehler) {
|
||
if (staffeln.length < 1 || staffeln.length > 6) staffelFehler = 'Du kannst 1 bis 6 Staffeln anlegen.'
|
||
else if (staffeln[0].abNaechten !== 1) staffelFehler = 'Die erste Staffel beginnt bei 1 Nacht.'
|
||
else if (staffeln.some((s, i) => i > 0 && s.abNaechten <= staffeln[i - 1].abNaechten)) staffelFehler = 'Die Nächte der Staffeln müssen von oben nach unten größer werden, keine Zahl doppelt.'
|
||
}
|
||
if (staffelFehler) fehler.staffeln = staffelFehler
|
||
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()
|
||
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
|
||
const regel = steuerart.value === 'regel'
|
||
const ustWert = (name, steuer, alt) => {
|
||
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
|
||
const n = ganzzahl(steuer)
|
||
if (![0, 7, 19].includes(n)) { fehler[name] = 'Erlaubt sind 0, 7 oder 19 Prozent.'; return null }
|
||
return n
|
||
}
|
||
const storn = String(storno.value).trim()
|
||
if (storn.length < 20 || storn.length > 5000) fehler.stornobedingungen = 'Die Stornobedingungen brauchen 20 bis 5000 Zeichen.'
|
||
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),
|
||
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),
|
||
ustReinigung: ustWert('ustReinigung', ustRe, preise.ustReinigung ?? 7),
|
||
fristTage: zahl('fristTage', frist, 'Bitte gib eine ganze Zahl von 1 bis 14 an.', 1, 14),
|
||
stornobedingungen: storn,
|
||
}
|
||
if (Object.keys(fehler).length) return zeigeFehler(form, { message: '', fehler })
|
||
knopf.disabled = true
|
||
try {
|
||
await ctx.request(`${basis}/preise?${q}`, { method: 'PUT', body })
|
||
if (!ctx.aktuell()) return
|
||
setzeFehler(form, {})
|
||
ctx.zeigeMeldung('Die Preise sind gespeichert.', 'ok')
|
||
await vorschau()
|
||
} catch (err) {
|
||
zeigeApiFehler(form, err)
|
||
}
|
||
knopf.disabled = false
|
||
}
|
||
|
||
wurzel.replaceChildren(form,
|
||
abschnitt('Beispielrechnung', 'So rechnet dein Gast. Sie nutzt die gespeicherten Preise.',
|
||
h('div', { class: 'bk-vorschaufelder' }, feld('naechte', 'Nächte', naechteEl), feld('personen', 'Personen', personenEl)), vorschauText))
|
||
vorschau()
|
||
return wurzel
|
||
}
|