From 1bb172ad9196f3027bc59258ffbd2924aa231f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:18:46 +0000 Subject: [PATCH] Kontrolle: Storno einer Anzahlung bei bestehendem Schluss-Entwurf der AB abgewiesen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 409 'Erst den Entwurf der Schlussrechnung löschen.'; das Storno einer Anzahlung laeuft unter der Kundensperre. Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/rechnungsbau.js | 7 +++++++ server/services/finance/stripeFinance.js | 22 +++++++++++++++------- test/auftraegeKontrolleRechnung.test.js | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index 1330822..da55fe6 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -409,3 +409,10 @@ export async function pruefeVorSenden({ invoiceId, art, auftragIdMetadaten = '', } } } + +/** Gibt es zur AB (aus dem Projektdatensatz der Rechnung, ersatzweise aus den Stripe-Metadaten) einen Entwurf der Schlussrechnung? */ +export async function hatSchlussEntwurf({ invoiceId, auftragIdMetadaten = '' }) { + const auftragId = (await getProjektRechnung(invoiceId))?.auftragId || auftragIdMetadaten + if (!auftragId) return false + return (await ladeFruehere(auftragId)).some((f) => f.art === 'schluss' && f.status === 'draft') +} diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 09a65e2..7dfb3ac 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, pruefeVorSenden } from '../auftraege/rechnungsbau.js' +import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet, pruefeVorSenden, hatSchlussEntwurf } from '../auftraege/rechnungsbau.js' import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' @@ -507,13 +507,21 @@ export async function voidInvoice(invoiceId) { const invoice = await readInvoice(invoiceId) if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.') - try { - await getStripe().invoices.voidInvoice(invoice.id) - await bucheStorniert(invoice.id, invoice.art) - return await readInvoice(invoice.id) - } finally { - invalidateFinanceCache() + const stornieren = async () => { + // Der Schluss-Entwurf enthaelt die Minuszeile dieser Anzahlung: wuerde sie storniert, ginge er zu niedrig hinaus + if (invoice.art === 'anzahlung' && (await hatSchlussEntwurf({ invoiceId: invoice.id, auftragIdMetadaten: invoice.auftragId }))) { + throw httpError(409, 'Erst den Entwurf der Schlussrechnung löschen.') + } + try { + await getStripe().invoices.voidInvoice(invoice.id) + await bucheStorniert(invoice.id, invoice.art) + return await readInvoice(invoice.id) + } finally { + invalidateFinanceCache() + } } + // Eine Anzahlung unter der Sperre des Kunden: das Anlegen und Senden einer Schlussrechnung laeuft nie zwischen Pruefung und Storno + return invoice.art === 'anzahlung' && invoice.webklarCustomerId ? withLocks(sperreRechnung(invoice.webklarCustomerId), stornieren) : stornieren() } /** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */ diff --git a/test/auftraegeKontrolleRechnung.test.js b/test/auftraegeKontrolleRechnung.test.js index 627d4de..ab43521 100644 --- a/test/auftraegeKontrolleRechnung.test.js +++ b/test/auftraegeKontrolleRechnung.test.js @@ -104,3 +104,19 @@ test('Senden: ein gültiger Anzahlungs- oder Schluss-Entwurf geht wie bisher', a assert.equal((await sendInvoiceNow(s.id)).status, 'offen') assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') }) + +test('Storno: eine Anzahlung wird nicht storniert, solange zur AB ein Schluss-Entwurf besteht (409); nach dem Löschen des Entwurfs geht es', async () => { + const ab = await legeAuftragAn() + const a = await anzahlung(ab, 50, { senden: true }) + const s = await schluss(ab) + await assert.rejects(voidInvoice(a.id), (err) => err.status === 409 && err.message === 'Erst den Entwurf der Schlussrechnung löschen.') + assert.equal(st.invoices.get(a.id).status, 'open') + await deleteDraft(s.id) + assert.equal((await voidInvoice(a.id)).status, 'storniert') +}) + +test('Storno: eine Anzahlung ohne Schluss-Entwurf lässt sich stornieren', async () => { + const ab = await legeAuftragAn() + const a = await anzahlung(ab, 50, { senden: true }) + assert.equal((await voidInvoice(a.id)).status, 'storniert') +})