Rechnungsmodul: Entwurf/Ausstellen/Bezahlt/Storno, PDF, Firmenstammdaten
This commit is contained in:
parent
012f0aa0db
commit
43dc5bc827
18 changed files with 783 additions and 3 deletions
|
|
@ -25,12 +25,14 @@
|
|||
"mysql2": "^3.24.4",
|
||||
"nodemailer": "^10.0.10",
|
||||
"otpauth": "^9.5.2",
|
||||
"pdfkit": "^0.20.2",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kc/connector-mock": "workspace:*",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import { resourcesModule } from './resources/index.js';
|
|||
import { domainsModule } from './domains/index.js';
|
||||
import { catalogModule } from './catalog/index.js';
|
||||
import { ticketsModule } from './tickets/index.js';
|
||||
import { invoicesModule } from './invoices/index.js';
|
||||
import { ordersModule } from './orders/index.js';
|
||||
import { backupModule } from './backup/index.js';
|
||||
|
||||
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
|
||||
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, backupModule];
|
||||
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule];
|
||||
|
|
|
|||
223
apps/api/src/modules/invoices/index.ts
Normal file
223
apps/api/src/modules/invoices/index.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
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) {
|
||||
const net = Math.round(unitNetCents * quantity);
|
||||
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, 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 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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), 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); 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, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
[randomUUID(), id, it.contractId ?? null, it.description, it.quantity, it.unitPriceNetCents, it.taxBp, 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). */
|
||||
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 = JSON.parse(c.price_snapshot_json);
|
||||
return { orgId: c.org_id, description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId };
|
||||
});
|
||||
|
||||
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 due = b.dueDate ?? new Date(Date.now() + s.defaultDueDays * 86400000).toISOString().slice(0, 10);
|
||||
const number = await tx(async (c) => {
|
||||
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);
|
||||
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);
|
||||
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 * 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]);
|
||||
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). */
|
||||
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 s = await settings();
|
||||
const creditId = randomUUID();
|
||||
const number = await tx(async (c) => {
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
[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;
|
||||
});
|
||||
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 * 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]);
|
||||
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 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 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 })),
|
||||
};
|
||||
reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`);
|
||||
return reply.send(renderInvoicePdf(inv, s));
|
||||
});
|
||||
},
|
||||
};
|
||||
65
apps/api/src/modules/invoices/pdf.ts
Normal file
65
apps/api/src/modules/invoices/pdf.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import PDFDocument from 'pdfkit';
|
||||
|
||||
export interface CompanySettings {
|
||||
name: string | null; street: string | null; zip: string | null; city: string | null; country: string | null;
|
||||
taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null;
|
||||
invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null;
|
||||
}
|
||||
export interface InvoiceItemForPdf { description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number }
|
||||
export interface InvoiceForPdf {
|
||||
number: string | null; issueDate: string | null; dueDate: string | null; status: string; paymentMethod: string | null; note: string | null;
|
||||
totalNetCents: number; totalTaxCents: number; totalGrossCents: number;
|
||||
customer: { name: string | null; street: string | null; zip: string | null; city: string | null; country: string | null; vatId: string | null };
|
||||
items: InvoiceItemForPdf[];
|
||||
}
|
||||
const eur = (c: number) => (c / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||||
const de = (d: string | null) => (d ? new Date(d).toLocaleDateString('de-DE') : '–');
|
||||
|
||||
/** Erzeugt ein einfaches, rechtlich vollständiges Rechnungs-PDF (Pflichtangaben nach § 14 UStG,
|
||||
* soweit aus den vorhandenen Daten ableitbar) als Node-Stream. */
|
||||
export function renderInvoicePdf(inv: InvoiceForPdf, s: CompanySettings): PDFKit.PDFDocument {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
const isCredit = inv.totalGrossCents < 0;
|
||||
|
||||
doc.fontSize(9).fillColor('#555').text([s.name, s.street, `${s.zip ?? ''} ${s.city ?? ''}`.trim()].filter(Boolean).join(' · '), 50, 50, { width: 495 });
|
||||
doc.moveDown(2);
|
||||
doc.fontSize(10).fillColor('#000');
|
||||
doc.text(inv.customer.name ?? '', 50, 120);
|
||||
if (inv.customer.street) doc.text(inv.customer.street);
|
||||
doc.text(`${inv.customer.zip ?? ''} ${inv.customer.city ?? ''}`.trim());
|
||||
if (inv.customer.country && inv.customer.country !== 'DE') doc.text(inv.customer.country);
|
||||
if (inv.customer.vatId) doc.text(`USt-IdNr.: ${inv.customer.vatId}`);
|
||||
|
||||
doc.fontSize(18).text(isCredit ? 'Stornorechnung' : 'Rechnung', 50, 200);
|
||||
doc.fontSize(10);
|
||||
const meta: [string, string][] = [['Rechnungsnr.', inv.number ?? '–'], ['Rechnungsdatum', de(inv.issueDate)], ['Fällig am', de(inv.dueDate)]];
|
||||
if (s.taxNumber) meta.push(['Steuernummer', s.taxNumber]); if (s.vatId) meta.push(['USt-IdNr.', s.vatId]);
|
||||
let y = 230; for (const [k, v] of meta) { doc.text(k, 350, y, { width: 100 }); doc.text(v, 450, y, { width: 95, align: 'right' }); y += 16; }
|
||||
|
||||
const top = y + 20; const col = { desc: 50, qty: 300, unit: 350, tax: 420, sum: 470 };
|
||||
doc.fontSize(9).fillColor('#555');
|
||||
doc.text('Beschreibung', col.desc, top).text('Menge', col.qty, top).text('Einzelpreis', col.unit, top).text('USt.', col.tax, top).text('Netto', col.sum, top, { width: 75, align: 'right' });
|
||||
doc.moveTo(50, top + 14).lineTo(545, top + 14).strokeColor('#ccc').stroke();
|
||||
let ry = top + 20; doc.fillColor('#000');
|
||||
for (const it of inv.items) {
|
||||
const h = doc.heightOfString(it.description, { width: 240 });
|
||||
doc.text(it.description, col.desc, ry, { width: 240 }); doc.text(String(it.quantity), col.qty, ry);
|
||||
doc.text(eur(it.unitPriceNetCents), col.unit, ry); doc.text(`${it.taxBp / 100} %`, col.tax, ry);
|
||||
doc.text(eur(it.netCents), col.sum, ry, { width: 75, align: 'right' });
|
||||
ry += Math.max(h, 14) + 6;
|
||||
}
|
||||
doc.moveTo(50, ry).lineTo(545, ry).strokeColor('#ccc').stroke(); ry += 10;
|
||||
const sumLine = (label: string, val: string, bold = false) => { doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').text(label, 400, ry, { width: 70 }); doc.text(val, col.sum, ry, { width: 75, align: 'right' }); ry += 16; };
|
||||
sumLine('Netto', eur(inv.totalNetCents)); sumLine('USt.', eur(inv.totalTaxCents)); sumLine('Gesamt', eur(inv.totalGrossCents), true);
|
||||
doc.font('Helvetica');
|
||||
|
||||
ry += 20;
|
||||
if (inv.paymentMethod) { doc.fontSize(10).text(`Zahlungsart: ${inv.paymentMethod}`, 50, ry); ry += 16; }
|
||||
if (s.iban) { doc.text(`${s.bankName ?? ''} · IBAN ${s.iban}${s.bic ? ` · BIC ${s.bic}` : ''}`.trim(), 50, ry); ry += 16; }
|
||||
if (!isCredit && inv.totalGrossCents > 0) { doc.text(`Bitte überweisen Sie den Betrag bis zum ${de(inv.dueDate)} unter Angabe der Rechnungsnummer ${inv.number}.`, 50, ry, { width: 495 }); ry += 20; }
|
||||
if (inv.note) { doc.fontSize(9).fillColor('#555').text(inv.note, 50, ry, { width: 495 }); }
|
||||
|
||||
if (s.footerText) doc.fontSize(8).fillColor('#888').text(s.footerText, 50, 770, { width: 495, align: 'center' });
|
||||
doc.end();
|
||||
return doc;
|
||||
}
|
||||
69
apps/api/test/invoices.test.ts
Normal file
69
apps/api/test/invoices.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp } from '../src/server.js';
|
||||
import { call, code, login, makeUser } from './helpers.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
beforeAll(async () => { app = await buildApp(); await app.ready(); });
|
||||
async function staff(email: string, role: string) {
|
||||
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
|
||||
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
|
||||
}
|
||||
async function customer(admin: any, name: string, mail: string) {
|
||||
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
|
||||
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
|
||||
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
|
||||
}
|
||||
const COMPANY = { name: 'Testfirma GmbH', street: 'Musterstr. 1', zip: '12345', city: 'Musterstadt', taxNumber: '12/345/67890' };
|
||||
|
||||
describe('Rechnungen', () => {
|
||||
it('Entwurf, Positionen, unvollständige Firmendaten blockieren das Ausstellen, danach ausstellen/PDF/bezahlt/Storno, Unveränderlichkeit, Mandantentrennung', async () => {
|
||||
const admin = await staff('inv-admin@example.com', 'superadmin'); const acc = await staff('inv-acc@example.com', 'accounting'); const support = await staff('inv-support@example.com', 'support');
|
||||
const A = await customer(admin, 'Kunde A', 'inv-a@example.com'); const B = await customer(admin, 'Kunde B', 'inv-b@example.com');
|
||||
|
||||
expect((await call(app, support, 'POST', '/invoices', { orgId: A.org })).statusCode).toBe(403); // Support nur lesend
|
||||
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org, paymentMethod: 'Überweisung' })).json();
|
||||
expect((await call(app, A.client, 'GET', `/invoices/${draft.id}`)).statusCode).toBe(404); // Kunde sieht Entwurf nicht
|
||||
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().status).toBe('draft');
|
||||
|
||||
const items = [{ description: 'Hosting Paket M, September', quantity: 1, unitPriceNetCents: 10000, taxBp: 1900 }, { description: 'Domain-Aufschlag', quantity: 2, unitPriceNetCents: 500, taxBp: 1900 }];
|
||||
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items })).statusCode).toBe(200);
|
||||
const withItems = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json();
|
||||
expect(withItems.totalNetCents).toBe(11000); expect(withItems.totalTaxCents).toBe(2090); expect(withItems.totalGrossCents).toBe(13090);
|
||||
|
||||
// ohne vollständige Firmendaten kein Ausstellen
|
||||
const blocked = await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('COMPANY_SETTINGS_INCOMPLETE');
|
||||
expect((await call(app, acc, 'PUT', '/admin/company-settings', COMPANY)).statusCode).toBe(403); // nur superadmin
|
||||
expect((await call(app, admin, 'PUT', '/admin/company-settings', COMPANY)).statusCode).toBe(200);
|
||||
expect((await call(app, acc, 'GET', '/admin/company-settings')).json().complete).toBe(true);
|
||||
|
||||
const issued = await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); expect(issued.statusCode).toBe(200); const number = issued.json().number; expect(number).toMatch(/^RE-\d+$/);
|
||||
// ab jetzt unveränderlich
|
||||
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items })).statusCode).toBe(409);
|
||||
expect((await call(app, acc, 'DELETE', `/invoices/${draft.id}`)).statusCode).toBe(403);
|
||||
|
||||
// Kunde sieht sie jetzt, PDF ist ladbar; fremder Kunde nicht
|
||||
const seen = (await call(app, A.client, 'GET', `/invoices/${draft.id}`)).json(); expect(seen.number).toBe(number); expect(seen.status).toBe('open');
|
||||
expect((await call(app, B.client, 'GET', `/invoices/${draft.id}`)).statusCode).toBe(404);
|
||||
const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: A.client.cookie } });
|
||||
expect(pdf.statusCode).toBe(200); expect(pdf.headers['content-type']).toBe('application/pdf'); expect(pdf.rawPayload.subarray(0, 4).toString()).toBe('%PDF');
|
||||
|
||||
// Storno: neue Rechnung mit umgekehrten Vorzeichen, Original unveränderlich als storniert markiert
|
||||
const credit = await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`, { reason: 'Testkorrektur' }); expect(credit.statusCode).toBe(200);
|
||||
const orig = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json(); expect(orig.status).toBe('cancelled'); expect(orig.cancelledByInvoiceId).toBe(credit.json().creditInvoiceId);
|
||||
const cr = (await call(app, acc, 'GET', `/invoices/${credit.json().creditInvoiceId}`)).json(); expect(cr.totalGrossCents).toBe(-13090); expect(cr.cancelsInvoiceId).toBe(draft.id);
|
||||
expect((await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).statusCode).toBe(409); // nicht doppelt stornierbar
|
||||
expect((await call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`)).statusCode).toBe(409); // stornierte Rechnung nicht mehr "bezahlbar"
|
||||
|
||||
// zweite, normal bezahlte Rechnung
|
||||
const d2 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
|
||||
await call(app, acc, 'PUT', `/invoices/${d2.id}/items`, { items: [{ description: 'Setup', quantity: 1, unitPriceNetCents: 2000, taxBp: 1900 }] });
|
||||
await call(app, acc, 'POST', `/invoices/${d2.id}/issue`);
|
||||
expect((await call(app, acc, 'POST', `/invoices/${d2.id}/mark-paid`)).statusCode).toBe(200);
|
||||
expect((await call(app, acc, 'GET', `/invoices/${d2.id}`)).json().status).toBe('paid');
|
||||
|
||||
// Listen: Kunde sieht nur eigene, keine Entwürfe
|
||||
const custList = (await call(app, A.client, 'GET', '/invoices')).json(); expect(custList.every((i: any) => i.status !== 'draft')).toBe(true); expect(custList.length).toBe(3);
|
||||
const staffOpenB = (await call(app, acc, 'GET', `/invoices?org=${B.org}`)).json(); expect(staffOpenB).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
|
|||
import '../src/core/config.js';
|
||||
|
||||
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
|
||||
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
||||
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
||||
beforeAll(async () => {
|
||||
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
|
||||
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
|
||||
|
|
@ -13,5 +13,6 @@ beforeAll(async () => {
|
|||
await c.query("UPDATE customer_sequences SET next_value = 10000; UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END".split(';')[0]);
|
||||
await c.query("UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END");
|
||||
await c.query('UPDATE domain_settings SET tier = 1, margin_type = NULL, margin_value = NULL');
|
||||
await c.query("UPDATE company_settings SET name=NULL, street=NULL, zip=NULL, city=NULL, country='DE', tax_number=NULL, vat_id=NULL, bank_name=NULL, iban=NULL, bic=NULL, invoice_prefix='RE', default_due_days=14, payment_methods=JSON_ARRAY('Überweisung'), footer_text=NULL WHERE id=1");
|
||||
await c.end();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { api, errMsg } from '@/lib/api';
|
|||
import { useSession } from '@/lib/session';
|
||||
import { SecretField } from '@/components/SecretField';
|
||||
import { DomainRecords } from '@/components/DomainRecords';
|
||||
import { InvoiceList } from '@/components/InvoiceList';
|
||||
import { Alert, ContractStatusBadge, Empty, Field, fmt, OrderStatusBadge, ResState, ROLE_LABEL, Status } from '@/components/ui';
|
||||
|
||||
interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record<string, string | null>; members: { id: string; email: string; name: string; status: string; role: string }[] }
|
||||
|
|
@ -60,6 +61,7 @@ export default function Kunde() {
|
|||
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Status</th><th>Gültig bis</th><th>Lizenzschlüssel</th></tr></thead><tbody>
|
||||
{ress.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td><td><ResState value={r.state} /></td><td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td></tr>)}</tbody></table></div>}</div>
|
||||
{can('domains.read') && <section aria-labelledby="h-dom"><h2 id="h-dom">Domains</h2><DomainRecords orgId={String(id)} /></section>}
|
||||
{can('invoices.read') && <section aria-labelledby="h-inv"><InvoiceList orgId={String(id)} /></section>}
|
||||
<div className="card"><h2>Bestellungen</h2>
|
||||
{orders === null ? <p className="muted" role="status">Wird geladen …</p> : orders.length === 0 ? <Empty title="Noch keine Bestellungen" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Positionen</th><th>Bestellt am</th><th>Status</th></tr></thead><tbody>
|
||||
|
|
|
|||
60
apps/web/src/app/(app)/einstellungen/firma/page.tsx
Normal file
60
apps/web/src/app/(app)/einstellungen/firma/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field } from '@/components/ui';
|
||||
|
||||
interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean }
|
||||
|
||||
export default function Firma() {
|
||||
const { can } = useSession(); const w = can('settings.write');
|
||||
const [s, setS] = useState<Settings | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
|
||||
const load = useCallback(() => api<Settings>('GET', '/admin/company-settings').then(setS).catch((e) => setErr(errMsg(e))), []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function save(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined);
|
||||
try {
|
||||
await api('PUT', '/admin/company-settings', {
|
||||
name: v('name'), street: v('street'), zip: v('zip'), city: v('city'), country: v('country') ?? 'DE',
|
||||
taxNumber: v('taxNumber'), vatId: v('vatId'), bankName: v('bankName'), iban: v('iban'), bic: v('bic'),
|
||||
invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14),
|
||||
paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean),
|
||||
footerText: v('footerText') ?? null,
|
||||
});
|
||||
setOk('Gespeichert.'); void load();
|
||||
} catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
return (<>
|
||||
<h2>Firmenstammdaten</h2>
|
||||
<p className="muted">Erscheinen auf jeder Rechnung. Ohne Name, Anschrift und Steuernummer/USt-IdNr. können keine Rechnungen ausgestellt werden.</p>
|
||||
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
{!s.complete && <Alert kind="warn">Die Angaben sind noch unvollständig. Rechnungen lassen sich erst ausstellen, wenn Name, Anschrift und Steuernummer oder USt-IdNr. eingetragen sind.</Alert>}
|
||||
<form className="card" onSubmit={save}>
|
||||
<div className="cols">
|
||||
<Field id="name" label="Firmenname"><input id="name" name="name" defaultValue={s.name ?? ''} disabled={!w} /></Field>
|
||||
<Field id="street" label="Straße, Nr."><input id="street" name="street" defaultValue={s.street ?? ''} disabled={!w} /></Field>
|
||||
<Field id="zip" label="PLZ"><input id="zip" name="zip" defaultValue={s.zip ?? ''} disabled={!w} /></Field>
|
||||
<Field id="city" label="Ort"><input id="city" name="city" defaultValue={s.city ?? ''} disabled={!w} /></Field>
|
||||
<Field id="country" label="Land (ISO)"><input id="country" name="country" maxLength={2} defaultValue={s.country} disabled={!w} /></Field>
|
||||
<Field id="taxNumber" label="Steuernummer"><input id="taxNumber" name="taxNumber" defaultValue={s.taxNumber ?? ''} disabled={!w} /></Field>
|
||||
<Field id="vatId" label="USt-IdNr." hint="Optional, ersetzt bei Bedarf die Steuernummer"><input id="vatId" name="vatId" defaultValue={s.vatId ?? ''} disabled={!w} /></Field>
|
||||
</div>
|
||||
<h3>Bankverbindung</h3>
|
||||
<div className="cols">
|
||||
<Field id="bankName" label="Bank"><input id="bankName" name="bankName" defaultValue={s.bankName ?? ''} disabled={!w} /></Field>
|
||||
<Field id="iban" label="IBAN"><input id="iban" name="iban" defaultValue={s.iban ?? ''} disabled={!w} /></Field>
|
||||
<Field id="bic" label="BIC"><input id="bic" name="bic" defaultValue={s.bic ?? ''} disabled={!w} /></Field>
|
||||
</div>
|
||||
<h3>Rechnungseinstellungen</h3>
|
||||
<div className="cols">
|
||||
<Field id="invoicePrefix" label="Rechnungsnummern-Präfix" hint="z. B. RE → RE-1000, RE-1001, …"><input id="invoicePrefix" name="invoicePrefix" defaultValue={s.invoicePrefix} disabled={!w} /></Field>
|
||||
<Field id="defaultDueDays" label="Zahlungsziel (Tage)"><input id="defaultDueDays" name="defaultDueDays" type="number" min={0} max={180} defaultValue={s.defaultDueDays} disabled={!w} /></Field>
|
||||
<Field id="paymentMethods" label="Zahlungsarten" hint="kommagetrennt"><input id="paymentMethods" name="paymentMethods" defaultValue={s.paymentMethods.join(', ')} disabled={!w} /></Field>
|
||||
</div>
|
||||
<Field id="footerText" label="Fußzeile auf der Rechnung (optional)" hint="z. B. Geschäftsführer, Registergericht"><input id="footerText" name="footerText" defaultValue={s.footerText ?? ''} disabled={!w} /></Field>
|
||||
{w ? <button className="btn primary" type="submit">Speichern</button> : <p className="muted small">Nur Superadministratoren können diese Angaben ändern.</p>}
|
||||
</form>
|
||||
</>);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
|
|||
const tabs = [
|
||||
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
|
||||
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
|
||||
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
|
||||
].filter((t) => t.show);
|
||||
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||
return (<>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
{!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')}
|
||||
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')}
|
||||
{!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && link('/tickets', 'Tickets')}
|
||||
{!enrollNeeded && (me.kind === 'customer' || can('invoices.read')) && link('/rechnungen', 'Rechnungen')}
|
||||
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
|
||||
{!enrollNeeded && link('/domains', 'Domain prüfen')}
|
||||
{!enrollNeeded && me.kind === 'staff' && <>
|
||||
|
|
|
|||
72
apps/web/src/app/(app)/rechnungen/[id]/page.tsx
Normal file
72
apps/web/src/app/(app)/rechnungen/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui';
|
||||
|
||||
interface Item { id: string; description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number }
|
||||
interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean }
|
||||
type Row = { description: string; quantity: string; unitPrice: string; taxBp: string };
|
||||
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
|
||||
|
||||
export default function RechnungDetail() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read');
|
||||
const [inv, setInv] = useState<Inv | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
|
||||
const [rows, setRows] = useState<Row[]>([emptyRow()]); const [busy, setBusy] = useState(false);
|
||||
const load = useCallback(() => api<Inv>('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) })) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
|
||||
async function saveItems(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) }));
|
||||
if (items.length === 0) { setErr('Bitte mindestens eine Position angeben.'); return; }
|
||||
await run(() => api('PUT', `/invoices/${id}/items`, { items }), 'Gespeichert.');
|
||||
}
|
||||
const setRow = (i: number, k: keyof Row, v: string) => setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, [k]: v } : r)));
|
||||
|
||||
if (!inv) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
const isDraft = inv.status === 'draft';
|
||||
return (<>
|
||||
<div className="row between"><h1>{inv.number ?? 'Entwurf'} {inv.orgName && <span className="muted">· {inv.orgName}</span>}</h1><Link className="btn" href="/rechnungen">← Rechnungen</Link></div>
|
||||
<p className="muted small"><InvoiceStatusBadge value={inv.status} overdue={inv.overdue} />{inv.issueDate && <> · ausgestellt {fmt(inv.issueDate)}</>}{inv.dueDate && <> · fällig {fmt(inv.dueDate)}</>}{inv.paymentMethod && <> · {inv.paymentMethod}</>}</p>
|
||||
{inv.cancelsInvoiceId && <Alert kind="info">Dies ist eine Stornorechnung zu <Link href={`/rechnungen/${inv.cancelsInvoiceId}`}>einer anderen Rechnung</Link>.</Alert>}
|
||||
{inv.cancelledByInvoiceId && <Alert kind="warn">Diese Rechnung wurde storniert. Storno: <Link href={`/rechnungen/${inv.cancelledByInvoiceId}`}>ansehen</Link></Alert>}
|
||||
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
|
||||
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
|
||||
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
|
||||
<Field id={`d${i}`} label="Beschreibung"><input id={`d${i}`} value={r.description} onChange={(e) => setRow(i, 'description', e.target.value)} /></Field>
|
||||
<Field id={`q${i}`} label="Menge"><input id={`q${i}`} value={r.quantity} onChange={(e) => setRow(i, 'quantity', e.target.value)} /></Field>
|
||||
<Field id={`u${i}`} label="Einzelpreis netto (€)"><input id={`u${i}`} value={r.unitPrice} onChange={(e) => setRow(i, 'unitPrice', e.target.value)} placeholder="0,00" /></Field>
|
||||
<Field id={`t${i}`} label="USt. %"><input id={`t${i}`} value={r.taxBp} onChange={(e) => setRow(i, 'taxBp', e.target.value)} /></Field>
|
||||
<button className="btn small" type="button" onClick={() => setRows((rs) => rs.filter((_, idx) => idx !== i))} disabled={rows.length === 1}>Entfernen</button>
|
||||
</div>))}
|
||||
<div className="row" style={{ margin: '8px 0 16px' }}><button className="btn small" type="button" onClick={() => setRows((rs) => [...rs, emptyRow()])}>+ Position</button></div>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={busy}>Positionen speichern</button>
|
||||
<button className="btn" type="button" disabled={busy} onClick={() => run(() => api('POST', `/invoices/${id}/issue`, {}), 'Ausgestellt.')}>Ausstellen</button>
|
||||
<button className="btn" type="button" disabled={busy} onClick={() => confirm('Entwurf wirklich löschen?') && run(async () => { await api('DELETE', `/invoices/${id}`); location.href = '/rechnungen'; })}>Entwurf löschen</button>
|
||||
</div>
|
||||
<p className="muted small">Solange die Rechnung ein Entwurf ist, lassen sich Positionen beliebig ändern. Nach dem Ausstellen ist sie unveränderlich und nur noch per Storno korrigierbar.</p>
|
||||
</form>) : (
|
||||
<div className="card"><table style={{ width: '100%' }}><thead><tr><th style={{ textAlign: 'left' }}>Beschreibung</th><th>Menge</th><th>Einzelpreis</th><th>USt.</th><th style={{ textAlign: 'right' }}>Netto</th></tr></thead><tbody>
|
||||
{inv.items.map((it) => (<tr key={it.id}><td>{it.description}</td><td style={{ textAlign: 'center' }}>{it.quantity}</td><td style={{ textAlign: 'center' }}>{eur(it.unitPriceNetCents)}</td><td style={{ textAlign: 'center' }}>{it.taxBp / 100} %</td><td style={{ textAlign: 'right' }}>{eur(it.netCents)}</td></tr>))}
|
||||
</tbody></table></div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ maxWidth: 320, marginLeft: 'auto' }}>
|
||||
<div className="row between"><span>Netto</span><span>{eur(inv.totalNetCents)}</span></div>
|
||||
<div className="row between"><span>USt.</span><span>{eur(inv.totalTaxCents)}</span></div>
|
||||
<div className="row between" style={{ fontWeight: 600 }}><span>Gesamt</span><span>{eur(inv.totalGrossCents)}</span></div>
|
||||
</div>
|
||||
|
||||
{!isDraft && <div className="row" style={{ marginTop: 16 }}>
|
||||
<a className="btn" href={`/api/invoices/${id}/pdf`} target="_blank" rel="noreferrer">PDF ansehen</a>
|
||||
{inv.canWrite && inv.status === 'open' && <button className="btn" disabled={busy} onClick={() => run(() => api('POST', `/invoices/${id}/mark-paid`), 'Als bezahlt markiert.')}>Als bezahlt markieren</button>}
|
||||
{inv.canWrite && inv.status === 'open' && <button className="btn" disabled={busy} onClick={() => { const reason = prompt('Grund für den Storno (optional):') ?? undefined; run(() => api('POST', `/invoices/${id}/cancel`, reason ? { reason } : {}), 'Storniert.'); }}>Stornieren</button>}
|
||||
</div>}
|
||||
{inv.note && <p className="muted small" style={{ marginTop: 16 }}>Notiz: {inv.note}</p>}
|
||||
</>);
|
||||
}
|
||||
3
apps/web/src/app/(app)/rechnungen/page.tsx
Normal file
3
apps/web/src/app/(app)/rechnungen/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
'use client';
|
||||
import { InvoiceList } from '@/components/InvoiceList';
|
||||
export default function Rechnungen() { return (<><h1>Rechnungen</h1><InvoiceList /></>); }
|
||||
42
apps/web/src/components/InvoiceList.tsx
Normal file
42
apps/web/src/components/InvoiceList.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui';
|
||||
|
||||
interface Inv { id: string; number: string | null; orgId: string; orgName: string; customerNumber: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; totalGrossCents: number; createdAt: string }
|
||||
interface Cust { id: string; name: string; customerNumber: string }
|
||||
|
||||
export function InvoiceList({ orgId }: { orgId?: string }) {
|
||||
const { can } = useSession(); const w = can('invoices.write');
|
||||
const [list, setList] = useState<Inv[] | null>(null); const [status, setStatus] = useState(''); const [err, setErr] = useState('');
|
||||
const [open, setOpen] = useState(false); const [custs, setCusts] = useState<Cust[]>([]); const [newOrg, setNewOrg] = useState(orgId ?? '');
|
||||
const load = useCallback(() => api<Inv[]>('GET', `/invoices?${new URLSearchParams({ ...(orgId ? { org: orgId } : {}), ...(status ? { status } : {}) })}`).then(setList).catch((e) => setErr(errMsg(e))), [orgId, status]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
useEffect(() => { if (w && !orgId) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setNewOrg(l[0]?.id ?? ''); }).catch(() => undefined); }, [w, orgId]);
|
||||
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget);
|
||||
try { const inv = await api<{ id: string }>('POST', '/invoices', { orgId: newOrg, paymentMethod: f.get('paymentMethod') || undefined, note: f.get('note') || undefined }); location.href = `/rechnungen/${inv.id}`; }
|
||||
catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
return (<>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{w && <div className="row between" style={{ marginBottom: 16 }}><h2 style={{ margin: 0 }}>Rechnungen</h2><button className="btn primary" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Rechnung (Entwurf)</button></div>}
|
||||
{open && <form className="card" onSubmit={create}>
|
||||
{!orgId && <Field id="org" label="Kunde"><select id="org" value={newOrg} onChange={(e) => setNewOrg(e.target.value)}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>}
|
||||
<Field id="paymentMethod" label="Zahlungsart (optional)"><input id="paymentMethod" name="paymentMethod" /></Field>
|
||||
<Field id="note" label="Notiz (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={!newOrg}>Anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div>
|
||||
</form>}
|
||||
<div className="card">
|
||||
{w && <Field id="ifs" label="Status"><select id="ifs" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option><option value="draft">Entwurf</option><option value="open">Offen</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option></select></Field>}
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Rechnungen" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th>{!orgId && <th>Kunde</th>}<th>Status</th><th>Datum</th><th>Fällig</th><th>Betrag</th></tr></thead><tbody>
|
||||
{list.map((i) => (<tr key={i.id}><td className="mono"><Link href={`/rechnungen/${i.id}`}>{i.number ?? 'Entwurf'}</Link></td>{!orgId && <td>{i.orgName}</td>}
|
||||
<td><InvoiceStatusBadge value={i.status} overdue={i.overdue} /></td><td className="small">{i.issueDate ? fmt(i.issueDate) : '–'}</td><td className="small">{i.dueDate ? fmt(i.dueDate) : '–'}</td><td>{eur(i.totalGrossCents)}</td></tr>))}
|
||||
</tbody></table></div>}
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
|
|
@ -45,6 +45,8 @@ const CONTRACT: Record<string, [string, string, string]> = {
|
|||
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
|
||||
export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
|
||||
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value);
|
||||
const INVOICE: Record<string, [string, string, string]> = { draft: ['info', '○', 'Entwurf'], open: ['warn', '◐', 'Offen'], paid: ['ok', '✓', 'Bezahlt'], cancelled: ['lock', '■', 'Storniert'] };
|
||||
export const InvoiceStatusBadge = ({ value, overdue }: { value: string; overdue?: boolean }) => overdue ? <span className="badge err"><span aria-hidden="true">▲</span>Überfällig</span> : badge(INVOICE, value);
|
||||
const TICKET: Record<string, [string, string, string]> = {
|
||||
open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'],
|
||||
resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue