diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index ecfd309..55d24cc 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -201,10 +201,21 @@ export const invoicesModule: KcModule = { const items = await query('SELECT 1 AS x FROM invoice_items WHERE invoice_id = ?', [id]); if (items.length === 0) throw badRequest('Eine Rechnung ohne Positionen kann nicht ausgestellt werden.', 'NO_ITEMS'); const s = await settings(); if (!complete(s)) throw badRequest('Die Firmenstammdaten sind unvollständig (Name, Anschrift, Steuernummer/USt-IdNr.). Bitte unter Einstellungen ergänzen, bevor Rechnungen ausgestellt werden.', 'COMPANY_SETTINGS_INCOMPLETE'); + const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [v.org_id]); const due = b.dueDate ?? new Date(Date.now() + s.defaultDueDays * 86400000).toISOString().slice(0, 10); + // Absender-/Empfängerdaten werden JETZT eingefroren (nicht mehr live beim PDF-Abruf gelesen) – ändert sich später + // IBAN, Steuernummer oder die Kundenanschrift, bleibt diese schon ausgestellte Rechnung unverändert, wie es sein muss. + const sellerSnap = { name: s.name, street: s.street, zip: s.zip, city: s.city, country: s.country, taxNumber: s.taxNumber, vatId: s.vatId, bankName: s.bankName, iban: s.iban, bic: s.bic, footerText: s.footerText }; + const buyerSnap = { name: org?.company || org?.name || null, street: org?.street ?? null, zip: org?.zip ?? null, city: org?.city ?? null, country: org?.country ?? 'DE', vatId: org?.vat_id ?? null }; const number = await tx(async (c) => { + // Zeile sperren statt nur zu lesen: verhindert, dass zwei gleichzeitige "Ausstellen"-Aufrufe beide eine Nummer + // ziehen (Nummernlücke) oder beide durchlaufen. Der Verlierer sieht nach dem Warten status≠'draft' und bricht ab, + // ohne je eine Nummer verbraucht zu haben. + const locked = await one('SELECT status FROM invoices WHERE id = ? FOR UPDATE', [id], c); + if (!locked || locked.status !== 'draft') throw conflict('Die Rechnung wurde inzwischen von einem anderen Vorgang ausgestellt.', 'STALE_STATE'); const n = await nextInvoiceNumber(c, s.invoicePrefix); - await run("UPDATE invoices SET number = ?, status = 'open', issue_date = CURDATE(), due_date = ?, issued_at = UTC_TIMESTAMP(3) WHERE id = ?", [n, due, id], c); + await run("UPDATE invoices SET number = ?, status = 'open', issue_date = CURDATE(), due_date = ?, issued_at = UTC_TIMESTAMP(3), seller_snapshot_json = ?, buyer_snapshot_json = ? WHERE id = ?", + [n, due, JSON.stringify(sellerSnap), JSON.stringify(buyerSnap), id], c); return n; }); await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.issue', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number } }); @@ -214,27 +225,34 @@ export const invoicesModule: KcModule = { app.post('/invoices/:id/mark-paid', async (req) => { const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound(); - if (v.status !== 'open') throw conflict('Nur ausgestellte, noch offene Rechnungen können als bezahlt markiert werden.', 'INVOICE_NOT_OPEN'); - await run("UPDATE invoices SET status = 'paid', paid_at = UTC_TIMESTAMP(3) WHERE id = ?", [id]); + const v = await one('SELECT org_id FROM invoices WHERE id = ?', [id]); if (!v) throw notFound(); + await tx(async (c) => { + const locked = await one('SELECT status FROM invoices WHERE id = ? FOR UPDATE', [id], c); + if (!locked || locked.status !== 'open') throw conflict('Nur ausgestellte, noch offene Rechnungen können als bezahlt markiert werden.', 'INVOICE_NOT_OPEN'); + await run("UPDATE invoices SET status = 'paid', paid_at = UTC_TIMESTAMP(3) WHERE id = ?", [id], c); + }); await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.paid', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) }); return { ok: true }; }); /** Storno: erzeugt eine neue, ausgestellte Rechnung mit umgekehrten Vorzeichen und verweist auf das Original. - * Die ursprüngliche Rechnung wird NIE gelöscht oder verändert (gesetzliche Vorgabe). */ + * Die ursprüngliche Rechnung wird NIE gelöscht oder verändert (gesetzliche Vorgabe). Übernimmt den eingefrorenen + * Absender-/Empfänger-Schnappschuss des Originals (nicht die evtl. inzwischen geänderten aktuellen Stammdaten). */ app.post('/invoices/:id/cancel', async (req) => { const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const b = z.object({ reason: z.string().trim().max(300).optional() }).parse(req.body ?? {}); - const res = await loadInvoice(id); if (!res) throw notFound(); const v = res.v; - if (v.status !== 'open') throw conflict('Nur offene Rechnungen können storniert werden. Eine bezahlte Rechnung erst als Storno mit Rückzahlungsvermerk erfassen.', 'INVOICE_NOT_OPEN'); + const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound(); + const items = await query('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order', [id]); const s = await settings(); const creditId = randomUUID(); const number = await tx(async (c) => { + const locked = await one('SELECT status, seller_snapshot_json, buyer_snapshot_json FROM invoices WHERE id = ? FOR UPDATE', [id], c); + if (!locked || locked.status !== 'open') throw conflict('Nur offene Rechnungen können storniert werden. Eine bezahlte Rechnung erst als Storno mit Rückzahlungsvermerk erfassen.', 'INVOICE_NOT_OPEN'); + const jstr = (x: unknown) => (x == null ? null : typeof x === 'string' ? x : JSON.stringify(x)); const n = await nextInvoiceNumber(c, s.invoicePrefix); - await run('INSERT INTO invoices (id, number, org_id, status, issue_date, due_date, total_net_cents, total_tax_cents, total_gross_cents, note, cancels_invoice_id, created_by, issued_at) VALUES (?,?,?,\'open\',CURDATE(),CURDATE(),?,?,?,?,?,?,UTC_TIMESTAMP(3))', - [creditId, n, v.org_id, -v.total_net_cents, -v.total_tax_cents, -v.total_gross_cents, b.reason ? `Storno zu ${v.number}: ${b.reason}` : `Storno zu ${v.number}`, id, a.user.id], c); - for (const it of res.items) await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)', + await run('INSERT INTO invoices (id, number, org_id, status, issue_date, due_date, total_net_cents, total_tax_cents, total_gross_cents, note, seller_snapshot_json, buyer_snapshot_json, cancels_invoice_id, created_by, issued_at) VALUES (?,?,?,\'open\',CURDATE(),CURDATE(),?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', + [creditId, n, v.org_id, -v.total_net_cents, -v.total_tax_cents, -v.total_gross_cents, b.reason ? `Storno zu ${v.number}: ${b.reason}` : `Storno zu ${v.number}`, jstr(locked.seller_snapshot_json), jstr(locked.buyer_snapshot_json), id, a.user.id], c); + for (const it of items) await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)', [randomUUID(), creditId, it.contract_id, it.description, -Number(it.quantity), it.unit_price_net_cents, it.tax_bp, -it.net_cents, -it.tax_cents, -it.gross_cents, it.sort_order], c); await run("UPDATE invoices SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3), cancelled_by_invoice_id = ? WHERE id = ?", [creditId, id], c); return n; @@ -245,9 +263,13 @@ export const invoicesModule: KcModule = { app.delete('/invoices/:id', async (req) => { const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound(); - if (v.status !== 'draft') throw forbidden('Ausgestellte Rechnungen können nicht gelöscht werden, nur storniert.', 'INVOICE_NOT_DRAFT'); - await run('DELETE FROM invoice_items WHERE invoice_id = ?', [id]); await run('DELETE FROM invoices WHERE id = ?', [id]); + const v = await one('SELECT org_id FROM invoices WHERE id = ?', [id]); if (!v) throw notFound(); + await tx(async (c) => { + const locked = await one('SELECT status FROM invoices WHERE id = ? FOR UPDATE', [id], c); + if (!locked) throw notFound(); + if (locked.status !== 'draft') throw forbidden('Ausgestellte Rechnungen können nicht gelöscht werden, nur storniert.', 'INVOICE_NOT_DRAFT'); + await run('DELETE FROM invoice_items WHERE invoice_id = ?', [id], c); await run('DELETE FROM invoices WHERE id = ?', [id], c); + }); await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.delete_draft', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) }); return { ok: true }; }); @@ -258,16 +280,21 @@ export const invoicesModule: KcModule = { const res = await loadInvoice(id); if (!res || !canInOrg(a.principal, res.v.org_id, 'invoices.read', 'invoices.read') || (!staff && res.v.status === 'draft')) throw notFound(); if (res.v.status === 'draft') throw badRequest('Für Entwürfe gibt es noch kein PDF. Bitte zuerst ausstellen.', 'INVOICE_DRAFT'); - const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [res.v.org_id]); - const s = await settings(); + const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x); + let seller = jparse(res.v.seller_snapshot_json) as CompanySettings | null; + let customer = jparse(res.v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null; + if (!seller || !customer) { // vor Migration 020 ausgestellt: kein eingefrorener Stand vorhanden, Rückfall auf die damals übliche Live-Anzeige + const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [res.v.org_id]); + seller ??= await settings(); + customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null }; + } const inv: InvoiceForPdf = { number: res.v.number, issueDate: res.v.issue_date, dueDate: res.v.due_date, status: res.v.status, paymentMethod: res.v.payment_method, note: res.v.note, totalNetCents: res.v.total_net_cents, totalTaxCents: res.v.total_tax_cents, totalGrossCents: res.v.total_gross_cents, - customer: { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null }, - items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), + customer, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), }; reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`); - return reply.send(renderInvoicePdf(inv, s)); + return reply.send(renderInvoicePdf(inv, seller)); }); }, }; diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts index 57ec9f7..c27db44 100644 --- a/apps/api/test/invoices.test.ts +++ b/apps/api/test/invoices.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../src/server.js'; -import { run } from '../src/core/db.js'; +import { one, run } from '../src/core/db.js'; import { runOnce } from '../../worker/src/jobs.js'; import { call, code, login, makeUser } from './helpers.js'; @@ -178,3 +178,73 @@ describe('Mahnwesen: überfällige Rechnungen und fällige Verträge', () => { expect((await call(app, A.client, 'GET', '/invoices/reminders')).statusCode).toBe(403); // Kunden nicht }); }); + +describe('Rechnungen: eingefrorener Absender-/Empfänger-Stand, atomare Statuswechsel', () => { + it('Ausstellen friert Firmen- und Kundendaten ein; spätere Änderungen wirken sich nicht rückwirkend aus', async () => { + const admin = await staff('snap-admin@example.com', 'superadmin'); const acc = await staff('snap-acc@example.com', 'accounting'); + await call(app, admin, 'PUT', '/admin/company-settings', { ...COMPANY, iban: 'DE00 ALT 0000 0000 0000 00' }); + const A = await customer(admin, 'Kunde Snap', 'snap-a@example.com'); + const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }] }); + await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); + + const row = await one('SELECT seller_snapshot_json, buyer_snapshot_json FROM invoices WHERE id = ?', [draft.id]); + const seller = typeof row!.seller_snapshot_json === 'string' ? JSON.parse(row!.seller_snapshot_json) : row!.seller_snapshot_json; + expect(seller.iban).toBe('DE00 ALT 0000 0000 0000 00'); expect(seller.name).toBe(COMPANY.name); + + // Firmendaten ändern sich NACH dem Ausstellen + await call(app, admin, 'PUT', '/admin/company-settings', { ...COMPANY, name: 'Neue Firma GmbH', iban: 'DE00 NEU 0000 0000 0000 00' }); + const rowAfter = await one('SELECT seller_snapshot_json FROM invoices WHERE id = ?', [draft.id]); + const sellerAfter = typeof rowAfter!.seller_snapshot_json === 'string' ? JSON.parse(rowAfter!.seller_snapshot_json) : rowAfter!.seller_snapshot_json; + expect(sellerAfter.iban).toBe('DE00 ALT 0000 0000 0000 00'); expect(sellerAfter.name).toBe(COMPANY.name); // unverändert trotz späterer Änderung + + // PDF bleibt trotz geänderter Firmendaten abrufbar und liest den eingefrorenen Stand (kein Absturz, alte Daten) + const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: acc.cookie, 'x-csrf-token': acc.csrf } }); + expect(pdf.statusCode).toBe(200); expect(pdf.rawPayload.subarray(0, 4).toString()).toBe('%PDF'); + + // Storno übernimmt den eingefrorenen Stand des Originals, nicht die inzwischen geänderten aktuellen Firmendaten + const credit = (await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).json(); + const creditRow = await one('SELECT seller_snapshot_json FROM invoices WHERE id = ?', [credit.creditInvoiceId]); + const creditSeller = typeof creditRow!.seller_snapshot_json === 'string' ? JSON.parse(creditRow!.seller_snapshot_json) : creditRow!.seller_snapshot_json; + expect(creditSeller.iban).toBe('DE00 ALT 0000 0000 0000 00'); + }); + + it('Gleichzeitiges Ausstellen derselben Rechnung: genau ein Versuch gewinnt, keine Nummernlücke', async () => { + const admin = await staff('race-admin@example.com', 'superadmin'); const acc = await staff('race-acc@example.com', 'accounting'); + await call(app, admin, 'PUT', '/admin/company-settings', COMPANY); + const A = await customer(admin, 'Kunde Race', 'race-a@example.com'); + const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }] }); + + const [r1, r2] = await Promise.all([call(app, acc, 'POST', `/invoices/${draft.id}/issue`), call(app, acc, 'POST', `/invoices/${draft.id}/issue`)]); + const codes = [r1.statusCode, r2.statusCode].sort(); expect(codes).toEqual([200, 409]); + const winner = r1.statusCode === 200 ? r1.json() : r2.json(); + const n1 = Number(winner.number.split('-')[1]); + + // die nächste, unabhängige Rechnung bekommt die direkt folgende Nummer – kein Sprung durch den Verlierer + const draft2 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft2.id}/items`, { items: [{ description: 'y', quantity: 1, unitPriceNetCents: 500, taxBp: 1900 }] }); + const issued2 = (await call(app, acc, 'POST', `/invoices/${draft2.id}/issue`)).json(); + expect(Number(issued2.number.split('-')[1])).toBe(n1 + 1); + }); + + it('Gleichzeitiges Bezahlen/Stornieren: nur ein Versuch gewinnt', async () => { + const admin = await staff('race2-admin@example.com', 'superadmin'); const acc = await staff('race2-acc@example.com', 'accounting'); + await call(app, admin, 'PUT', '/admin/company-settings', COMPANY); + const A = await customer(admin, 'Kunde Race2', 'race2-a@example.com'); + const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }] }); + await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); + + const [p1, p2] = await Promise.all([call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`), call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`)]); + expect([p1.statusCode, p2.statusCode].sort()).toEqual([200, 409]); + + const draft3 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft3.id}/items`, { items: [{ description: 'z', quantity: 1, unitPriceNetCents: 200, taxBp: 1900 }] }); + await call(app, acc, 'POST', `/invoices/${draft3.id}/issue`); + const [c1, c2] = await Promise.all([call(app, acc, 'POST', `/invoices/${draft3.id}/cancel`), call(app, acc, 'POST', `/invoices/${draft3.id}/cancel`)]); + expect([c1.statusCode, c2.statusCode].sort()).toEqual([200, 409]); + const invAfter = (await call(app, acc, 'GET', `/invoices/${draft3.id}`)).json(); + expect(invAfter.status).toBe('cancelled'); // nicht doppelt storniert, genau ein Storno-Verweis + }); +}); diff --git a/migrations/020_invoice_party_snapshots.sql b/migrations/020_invoice_party_snapshots.sql new file mode 100644 index 0000000..e9a4b5e --- /dev/null +++ b/migrations/020_invoice_party_snapshots.sql @@ -0,0 +1,7 @@ +-- Rechnungen waren bei Betrag/Positionen bereits eingefroren, aber Firmen- und Kundenanschrift wurden beim +-- PDF-Abruf jedes Mal LIVE aus den aktuellen Stammdaten gelesen. Ändert sich später IBAN, Steuernummer oder +-- die Kundenanschrift, zeigte eine schon ausgestellte Rechnung rückwirkend andere Daten (extern gefundenes P0). +-- Ab jetzt wird beim Ausstellen ein vollständiger Absender-/Empfänger-Schnappschuss eingefroren; das PDF liest +-- nur noch daraus. NULL = vor dieser Migration ausgestellt (Rückfall auf die alte Live-Anzeige). +ALTER TABLE invoices ADD COLUMN seller_snapshot_json JSON NULL AFTER note; +ALTER TABLE invoices ADD COLUMN buyer_snapshot_json JSON NULL AFTER seller_snapshot_json;