Mails wurden bisher nur als Klartext verschickt. sendMail/sendTemplateMail
verschicken jetzt zusätzlich eine einfache, mandantenfarbige HTML-Version
(renderTemplateHtml in @kc/platform/mail): ein Platzhalter, dessen Name auf
"Link" endet, wird darin zu einer Schaltfläche statt einer nackten URL; der
Klartext-Teil behält den Link weiterhin sichtbar (Fallback für Mailprogramme
ohne HTML-Ansicht).
ticket_created/ticket_message bekommen dafür eine neue Variable
{{ticketLink}}, invoice_issued {{invoiceLink}} (Migration 025 – nur
Vorlagen mit noch unverändertem Text werden angepasst, damit von Personal
bereits bearbeitete Texte nicht überschrieben werden).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
305 lines
27 KiB
TypeScript
305 lines
27 KiB
TypeScript
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 { config } from '../../core/config.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), invoiceLink: `${config.baseUrl}/rechnungen/${id}` }, `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));
|
||
});
|
||
},
|
||
};
|