Rechnungen: Absender-/Empfängerdaten beim Ausstellen einfrieren, Statuswechsel atomar (behebt Codex-Fund #95)
This commit is contained in:
parent
6fec6277f5
commit
e7e5f31ebe
3 changed files with 123 additions and 19 deletions
|
|
@ -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));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue