Rechnungen: Absender-/Empfängerdaten beim Ausstellen einfrieren, Statuswechsel atomar (behebt Codex-Fund #95)

This commit is contained in:
Kundencenter 2026-09-28 10:29:00 +02:00
parent 6fec6277f5
commit e7e5f31ebe
3 changed files with 123 additions and 19 deletions

View file

@ -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
});
});