Buchungskalender: Abschluss – S2 Rechnungsnummer unter einer Sperre, unique-Index; S3 Storno mit Ursprungs-Aussteller
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
435dd0b746
commit
727613d0e2
4 files changed
+87
-19
No files matched your search
@@ -291,7 +291,7 @@ export async function storniere(ctx, anfrageId, { grund = '', erstattungCent = 0
|
|||||||
let stornoRechnung = null
|
let stornoRechnung = null
|
||||||
if (a.rechnungId) {
|
if (a.rechnungId) {
|
||||||
const orig = await ladeRechnung(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)) })
|
const anfrage = await aktualisiereAnfrage(a.$id, { status: 'storniert', stornoGrund: text(grund, 500), storniertAt: iso(ms(now)) })
|
||||||
return { anfrage, stornoRechnung, neu: true }
|
return { anfrage, stornoRechnung, neu: true }
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ export const listeRechnungen = (featureId) => listeRe(featureId)
|
|||||||
|
|
||||||
const AUSSTELLER = ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email', 'telefon', 'steuernummer', 'ustId', 'iban', 'bic', 'bank']
|
const AUSSTELLER = ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email', 'telefon', 'steuernummer', 'ustId', 'iban', 'bic', 'bank']
|
||||||
|
|
||||||
/** Naechste lueckenlose Nummer '<Praefix>-<Jahr>-<NNNN>'; Jahr = Berliner Kalenderjahr, Jahreswechsel setzt den Zaehler auf 1. */
|
/**
|
||||||
export function naechsteNummer(featureId, now = new Date()) {
|
* Naechste lueckenlose Nummer '<Praefix>-<Jahr>-<NNNN>' 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 () => {
|
return withLocks(['uk-rechnungsnr:' + featureId], async () => {
|
||||||
const einst = await ladeEinstellungen(featureId)
|
const einst = await ladeEinstellungen(featureId)
|
||||||
const jahr = Number(heute(now).slice(0, 4))
|
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
|
const n = z && z.jahr === jahr && Number.isInteger(z.n) ? z.n + 1 : 1
|
||||||
await speichereEinstellungen(featureId, { rechnungZaehler: { jahr, n } })
|
await speichereEinstellungen(featureId, { rechnungZaehler: { jahr, n } })
|
||||||
const praefix = (einst?.rechnungsPraefix || '').trim() || 'RE'
|
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) => ({
|
const negiere = (summen) => ({
|
||||||
...summen,
|
...summen,
|
||||||
bruttoCent: -summen.bruttoCent,
|
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. */
|
/** 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') {
|
if (!Array.isArray(anfrage?.posten) || !anfrage.summen || typeof anfrage.summen.bruttoCent !== 'number') {
|
||||||
throw new UkFehler(409, 'status_ungueltig', { posten: 'Posten oder Summen fehlen.' })
|
throw new UkFehler(409, 'status_ungueltig', { posten: 'Posten oder Summen fehlen.' })
|
||||||
}
|
}
|
||||||
const storno = art === 'storno'
|
const storno = art === 'storno'
|
||||||
const e = ctx.einstellungen || {}
|
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 unterkunft = await ladeUnterkunft(anfrage.unterkunftId)
|
||||||
const posten = anfrage.posten.map((p) => (storno ? { ...p, einzelCent: -p.einzelCent } : { ...p }))
|
const posten = anfrage.posten.map((p) => (storno ? { ...p, einzelCent: -p.einzelCent } : { ...p }))
|
||||||
const summen = storno ? negiere(anfrage.summen) : anfrage.summen
|
const summen = storno ? negiere(anfrage.summen) : anfrage.summen
|
||||||
const nummer = await naechsteNummer(ctx.featureId, now)
|
|
||||||
const inhalt = {
|
const inhalt = {
|
||||||
aussteller,
|
aussteller,
|
||||||
empfaenger: anfrage.rechnungsadresse ?? null,
|
empfaenger: ursprung ? (ursprung.empfaenger ?? null) : (anfrage.rechnungsadresse ?? null),
|
||||||
unterkunft: { name: unterkunft?.name ?? '' },
|
unterkunft: { name: unterkunft?.name ?? '' },
|
||||||
von: anfrage.von,
|
von: anfrage.von,
|
||||||
bis: anfrage.bis,
|
bis: anfrage.bis,
|
||||||
@@ -53,16 +71,17 @@ export async function stelleAus(ctx, anfrage, { art = 'rechnung', bezugNummer, n
|
|||||||
summen,
|
summen,
|
||||||
steuerart: anfrage.summen.steuerart ?? ((anfrage.summen.ust || []).length === 0 ? 'kleinunternehmer' : 'regel'),
|
steuerart: anfrage.summen.steuerart ?? ((anfrage.summen.ust || []).length === 0 ? 'kleinunternehmer' : 'regel'),
|
||||||
zahlart: anfrage.zahlart || '',
|
zahlart: anfrage.zahlart || '',
|
||||||
bezugNummer: bezugNummer ?? '',
|
bezugNummer: bezugNummer ?? bezug?.nummer ?? '',
|
||||||
anfrageNummer: anfrage.nummer,
|
anfrageNummer: anfrage.nummer,
|
||||||
}
|
}
|
||||||
return legeRechnungAn({
|
return mitNummer(ctx.featureId, now, (nummer) =>
|
||||||
featureId: ctx.featureId,
|
legeRechnungAn({
|
||||||
anfrageId: anfrage.$id,
|
featureId: ctx.featureId,
|
||||||
art,
|
anfrageId: anfrage.$id,
|
||||||
nummer,
|
art,
|
||||||
datum: heute(now),
|
nummer,
|
||||||
bruttoCent: summen.bruttoCent,
|
datum: heute(now),
|
||||||
inhalt,
|
bruttoCent: summen.bruttoCent,
|
||||||
})
|
inhalt,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ export const UNTERKUNFT_COLLECTIONS = {
|
|||||||
int('bruttoCent'),
|
int('bruttoCent'),
|
||||||
str('inhalt', 20000),
|
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: {
|
ukEinstellungen: {
|
||||||
attributes: [
|
attributes: [
|
||||||
|
|||||||
@@ -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')
|
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', () => {
|
test('Token: Hash passt nur zum Token; Formtoken', () => {
|
||||||
const { token, hash } = tk.neuesToken()
|
const { token, hash } = tk.neuesToken()
|
||||||
assert.equal(tk.tokenPasst(hash, token), true)
|
assert.equal(tk.tokenPasst(hash, token), true)
|
||||||
|
|||||||
Reference in new issue
Block a user