diff --git a/README.md b/README.md index b01758b..b067876 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional). Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`). Support-Tickets (je Kunde, mit internen Notizen für Personal, Dateianhänge als Bild/PDF, Discord-Benachrichtigung). -Noch **nicht** vorhanden: Rechnungen/Zahlungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung). +Rechnungen (`docs/rechnungen.md`): Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF-Erzeugung, Firmenstammdaten unter Einstellungen → Firma. +Noch **nicht** vorhanden: Zahlungsanbieter-Anbindung, automatische wiederkehrende Rechnungsstellung, E-Mail-Versand von Rechnungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung). ## Modularität API-Module liegen in `apps/api/src/modules/` und implementieren `KcModule` (`core/module.ts`). diff --git a/apps/api/package.json b/apps/api/package.json index f9c911f..fec63b6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 9d0472d..1cd3618 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -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]; diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts new file mode 100644 index 0000000..1151c43 --- /dev/null +++ b/apps/api/src/modules/invoices/index.ts @@ -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 { + 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 { + 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, 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 = { 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)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record)[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)); + }); + }, +}; diff --git a/apps/api/src/modules/invoices/pdf.ts b/apps/api/src/modules/invoices/pdf.ts new file mode 100644 index 0000000..c76247d --- /dev/null +++ b/apps/api/src/modules/invoices/pdf.ts @@ -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; +} diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts new file mode 100644 index 0000000..68a6410 --- /dev/null +++ b/apps/api/test/invoices.test.ts @@ -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([]); + }); +}); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index cf7c710..2d6c356 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -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(); }); diff --git a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx index bf90a5b..3c97ff9 100644 --- a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx +++ b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx @@ -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; members: { id: string; email: string; name: string; status: string; role: string }[] } @@ -60,6 +61,7 @@ export default function Kunde() {
{ress.map((r) => )}
NameStatusGültig bisLizenzschlüssel
{r.name}{r.stale && <> Veraltet}{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}{r.canReveal ? : –}
} {can('domains.read') &&

Domains

} + {can('invoices.read') &&
}

Bestellungen

{orders === null ?

Wird geladen …

: orders.length === 0 ? :
diff --git a/apps/web/src/app/(app)/einstellungen/firma/page.tsx b/apps/web/src/app/(app)/einstellungen/firma/page.tsx new file mode 100644 index 0000000..e8024f9 --- /dev/null +++ b/apps/web/src/app/(app)/einstellungen/firma/page.tsx @@ -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(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); + const load = useCallback(() => api('GET', '/admin/company-settings').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function save(e: FormEvent) { + 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 ? {err} :

Wird geladen …

; + return (<> +

Firmenstammdaten

+

Erscheinen auf jeder Rechnung. Ohne Name, Anschrift und Steuernummer/USt-IdNr. können keine Rechnungen ausgestellt werden.

+ {err && {err}}{ok && {ok}} + {!s.complete && Die Angaben sind noch unvollständig. Rechnungen lassen sich erst ausstellen, wenn Name, Anschrift und Steuernummer oder USt-IdNr. eingetragen sind.} +
+
+ + + + + + + +
+

Bankverbindung

+
+ + + +
+

Rechnungseinstellungen

+
+ + + +
+ + {w ? :

Nur Superadministratoren können diese Angaben ändern.

} + + ); +} diff --git a/apps/web/src/app/(app)/einstellungen/layout.tsx b/apps/web/src/app/(app)/einstellungen/layout.tsx index 20d13f4..c0fe73e 100644 --- a/apps/web/src/app/(app)/einstellungen/layout.tsx +++ b/apps/web/src/app/(app)/einstellungen/layout.tsx @@ -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 Keine Berechtigung.; return (<> diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 047db45..ad1009a 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -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' && <> diff --git a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx new file mode 100644 index 0000000..187f731 --- /dev/null +++ b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx @@ -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(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); + const [rows, setRows] = useState([emptyRow()]); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('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, 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) { + 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 ? {err} :

Wird geladen …

; + const isDraft = inv.status === 'draft'; + return (<> +

{inv.number ?? 'Entwurf'} {inv.orgName && · {inv.orgName}}

← Rechnungen
+

{inv.issueDate && <> · ausgestellt {fmt(inv.issueDate)}}{inv.dueDate && <> · fällig {fmt(inv.dueDate)}}{inv.paymentMethod && <> · {inv.paymentMethod}}

+ {inv.cancelsInvoiceId && Dies ist eine Stornorechnung zu einer anderen Rechnung.} + {inv.cancelledByInvoiceId && Diese Rechnung wurde storniert. Storno: ansehen} + {err && {err}}{ok && {ok}} + + {isDraft && inv.canWrite ? (

Positionen

+ {rows.map((r, i) => (
+ setRow(i, 'description', e.target.value)} /> + setRow(i, 'quantity', e.target.value)} /> + setRow(i, 'unitPrice', e.target.value)} placeholder="0,00" /> + setRow(i, 'taxBp', e.target.value)} /> + +
))} +
+
+ + +
+

Solange die Rechnung ein Entwurf ist, lassen sich Positionen beliebig ändern. Nach dem Ausstellen ist sie unveränderlich und nur noch per Storno korrigierbar.

+ ) : ( +
Nr.PositionenBestellt amStatus
+ {inv.items.map((it) => ())} +
BeschreibungMengeEinzelpreisUSt.Netto
{it.description}{it.quantity}{eur(it.unitPriceNetCents)}{it.taxBp / 100} %{eur(it.netCents)}
+ )} + +
+
Netto{eur(inv.totalNetCents)}
+
USt.{eur(inv.totalTaxCents)}
+
Gesamt{eur(inv.totalGrossCents)}
+
+ + {!isDraft &&
+ PDF ansehen + {inv.canWrite && inv.status === 'open' && } + {inv.canWrite && inv.status === 'open' && } +
} + {inv.note &&

Notiz: {inv.note}

} + ); +} diff --git a/apps/web/src/app/(app)/rechnungen/page.tsx b/apps/web/src/app/(app)/rechnungen/page.tsx new file mode 100644 index 0000000..528893d --- /dev/null +++ b/apps/web/src/app/(app)/rechnungen/page.tsx @@ -0,0 +1,3 @@ +'use client'; +import { InvoiceList } from '@/components/InvoiceList'; +export default function Rechnungen() { return (<>

Rechnungen

); } diff --git a/apps/web/src/components/InvoiceList.tsx b/apps/web/src/components/InvoiceList.tsx new file mode 100644 index 0000000..c01e3cd --- /dev/null +++ b/apps/web/src/components/InvoiceList.tsx @@ -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(null); const [status, setStatus] = useState(''); const [err, setErr] = useState(''); + const [open, setOpen] = useState(false); const [custs, setCusts] = useState([]); const [newOrg, setNewOrg] = useState(orgId ?? ''); + const load = useCallback(() => api('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('GET', '/admin/customers').then((l) => { setCusts(l); setNewOrg(l[0]?.id ?? ''); }).catch(() => undefined); }, [w, orgId]); + + async function create(e: FormEvent) { + 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 && {err}} + {w &&

Rechnungen

} + {open &&
+ {!orgId && } + + +
+
} +
+ {w && } + {list === null ?

Wird geladen …

: list.length === 0 ? : +
{!orgId && } + {list.map((i) => ({!orgId && } + ))} +
Nr.KundeStatusDatumFälligBetrag
{i.number ?? 'Entwurf'}{i.orgName}{i.issueDate ? fmt(i.issueDate) : '–'}{i.dueDate ? fmt(i.dueDate) : '–'}{eur(i.totalGrossCents)}
} +
+ ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 9632020..9c6d9dd 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -45,6 +45,8 @@ const CONTRACT: Record = { const badge = (m: Record, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return {l}; }; export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value); export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value); +const INVOICE: Record = { draft: ['info', '○', 'Entwurf'], open: ['warn', '◐', 'Offen'], paid: ['ok', '✓', 'Bezahlt'], cancelled: ['lock', '■', 'Storniert'] }; +export const InvoiceStatusBadge = ({ value, overdue }: { value: string; overdue?: boolean }) => overdue ? Überfällig : badge(INVOICE, value); const TICKET: Record = { open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'], resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'], diff --git a/docs/rechnungen.md b/docs/rechnungen.md new file mode 100644 index 0000000..e612eeb --- /dev/null +++ b/docs/rechnungen.md @@ -0,0 +1,49 @@ +# Rechnungen + +Eigenes Rechnungsmodul (`apps/api/src/modules/invoices`, Migration 016). Kein Invoice Ninja, keine +externe Buchhaltungssoftware – bewusst so gewählt. + +## Lebenszyklus +`draft` → `open` (ausgestellt) → `paid` **oder** `cancelled` (per Storno). + +- **Entwurf (`draft`):** frei bearbeitbar (Positionen ersetzen, löschen). Kunden sehen Entwürfe nie. +- **Ausstellen (`POST /invoices/:id/issue`):** vergibt die Rechnungsnummer (fortlaufend, `-`, + Standard `RE-1000, RE-1001, …`, **ohne Lücken und ohne Rücksetzung pro Jahr** – das ist bewusst so, + weil eine lückenlose Nummerierung einfacher rechtssicher zu halten ist als eine jährliche mit + Neustart). Ab hier ist die Rechnung **unveränderlich**: Positionen, Beträge und Nummer werden nie + wieder geändert. Setzt Ausstellungs- und Fälligkeitsdatum (Zahlungsziel aus den Firmeneinstellungen + oder explizit übergeben). +- **Bezahlt (`POST /invoices/:id/mark-paid`):** rein manuell gepflegt. Es gibt keine Anbindung an ein + Zahlungssystem – niemand hat bisher festgelegt, welcher Zahlungsanbieter (falls überhaupt einer) + genutzt werden soll. +- **Storno (`POST /invoices/:id/cancel`):** Eine ausgestellte Rechnung wird **nie** gelöscht oder + nachträglich verändert (§ 14 UStG, GoBD). Stattdessen entsteht eine neue, ausgestellte + Stornorechnung mit umgekehrten Vorzeichen (eigene Nummer aus derselben Zählerreihe), die auf die + Originalrechnung verweist (`cancelsInvoiceId`); die Originalrechnung bekommt den Status `cancelled` + und einen Rückverweis (`cancelledByInvoiceId`). Beide bleiben für immer einsehbar. + +## Firmenstammdaten (`company_settings`, `/einstellungen/firma`) +Name, Anschrift, Steuernummer/USt-IdNr., Bankverbindung, Rechnungspräfix, Zahlungsziel, Zahlungsarten +(Freitextliste), Fußzeile. **Beim ersten Einrichten absichtlich leer** – nichts davon wurde geraten. +Ohne vollständige Angaben (Name, Anschrift, Steuernummer oder USt-IdNr.) lässt sich keine Rechnung +ausstellen (`COMPANY_SETTINGS_INCOMPLETE`), Entwürfe lassen sich aber schon vorbereiten. Ändern kann +das nur ein Superadministrator (`settings.write`). + +## Rechte +`invoices.read`: Personal (alle Rollen) sowie Kunden für die eigene Organisation (nie Entwürfe). +`invoices.write`: Buchhaltung, Administratoren, Superadministratoren – nicht Support (nur lesend). + +## PDF +Wird bei jedem Aufruf aus den gespeicherten Daten neu gerendert (`pdfkit`, kein Zwischenspeichern), +`GET /invoices/:id/pdf`, nur für ausgestellte Rechnungen. Enthält die Pflichtangaben, soweit aus den +vorhandenen Daten ableitbar (Rechnungsnummer, Datum, Leistungsbeschreibung, Entgelt/Steuersatz je +Position, Steuernummer/USt-IdNr., Empfängeranschrift). **Keine rechtliche Prüfung durch einen +Steuerberater/Anwalt hat stattgefunden** – vor dem ersten Versand an echte Kunden empfohlen. + +## Offen / bewusst nicht gebaut +- Kein Zahlungsanbieter (SEPA-Lastschrift, Kartenzahlung, …) – Entscheidung steht aus. +- Keine automatische Rechnungsstellung aus Verträgen (wiederkehrend); es gibt nur + `GET /invoices/suggest-from-contract/:id` als Vorschlag für eine einzelne Position, die man von + Hand übernimmt. +- Kein E-Mail-Versand der Rechnung (liegt nur zum Abruf bereit, wie beim Lizenzschlüssel-Muster). +- Keine E-Rechnung (XRechnung/ZUGFeRD) – falls das für B2B mit Behörden nötig wird, gesondert klären. diff --git a/migrations/016_invoices.sql b/migrations/016_invoices.sql new file mode 100644 index 0000000..12dd9b8 --- /dev/null +++ b/migrations/016_invoices.sql @@ -0,0 +1,54 @@ +-- Rechnungen: Entwurf ist frei änderbar, ab "Ausstellen" unveränderlich (nur per Storno korrigierbar, +-- niemals nachträglich bearbeitet oder gelöscht – siehe docs/rechnungen.md). +CREATE TABLE company_settings ( + id TINYINT PRIMARY KEY DEFAULT 1, + name VARCHAR(200) NULL, street VARCHAR(200) NULL, zip VARCHAR(20) NULL, city VARCHAR(100) NULL, country CHAR(2) NULL DEFAULT 'DE', + tax_number VARCHAR(50) NULL, -- Steuernummer + vat_id VARCHAR(30) NULL, -- USt-IdNr. + bank_name VARCHAR(150) NULL, iban VARCHAR(34) NULL, bic VARCHAR(11) NULL, + invoice_prefix VARCHAR(10) NOT NULL DEFAULT 'RE', + default_due_days INT NOT NULL DEFAULT 14, + payment_methods JSON NOT NULL DEFAULT (JSON_ARRAY('Überweisung')), + footer_text VARCHAR(500) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +INSERT INTO company_settings (id) VALUES (1); +INSERT INTO number_sequences (name, next_value) VALUES ('invoice', 1000) ON DUPLICATE KEY UPDATE name = name; + +CREATE TABLE invoices ( + id CHAR(36) PRIMARY KEY, + number VARCHAR(30) NULL UNIQUE, -- erst ab "Ausstellen" vergeben; Entwurf hat keine Nummer + org_id CHAR(36) NOT NULL REFERENCES organizations(id), + status ENUM('draft','open','paid','cancelled') NOT NULL DEFAULT 'draft', + issue_date DATE NULL, + due_date DATE NULL, + currency CHAR(3) NOT NULL DEFAULT 'EUR', + total_net_cents INT NOT NULL DEFAULT 0, + total_tax_cents INT NOT NULL DEFAULT 0, + total_gross_cents INT NOT NULL DEFAULT 0, + payment_method VARCHAR(50) NULL, + note VARCHAR(500) NULL, + paid_at DATETIME(3) NULL, + cancels_invoice_id CHAR(36) NULL REFERENCES invoices(id), -- gesetzt bei einer Stornorechnung (negative Beträge) + cancelled_by_invoice_id CHAR(36) NULL REFERENCES invoices(id), -- Rückverweis von der stornierten Rechnung auf ihr Storno + created_by CHAR(36) NOT NULL REFERENCES users(id), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + issued_at DATETIME(3) NULL, + cancelled_at DATETIME(3) NULL, + KEY idx_inv_org (org_id), KEY idx_inv_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE invoice_items ( + id CHAR(36) PRIMARY KEY, + invoice_id CHAR(36) NOT NULL REFERENCES invoices(id), + contract_id CHAR(36) NULL REFERENCES contracts(id), + description VARCHAR(300) NOT NULL, + quantity DECIMAL(10,2) NOT NULL DEFAULT 1, + unit_price_net_cents INT NOT NULL, + tax_bp INT NOT NULL, + net_cents INT NOT NULL, + tax_cents INT NOT NULL, + gross_cents INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + KEY idx_ii_invoice (invoice_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e16b2ab..caf4b19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: otpauth: specifier: ^9.5.2 version: 9.5.2 + pdfkit: + specifier: ^0.20.2 + version: 0.20.2 zod: specifier: ^4.6.5 version: 4.6.5 @@ -59,6 +62,9 @@ importers: '@types/nodemailer': specifier: ^8.0.2 version: 8.0.2 + '@types/pdfkit': + specifier: ^0.17.6 + version: 0.17.6 tsx: specifier: ^4.23.15 version: 4.23.15 @@ -671,6 +677,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.4.0': resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} engines: {node: '>= 20.19.0'} @@ -886,6 +900,9 @@ packages: '@types/nodemailer@8.0.2': resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==} + '@types/pdfkit@0.17.6': + resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -1075,11 +1092,21 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.26: resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} engines: {node: '>=6.0.0'} hasBin: true + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -1097,6 +1124,10 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1127,6 +1158,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -1191,6 +1225,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + find-my-way@9.9.0: resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} engines: {node: '>=20'} @@ -1199,6 +1236,9 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1313,6 +1353,9 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -1399,10 +1442,16 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + pdfkit@0.20.2: + resolution: {integrity: sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1420,6 +1469,9 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + png-js@2.0.0: + resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -1473,6 +1525,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -1569,6 +1624,9 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@6.2.0: resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} @@ -1612,6 +1670,12 @@ packages: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2064,6 +2128,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.6': optional: true + '@noble/ciphers@1.3.0': {} + + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.4.0': {} '@node-rs/argon2-android-arm-eabi@2.2.1': @@ -2202,6 +2270,10 @@ snapshots: dependencies: '@types/node': 26.6.3 + '@types/pdfkit@0.17.6': + dependencies: + '@types/node': 26.6.3 + '@types/qrcode@1.5.6': dependencies: '@types/node': 26.6.3 @@ -2321,8 +2393,16 @@ snapshots: aws-ssl-profiles@1.1.2: {} + base64-js@0.0.8: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.11.26: {} + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + camelcase@5.3.1: {} caniuse-lite@1.0.30001812: {} @@ -2337,6 +2417,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + clone@2.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2355,6 +2437,8 @@ snapshots: detect-libc@2.1.2: {} + dfa@1.2.0: {} + dijkstrajs@1.0.3: {} discord-api-types@0.38.55: {} @@ -2466,6 +2550,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + find-my-way@9.9.0: dependencies: fast-deep-equal: 3.1.3 @@ -2477,6 +2563,18 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + fsevents@2.3.3: optional: true @@ -2561,6 +2659,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -2641,8 +2744,19 @@ snapshots: p-try@2.2.0: {} + pako@0.2.9: {} + path-exists@4.0.0: {} + pdfkit@0.20.2: + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + fflate: 0.8.3 + fontkit: 2.0.4 + linebreak: 1.1.0 + png-js: 2.0.0 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2667,6 +2781,10 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + png-js@2.0.0: + dependencies: + fflate: 0.8.3 + pngjs@5.0.0: {} postcss@8.5.23: @@ -2710,6 +2828,8 @@ snapshots: require-main-filename@2.0.0: {} + restructure@3.0.2: {} + ret@0.5.0: {} reusify@1.1.0: {} @@ -2820,6 +2940,8 @@ snapshots: dependencies: real-require: 1.0.0 + tiny-inflate@1.0.3: {} + tinybench@6.2.0: {} tinyexec@1.3.1: {} @@ -2870,6 +2992,16 @@ snapshots: undici@8.11.2: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0