Rechnungsmodul: Entwurf/Ausstellen/Bezahlt/Storno, PDF, Firmenstammdaten

This commit is contained in:
Kundencenter 2026-09-27 09:05:13 +02:00
parent 012f0aa0db
commit 43dc5bc827
18 changed files with 783 additions and 3 deletions

View file

@ -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). 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`). 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). 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 ## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`). API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`).

View file

@ -25,12 +25,14 @@
"mysql2": "^3.24.4", "mysql2": "^3.24.4",
"nodemailer": "^10.0.10", "nodemailer": "^10.0.10",
"otpauth": "^9.5.2", "otpauth": "^9.5.2",
"pdfkit": "^0.20.2",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@kc/connector-mock": "workspace:*", "@kc/connector-mock": "workspace:*",
"@types/node": "^26.6.3", "@types/node": "^26.6.3",
"@types/nodemailer": "^8.0.2", "@types/nodemailer": "^8.0.2",
"@types/pdfkit": "^0.17.6",
"tsx": "^4.23.15", "tsx": "^4.23.15",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vitest": "^5.0.2" "vitest": "^5.0.2"

View file

@ -8,8 +8,9 @@ import { resourcesModule } from './resources/index.js';
import { domainsModule } from './domains/index.js'; import { domainsModule } from './domains/index.js';
import { catalogModule } from './catalog/index.js'; import { catalogModule } from './catalog/index.js';
import { ticketsModule } from './tickets/index.js'; import { ticketsModule } from './tickets/index.js';
import { invoicesModule } from './invoices/index.js';
import { ordersModule } from './orders/index.js'; import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/index.js'; import { backupModule } from './backup/index.js';
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */ /** 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];

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

View 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;
}

View 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([]);
});
});

View file

@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
import '../src/core/config.js'; import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */ /** 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 () => { beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen'); 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' }); 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 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 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 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(); await c.end();
}); });

View file

@ -6,6 +6,7 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { SecretField } from '@/components/SecretField'; import { SecretField } from '@/components/SecretField';
import { DomainRecords } from '@/components/DomainRecords'; import { DomainRecords } from '@/components/DomainRecords';
import { InvoiceList } from '@/components/InvoiceList';
import { Alert, ContractStatusBadge, Empty, Field, fmt, OrderStatusBadge, ResState, ROLE_LABEL, Status } from '@/components/ui'; 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 }[] } 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> <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> {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('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> <div className="card"><h2>Bestellungen</h2>
{orders === null ? <p className="muted" role="status">Wird geladen …</p> : orders.length === 0 ? <Empty title="Noch keine Bestellungen" /> : {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> <div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Positionen</th><th>Bestellt am</th><th>Status</th></tr></thead><tbody>

View 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>
</>);
}

View file

@ -11,6 +11,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
const tabs = [ const tabs = [
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') }, { href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') }, { href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
].filter((t) => t.show); ].filter((t) => t.show);
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>; if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
return (<> return (<>

View file

@ -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('contracts.read')) && link('/vertraege', 'Verträge')}
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')} {!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('tickets.read')) && link('/tickets', 'Tickets')}
{!enrollNeeded && (me.kind === 'customer' || can('invoices.read')) && link('/rechnungen', 'Rechnungen')}
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')} {!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
{!enrollNeeded && link('/domains', 'Domain prüfen')} {!enrollNeeded && link('/domains', 'Domain prüfen')}
{!enrollNeeded && me.kind === 'staff' && <> {!enrollNeeded && me.kind === 'staff' && <>

View 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>}
</>);
}

View file

@ -0,0 +1,3 @@
'use client';
import { InvoiceList } from '@/components/InvoiceList';
export default function Rechnungen() { return (<><h1>Rechnungen</h1><InvoiceList /></>); }

View 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>
</>);
}

View file

@ -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>; }; 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 OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, 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]> = { const TICKET: Record<string, [string, string, string]> = {
open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'], open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'],
resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'], resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'],

49
docs/rechnungen.md Normal file
View file

@ -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, `<Präfix>-<Zähler>`,
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.

View file

@ -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;

132
pnpm-lock.yaml generated
View file

@ -46,6 +46,9 @@ importers:
otpauth: otpauth:
specifier: ^9.5.2 specifier: ^9.5.2
version: 9.5.2 version: 9.5.2
pdfkit:
specifier: ^0.20.2
version: 0.20.2
zod: zod:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5 version: 4.6.5
@ -59,6 +62,9 @@ importers:
'@types/nodemailer': '@types/nodemailer':
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
'@types/pdfkit':
specifier: ^0.17.6
version: 0.17.6
tsx: tsx:
specifier: ^4.23.15 specifier: ^4.23.15
version: 4.23.15 version: 4.23.15
@ -671,6 +677,14 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@noble/hashes@2.4.0':
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
@ -886,6 +900,9 @@ packages:
'@types/nodemailer@8.0.2': '@types/nodemailer@8.0.2':
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==} resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
'@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@ -1075,11 +1092,21 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'} 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: baseline-browser-mapping@2.11.26:
resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
camelcase@5.3.1: camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1097,6 +1124,10 @@ packages:
cliui@6.0.0: cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -1127,6 +1158,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
dijkstrajs@1.0.3: dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
@ -1191,6 +1225,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
find-my-way@9.9.0: find-my-way@9.9.0:
resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -1199,6 +1236,9 @@ packages:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'} engines: {node: '>=8'}
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1313,6 +1353,9 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
locate-path@5.0.0: locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1399,10 +1442,16 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
pdfkit@0.20.2:
resolution: {integrity: sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1420,6 +1469,9 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true hasBin: true
png-js@2.0.0:
resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==}
pngjs@5.0.0: pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -1473,6 +1525,9 @@ packages:
require-main-filename@2.0.0: require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
ret@0.5.0: ret@0.5.0:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1569,6 +1624,9 @@ packages:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tinybench@6.2.0: tinybench@6.2.0:
resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@ -1612,6 +1670,12 @@ packages:
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
engines: {node: '>=22.19.0'} 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: vite@8.3.1:
resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -2064,6 +2128,10 @@ snapshots:
'@next/swc-win32-x64-msvc@16.3.6': '@next/swc-win32-x64-msvc@16.3.6':
optional: true optional: true
'@noble/ciphers@1.3.0': {}
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.4.0': {} '@noble/hashes@2.4.0': {}
'@node-rs/argon2-android-arm-eabi@2.2.1': '@node-rs/argon2-android-arm-eabi@2.2.1':
@ -2202,6 +2270,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 26.6.3 '@types/node': 26.6.3
'@types/pdfkit@0.17.6':
dependencies:
'@types/node': 26.6.3
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
dependencies: dependencies:
'@types/node': 26.6.3 '@types/node': 26.6.3
@ -2321,8 +2393,16 @@ snapshots:
aws-ssl-profiles@1.1.2: {} aws-ssl-profiles@1.1.2: {}
base64-js@0.0.8: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.11.26: {} baseline-browser-mapping@2.11.26: {}
brotli@1.3.3:
dependencies:
base64-js: 1.5.1
camelcase@5.3.1: {} camelcase@5.3.1: {}
caniuse-lite@1.0.30001812: {} caniuse-lite@1.0.30001812: {}
@ -2337,6 +2417,8 @@ snapshots:
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wrap-ansi: 6.2.0 wrap-ansi: 6.2.0
clone@2.1.2: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -2355,6 +2437,8 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dfa@1.2.0: {}
dijkstrajs@1.0.3: {} dijkstrajs@1.0.3: {}
discord-api-types@0.38.55: {} discord-api-types@0.38.55: {}
@ -2466,6 +2550,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 picomatch: 4.0.7
fflate@0.8.3: {}
find-my-way@9.9.0: find-my-way@9.9.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@ -2477,6 +2563,18 @@ snapshots:
locate-path: 5.0.0 locate-path: 5.0.0
path-exists: 4.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: fsevents@2.3.3:
optional: true optional: true
@ -2561,6 +2659,11 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-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: locate-path@5.0.0:
dependencies: dependencies:
p-locate: 4.1.0 p-locate: 4.1.0
@ -2641,8 +2744,19 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
pako@0.2.9: {}
path-exists@4.0.0: {} 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: {} picocolors@1.1.1: {}
picomatch@4.0.7: {} picomatch@4.0.7: {}
@ -2667,6 +2781,10 @@ snapshots:
sonic-boom: 4.2.1 sonic-boom: 4.2.1
thread-stream: 4.2.0 thread-stream: 4.2.0
png-js@2.0.0:
dependencies:
fflate: 0.8.3
pngjs@5.0.0: {} pngjs@5.0.0: {}
postcss@8.5.23: postcss@8.5.23:
@ -2710,6 +2828,8 @@ snapshots:
require-main-filename@2.0.0: {} require-main-filename@2.0.0: {}
restructure@3.0.2: {}
ret@0.5.0: {} ret@0.5.0: {}
reusify@1.1.0: {} reusify@1.1.0: {}
@ -2820,6 +2940,8 @@ snapshots:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
tiny-inflate@1.0.3: {}
tinybench@6.2.0: {} tinybench@6.2.0: {}
tinyexec@1.3.1: {} tinyexec@1.3.1: {}
@ -2870,6 +2992,16 @@ snapshots:
undici@8.11.2: {} 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): vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0