Buchungskalender: Stornobedingungen je Unterkunft, Zustimmung und Bestellstatus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:26:22 +00:00
1 parent efeb99e214
commit 05006f1480
5 files changed
+123 -29

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@ export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis =
} catch (err) {
zeigeFehler(form, fehlerFuerFormular(err))
if (err?.status === 409 && typeof nachSenden === 'function') {
try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ }
try { await nachSenden(null, { meldung: fehlerFuerFormular(err).message }) } catch { /* der Fehler steht schon am Formular */ }
return
}
}
+5
View File
@@ -6,6 +6,8 @@
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.'
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 }
@@ -81,6 +83,9 @@ export async function zeichnePreise(ctx, unterkunft) {
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
+24 -19
View File
@@ -1,18 +1,17 @@
/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung,
Bankverbindung, Akzentfarbe, Stornobedingungen (Mustertext), Stripe-Konto und Online-Zahlung.
Bankverbindung, Akzentfarbe, Stripe-Konto und Online-Zahlung.
API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon,
steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag, stornobedingungen },
steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag },
stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean };
PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt auf dem Server);
PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt, false widerruft);
POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }.
Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */
import { abschnitt, allgemeinerFehler, feld, h, hakenFeld, setzeFehler } from './shop-dom.js'
import { abschnitt, allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler } from './shop-dom.js'
import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.'
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).'
export const AUFTRAG_WIDERRUF = 'Ohne diese Zustimmung können keine Buchungen bestätigt werden. Zustimmung wirklich zurückziehen?'
export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.'
const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.'
const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' }
@@ -44,23 +43,31 @@ export async function render(panel, ctx = {}) {
const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE'
if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') }
const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true
const iban = textfeld({ maxlength: 40 }); iban.value = zeichen(e.iban)
const iban = textfeld({ maxlength: 34 }); iban.value = zeichen(e.iban)
const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic)
const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank)
const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111'
const storno = h('textarea', { rows: 6, maxlength: 5000 }); storno.value = zeichen(e.stornobedingungen)
const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv'
const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT)
const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked }
auftrag.addEventListener('change', zeigeAuftrag)
// Widerruf einer gespeicherten Zustimmung nur nach Rueckfrage in der Seite
const widerrufPlatz = h('div', { 'data-widerruf-platz': '' })
auftrag.addEventListener('change', () => {
if (!auftrag.checked && e.rechnungAuftrag === true && !widerrufPlatz.firstChild) {
auftrag.checked = true
const zu = () => { widerrufPlatz.replaceChildren(); auftrag.focus() }
const box = frage('Zustimmung zurückziehen', h('p', null, AUFTRAG_WIDERRUF),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'widerruf-ja', onclick: () => { auftrag.checked = false; zeigeAuftrag(); zu() } }, 'Ja, zurückziehen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'widerruf-nein', onclick: zu }, 'Abbrechen')))
widerrufPlatz.replaceChildren(box)
box.focus()
}
zeigeAuftrag()
})
zeigeAuftrag()
const musterKnopf = h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster',
onclick: () => { storno.value = STORNO_MUSTER },
}, 'Mustertext übernehmen')
/* ------------------------------------------------------------------ Stripe --- */
let beschaeftigt = false
async function stripeAktion(pfad, { weiter = false } = {}) {
@@ -116,15 +123,13 @@ export async function render(panel, ctx = {}) {
? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.'
: '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'),
hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT),
auftragHinweis),
auftragHinweis,
widerrufPlatz),
abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.',
feld('iban', 'IBAN', iban),
feld('bic', 'BIC', bic),
feld('bank', 'Name der Bank', bank)),
abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')),
abschnitt('Stornobedingungen', 'Sie stehen auf der Buchungsseite und gelten für neue Freigaben.',
feld('stornobedingungen', 'Stornobedingungen', storno, STORNO_HINWEIS),
h('div', { class: 'sh-aktionen' }, musterKnopf)),
status === 'aktiv'
? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.'))
: null,
@@ -149,7 +154,7 @@ export async function render(panel, ctx = {}) {
email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(),
iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(),
rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p,
rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value, stornobedingungen: storno.value.trim(),
rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value,
onlineZahlung: status === 'aktiv' && online.checked,
},
}
+14 -2
View File
@@ -5,6 +5,7 @@
Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { statusKarte } from './funktion-bestellen.js'
import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
@@ -74,6 +75,7 @@ function projektwahl(s) {
select.addEventListener('change', () => {
auswahl.projectId = select.value
s.projekt = s.projects.find((p) => p.projectId === select.value)
s.neueAnfrage = false
zeigeMeldung(s, '')
zeigeProjekt(s)
})
@@ -113,6 +115,7 @@ function zeichneAnsicht(s, { status, aktuell }) {
s.status = status
const zustand = status?.state || zustandVon(s.projekt)
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
else if (zustand === 'ordered') zeigeBestellung(s)
else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell })
else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.'))
}
@@ -121,7 +124,8 @@ function zeichneAnsicht(s, { status, aktuell }) {
function zeigeAnfrage(s, { status, aktuell }) {
const projectId = s.projekt.projectId
const ansicht = status?.anfrage
const neuLaden = () => zeigeProjekt(s)
// nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt
const neuLaden = async (o) => { await zeigeProjekt(s); if (o?.meldung && S === s) zeigeMeldung(s, o.meldung, 'fehler') }
if (ansicht?.status === 'angenommen') {
s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true }))
} else if (ansicht?.status === 'offen') {
@@ -135,11 +139,19 @@ function zeigeAnfrage(s, { status, aktuell }) {
projectId,
vorgabe: ansicht?.anfrage || null,
preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €',
nachSenden: async () => { s.neueAnfrage = false; if (aktuell()) await neuLaden() },
nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) },
})
}
}
/* Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“ (generische Bestellseite, Checkout /api/funktionen/unterkunft/checkout). */
function zeigeBestellung(s) {
const eintrag = { ...s.projekt.features?.unterkunft, state: 'ordered' }
s.buehne.replaceChildren(statusKarte({ projekt: s.projekt, bestellung: eintrag, kundenansicht: false, key: 'unterkunft', name: 'Buchungskalender' }, {
weiter: async ({ meldung }) => { await zeigeProjekt(s); if (S === s) zeigeMeldung(s, meldung, 'fehler') },
}))
}
function zeigeReiter(s, { status, aktuell, zustand }) {
const neuLaden = async () => {
const neu = await ladeStatus(s.projekt.projectId)
+79 -7
View File
@@ -470,7 +470,8 @@ test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), asyn
/* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */
const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js')
const { render: renderEinstellungen, STORNO_MUSTER, STORNO_HINWEIS, AUFTRAG_TEXT } = await import('../public/buchungen-tab-einstellungen.js')
const { STORNO_MUSTER, STORNO_HINWEIS } = await import('../public/buchungen-preise.js')
const { render: renderEinstellungen, AUFTRAG_TEXT, AUFTRAG_WIDERRUF } = await import('../public/buchungen-tab-einstellungen.js')
const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js')
const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js')
@@ -482,7 +483,7 @@ const zeigeMit = async (fx, modul, ctxExtra = {}) => {
await fx.ruhe()
return { panel, ctx }
}
const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true, stornobedingungen: '' }
const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true }
const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o })
test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({
@@ -561,18 +562,43 @@ test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen ex
})()
})
test('Einstellungen: Mustertext übernehmen füllt die Stornobedingungen, Hinweis „keine Rechtsberatung“', mit(esServer(), async (fx) => {
test('Einstellungen: keine Stornobedingungen mehr; IBAN maxlength 34', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const t = fx.finde(panel, '[data-feld="stornobedingungen"]')
assert.equal(t.value, '')
assert.match(fx.text(t.parentNode), new RegExp(STORNO_HINWEIS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.equal(fx.finde(panel, '[data-feld="stornobedingungen"]'), null)
assert.equal(fx.finde(panel, '[data-feld="iban"]').getAttribute('maxlength'), '34')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal('stornobedingungen' in posts(fx)[0].body, false)
}))
test('Preis-Editor: Mustertext übernehmen füllt nur das Feld (kein Speichern), Hinweis „keine Rechtsberatung“', mit(ukServer(), async (fx) => {
const panel = await preisEditor(fx)
const t = fx.finde(panel, '#bk-p-storno')
assert.match(fx.text(fx.finde(panel, '[data-hinweis="storno-muster"]')), /Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung\./)
assert.equal(STORNO_HINWEIS, 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.')
const vorher = posts(fx).length
await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]'))
assert.equal(t.value, STORNO_MUSTER)
assert.match(STORNO_MUSTER, /^Stornierung bis 14 Tage vor Anreise: kostenlos\./)
assert.match(STORNO_MUSTER, /Maßgeblich ist der Eingang bei uns\.$/)
assert.equal(posts(fx).length, vorher)
}))
test('Einstellungen: Zustimmung zurückziehen fragt nach; Abbrechen behält, Ja sendet rechnungAuftrag:false', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]')
await fx.klick(box)
assert.ok(box.checked)
assert.match(fx.text(panel), new RegExp(AUFTRAG_WIDERRUF.slice(0, 50)))
await fx.klick(fx.finde(panel, '[data-aktion="widerruf-nein"]'))
assert.ok(box.checked)
assert.equal(fx.finde(panel, '[data-aktion="widerruf-ja"]'), null)
await fx.klick(box)
await fx.klick(fx.finde(panel, '[data-aktion="widerruf-ja"]'))
assert.equal(box.checked, false)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.stornobedingungen, STORNO_MUSTER)
assert.equal(posts(fx)[0].body.rechnungAuftrag, false)
}))
test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => {
@@ -708,3 +734,49 @@ test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wun
})()
vergissAuswahl()
})
test('Ansicht #buchungen: Zustand ordered zeigt „Zahlung offen“ mit Zahlung fortsetzen (Checkout unterkunft)', async () => {
vergissAuswahl()
await mit((a) => {
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'ordered' } } }] } }
if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'ordered' } }
if (a.url === '/api/funktionen/unterkunft/checkout') return { status: 409, body: { error: 'Bitte stimme zu.', code: 'andere' } }
return { body: {} }
}, async (fx) => {
const root = h('div')
fx.document.body.append(root)
await initBuchungen(root)
await fx.ruhe()
assert.match(fx.text(root), /Zahlung offen/)
await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]'))
await fx.ruhe()
assert.ok(posts(fx).some((a) => a.url === '/api/funktionen/unterkunft/checkout'))
})()
vergissAuswahl()
})
test('Anfrageformular: 409 lädt die Ansicht neu, die Meldung bleibt sichtbar; Projektwechsel setzt „neue Anfrage“ zurück', async () => {
vergissAuswahl()
let status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } }
await mit((a) => {
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } }
if (a.url.startsWith('/api/unterkunft/status')) return { body: status }
if (a.method === 'POST') { status = { state: 'off', anfrage: { status: 'offen', anfrage: {} } }; return { status: 409, body: { fehler: 'anfrage_laeuft' } } }
return { body: {} }
}, async (fx) => {
const root = h('div')
fx.document.body.append(root)
await initBuchungen(root)
await fx.ruhe()
await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]'))
await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.ok(fx.finde(root, '[data-anfrage="offen"]'))
assert.match(fx.text(fx.finde(root, '.sh-meldung')), /nicht geklappt/)
status = { state: 'off', anfrage: { status: 'abgelehnt', begruendung: 'Nein' } }
await fx.setze(fx.finde(root, '#buchungen-project'), 'p2')
await fx.ruhe()
assert.ok(fx.finde(root, '[data-anfrage="abgelehnt"]'))
})()
vergissAuswahl()
})