From 32cffb35189cca215093b5fde628057b70fab924 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:30:20 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Verwaltungs-API=20=E2=80=93?= =?UTF-8?q?=20Einstellungen,=20Zustimmung,=20Online-Zahlung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/unterkunft.js | 43 +++++++++++--------- server/services/unterkunft/ablauf.js | 7 ++++ test/unterkunftAblauf.test.js | 2 +- test/unterkunftBuchung.test.js | 2 +- test/unterkunftVerwaltung.test.js | 60 ++++++++++++++++++++++++---- 5 files changed, 87 insertions(+), 27 deletions(-) diff --git a/server/routes/unterkunft.js b/server/routes/unterkunft.js index c94dfb7..8eb3269 100644 --- a/server/routes/unterkunft.js +++ b/server/routes/unterkunft.js @@ -238,45 +238,53 @@ router.get('/status', route(async (req, res) => { antwort.onlineZahlung = einst?.onlineZahlung === true antwort.unterkuenfte = unterkuenfte.length antwort.rechnungenVorhanden = rechnungen.length > 0 - for (const u of unterkuenfte) { - const p = await sp.ladePreise(u.$id) - if (p?.steuerart) { - antwort.steuerart = p.steuerart - break - } - } + const preise = await Promise.all(unterkuenfte.map((u) => sp.ladePreise(u.$id))) + const mitArt = preise.find((p) => p?.steuerart) + if (mitArt) antwort.steuerart = mitArt.steuerart res.json(antwort) })) // ---- Einstellungen -function einstellungenAntwort(einst) { +async function einstellungenAntwort(ctx, einst) { const e = einst ?? {} const ansicht = Object.fromEntries(vw.EINSTELLUNG_FELDER.map((k) => [k, e[k] ?? ''])) ansicht.onlineZahlung = e.onlineZahlung === true - ansicht.rechnungsPraefixFrei = vw.zaehlerLeer(e) - ansicht.stripe = { status: stripeZustand(e) } - return { einstellungen: ansicht, vollstaendig: vw.pruefeEinstellungen({}, e).werte !== undefined } + ansicht.rechnungAuftrag = Boolean(e.rechnungAuftragAt) + const [shop, rechnungen] = await Promise.all([shopMitKonto(ctx.customerId, ctx.projectId), sp.listeRechnungen(ctx.featureId)]) + return { + einstellungen: ansicht, + stripe: { status: stripeZustand(e), vorhandenesShopKonto: Boolean(shop) }, + rechnungenVorhanden: rechnungen.length > 0, + } } router.get('/einstellungen', route(async (req, res) => { const ctx = await mitProjekt(req) - res.json(einstellungenAntwort(await sp.ladeEinstellungen(ctx.featureId))) + res.json(await einstellungenAntwort(ctx, await sp.ladeEinstellungen(ctx.featureId))) })) router.put('/einstellungen', ...schreib, route(async (req, res) => { const ctx = await mitProjekt(req, { schreiben: true }) const eingabe = body(req).einstellungen && typeof body(req).einstellungen === 'object' ? body(req).einstellungen : body(req) - const neu = await withLocks(['uk-einst:' + ctx.featureId], async () => { + const neu = await withLocks(['uk-einst:' + ctx.featureId, 'uk-rechnungsnr:' + ctx.featureId], async () => { const vorher = (await sp.ladeEinstellungen(ctx.featureId)) ?? {} - const r = vw.pruefeEinstellungen(eingabe, vorher) + const { rechnungAuftrag, ...rest } = eingabe + if (rechnungAuftrag !== undefined && typeof rechnungAuftrag !== 'boolean') throw ungueltig({ rechnungAuftrag: 'Ungültige Eingabe.' }) + const r = vw.pruefeEinstellungen(rest, vorher) if (r.fehler) throw ungueltig(r.fehler) + if (r.werte.onlineZahlung === true && stripeZustand(vorher) !== 'aktiv') { + throw new UkFehler(409, 'stripe_nicht_aktiv', { onlineZahlung: 'Online-Zahlung ist erst möglich, wenn dein Stripe-Konto aktiv ist.' }) + } if (r.werte.rechnungsPraefix !== undefined && r.werte.rechnungsPraefix !== (vorher.rechnungsPraefix ?? '') && !vw.zaehlerLeer(vorher)) { throw new UkFehler(409, 'praefix_gesperrt', { rechnungsPraefix: 'Das Präfix kann nach der ersten Rechnung nicht mehr geändert werden.' }) } - return sp.speichereEinstellungen(ctx.featureId, r.werte) + const werte = { ...r.werte } + if (rechnungAuftrag === true && !vorher.rechnungAuftragAt) werte.rechnungAuftragAt = new Date().toISOString() + if (rechnungAuftrag === false) werte.rechnungAuftragAt = null + return sp.speichereEinstellungen(ctx.featureId, werte) }) - res.json(einstellungenAntwort(neu)) + res.json(await einstellungenAntwort(ctx, neu)) })) // ---- Unterkuenfte @@ -535,9 +543,8 @@ router.get('/rechnungen', route(async (req, res) => { art: r.art ?? 'rechnung', datum: r.datum ?? '', bruttoCent: r.bruttoCent ?? 0, - anfrageId: r.anfrageId ?? '', anfrageNummer: r.inhalt?.anfrageNummer ?? '', - empfaenger: r.inhalt?.empfaenger?.firma || r.inhalt?.empfaenger?.name || '', + gastName: r.inhalt?.empfaenger?.firma || r.inhalt?.empfaenger?.name || '', })), }) })) diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index b8c2d5d..66548f2 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -49,6 +49,11 @@ function fuerFeature(ctx, a) { return a } +/** Ohne Rechnungsauftrag (Zustimmung des Vermieters) darf nichts freigegeben oder verbindlich gebucht werden. */ +function brauchtZustimmung(ctx) { + if (!ctx?.einstellungen?.rechnungAuftragAt) throw new UkFehler(409, 'zustimmung_fehlt') +} + const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '') const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ @@ -115,6 +120,7 @@ export async function legeAnfrageAn(ctx, eingabe, { now = new Date(), ipHash = ' export async function gibFrei(ctx, anfrageId, { posten, fristTage, nachricht, now = new Date() } = {}) { const pre = fuerFeature(ctx, await ladeAnfrage(anfrageId)) + brauchtZustimmung(ctx) const ergebnis = await withLocks(withLocksKey(pre.unterkunftId), async () => { const a = fuerFeature(ctx, await ladeAnfrage(anfrageId)) if (a.status !== 'neu') throw new UkFehler(409, 'status_ungueltig') @@ -192,6 +198,7 @@ export async function bucheVerbindlich(ctx, anfrageId, token, { rechnungsadresse if (a.status === 'gebucht' || a.status === 'bezahlt') { return { anfrage: a, rechnung: a.rechnungId ? await ladeRechnung(a.rechnungId) : null, neu: false } } + brauchtZustimmung(ctx) if (a.status === 'abgelaufen') throw new UkFehler(410, 'frist_abgelaufen') if (a.status !== 'freigegeben') throw new UkFehler(409, 'status_ungueltig') if (!(Date.parse(a.fristAt) > ms(now))) throw new UkFehler(410, 'frist_abgelaufen') diff --git a/test/unterkunftAblauf.test.js b/test/unterkunftAblauf.test.js index ca53a3d..9ee19e4 100644 --- a/test/unterkunftAblauf.test.js +++ b/test/unterkunftAblauf.test.js @@ -21,7 +21,7 @@ const { istFrei } = await import('../server/services/unterkunft/belegung.js') after(() => aw.close()) const NOW = new Date('2026-11-01T12:00:00Z') -const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', strasse: 'Str 1', plz: '10115', ort: 'Berlin', steuernummer: '12/345', onlineZahlung: false } } +const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', strasse: 'Str 1', plz: '10115', ort: 'Berlin', steuernummer: '12/345', onlineZahlung: false, rechnungAuftragAt: '2026-10-01T00:00:00.000Z' } } const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo' } const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' } diff --git a/test/unterkunftBuchung.test.js b/test/unterkunftBuchung.test.js index e6adca0..f15201e 100644 --- a/test/unterkunftBuchung.test.js +++ b/test/unterkunftBuchung.test.js @@ -42,7 +42,7 @@ const vorher = { ...config.unterkunft } const einstellungen = (extra = {}) => ({ $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Str 1', plz: '78576', ort: 'Stetten', email: 'x@y.de', - steuernummer: '12/345', iban: 'DE00', akzentfarbe: '#112233', ...extra, + steuernummer: '12/345', iban: 'DE00', akzentfarbe: '#112233', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', ...extra, }) async function seedBasis(einstExtra = {}) { diff --git a/test/unterkunftVerwaltung.test.js b/test/unterkunftVerwaltung.test.js index fcce1a5..925073e 100644 --- a/test/unterkunftVerwaltung.test.js +++ b/test/unterkunftVerwaltung.test.js @@ -41,7 +41,7 @@ const del = (pfad, opts = { cookie }) => send('DELETE', `/api/unterkunft${pfad}? const EINST = { firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Weg 1', plz: '78576', ort: 'Stetten', email: 'mimi@example.com', - telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'DH', akzentfarbe: '#112233', + telefon: '', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'DH', akzentfarbe: '#112233', } const PREISE = { staffeln: [{ abNaechten: 1, preisProNachtCent: 10000 }, { abNaechten: 7, preisProNachtCent: 9000 }], @@ -140,14 +140,16 @@ test('Einstellungen: speichern, Rueckgabe ohne Stripe-Konto-ID, USt-ID statt Ste assert.equal(ok.body.einstellungen.ustId, 'DE123456789') assert.equal(ok.body.einstellungen.iban, 'DE89370400440532013000') assert.equal(ok.body.einstellungen.firma, 'Haus') - assert.equal(ok.body.vollstaendig, true) assert.equal(ok.body.einstellungen.stripeAccountId, undefined) - const schlecht = await put('/einstellungen', { email: 'kaputt', akzentfarbe: 'rot', iban: 'x', rechnungsPraefix: 'A-B', onlineZahlung: true }) + assert.equal(ok.body.einstellungen.rechnungAuftrag, true) + const schlecht = await put('/einstellungen', { email: 'kaputt', akzentfarbe: 'rot', iban: 'x', rechnungsPraefix: 'A-B' }) assert.equal(schlecht.status, 400) - for (const f of ['email', 'akzentfarbe', 'iban', 'rechnungsPraefix', 'onlineZahlung']) assert.ok(schlecht.body.felder[f], f) + for (const f of ['email', 'akzentfarbe', 'iban', 'rechnungsPraefix']) assert.ok(schlecht.body.felder[f], f) const g = await get('/einstellungen') assert.equal(g.body.einstellungen.firma, 'Haus') - assert.equal(g.body.einstellungen.rechnungsPraefixFrei, true) + assert.equal(g.body.rechnungenVorhanden, false) + assert.deepEqual(g.body.stripe, { status: 'keins', vorhandenesShopKonto: false }) + assert.deepEqual(Object.keys(g.body).sort(), ['einstellungen', 'rechnungenVorhanden', 'stripe']) }) test('Einstellungen: Praefix nach der ersten Rechnung 409, gleicher Wert bleibt erlaubt', async () => { @@ -158,7 +160,8 @@ test('Einstellungen: Praefix nach der ersten Rechnung 409, gleicher Wert bleibt assert.ok(r.body.felder.rechnungsPraefix) assert.equal(aw.docs('ukEinstellungen').get(FEATURE).rechnungsPraefix, 'DH') assert.equal((await put('/einstellungen', { ...EINST, firma: 'Neu GmbH' })).status, 200) - assert.equal((await get('/einstellungen')).body.einstellungen.rechnungsPraefixFrei, false) + aw.seed('ukRechnungen', { $id: 'r9', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 1, inhalt: '{}' }) + assert.equal((await get('/einstellungen')).body.rechnungenVorhanden, true) }) // ------------------------------------------------------------------------------------------------ Unterkuenfte @@ -477,7 +480,9 @@ test('Rechnungen: Liste nur des Projekts; PDF fremde Rechnung 404, eigene 200 od const l = (await get('/rechnungen')).body.rechnungen assert.deepEqual(l.map((r) => r.id), ['r1']) assert.equal(l[0].anfrageNummer, 'A-X') - assert.equal(l[0].empfaenger, 'Eva') + assert.equal(l[0].gastName, 'Eva') + assert.equal(l[0].empfaenger, undefined) + assert.equal(l[0].anfrageId, undefined) assert.equal((await get('/rechnungen/rfremd.pdf')).status, 404) assert.equal((await get('/rechnungen/nix.pdf')).status, 404) assert.equal((await get('/rechnungen/r1')).status, 404) @@ -552,3 +557,44 @@ test('pruefeEinstellungen: nur gesendete Felder, Vollstaendigkeit aus vorher + e assert.deepEqual(r.werte, { telefon: '123' }) assert.ok(pruefeEinstellungen({ steuernummer: '' }, { ...EINST }).fehler.steuernummer) }) + +test('Einstellungen: rechnungAuftrag setzt/widerruft rechnungAuftragAt', async () => { + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungAuftragAt: undefined }) + assert.equal((await get('/einstellungen')).body.einstellungen.rechnungAuftrag, false) + const an = await put('/einstellungen', { ...EINST, rechnungAuftrag: true }) + assert.equal(an.body.einstellungen.rechnungAuftrag, true) + const zeit = aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt + assert.ok(zeit) + await put('/einstellungen', { ...EINST, rechnungAuftrag: true }) + assert.equal(new Date(aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt).getTime(), new Date(zeit).getTime()) + const aus = await put('/einstellungen', { ...EINST, rechnungAuftrag: false }) + assert.equal(aus.body.einstellungen.rechnungAuftrag, false) + assert.ok(!aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt) + assert.equal((await put('/einstellungen', { rechnungAuftrag: 'ja' })).status, 400) +}) + +test('Zustimmung: ohne rechnungAuftrag verweigern freigeben und bucheVerbindlich mit 409 zustimmung_fehlt', async () => { + aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungAuftragAt: undefined }) + anfrage('a1') + const r = await post('/anfragen/a1/freigeben', { posten: POSTEN }) + assert.equal(r.status, 409) + assert.equal(r.body.fehler, 'zustimmung_fehlt') + assert.equal(aw.docs('ukAnfragen').get('a1').status, 'neu') + const ab = await import('../server/services/unterkunft/ablauf.js') + anfrage('a2', { status: 'freigegeben', tokenHash: 'x', fristAt: new Date(Date.now() + 86400000).toISOString(), posten: JSON.stringify(POSTEN), summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 20000, ust: [] }) }) + const ctx = { featureId: FEATURE, einstellungen: { ...EINST } } + await assert.rejects(ab.bucheVerbindlich(ctx, 'a2', 'x', { rechnungsadresse: {}, zahlart: 'vorort' }), (e) => e instanceof UkFehler && e.code === 'zustimmung_fehlt' || e.code === 'nicht_gefunden') + assert.equal(aw.docs('ukAnfragen').get('a2').status, 'freigegeben') + assert.equal(aw.docs('ukRechnungen').size, 0) +}) + +test('Online-Zahlung: nur mit aktivem Stripe-Konto, sonst 409 mit felder.onlineZahlung', async () => { + await post('/stripe/verbinden') + st.setAccount(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, { charges_enabled: false }) + await post('/stripe/aktualisieren') + assert.equal((await get('/status')).body.stripe.status, 'onboarding') + const r = await put('/einstellungen', { onlineZahlung: true }) + assert.equal(r.status, 409) + assert.ok(r.body.felder.onlineZahlung) + assert.notEqual(aw.docs('ukEinstellungen').get(FEATURE).onlineZahlung, true) +})