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

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