kundencenter/apps/api/src/modules/invoices/index.ts
Kundencenter a68b4cf6ec feat: SMTP-Stack mit konfigurierbaren Mailvorlagen
Bisher gab es nur zwei fest im Code verdrahtete E-Mails (Einladung,
Passwort-Reset) und die SMTP-Verbindung kam ausschließlich aus
Umgebungsvariablen. Jetzt:

- SMTP-Verbindung unter Einstellungen > E-Mail konfigurierbar (Host,
  Port, Verschlüsselung, Zugangsdaten verschlüsselt gespeichert,
  Absender), mit Testmail-Versand. Die Mail-Logik wandert dafür nach
  @kc/platform/mail, damit API und Worker sie gemeinsam nutzen.
- Mailvorlagen-Verwaltung: jedes Ereignis hat eine feste "Definition"
  (Name, Auslöser, verfügbare Platzhalter/Daten), Betreff/Text sind
  editierbar, je Vorlage einzeln aktivierbar, mit Testversand anhand
  von Beispieldaten. Unbekannte Platzhalter werden beim Speichern
  abgelehnt.
- Neue Ereignisse verdrahtet: Ticket erstellt (Bestätigung an Kunde),
  Personal antwortet auf Ticket (Benachrichtigung an Kunde), Rechnung
  ausgestellt, sowie die zuvor zurückgestellte Funktion "Panel-
  Zugangsdaten per E-Mail senden" nach einem Passwort-Reset. Diese
  laufen über die Jobqueue (mail.template), analog zu discord.notify.
- Versandprotokoll (letzte 100 Versuche, ohne Inhalte) einsehbar unter
  Einstellungen > E-Mail.
- Migration 022, Rechte email.read (Admin+) / email.write (Superadmin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:53:29 +02:00

304 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
import { renderInvoicePdf, type CompanySettings, type InvoiceForPdf } from './pdf.js';
/** Kaufmännisches Runden (halb auf), wie in @kc/platform/pricing – hier lokal, weil Rechnungspositionen
* (Freitext, Dezimalmenge) sich nicht in das Produkt-Preisschema von calculatePrice pressen lassen. */
const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2));
function lineAmounts(unitNetCents: number, quantity: number, taxBp: number, discountBp = 0) {
const net = divRound(Math.round(unitNetCents * quantity) * (10000 - discountBp), 10000);
const tax = divRound(net * taxBp, 10000);
return { net, tax, gross: net + tax };
}
async function nextInvoiceNumber(c: PoolConnection, prefix: string): Promise<string> {
await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'invoice'", [], c);
return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
}
async function settings(): Promise<CompanySettings> {
const s = await one('SELECT * FROM company_settings WHERE id = 1');
return {
name: s?.name ?? null, street: s?.street ?? null, zip: s?.zip ?? null, city: s?.city ?? null, country: s?.country ?? 'DE',
taxNumber: s?.tax_number ?? null, vatId: s?.vat_id ?? null, bankName: s?.bank_name ?? null, iban: s?.iban ?? null, bic: s?.bic ?? null,
invoicePrefix: s?.invoice_prefix ?? 'RE', defaultDueDays: Number(s?.default_due_days ?? 14),
paymentMethods: (typeof s?.payment_methods === 'string' ? JSON.parse(s.payment_methods) : s?.payment_methods) ?? ['Überweisung'],
footerText: s?.footer_text ?? null,
};
}
const complete = (s: CompanySettings) => !!(s.name && s.street && s.zip && s.city && (s.taxNumber || s.vatId));
const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents });
const invoiceView = (v: any) => ({
id: v.id, number: v.number, orgId: v.org_id, orgName: v.org_name, customerNumber: v.customer_number, status: v.status,
issueDate: v.issue_date, dueDate: v.due_date, overdue: v.status === 'open' && v.due_date && new Date(v.due_date) < new Date(),
currency: v.currency, totalNetCents: v.total_net_cents, totalTaxCents: v.total_tax_cents, totalGrossCents: v.total_gross_cents,
paymentMethod: v.payment_method, note: v.note, paidAt: v.paid_at, cancelsInvoiceId: v.cancels_invoice_id, cancelledByInvoiceId: v.cancelled_by_invoice_id,
createdAt: v.created_at, issuedAt: v.issued_at, cancelledAt: v.cancelled_at,
});
const INVOICE_SQL = 'SELECT v.*, g.name AS org_name, g.customer_number FROM invoices v JOIN organizations g ON g.id = v.org_id';
async function loadInvoice(id: string) {
const v = await one(`${INVOICE_SQL} WHERE v.id = ?`, [id]);
if (!v) return null;
const items = await query('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order', [id]);
return { v, items };
}
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
const mailTo = (to: string, template: string, vars: Record<string, string>, key: string, correlationId: string) => enqueue('mail.template', { to, key: template, vars }, { idempotencyKey: key, correlationId });
const deDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE');
const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
export const invoicesModule: KcModule = {
name: 'invoices',
permissions: {
staff: { support: ['invoices.read'], accounting: ['invoices.read', 'invoices.write'], admin: ['invoices.read', 'invoices.write'], superadmin: ['invoices.read', 'invoices.write', 'settings.write'] },
org: { owner: ['invoices.read'], admin: ['invoices.read'], member: ['invoices.read'] },
},
register(app: FastifyInstance) {
// ---- Firmenstammdaten (für den Rechnungskopf) ---------------------------
app.get('/admin/company-settings', async (req) => { requirePermission(req, 'invoices.read'); const s = await settings(); return { ...s, complete: complete(s) }; });
app.put('/admin/company-settings', async (req) => {
const a = requirePermission(req, 'settings.write');
const b = z.object({
name: z.string().trim().max(200).optional(), street: z.string().trim().max(200).optional(), zip: z.string().trim().max(20).optional(), city: z.string().trim().max(100).optional(), country: z.string().length(2).optional(),
taxNumber: z.string().trim().max(50).optional(), vatId: z.string().trim().max(30).optional(), bankName: z.string().trim().max(150).optional(), iban: z.string().trim().max(34).optional(), bic: z.string().trim().max(11).optional(),
invoicePrefix: z.string().trim().regex(/^[A-Za-z0-9]{1,10}$/).optional(), defaultDueDays: z.number().int().min(0).max(180).optional(),
paymentMethods: z.array(z.string().trim().min(1).max(50)).min(1).max(10).optional(), footerText: z.string().trim().max(500).nullable().optional(),
}).parse(req.body);
const cols: Record<string, string> = { name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text' };
const sets: string[] = []; const params: unknown[] = [];
for (const [k, col] of Object.entries(cols)) if ((b as Record<string, unknown>)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record<string, unknown>)[k]); }
if (b.paymentMethods) { sets.push('payment_methods = ?'); params.push(JSON.stringify(b.paymentMethods)); }
if (sets.length) await run(`UPDATE company_settings SET ${sets.join(', ')} WHERE id = 1`, params);
await audit({ actorType: 'user', actorId: a.user.id, action: 'company_settings.update', resourceType: 'company_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, iban: b.iban ? '***' : undefined } });
return { ok: true };
});
// ---- Rechnungen: Entwurf, Positionen, Ausstellen, Bezahlt, Storno -------
app.get('/invoices', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), status: z.enum(['draft', 'open', 'paid', 'cancelled']).optional() }).parse(req.query);
const staff = can(a.principal, 'invoices.read');
const myOrgs = a.principal.memberships.map((m) => m.orgId);
if (!staff && myOrgs.length === 0) return [];
if (staff && q.org && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [q.org]))) throw notFound();
const orgs = staff ? (q.org ? [q.org] : null) : myOrgs;
const orgPlaceholders = orgs ? orgs.map(() => '?').join(',') : '';
const rows = await query(
`${INVOICE_SQL} WHERE (${orgs ? `v.org_id IN (${orgPlaceholders})` : '1=1'}) AND (? IS NULL OR v.status = ?)${staff ? '' : " AND v.status != 'draft'"} ORDER BY v.created_at DESC LIMIT 200`,
[...(orgs ?? []), q.status ?? null, q.status ?? null]);
return rows.map(invoiceView);
});
/** Mahnwesen-Überblick: überfällige Rechnungen und aktive Verträge, die vermutlich neu in Rechnung gestellt werden müssen
* (letzte Rechnung liegt länger zurück als die Verlängerungslaufzeit des Vertrags). Nur Personal. */
app.get('/invoices/reminders', async (req) => {
requirePermission(req, 'invoices.read');
const overdue = (await query(`${INVOICE_SQL} WHERE v.status = 'open' AND v.due_date < CURDATE() ORDER BY v.due_date`)).map(invoiceView);
const contracts = await query(`
SELECT c.id, c.number, c.org_id, c.started_at, c.renewal_term_months, g.name AS org_name, g.customer_number,
JSON_VALUE(c.price_snapshot_json, '$.name') AS product_name, JSON_VALUE(c.price_snapshot_json, '$.recurring.gross') AS last_gross_cents,
(SELECT MAX(i.issue_date) FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id WHERE ii.contract_id = c.id AND i.status IN ('open', 'paid')) AS last_invoiced
FROM contracts c JOIN organizations g ON g.id = c.org_id
WHERE c.status = 'active' AND c.renewal = 'auto' AND c.renewal_term_months > 0`);
const dueContracts = contracts
.map((c) => { const since = c.last_invoiced ?? c.started_at; const due = new Date(since); due.setMonth(due.getMonth() + c.renewal_term_months);
return { id: c.id, number: c.number, orgId: c.org_id, orgName: c.org_name, customerNumber: c.customer_number, productName: c.product_name, lastInvoicedAt: c.last_invoiced, dueSince: due.toISOString().slice(0, 10), lastGrossCents: c.last_gross_cents === null ? null : Number(c.last_gross_cents) }; })
.filter((c) => new Date(c.dueSince) <= new Date())
.sort((a, b) => a.dueSince.localeCompare(b.dueSince));
return { overdueInvoices: overdue, dueContracts };
});
app.post('/invoices', async (req) => {
const a = requirePermission(req, 'invoices.write');
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), paymentMethod: z.string().max(50).optional() }).parse(req.body);
if (!(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw notFound();
const id = randomUUID();
await run('INSERT INTO invoices (id, org_id, note, payment_method, created_by) VALUES (?,?,?,?,?)', [id, b.orgId, b.note ?? null, b.paymentMethod ?? null, a.user.id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'invoice.create', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { id };
});
app.get('/invoices/:id', async (req) => {
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const staff = can(a.principal, 'invoices.read');
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();
return { ...invoiceView(res.v), items: res.items.map(itemView), canWrite: staff };
});
/** Ersetzt die Positionen eines Entwurfs vollständig (einfacher als Einzel-CRUD, ausreichend für eine Entwurfsphase). */
app.put('/invoices/:id/items', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ items: z.array(z.object({
description: z.string().trim().min(1).max(300), quantity: z.number().positive().max(100000), unitPriceNetCents: z.number().int().min(0).max(100_000_00), taxBp: z.number().int().min(0).max(3000), discountBp: z.number().int().min(0).max(10000).default(0), contractId: z.string().uuid().optional(),
})).min(1).max(100) }).parse(req.body);
const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound();
if (v.status !== 'draft') throw conflict('Nur Entwürfe können bearbeitet werden. Ausgestellte Rechnungen sind unveränderlich (nur Storno möglich).', 'INVOICE_NOT_DRAFT');
let net = 0, tax = 0, gross = 0;
await tx(async (c) => {
await run('DELETE FROM invoice_items WHERE invoice_id = ?', [id], c);
for (const [idx, it] of b.items.entries()) {
const a2 = lineAmounts(it.unitPriceNetCents, it.quantity, it.taxBp, it.discountBp); net += a2.net; tax += a2.tax; gross += a2.gross;
await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, discount_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
[randomUUID(), id, it.contractId ?? null, it.description, it.quantity, it.unitPriceNetCents, it.taxBp, it.discountBp, a2.net, a2.tax, a2.gross, idx], c);
}
await run('UPDATE invoices SET total_net_cents = ?, total_tax_cents = ?, total_gross_cents = ? WHERE id = ?', [net, tax, gross, id], c);
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.items.update', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { items: b.items.length, totalGrossCents: gross } });
return { ok: true };
});
/** Aus einem Vertrag die letzte Preisangabe als Positionsvorschlag übernehmen (nichts wird automatisch gespeichert). */
/** Vorschlag für eine Rechnungsposition aus dem eingefrorenen Preis-Snapshot eines Vertrags (die laufende Periode). */
app.get('/invoices/suggest-from-contract/:contractId', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { contractId } = z.object({ contractId: z.string().uuid() }).parse(req.params);
const c = await one('SELECT * FROM contracts WHERE id = ?', [contractId]); if (!c) throw notFound();
const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json;
return { orgId: c.org_id, items: [{ description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId }] };
});
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
app.get('/invoices/suggest-from-product/:productId', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { productId } = z.object({ productId: z.string().uuid() }).parse(req.params);
const q = z.object({ termMonths: z.coerce.number().int().min(1).max(120).optional() }).parse(req.query);
const p = await one('SELECT p.*, v.id AS vid, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
if (!p) throw notFound();
let recurringCents = p.recurring_cents; let label = p.name;
if (q.termMonths !== undefined) {
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, q.termMonths]);
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
recurringCents = tier.recurring_cents; label = `${p.name} (${q.termMonths} Monate)`;
}
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
const items = [];
if (price.setup.net > 0) items.push({ description: `Einrichtung: ${p.name}`, quantity: 1, unitPriceNetCents: price.setup.net, taxBp: p.tax_bp });
if (price.recurring.net > 0) items.push({ description: label, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
if (items.length === 0) items.push({ description: label, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
return { items };
});
/** Vorschlag aus der Domain-Aufstellung: der dort errechnete Verkaufspreis (nie der Einkauf), plus Einrichtung falls vorhanden. */
app.get('/invoices/suggest-from-domain/:domainRecordId', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { domainRecordId } = z.object({ domainRecordId: z.string().uuid() }).parse(req.params);
const d = await one('SELECT * FROM domain_records WHERE id = ?', [domainRecordId]); if (!d) throw notFound();
if (d.sell_net_cents === null) throw badRequest('Für diese Domain ist noch kein Verkaufspreis hinterlegt (Aufschlag fehlt unter Domains → Preisliste). Bitte dort ergänzen oder die Position von Hand erfassen.', 'NO_SELL_PRICE');
const items = [];
if (d.setup_cost_cents > 0) { const setupNet = Math.round((d.setup_cost_cents * 10000) / (10000 + d.tax_bp)); items.push({ description: `Einrichtung ${d.domain}`, quantity: 1, unitPriceNetCents: setupNet, taxBp: d.tax_bp }); }
items.push({ description: d.term_months ? `${d.domain} (${d.term_months} Monate)` : d.domain, quantity: 1, unitPriceNetCents: d.sell_net_cents, taxBp: d.tax_bp });
return { orgId: d.org_id, items };
});
app.post('/invoices/:id/issue', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ dueDate: z.string().date().optional() }).parse(req.body ?? {});
const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound();
if (v.status !== 'draft') throw conflict('Die Rechnung wurde bereits ausgestellt.', 'INVOICE_NOT_DRAFT');
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, b.billing_email, b.contact_name 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), 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 } });
await notify('invoice.issued', { number, gross: v.total_gross_cents }, `invoice.issued:${id}`, req.correlationId);
if (org?.billing_email) await mailTo(org.billing_email, 'invoice_issued', { name: org.contact_name || org.company || org.name, number, amount: deMoney(v.total_gross_cents), dueDate: deDate(due) }, `mail:invoice.issued:${id}`, req.correlationId);
return { ok: true, number };
});
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 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). Ü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 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, 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, discount_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.discount_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;
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.cancel', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { creditNumber: number } });
return { ok: true, creditInvoiceId: creditId, creditNumber: number };
});
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 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 };
});
app.get('/invoices/:id/pdf', async (req, reply) => {
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const staff = can(a.principal, 'invoices.read');
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 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, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_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, seller));
});
},
};