From ab3e6938dab5de5df50635a8eacfbfd577fc50c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:17:11 +0000 Subject: [PATCH] Rechnungen: Sperre je Kunde gegen doppelte Abrechnung (Aufwand, Anzahlung, Schluss) Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/stripeFinance.js | 8 +++-- test/auftraegeStripe.test.js | 40 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 468a5f1..b4787f2 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -2,7 +2,8 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.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 { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' @@ -330,7 +331,10 @@ export async function createProjectInvoice({ claimCreate(fingerprint) try { 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) return invoice } catch (err) { diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js index c81715d..043ecc6 100644 --- a/test/auftraegeStripe.test.js +++ b/test/auftraegeStripe.test.js @@ -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.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) +})