From d2c48ab99bced7a1344cff6ce3330780f9adf39b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:18:36 +0000 Subject: [PATCH] =?UTF-8?q?Rechnungen:=20Routen=20f=C3=BCr=20Projektrechnu?= =?UTF-8?q?ngen=20und=20Vorschau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 26 ++++- test/auftraegeRoutesRechnungen.test.js | 127 +++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 test/auftraegeRoutesRechnungen.test.js diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index f48008e..62e0b9e 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -22,13 +22,14 @@ import { createProjectInvoice, deleteDraft, listInvoices, + rechnungsVorschau, sendInvoiceNow, voidInvoice, } from '../../services/finance/stripeFinance.js' /** * Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, - * Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein + * Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein * und rufen die Dienste in server/services/finance auf. */ const router = Router() @@ -209,11 +210,22 @@ router.get('/rechnungen', async (req, res) => { }) router.post('/rechnungen', async (req, res) => { - const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {} + const { + customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, + daysUntilDue, memo, senden, idempotencyKey, + } = req.body || {} try { + // Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch const rechnung = await createProjectInvoice({ customerId, + art, + projektId, + auftragId, items, + arbeitsblattIds, + anzahlung, + zeilentexte, + leistungszeitraum, daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE, memo, // Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf @@ -228,6 +240,16 @@ router.post('/rechnungen', async (req, res) => { } }) +// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff +router.post('/rechnungen/vorschau', async (req, res) => { + const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {} + try { + return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum })) + } catch (err) { + return sendFinanceError(res, 'Rechnungsvorschau', err) + } +}) + router.post('/rechnungen/:id/senden', async (req, res) => { try { return res.json({ rechnung: await sendInvoiceNow(req.params.id) }) diff --git a/test/auftraegeRoutesRechnungen.test.js b/test/auftraegeRoutesRechnungen.test.js new file mode 100644 index 0000000..c67eb44 --- /dev/null +++ b/test/auftraegeRoutesRechnungen.test.js @@ -0,0 +1,127 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +const aw = await startAppwriteMock({ + schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }, + datetimes: AUFTRAEGE_DATETIMES, + defaultLimit: 25, +}) +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 { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') +const { startTestApp, post, rawGet } = await import('./support/testApp.js') +const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') + +const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +seedRechnungsDaten(aw) +config.auftraege.mode = 'on' +beforeEach(async () => { + await leereRechnungsDaten(aw, st) + invalidateFinanceCache() + resetInvoiceCreateGuardForTests() +}) + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const staff = { headers: { Authorization: 'Bearer jwt-staff' } } +const base = '/api/admin/finanzen' +const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] +const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.' +const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET') +const erwarte = async (pfad, body, status, error) => { + const res = await post(`${base}${pfad}`, body, admin) + assert.equal(res.status, status, JSON.stringify(res.body)) + assert.deepEqual(res.body, { error }) +} + +test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => { + const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200]) + assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4') +}) + +test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => { + const ab = await legeAuftragAn() + const antwort = async (body) => { + const res = await post(`${base}/rechnungen`, body, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + return res.body.rechnung + } + const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }) + assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', '']) + const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] }) + assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500]) + const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' }) + assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002']) + const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000]) +}) + +test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => { + const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents']) + assert.equal(res.body.summeCents, 32500) + assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07']) + assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026') + assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum']) + assert.equal(schreibaufrufe().length, 0) + assert.equal(aw.docs(config.collections.projektRechnungen).size, 0) +}) + +test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => { + const ab = await legeAuftragAn() + for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) { + await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.') + await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.') + await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.') + await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.') + await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.') + config.auftraege.mode = 'off' + try { + await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS) + } finally { + config.auftraege.mode = 'on' + } + } + assert.equal(schreibaufrufe().length, 0) +}) + +test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => { + const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) { + await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + } +}) + +test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => { + const ab = await legeAuftragAn() + await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.') + await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.') + await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.') + await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') + await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.') + assert.equal(schreibaufrufe().length, 0) +}) + +test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => { + for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) { + assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401) + assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403) + } + assert.equal(st.invoices.size, 0) + await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin) + await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin) + const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen + const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer] + assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']]) +})