Rechnungen: Sperre je Kunde gegen doppelte Abrechnung (Aufwand, Anzahlung, Schluss)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:17:11 +00:00
1 parent d264f2af5a
commit ab3e6938da
2 files changed
+46 -2

No files matched your search

+6 -2
View File
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto'
import { config } from '../../config.js' import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { addDays, berlinDate, daysBetween } from './dates.js' import { addDays, berlinDate, daysBetween } from './dates.js'
import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js' import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js'
import { withLocks } from '../keyedLock.js'
import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js' import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js'
import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js'
import { getSettings } from './settings.js' import { getSettings } from './settings.js'
@@ -330,7 +331,10 @@ export async function createProjectInvoice({
claimCreate(fingerprint) claimCreate(fingerprint)
try { try {
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy } const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
const invoice = await createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key }) const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
// Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander,
// damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen
const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen()
releaseCreate(fingerprint, true) releaseCreate(fingerprint, true)
return invoice return invoice
} catch (err) { } catch (err) {
+40
View File
@@ -163,3 +163,43 @@ test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne An
assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung']) assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung'])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben') assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben')
}) })
test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => {
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' })
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.status, 409)
assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
assert.equal(st.invoices.size, 1)
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1)
})
test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => {
const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo })
await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A'))
await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
const rest = await createProjectInvoice(eingabe(['ws3'], 'C'))
assert.equal(rest.totalCents, 10000)
assert.equal(st.invoices.size, 2)
})
test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => {
const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
})
test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => {
const ab = await legeAuftragAn()
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo })
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.status, 409)
assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`)
assert.equal(st.invoices.size, 1)
})