From e3ae152f127198fbd6e828f8dfe4bcb79d0fc07e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:18:22 +0000 Subject: [PATCH] Kontrolle: Senden eines Anzahlungs- oder Schluss-Entwurfs prueft die AB unter der Kundensperre erneut Unter sperreRechnung(kundenId): AB noch versendet oder bestaetigt, bei Schluss keine andere festgeschriebene Schlussrechnung, bei Anzahlung Summe aller festgeschriebenen Rechnungen plus diese nicht ueber der AB-Summe, sonst 409 und nichts wird finalisiert. Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/rechnungsbau.js | 31 +++++++++++- server/services/finance/stripeFinance.js | 14 +++++- test/auftraegeKontrolleRechnung.test.js | 58 ++++++++++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index 190a12f..1330822 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -17,7 +17,7 @@ import { } from './positionen.js' import { auftraegeAktiv } from './schalter.js' import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' -import { listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js' +import { getProjektRechnung, listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js' import { ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt, } from './arbeitsblaetter.js' @@ -380,3 +380,32 @@ async function baueAbRechnung(basis, eingabe) { } return schliesseAb({ ...basis, projekt, auftrag, positionen }) } + +const AB_UNGUELTIG = 'Die Auftragsbestätigung ist nicht mehr gültig (storniert, widerrufen, ersetzt oder schon abgerechnet). Der Entwurf wird nicht gesendet.' + +/** + * Letzte Pruefung vor dem Senden eines Anzahlungs- oder Schluss-Entwurfs (Aufrufer haelt sperreRechnung des Kunden): + * die AB muss noch versendet oder bestaetigt sein, bei einer Schlussrechnung darf keine andere Schlussrechnung + * festgeschrieben sein, und bei einer Anzahlung darf die Summe aller festgeschriebenen Rechnungen zur AB plus diese die + * AB-Summe nicht uebersteigen (zwei Entwuerfe zaehlen beim Anlegen nicht mit). Sonst AuftragError 409; nichts wird + * geschrieben ausser der Selbstheilung von ladeFruehere. Die AB kommt aus dem Projektdatensatz (nach umhaengen die neue), + * ersatzweise aus den Stripe-Metadaten; ohne AB gibt es nichts zu pruefen. + */ +export async function pruefeVorSenden({ invoiceId, art, auftragIdMetadaten = '', totalCents }) { + if (!AB_RECHNUNGS_ARTEN.includes(art)) return + const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten + if (!auftragId) return + const ab = await getAuftrag(auftragId) + if (!ab || !['versendet', 'bestaetigt'].includes(ab.status)) throw new AuftragError(409, AB_UNGUELTIG) + const andere = (await ladeFruehere(ab.id)).filter((f) => f.invoiceId !== invoiceId && FINALISIERT.includes(f.status)) + if (art === 'schluss' && andere.some((f) => f.art === 'schluss')) { + throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon eine Schlussrechnung. Der Entwurf wird nicht gesendet.') + } + // Eine Schlussrechnung darf mit Nachtraegen ueber der AB-Summe liegen: die Obergrenze gilt nur fuer die Anzahlung + if (art === 'anzahlung') { + const berechnet = andere.reduce((summe, f) => summe + f.betragCents, 0) + if (berechnet + totalCents > ab.summeCents) { + throw new AuftragError(409, `Die Summe der Rechnungen würde die Auftragssumme von ${euroDe(ab.summeCents)} übersteigen (bereits berechnet ${euroDe(berechnet)}). Der Entwurf wird nicht gesendet.`) + } + } +} diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 0740367..09a65e2 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -4,7 +4,7 @@ import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptio import { addDays, berlinDate, daysBetween } from './dates.js' import { RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js' import { withLocks } from '../keyedLock.js' -import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js' +import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet, pruefeVorSenden } from '../auftraege/rechnungsbau.js' import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' @@ -457,6 +457,17 @@ async function createInvoice({ eingabe, daysUntilDue: angegeben, note, senden, k */ export async function sendInvoiceNow(invoiceId) { const raw = await retrieveInvoice(invoiceId) + const vorab = mapInvoice(raw, { today: berlinDate() }) + // Anzahlungs- und Schluss-Entwuerfe: unter der Sperre des Kunden (wie das Anlegen) senden und vorher die AB erneut pruefen + if (vorab.source === 'projekt' && vorab.status === 'entwurf' && AB_RECHNUNGS_ARTEN.includes(vorab.art) && vorab.webklarCustomerId) { + return withLocks(sperreRechnung(vorab.webklarCustomerId), () => sendeRechnung(invoiceId, null, true)) + } + return sendeRechnung(invoiceId, raw, false) +} + +// raw = die schon gelesene Rechnung; unter der Sperre wird frisch gelesen (ein anderer Aufruf kann sie inzwischen gesendet haben) +async function sendeRechnung(invoiceId, raw, abPruefen) { + raw ??= await retrieveInvoice(invoiceId) const invoice = mapInvoice(raw, { today: berlinDate() }) // Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) @@ -464,6 +475,7 @@ export async function sendInvoiceNow(invoiceId) { if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') { throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.') } + if (isDraft && abPruefen) await pruefeVorSenden({ invoiceId: invoice.id, art: invoice.art, auftragIdMetadaten: invoice.auftragId, totalCents: invoice.totalCents }) // Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon // fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet. let customer = null diff --git a/test/auftraegeKontrolleRechnung.test.js b/test/auftraegeKontrolleRechnung.test.js index 01762a5..627d4de 100644 --- a/test/auftraegeKontrolleRechnung.test.js +++ b/test/auftraegeKontrolleRechnung.test.js @@ -15,7 +15,7 @@ const aw = await startAppwriteMock({ const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') -const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js') +const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js') const { createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { umhaengen } = await import('../server/services/auftraege/projektRechnungenStore.js') @@ -30,8 +30,9 @@ beforeEach(async () => { }) const letzterEntwurf = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1) +let zaehler = 0 const anzahlung = (ab, prozent, extra = {}) => - createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent, bezeichnung: `Anzahlung ${prozent}` }, ...extra }) + createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent, bezeichnung: `Anzahlung ${++zaehler}` }, ...extra }) const schluss = (ab, extra = {}) => createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, projektId: 'p1', ...extra }) test('Zahlungsziel: Anzahlung und Schluss ohne eigene Angabe nehmen das Zahlungsziel der AB, eine Angabe gilt vor der AB', async () => { @@ -50,3 +51,56 @@ test('Zahlungsziel: ohne AB-Bezug (frei, festbetrag) bleibt es bei 14 Tagen', as await createProjectInvoice({ customerId: 'k1', items: [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] }) assert.equal(letzterEntwurf().body.days_until_due, '14') }) + +const ENTWURF_ID = /^in_/ +const finalisiert = (id) => st.calls.some((c) => c.method === 'POST' && c.path === `/v1/invoices/${id}/finalize`) + +test('Senden: zwei Anzahlungs-Entwürfe à 60 % einer 800-€-AB – nur der erste geht, der zweite 409 und nichts finalisiert', async () => { + const ab = await legeAuftragAn() + const a = await anzahlung(ab, 60) + const b = await anzahlung(ab, 60) + assert.match(a.id, ENTWURF_ID) + await sendInvoiceNow(a.id) + st.calls.length = 0 + await assert.rejects(sendInvoiceNow(b.id), (err) => err.status === 409 && /übersteig|Auftragssumme/.test(err.message)) + assert.equal(finalisiert(b.id), false) + assert.equal(st.invoices.get(b.id).status, 'draft') +}) + +test('Senden: parallel gesendet geht von zwei 60-%-Entwürfen genau einer', async () => { + const ab = await legeAuftragAn() + const a = await anzahlung(ab, 60) + const b = await anzahlung(ab, 60) + const ergebnisse = await Promise.allSettled([sendInvoiceNow(a.id), sendInvoiceNow(b.id)]) + assert.deepEqual(ergebnisse.map((e) => e.status).sort(), ['fulfilled', 'rejected']) + assert.equal([a.id, b.id].filter((id) => st.invoices.get(id).status !== 'draft').length, 1) +}) + +test('Senden: ein Anzahlungs-Entwurf zu einer stornierten, widerrufenen oder ersetzten AB wird nicht gesendet', async () => { + for (const status of ['storniert', 'widerrufen', 'ersetzt']) { + const ab = await legeAuftragAn({}, { nummer: `AB-2026-${status}` }) + const entwurf = await anzahlung(ab, 30, { projektId: 'p1', anzahlung: { prozent: 30, bezeichnung: `A ${status}` } }) + await updateAuftrag(ab.id, { status }) + await assert.rejects(sendInvoiceNow(entwurf.id), (err) => err.status === 409 && /nicht mehr gültig/.test(err.message), status) + assert.equal(finalisiert(entwurf.id), false, status) + await deleteDraft(entwurf.id) + await updateAuftrag(ab.id, { status: 'storniert' }) + } +}) + +test('Senden: ein Schluss-Entwurf wird nicht gesendet, wenn zur AB schon eine andere Schlussrechnung festgeschrieben ist', async () => { + const ab = await legeAuftragAn() + const s = await schluss(ab) + await legeRechnungAn(st, 'in_fremd1', { status: 'open', total: 80000, art: 'schluss', auftragId: ab.id }) + await assert.rejects(sendInvoiceNow(s.id), (err) => err.status === 409) + assert.equal(finalisiert(s.id), false) +}) + +test('Senden: ein gültiger Anzahlungs- oder Schluss-Entwurf geht wie bisher', async () => { + const ab = await legeAuftragAn() + const a = await anzahlung(ab, 50) + assert.equal((await sendInvoiceNow(a.id)).status, 'offen') + const s = await schluss(ab) + assert.equal((await sendInvoiceNow(s.id)).status, 'offen') + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') +})