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
|
|
@ -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