From 727613d0e2700ed982a872756309d30fc73ce063 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 15:21:19 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20Abschluss=20=E2=80=93=20S2?= =?UTF-8?q?=20Rechnungsnummer=20unter=20einer=20Sperre,=20unique-Index;=20?= =?UTF-8?q?S3=20Storno=20mit=20Ursprungs-Aussteller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/unterkunft/ablauf.js | 2 +- server/services/unterkunft/rechnungen.js | 53 ++++++++++++++++-------- server/services/unterkunft/schema.js | 2 +- test/unterkunftRechnungen.test.js | 49 ++++++++++++++++++++++ 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/server/services/unterkunft/ablauf.js b/server/services/unterkunft/ablauf.js index 0787cd7..6a88583 100644 --- a/server/services/unterkunft/ablauf.js +++ b/server/services/unterkunft/ablauf.js @@ -291,7 +291,7 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0 let stornoRechnung = null if (a.rechnungId) { const orig = await ladeRechnung(a.rechnungId) - if (orig) stornoRechnung = await rechnungEinmal(ctx, a, 'storno', { bezugNummer: orig.nummer, now }) + if (orig) stornoRechnung = await rechnungEinmal(ctx, a, 'storno', { bezugNummer: orig.nummer, bezug: orig, now }) } const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) }) return { anfrage, stornoRechnung, neu: true } diff --git a/server/services/unterkunft/rechnungen.js b/server/services/unterkunft/rechnungen.js index db9c511..8e99b7d 100644 --- a/server/services/unterkunft/rechnungen.js +++ b/server/services/unterkunft/rechnungen.js @@ -9,8 +9,12 @@ export const listeRechnungen = (featureId) => listeRe(featureId) const AUSSTELLER = ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email', 'telefon', 'steuernummer', 'ustId', 'iban', 'bic', 'bank'] -/** Naechste lueckenlose Nummer '--'; Jahr = Berliner Kalenderjahr, Jahreswechsel setzt den Zaehler auf 1. */ -export function naechsteNummer(featureId, now = new Date()) { +/** + * Naechste lueckenlose Nummer '--' vergeben und fn(nummer) unter DERSELBEN Sperre ausfuehren (Zaehler-Erhoehung und + * Dokument-Anlage sind eine Einheit). Scheitert fn, wird der Zaehler auf den alten Stand zurueckgesetzt, sofern er noch unveraendert ist. + * Jahr = Berliner Kalenderjahr, Jahreswechsel setzt den Zaehler auf 1. + */ +export function mitNummer(featureId, now, fn) { return withLocks(['uk-rechnungsnr:' + featureId], async () => { const einst = await ladeEinstellungen(featureId) const jahr = Number(heute(now).slice(0, 4)) @@ -18,10 +22,23 @@ export function naechsteNummer(featureId, now = new Date()) { const n = z && z.jahr === jahr && Number.isInteger(z.n) ? z.n + 1 : 1 await speichereEinstellungen(featureId, { rechnungZaehler: { jahr, n } }) const praefix = (einst?.rechnungsPraefix || '').trim() || 'RE' - return `${praefix}-${jahr}-${String(n).padStart(4, '0')}` + try { + return await fn(`${praefix}-${jahr}-${String(n).padStart(4, '0')}`) + } catch (err) { + try { + const jetzt = (await ladeEinstellungen(featureId))?.rechnungZaehler + if (jetzt && jetzt.jahr === jahr && jetzt.n === n) await speichereEinstellungen(featureId, { rechnungZaehler: z ?? null }) + } catch (err2) { + console.warn(`[unterkunft] Rechnungszaehler nicht zurueckgesetzt: ${err2?.name ?? 'Fehler'}`) + } + throw err + } }) } +/** Naechste lueckenlose Nummer (ohne Dokument; fuer Tests und Vorschau). */ +export const naechsteNummer = (featureId, now = new Date()) => mitNummer(featureId, now, (nummer) => nummer) + const negiere = (summen) => ({ ...summen, bruttoCent: -summen.bruttoCent, @@ -30,20 +47,21 @@ const negiere = (summen) => ({ }) /** Rechnung oder Stornorechnung zu einer Anfrage ausstellen (Nummer, Dokument). Die Sperre der Unterkunft haelt der Aufrufer. */ -export async function stelleAus(ctx, anfrage, { art = 'rechnung', bezugNummer, now = new Date() } = {}) { +export async function stelleAus(ctx, anfrage, { art = 'rechnung', bezugNummer, bezug, now = new Date() } = {}) { if (!Array.isArray(anfrage?.posten) || !anfrage.summen || typeof anfrage.summen.bruttoCent !== 'number') { throw new UkFehler(409, 'status_ungueltig', { posten: 'Posten oder Summen fehlen.' }) } const storno = art === 'storno' const e = ctx.einstellungen || {} - const aussteller = Object.fromEntries(AUSSTELLER.map((k) => [k, e[k] ?? ''])) + // Stornorechnung: Aussteller und Empfaenger der Ursprungsrechnung (nicht die heutigen Einstellungen) + const ursprung = storno && bezug?.inhalt && typeof bezug.inhalt === 'object' ? bezug.inhalt : null + const aussteller = ursprung?.aussteller ?? Object.fromEntries(AUSSTELLER.map((k) => [k, e[k] ?? ''])) const unterkunft = await ladeUnterkunft(anfrage.unterkunftId) const posten = anfrage.posten.map((p) => (storno ? { ...p, einzelCent: -p.einzelCent } : { ...p })) const summen = storno ? negiere(anfrage.summen) : anfrage.summen - const nummer = await naechsteNummer(ctx.featureId, now) const inhalt = { aussteller, - empfaenger: anfrage.rechnungsadresse ?? null, + empfaenger: ursprung ? (ursprung.empfaenger ?? null) : (anfrage.rechnungsadresse ?? null), unterkunft: { name: unterkunft?.name ?? '' }, von: anfrage.von, bis: anfrage.bis, @@ -53,16 +71,17 @@ export async function stelleAus(ctx, anfrage, { art = 'rechnung', bezugNummer, n summen, steuerart: anfrage.summen.steuerart ?? ((anfrage.summen.ust || []).length === 0 ? 'kleinunternehmer' : 'regel'), zahlart: anfrage.zahlart || '', - bezugNummer: bezugNummer ?? '', + bezugNummer: bezugNummer ?? bezug?.nummer ?? '', anfrageNummer: anfrage.nummer, } - return legeRechnungAn({ - featureId: ctx.featureId, - anfrageId: anfrage.$id, - art, - nummer, - datum: heute(now), - bruttoCent: summen.bruttoCent, - inhalt, - }) + return mitNummer(ctx.featureId, now, (nummer) => + legeRechnungAn({ + featureId: ctx.featureId, + anfrageId: anfrage.$id, + art, + nummer, + datum: heute(now), + bruttoCent: summen.bruttoCent, + inhalt, + })) } diff --git a/server/services/unterkunft/schema.js b/server/services/unterkunft/schema.js index efdd5de..c1f2f23 100644 --- a/server/services/unterkunft/schema.js +++ b/server/services/unterkunft/schema.js @@ -115,7 +115,7 @@ export const UNTERKUNFT_COLLECTIONS = { int('bruttoCent'), str('inhalt', 20000), ], - indexes: [index('idx_feature', 'featureId'), index('idx_anfrage', 'anfrageId'), index('idx_feature_nummer', 'featureId', 'nummer')], + indexes: [index('idx_feature', 'featureId'), index('idx_anfrage', 'anfrageId'), { key: 'uniq_feature_nummer', type: 'unique', attributes: ['featureId', 'nummer'] }], }, ukEinstellungen: { attributes: [ diff --git a/test/unterkunftRechnungen.test.js b/test/unterkunftRechnungen.test.js index d55fefb..e9bd126 100644 --- a/test/unterkunftRechnungen.test.js +++ b/test/unterkunftRechnungen.test.js @@ -65,6 +65,55 @@ test('stelleAus storno negiert Posten und Summen, traegt bezugNummer', async () assert.equal((await re.ladeRechnung(s.$id)).nummer, 'RE-2026-0002') }) +const ANF = { + $id: 'a9', unterkunftId: 'u1', nummer: 'A-WXYZ', von: '2026-11-10', bis: '2026-11-12', personen: 2, zahlart: 'vorort', + rechnungsadresse: { name: 'Gast Neu' }, + posten: [{ text: 'Nacht', menge: 2, einzelCent: 10000, ustProzent: 7 }], + summen: { bruttoCent: 20000, nettoCent: 18692, ust: [{ satz: 7, nettoCent: 18692, steuerCent: 1308 }] }, +} + +test('Rechnungsnummer: scheitert die Dokument-Anlage, bleibt keine Luecke (Zaehler zurueck)', async () => { + const ctx = { featureId: 'f1', einstellungen: { firma: 'Haus GmbH' } } + const r1 = await re.stelleAus(ctx, ANF, { art: 'rechnung', now: NOW }) + assert.equal(r1.nummer, 'RE-2026-0001') + aw.failNext({ collection: 'ukRechnungen', method: 'POST', status: 500 }) + await assert.rejects(re.stelleAus(ctx, ANF, { art: 'rechnung', now: NOW })) + assert.deepEqual((await sp.ladeEinstellungen('f1')).rechnungZaehler, { jahr: 2026, n: 1 }) + const r2 = await re.stelleAus(ctx, ANF, { art: 'rechnung', now: NOW }) + assert.equal(r2.nummer, 'RE-2026-0002') + // erste Rechnung ueberhaupt scheitert: Zaehler bleibt leer + const ctx2 = { featureId: 'f2', einstellungen: {} } + aw.failNext({ collection: 'ukRechnungen', method: 'POST', status: 500 }) + await assert.rejects(re.stelleAus(ctx2, ANF, { art: 'rechnung', now: NOW })) + assert.ok(!((await sp.ladeEinstellungen('f2'))?.rechnungZaehler?.n > 0)) + assert.equal((await re.stelleAus(ctx2, ANF, { art: 'rechnung', now: NOW })).nummer, 'RE-2026-0001') +}) + +test('Rechnungsnummer: parallele Ausstellung mit einem Fehler dazwischen bleibt lueckenlos', async () => { + const ctx = { featureId: 'f1', einstellungen: {} } + aw.failNext({ collection: 'ukRechnungen', method: 'POST', status: 500 }) + const res = await Promise.allSettled(Array.from({ length: 4 }, () => re.stelleAus(ctx, ANF, { art: 'rechnung', now: NOW }))) + const nr = res.filter((x) => x.status === 'fulfilled').map((x) => x.value.nummer).sort() + assert.deepEqual(nr, ['RE-2026-0001', 'RE-2026-0002', 'RE-2026-0003']) +}) + +test('Stornorechnung traegt Aussteller und Empfaenger der Ursprungsrechnung, nicht die heutigen', async () => { + const alt = { featureId: 'f1', einstellungen: { firma: 'Alte Firma', iban: 'DE-ALT', steuernummer: '1/1' } } + const r = await re.stelleAus(alt, ANF, { art: 'rechnung', now: NOW }) + const neu = { featureId: 'f1', einstellungen: { firma: 'Neue Firma', iban: 'DE-NEU' } } + const s = await re.stelleAus(neu, { ...ANF, rechnungsadresse: { name: 'Anderer Name' } }, { art: 'storno', bezug: r, now: NOW }) + assert.equal(s.inhalt.aussteller.firma, 'Alte Firma') + assert.equal(s.inhalt.aussteller.iban, 'DE-ALT') + assert.deepEqual(s.inhalt.empfaenger, { name: 'Gast Neu' }) + assert.equal(s.inhalt.bezugNummer, r.nummer) +}) + +test('Schema: Rechnungsnummer ist je Feature eindeutig (unique-Index)', async () => { + const { UNTERKUNFT_COLLECTIONS } = await import('../server/services/unterkunft/schema.js') + const i = UNTERKUNFT_COLLECTIONS.ukRechnungen.indexes.find((x) => x.type === 'unique') + assert.deepEqual(i.attributes, ['featureId', 'nummer']) +}) + test('Token: Hash passt nur zum Token; Formtoken', () => { const { token, hash } = tk.neuesToken() assert.equal(tk.tokenPasst(hash, token), true)