Rechnungsmodul: Entwurf/Ausstellen/Bezahlt/Storno, PDF, Firmenstammdaten
This commit is contained in:
parent
012f0aa0db
commit
43dc5bc827
18 changed files with 783 additions and 3 deletions
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
223
apps/api/src/modules/invoices/index.ts
Normal file
223
apps/api/src/modules/invoices/index.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { PoolConnection } from 'mysql2/promise';
|
||||||
|
import { one, query, run, tx } from '../../core/db.js';
|
||||||
|
import { audit } from '../../core/audit.js';
|
||||||
|
import { enqueue } from '../../core/jobs.js';
|
||||||
|
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||||
|
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||||
|
import { can, canInOrg } from '../../core/policy.js';
|
||||||
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
import { renderInvoicePdf, type CompanySettings, type InvoiceForPdf } from './pdf.js';
|
||||||
|
|
||||||
|
/** Kaufmännisches Runden (halb auf), wie in @kc/platform/pricing – hier lokal, weil Rechnungspositionen
|
||||||
|
* (Freitext, Dezimalmenge) sich nicht in das Produkt-Preisschema von calculatePrice pressen lassen. */
|
||||||
|
const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2));
|
||||||
|
function lineAmounts(unitNetCents: number, quantity: number, taxBp: number) {
|
||||||
|
const net = Math.round(unitNetCents * quantity);
|
||||||
|
const tax = divRound(net * taxBp, 10000);
|
||||||
|
return { net, tax, gross: net + tax };
|
||||||
|
}
|
||||||
|
async function nextInvoiceNumber(c: PoolConnection, prefix: string): Promise<string> {
|
||||||
|
await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'invoice'", [], c);
|
||||||
|
return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
|
||||||
|
}
|
||||||
|
async function settings(): Promise<CompanySettings> {
|
||||||
|
const s = await one('SELECT * FROM company_settings WHERE id = 1');
|
||||||
|
return {
|
||||||
|
name: s?.name ?? null, street: s?.street ?? null, zip: s?.zip ?? null, city: s?.city ?? null, country: s?.country ?? 'DE',
|
||||||
|
taxNumber: s?.tax_number ?? null, vatId: s?.vat_id ?? null, bankName: s?.bank_name ?? null, iban: s?.iban ?? null, bic: s?.bic ?? null,
|
||||||
|
invoicePrefix: s?.invoice_prefix ?? 'RE', defaultDueDays: Number(s?.default_due_days ?? 14),
|
||||||
|
paymentMethods: (typeof s?.payment_methods === 'string' ? JSON.parse(s.payment_methods) : s?.payment_methods) ?? ['Überweisung'],
|
||||||
|
footerText: s?.footer_text ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const complete = (s: CompanySettings) => !!(s.name && s.street && s.zip && s.city && (s.taxNumber || s.vatId));
|
||||||
|
|
||||||
|
const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents });
|
||||||
|
const invoiceView = (v: any) => ({
|
||||||
|
id: v.id, number: v.number, orgId: v.org_id, orgName: v.org_name, customerNumber: v.customer_number, status: v.status,
|
||||||
|
issueDate: v.issue_date, dueDate: v.due_date, overdue: v.status === 'open' && v.due_date && new Date(v.due_date) < new Date(),
|
||||||
|
currency: v.currency, totalNetCents: v.total_net_cents, totalTaxCents: v.total_tax_cents, totalGrossCents: v.total_gross_cents,
|
||||||
|
paymentMethod: v.payment_method, note: v.note, paidAt: v.paid_at, cancelsInvoiceId: v.cancels_invoice_id, cancelledByInvoiceId: v.cancelled_by_invoice_id,
|
||||||
|
createdAt: v.created_at, issuedAt: v.issued_at, cancelledAt: v.cancelled_at,
|
||||||
|
});
|
||||||
|
const INVOICE_SQL = 'SELECT v.*, g.name AS org_name, g.customer_number FROM invoices v JOIN organizations g ON g.id = v.org_id';
|
||||||
|
|
||||||
|
async function loadInvoice(id: string) {
|
||||||
|
const v = await one(`${INVOICE_SQL} WHERE v.id = ?`, [id]);
|
||||||
|
if (!v) return null;
|
||||||
|
const items = await query('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order', [id]);
|
||||||
|
return { v, items };
|
||||||
|
}
|
||||||
|
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
|
||||||
|
|
||||||
|
export const invoicesModule: KcModule = {
|
||||||
|
name: 'invoices',
|
||||||
|
permissions: {
|
||||||
|
staff: { support: ['invoices.read'], accounting: ['invoices.read', 'invoices.write'], admin: ['invoices.read', 'invoices.write'], superadmin: ['invoices.read', 'invoices.write', 'settings.write'] },
|
||||||
|
org: { owner: ['invoices.read'], admin: ['invoices.read'], member: ['invoices.read'] },
|
||||||
|
},
|
||||||
|
register(app: FastifyInstance) {
|
||||||
|
// ---- Firmenstammdaten (für den Rechnungskopf) ---------------------------
|
||||||
|
app.get('/admin/company-settings', async (req) => { requirePermission(req, 'invoices.read'); const s = await settings(); return { ...s, complete: complete(s) }; });
|
||||||
|
app.put('/admin/company-settings', async (req) => {
|
||||||
|
const a = requirePermission(req, 'settings.write');
|
||||||
|
const b = z.object({
|
||||||
|
name: z.string().trim().max(200).optional(), street: z.string().trim().max(200).optional(), zip: z.string().trim().max(20).optional(), city: z.string().trim().max(100).optional(), country: z.string().length(2).optional(),
|
||||||
|
taxNumber: z.string().trim().max(50).optional(), vatId: z.string().trim().max(30).optional(), bankName: z.string().trim().max(150).optional(), iban: z.string().trim().max(34).optional(), bic: z.string().trim().max(11).optional(),
|
||||||
|
invoicePrefix: z.string().trim().regex(/^[A-Za-z0-9]{1,10}$/).optional(), defaultDueDays: z.number().int().min(0).max(180).optional(),
|
||||||
|
paymentMethods: z.array(z.string().trim().min(1).max(50)).min(1).max(10).optional(), footerText: z.string().trim().max(500).nullable().optional(),
|
||||||
|
}).parse(req.body);
|
||||||
|
const cols: Record<string, string> = { name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text' };
|
||||||
|
const sets: string[] = []; const params: unknown[] = [];
|
||||||
|
for (const [k, col] of Object.entries(cols)) if ((b as Record<string, unknown>)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record<string, unknown>)[k]); }
|
||||||
|
if (b.paymentMethods) { sets.push('payment_methods = ?'); params.push(JSON.stringify(b.paymentMethods)); }
|
||||||
|
if (sets.length) await run(`UPDATE company_settings SET ${sets.join(', ')} WHERE id = 1`, params);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'company_settings.update', resourceType: 'company_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, iban: b.iban ? '***' : undefined } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Rechnungen: Entwurf, Positionen, Ausstellen, Bezahlt, Storno -------
|
||||||
|
app.get('/invoices', async (req) => {
|
||||||
|
const a = requireAuth(req);
|
||||||
|
const q = z.object({ org: z.string().uuid().optional(), status: z.enum(['draft', 'open', 'paid', 'cancelled']).optional() }).parse(req.query);
|
||||||
|
const staff = can(a.principal, 'invoices.read');
|
||||||
|
const myOrgs = a.principal.memberships.map((m) => m.orgId);
|
||||||
|
if (!staff && myOrgs.length === 0) return [];
|
||||||
|
if (staff && q.org && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [q.org]))) throw notFound();
|
||||||
|
const orgs = staff ? (q.org ? [q.org] : null) : myOrgs;
|
||||||
|
const orgPlaceholders = orgs ? orgs.map(() => '?').join(',') : '';
|
||||||
|
const rows = await query(
|
||||||
|
`${INVOICE_SQL} WHERE (${orgs ? `v.org_id IN (${orgPlaceholders})` : '1=1'}) AND (? IS NULL OR v.status = ?)${staff ? '' : " AND v.status != 'draft'"} ORDER BY v.created_at DESC LIMIT 200`,
|
||||||
|
[...(orgs ?? []), q.status ?? null, q.status ?? null]);
|
||||||
|
return rows.map(invoiceView);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/invoices', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write');
|
||||||
|
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), paymentMethod: z.string().max(50).optional() }).parse(req.body);
|
||||||
|
if (!(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw notFound();
|
||||||
|
const id = randomUUID();
|
||||||
|
await run('INSERT INTO invoices (id, org_id, note, payment_method, created_by) VALUES (?,?,?,?,?)', [id, b.orgId, b.note ?? null, b.paymentMethod ?? null, a.user.id]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'invoice.create', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||||
|
return { id };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/invoices/:id', async (req) => {
|
||||||
|
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const staff = can(a.principal, 'invoices.read');
|
||||||
|
const res = await loadInvoice(id);
|
||||||
|
if (!res || !canInOrg(a.principal, res.v.org_id, 'invoices.read', 'invoices.read') || (!staff && res.v.status === 'draft')) throw notFound();
|
||||||
|
return { ...invoiceView(res.v), items: res.items.map(itemView), canWrite: staff };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ersetzt die Positionen eines Entwurfs vollständig (einfacher als Einzel-CRUD, ausreichend für eine Entwurfsphase). */
|
||||||
|
app.put('/invoices/:id/items', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const b = z.object({ items: z.array(z.object({
|
||||||
|
description: z.string().trim().min(1).max(300), quantity: z.number().positive().max(100000), unitPriceNetCents: z.number().int().min(0).max(100_000_00), taxBp: z.number().int().min(0).max(3000), contractId: z.string().uuid().optional(),
|
||||||
|
})).min(1).max(100) }).parse(req.body);
|
||||||
|
const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound();
|
||||||
|
if (v.status !== 'draft') throw conflict('Nur Entwürfe können bearbeitet werden. Ausgestellte Rechnungen sind unveränderlich (nur Storno möglich).', 'INVOICE_NOT_DRAFT');
|
||||||
|
let net = 0, tax = 0, gross = 0;
|
||||||
|
await tx(async (c) => {
|
||||||
|
await run('DELETE FROM invoice_items WHERE invoice_id = ?', [id], c);
|
||||||
|
for (const [idx, it] of b.items.entries()) {
|
||||||
|
const a2 = lineAmounts(it.unitPriceNetCents, it.quantity, it.taxBp); net += a2.net; tax += a2.tax; gross += a2.gross;
|
||||||
|
await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
[randomUUID(), id, it.contractId ?? null, it.description, it.quantity, it.unitPriceNetCents, it.taxBp, a2.net, a2.tax, a2.gross, idx], c);
|
||||||
|
}
|
||||||
|
await run('UPDATE invoices SET total_net_cents = ?, total_tax_cents = ?, total_gross_cents = ? WHERE id = ?', [net, tax, gross, id], c);
|
||||||
|
});
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.items.update', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { items: b.items.length, totalGrossCents: gross } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Aus einem Vertrag die letzte Preisangabe als Positionsvorschlag übernehmen (nichts wird automatisch gespeichert). */
|
||||||
|
app.get('/invoices/suggest-from-contract/:contractId', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write'); const { contractId } = z.object({ contractId: z.string().uuid() }).parse(req.params);
|
||||||
|
const c = await one('SELECT * FROM contracts WHERE id = ?', [contractId]); if (!c) throw notFound();
|
||||||
|
const snap = JSON.parse(c.price_snapshot_json);
|
||||||
|
return { orgId: c.org_id, description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/invoices/:id/issue', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const b = z.object({ dueDate: z.string().date().optional() }).parse(req.body ?? {});
|
||||||
|
const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound();
|
||||||
|
if (v.status !== 'draft') throw conflict('Die Rechnung wurde bereits ausgestellt.', 'INVOICE_NOT_DRAFT');
|
||||||
|
const items = await query('SELECT 1 AS x FROM invoice_items WHERE invoice_id = ?', [id]);
|
||||||
|
if (items.length === 0) throw badRequest('Eine Rechnung ohne Positionen kann nicht ausgestellt werden.', 'NO_ITEMS');
|
||||||
|
const s = await settings(); if (!complete(s)) throw badRequest('Die Firmenstammdaten sind unvollständig (Name, Anschrift, Steuernummer/USt-IdNr.). Bitte unter Einstellungen ergänzen, bevor Rechnungen ausgestellt werden.', 'COMPANY_SETTINGS_INCOMPLETE');
|
||||||
|
const due = b.dueDate ?? new Date(Date.now() + s.defaultDueDays * 86400000).toISOString().slice(0, 10);
|
||||||
|
const number = await tx(async (c) => {
|
||||||
|
const n = await nextInvoiceNumber(c, s.invoicePrefix);
|
||||||
|
await run("UPDATE invoices SET number = ?, status = 'open', issue_date = CURDATE(), due_date = ?, issued_at = UTC_TIMESTAMP(3) WHERE id = ?", [n, due, id], c);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.issue', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number } });
|
||||||
|
await notify('invoice.issued', { number, gross: v.total_gross_cents }, `invoice.issued:${id}`, req.correlationId);
|
||||||
|
return { ok: true, number };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/invoices/:id/mark-paid', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound();
|
||||||
|
if (v.status !== 'open') throw conflict('Nur ausgestellte, noch offene Rechnungen können als bezahlt markiert werden.', 'INVOICE_NOT_OPEN');
|
||||||
|
await run("UPDATE invoices SET status = 'paid', paid_at = UTC_TIMESTAMP(3) WHERE id = ?", [id]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.paid', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Storno: erzeugt eine neue, ausgestellte Rechnung mit umgekehrten Vorzeichen und verweist auf das Original.
|
||||||
|
* Die ursprüngliche Rechnung wird NIE gelöscht oder verändert (gesetzliche Vorgabe). */
|
||||||
|
app.post('/invoices/:id/cancel', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const b = z.object({ reason: z.string().trim().max(300).optional() }).parse(req.body ?? {});
|
||||||
|
const res = await loadInvoice(id); if (!res) throw notFound(); const v = res.v;
|
||||||
|
if (v.status !== 'open') throw conflict('Nur offene Rechnungen können storniert werden. Eine bezahlte Rechnung erst als Storno mit Rückzahlungsvermerk erfassen.', 'INVOICE_NOT_OPEN');
|
||||||
|
const s = await settings();
|
||||||
|
const creditId = randomUUID();
|
||||||
|
const number = await tx(async (c) => {
|
||||||
|
const n = await nextInvoiceNumber(c, s.invoicePrefix);
|
||||||
|
await run('INSERT INTO invoices (id, number, org_id, status, issue_date, due_date, total_net_cents, total_tax_cents, total_gross_cents, note, cancels_invoice_id, created_by, issued_at) VALUES (?,?,?,\'open\',CURDATE(),CURDATE(),?,?,?,?,?,?,UTC_TIMESTAMP(3))',
|
||||||
|
[creditId, n, v.org_id, -v.total_net_cents, -v.total_tax_cents, -v.total_gross_cents, b.reason ? `Storno zu ${v.number}: ${b.reason}` : `Storno zu ${v.number}`, id, a.user.id], c);
|
||||||
|
for (const it of res.items) await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
[randomUUID(), creditId, it.contract_id, it.description, -Number(it.quantity), it.unit_price_net_cents, it.tax_bp, -it.net_cents, -it.tax_cents, -it.gross_cents, it.sort_order], c);
|
||||||
|
await run("UPDATE invoices SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3), cancelled_by_invoice_id = ? WHERE id = ?", [creditId, id], c);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.cancel', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { creditNumber: number } });
|
||||||
|
return { ok: true, creditInvoiceId: creditId, creditNumber: number };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/invoices/:id', async (req) => {
|
||||||
|
const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound();
|
||||||
|
if (v.status !== 'draft') throw forbidden('Ausgestellte Rechnungen können nicht gelöscht werden, nur storniert.', 'INVOICE_NOT_DRAFT');
|
||||||
|
await run('DELETE FROM invoice_items WHERE invoice_id = ?', [id]); await run('DELETE FROM invoices WHERE id = ?', [id]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, orgId: v.org_id, action: 'invoice.delete_draft', resourceType: 'invoice', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/invoices/:id/pdf', async (req, reply) => {
|
||||||
|
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const staff = can(a.principal, 'invoices.read');
|
||||||
|
const res = await loadInvoice(id);
|
||||||
|
if (!res || !canInOrg(a.principal, res.v.org_id, 'invoices.read', 'invoices.read') || (!staff && res.v.status === 'draft')) throw notFound();
|
||||||
|
if (res.v.status === 'draft') throw badRequest('Für Entwürfe gibt es noch kein PDF. Bitte zuerst ausstellen.', 'INVOICE_DRAFT');
|
||||||
|
const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [res.v.org_id]);
|
||||||
|
const s = await settings();
|
||||||
|
const inv: InvoiceForPdf = {
|
||||||
|
number: res.v.number, issueDate: res.v.issue_date, dueDate: res.v.due_date, status: res.v.status, paymentMethod: res.v.payment_method, note: res.v.note,
|
||||||
|
totalNetCents: res.v.total_net_cents, totalTaxCents: res.v.total_tax_cents, totalGrossCents: res.v.total_gross_cents,
|
||||||
|
customer: { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null },
|
||||||
|
items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })),
|
||||||
|
};
|
||||||
|
reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`);
|
||||||
|
return reply.send(renderInvoicePdf(inv, s));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
65
apps/api/src/modules/invoices/pdf.ts
Normal file
65
apps/api/src/modules/invoices/pdf.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
|
export interface CompanySettings {
|
||||||
|
name: string | null; street: string | null; zip: string | null; city: string | null; country: string | null;
|
||||||
|
taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null;
|
||||||
|
invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null;
|
||||||
|
}
|
||||||
|
export interface InvoiceItemForPdf { description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number }
|
||||||
|
export interface InvoiceForPdf {
|
||||||
|
number: string | null; issueDate: string | null; dueDate: string | null; status: string; paymentMethod: string | null; note: string | null;
|
||||||
|
totalNetCents: number; totalTaxCents: number; totalGrossCents: number;
|
||||||
|
customer: { name: string | null; street: string | null; zip: string | null; city: string | null; country: string | null; vatId: string | null };
|
||||||
|
items: InvoiceItemForPdf[];
|
||||||
|
}
|
||||||
|
const eur = (c: number) => (c / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||||||
|
const de = (d: string | null) => (d ? new Date(d).toLocaleDateString('de-DE') : '–');
|
||||||
|
|
||||||
|
/** Erzeugt ein einfaches, rechtlich vollständiges Rechnungs-PDF (Pflichtangaben nach § 14 UStG,
|
||||||
|
* soweit aus den vorhandenen Daten ableitbar) als Node-Stream. */
|
||||||
|
export function renderInvoicePdf(inv: InvoiceForPdf, s: CompanySettings): PDFKit.PDFDocument {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||||
|
const isCredit = inv.totalGrossCents < 0;
|
||||||
|
|
||||||
|
doc.fontSize(9).fillColor('#555').text([s.name, s.street, `${s.zip ?? ''} ${s.city ?? ''}`.trim()].filter(Boolean).join(' · '), 50, 50, { width: 495 });
|
||||||
|
doc.moveDown(2);
|
||||||
|
doc.fontSize(10).fillColor('#000');
|
||||||
|
doc.text(inv.customer.name ?? '', 50, 120);
|
||||||
|
if (inv.customer.street) doc.text(inv.customer.street);
|
||||||
|
doc.text(`${inv.customer.zip ?? ''} ${inv.customer.city ?? ''}`.trim());
|
||||||
|
if (inv.customer.country && inv.customer.country !== 'DE') doc.text(inv.customer.country);
|
||||||
|
if (inv.customer.vatId) doc.text(`USt-IdNr.: ${inv.customer.vatId}`);
|
||||||
|
|
||||||
|
doc.fontSize(18).text(isCredit ? 'Stornorechnung' : 'Rechnung', 50, 200);
|
||||||
|
doc.fontSize(10);
|
||||||
|
const meta: [string, string][] = [['Rechnungsnr.', inv.number ?? '–'], ['Rechnungsdatum', de(inv.issueDate)], ['Fällig am', de(inv.dueDate)]];
|
||||||
|
if (s.taxNumber) meta.push(['Steuernummer', s.taxNumber]); if (s.vatId) meta.push(['USt-IdNr.', s.vatId]);
|
||||||
|
let y = 230; for (const [k, v] of meta) { doc.text(k, 350, y, { width: 100 }); doc.text(v, 450, y, { width: 95, align: 'right' }); y += 16; }
|
||||||
|
|
||||||
|
const top = y + 20; const col = { desc: 50, qty: 300, unit: 350, tax: 420, sum: 470 };
|
||||||
|
doc.fontSize(9).fillColor('#555');
|
||||||
|
doc.text('Beschreibung', col.desc, top).text('Menge', col.qty, top).text('Einzelpreis', col.unit, top).text('USt.', col.tax, top).text('Netto', col.sum, top, { width: 75, align: 'right' });
|
||||||
|
doc.moveTo(50, top + 14).lineTo(545, top + 14).strokeColor('#ccc').stroke();
|
||||||
|
let ry = top + 20; doc.fillColor('#000');
|
||||||
|
for (const it of inv.items) {
|
||||||
|
const h = doc.heightOfString(it.description, { width: 240 });
|
||||||
|
doc.text(it.description, col.desc, ry, { width: 240 }); doc.text(String(it.quantity), col.qty, ry);
|
||||||
|
doc.text(eur(it.unitPriceNetCents), col.unit, ry); doc.text(`${it.taxBp / 100} %`, col.tax, ry);
|
||||||
|
doc.text(eur(it.netCents), col.sum, ry, { width: 75, align: 'right' });
|
||||||
|
ry += Math.max(h, 14) + 6;
|
||||||
|
}
|
||||||
|
doc.moveTo(50, ry).lineTo(545, ry).strokeColor('#ccc').stroke(); ry += 10;
|
||||||
|
const sumLine = (label: string, val: string, bold = false) => { doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').text(label, 400, ry, { width: 70 }); doc.text(val, col.sum, ry, { width: 75, align: 'right' }); ry += 16; };
|
||||||
|
sumLine('Netto', eur(inv.totalNetCents)); sumLine('USt.', eur(inv.totalTaxCents)); sumLine('Gesamt', eur(inv.totalGrossCents), true);
|
||||||
|
doc.font('Helvetica');
|
||||||
|
|
||||||
|
ry += 20;
|
||||||
|
if (inv.paymentMethod) { doc.fontSize(10).text(`Zahlungsart: ${inv.paymentMethod}`, 50, ry); ry += 16; }
|
||||||
|
if (s.iban) { doc.text(`${s.bankName ?? ''} · IBAN ${s.iban}${s.bic ? ` · BIC ${s.bic}` : ''}`.trim(), 50, ry); ry += 16; }
|
||||||
|
if (!isCredit && inv.totalGrossCents > 0) { doc.text(`Bitte überweisen Sie den Betrag bis zum ${de(inv.dueDate)} unter Angabe der Rechnungsnummer ${inv.number}.`, 50, ry, { width: 495 }); ry += 20; }
|
||||||
|
if (inv.note) { doc.fontSize(9).fillColor('#555').text(inv.note, 50, ry, { width: 495 }); }
|
||||||
|
|
||||||
|
if (s.footerText) doc.fontSize(8).fillColor('#888').text(s.footerText, 50, 770, { width: 495, align: 'center' });
|
||||||
|
doc.end();
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
69
apps/api/test/invoices.test.ts
Normal file
69
apps/api/test/invoices.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { buildApp } from '../src/server.js';
|
||||||
|
import { call, code, login, makeUser } from './helpers.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
beforeAll(async () => { app = await buildApp(); await app.ready(); });
|
||||||
|
async function staff(email: string, role: string) {
|
||||||
|
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
|
||||||
|
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
|
||||||
|
}
|
||||||
|
async function customer(admin: any, name: string, mail: string) {
|
||||||
|
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
|
||||||
|
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
|
||||||
|
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
|
||||||
|
}
|
||||||
|
const COMPANY = { name: 'Testfirma GmbH', street: 'Musterstr. 1', zip: '12345', city: 'Musterstadt', taxNumber: '12/345/67890' };
|
||||||
|
|
||||||
|
describe('Rechnungen', () => {
|
||||||
|
it('Entwurf, Positionen, unvollständige Firmendaten blockieren das Ausstellen, danach ausstellen/PDF/bezahlt/Storno, Unveränderlichkeit, Mandantentrennung', async () => {
|
||||||
|
const admin = await staff('inv-admin@example.com', 'superadmin'); const acc = await staff('inv-acc@example.com', 'accounting'); const support = await staff('inv-support@example.com', 'support');
|
||||||
|
const A = await customer(admin, 'Kunde A', 'inv-a@example.com'); const B = await customer(admin, 'Kunde B', 'inv-b@example.com');
|
||||||
|
|
||||||
|
expect((await call(app, support, 'POST', '/invoices', { orgId: A.org })).statusCode).toBe(403); // Support nur lesend
|
||||||
|
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org, paymentMethod: 'Überweisung' })).json();
|
||||||
|
expect((await call(app, A.client, 'GET', `/invoices/${draft.id}`)).statusCode).toBe(404); // Kunde sieht Entwurf nicht
|
||||||
|
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().status).toBe('draft');
|
||||||
|
|
||||||
|
const items = [{ description: 'Hosting Paket M, September', quantity: 1, unitPriceNetCents: 10000, taxBp: 1900 }, { description: 'Domain-Aufschlag', quantity: 2, unitPriceNetCents: 500, taxBp: 1900 }];
|
||||||
|
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items })).statusCode).toBe(200);
|
||||||
|
const withItems = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json();
|
||||||
|
expect(withItems.totalNetCents).toBe(11000); expect(withItems.totalTaxCents).toBe(2090); expect(withItems.totalGrossCents).toBe(13090);
|
||||||
|
|
||||||
|
// ohne vollständige Firmendaten kein Ausstellen
|
||||||
|
const blocked = await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('COMPANY_SETTINGS_INCOMPLETE');
|
||||||
|
expect((await call(app, acc, 'PUT', '/admin/company-settings', COMPANY)).statusCode).toBe(403); // nur superadmin
|
||||||
|
expect((await call(app, admin, 'PUT', '/admin/company-settings', COMPANY)).statusCode).toBe(200);
|
||||||
|
expect((await call(app, acc, 'GET', '/admin/company-settings')).json().complete).toBe(true);
|
||||||
|
|
||||||
|
const issued = await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); expect(issued.statusCode).toBe(200); const number = issued.json().number; expect(number).toMatch(/^RE-\d+$/);
|
||||||
|
// ab jetzt unveränderlich
|
||||||
|
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items })).statusCode).toBe(409);
|
||||||
|
expect((await call(app, acc, 'DELETE', `/invoices/${draft.id}`)).statusCode).toBe(403);
|
||||||
|
|
||||||
|
// Kunde sieht sie jetzt, PDF ist ladbar; fremder Kunde nicht
|
||||||
|
const seen = (await call(app, A.client, 'GET', `/invoices/${draft.id}`)).json(); expect(seen.number).toBe(number); expect(seen.status).toBe('open');
|
||||||
|
expect((await call(app, B.client, 'GET', `/invoices/${draft.id}`)).statusCode).toBe(404);
|
||||||
|
const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: A.client.cookie } });
|
||||||
|
expect(pdf.statusCode).toBe(200); expect(pdf.headers['content-type']).toBe('application/pdf'); expect(pdf.rawPayload.subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
|
||||||
|
// Storno: neue Rechnung mit umgekehrten Vorzeichen, Original unveränderlich als storniert markiert
|
||||||
|
const credit = await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`, { reason: 'Testkorrektur' }); expect(credit.statusCode).toBe(200);
|
||||||
|
const orig = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json(); expect(orig.status).toBe('cancelled'); expect(orig.cancelledByInvoiceId).toBe(credit.json().creditInvoiceId);
|
||||||
|
const cr = (await call(app, acc, 'GET', `/invoices/${credit.json().creditInvoiceId}`)).json(); expect(cr.totalGrossCents).toBe(-13090); expect(cr.cancelsInvoiceId).toBe(draft.id);
|
||||||
|
expect((await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).statusCode).toBe(409); // nicht doppelt stornierbar
|
||||||
|
expect((await call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`)).statusCode).toBe(409); // stornierte Rechnung nicht mehr "bezahlbar"
|
||||||
|
|
||||||
|
// zweite, normal bezahlte Rechnung
|
||||||
|
const d2 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
|
||||||
|
await call(app, acc, 'PUT', `/invoices/${d2.id}/items`, { items: [{ description: 'Setup', quantity: 1, unitPriceNetCents: 2000, taxBp: 1900 }] });
|
||||||
|
await call(app, acc, 'POST', `/invoices/${d2.id}/issue`);
|
||||||
|
expect((await call(app, acc, 'POST', `/invoices/${d2.id}/mark-paid`)).statusCode).toBe(200);
|
||||||
|
expect((await call(app, acc, 'GET', `/invoices/${d2.id}`)).json().status).toBe('paid');
|
||||||
|
|
||||||
|
// Listen: Kunde sieht nur eigene, keine Entwürfe
|
||||||
|
const custList = (await call(app, A.client, 'GET', '/invoices')).json(); expect(custList.every((i: any) => i.status !== 'draft')).toBe(true); expect(custList.length).toBe(3);
|
||||||
|
const staffOpenB = (await call(app, acc, 'GET', `/invoices?org=${B.org}`)).json(); expect(staffOpenB).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
|
||||||
import '../src/core/config.js';
|
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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
60
apps/web/src/app/(app)/einstellungen/firma/page.tsx
Normal file
60
apps/web/src/app/(app)/einstellungen/firma/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
'use client';
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { Alert, Field } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean }
|
||||||
|
|
||||||
|
export default function Firma() {
|
||||||
|
const { can } = useSession(); const w = can('settings.write');
|
||||||
|
const [s, setS] = useState<Settings | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
|
||||||
|
const load = useCallback(() => api<Settings>('GET', '/admin/company-settings').then(setS).catch((e) => setErr(errMsg(e))), []);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
async function save(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined);
|
||||||
|
try {
|
||||||
|
await api('PUT', '/admin/company-settings', {
|
||||||
|
name: v('name'), street: v('street'), zip: v('zip'), city: v('city'), country: v('country') ?? 'DE',
|
||||||
|
taxNumber: v('taxNumber'), vatId: v('vatId'), bankName: v('bankName'), iban: v('iban'), bic: v('bic'),
|
||||||
|
invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14),
|
||||||
|
paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean),
|
||||||
|
footerText: v('footerText') ?? null,
|
||||||
|
});
|
||||||
|
setOk('Gespeichert.'); void load();
|
||||||
|
} catch (x) { setErr(errMsg(x)); }
|
||||||
|
}
|
||||||
|
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||||
|
return (<>
|
||||||
|
<h2>Firmenstammdaten</h2>
|
||||||
|
<p className="muted">Erscheinen auf jeder Rechnung. Ohne Name, Anschrift und Steuernummer/USt-IdNr. können keine Rechnungen ausgestellt werden.</p>
|
||||||
|
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||||
|
{!s.complete && <Alert kind="warn">Die Angaben sind noch unvollständig. Rechnungen lassen sich erst ausstellen, wenn Name, Anschrift und Steuernummer oder USt-IdNr. eingetragen sind.</Alert>}
|
||||||
|
<form className="card" onSubmit={save}>
|
||||||
|
<div className="cols">
|
||||||
|
<Field id="name" label="Firmenname"><input id="name" name="name" defaultValue={s.name ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="street" label="Straße, Nr."><input id="street" name="street" defaultValue={s.street ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="zip" label="PLZ"><input id="zip" name="zip" defaultValue={s.zip ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="city" label="Ort"><input id="city" name="city" defaultValue={s.city ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="country" label="Land (ISO)"><input id="country" name="country" maxLength={2} defaultValue={s.country} disabled={!w} /></Field>
|
||||||
|
<Field id="taxNumber" label="Steuernummer"><input id="taxNumber" name="taxNumber" defaultValue={s.taxNumber ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="vatId" label="USt-IdNr." hint="Optional, ersetzt bei Bedarf die Steuernummer"><input id="vatId" name="vatId" defaultValue={s.vatId ?? ''} disabled={!w} /></Field>
|
||||||
|
</div>
|
||||||
|
<h3>Bankverbindung</h3>
|
||||||
|
<div className="cols">
|
||||||
|
<Field id="bankName" label="Bank"><input id="bankName" name="bankName" defaultValue={s.bankName ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="iban" label="IBAN"><input id="iban" name="iban" defaultValue={s.iban ?? ''} disabled={!w} /></Field>
|
||||||
|
<Field id="bic" label="BIC"><input id="bic" name="bic" defaultValue={s.bic ?? ''} disabled={!w} /></Field>
|
||||||
|
</div>
|
||||||
|
<h3>Rechnungseinstellungen</h3>
|
||||||
|
<div className="cols">
|
||||||
|
<Field id="invoicePrefix" label="Rechnungsnummern-Präfix" hint="z. B. RE → RE-1000, RE-1001, …"><input id="invoicePrefix" name="invoicePrefix" defaultValue={s.invoicePrefix} disabled={!w} /></Field>
|
||||||
|
<Field id="defaultDueDays" label="Zahlungsziel (Tage)"><input id="defaultDueDays" name="defaultDueDays" type="number" min={0} max={180} defaultValue={s.defaultDueDays} disabled={!w} /></Field>
|
||||||
|
<Field id="paymentMethods" label="Zahlungsarten" hint="kommagetrennt"><input id="paymentMethods" name="paymentMethods" defaultValue={s.paymentMethods.join(', ')} disabled={!w} /></Field>
|
||||||
|
</div>
|
||||||
|
<Field id="footerText" label="Fußzeile auf der Rechnung (optional)" hint="z. B. Geschäftsführer, Registergericht"><input id="footerText" name="footerText" defaultValue={s.footerText ?? ''} disabled={!w} /></Field>
|
||||||
|
{w ? <button className="btn primary" type="submit">Speichern</button> : <p className="muted small">Nur Superadministratoren können diese Angaben ändern.</p>}
|
||||||
|
</form>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||||
const tabs = [
|
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 (<>
|
||||||
|
|
|
||||||
|
|
@ -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' && <>
|
||||||
|
|
|
||||||
72
apps/web/src/app/(app)/rechnungen/[id]/page.tsx
Normal file
72
apps/web/src/app/(app)/rechnungen/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client';
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { Alert, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Item { id: string; description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number }
|
||||||
|
interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean }
|
||||||
|
type Row = { description: string; quantity: string; unitPrice: string; taxBp: string };
|
||||||
|
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
|
||||||
|
|
||||||
|
export default function RechnungDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read');
|
||||||
|
const [inv, setInv] = useState<Inv | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
|
||||||
|
const [rows, setRows] = useState<Row[]>([emptyRow()]); const [busy, setBusy] = useState(false);
|
||||||
|
const load = useCallback(() => api<Inv>('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) })) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
|
||||||
|
async function saveItems(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) }));
|
||||||
|
if (items.length === 0) { setErr('Bitte mindestens eine Position angeben.'); return; }
|
||||||
|
await run(() => api('PUT', `/invoices/${id}/items`, { items }), 'Gespeichert.');
|
||||||
|
}
|
||||||
|
const setRow = (i: number, k: keyof Row, v: string) => setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, [k]: v } : r)));
|
||||||
|
|
||||||
|
if (!inv) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||||
|
const isDraft = inv.status === 'draft';
|
||||||
|
return (<>
|
||||||
|
<div className="row between"><h1>{inv.number ?? 'Entwurf'} {inv.orgName && <span className="muted">· {inv.orgName}</span>}</h1><Link className="btn" href="/rechnungen">← Rechnungen</Link></div>
|
||||||
|
<p className="muted small"><InvoiceStatusBadge value={inv.status} overdue={inv.overdue} />{inv.issueDate && <> · ausgestellt {fmt(inv.issueDate)}</>}{inv.dueDate && <> · fällig {fmt(inv.dueDate)}</>}{inv.paymentMethod && <> · {inv.paymentMethod}</>}</p>
|
||||||
|
{inv.cancelsInvoiceId && <Alert kind="info">Dies ist eine Stornorechnung zu <Link href={`/rechnungen/${inv.cancelsInvoiceId}`}>einer anderen Rechnung</Link>.</Alert>}
|
||||||
|
{inv.cancelledByInvoiceId && <Alert kind="warn">Diese Rechnung wurde storniert. Storno: <Link href={`/rechnungen/${inv.cancelledByInvoiceId}`}>ansehen</Link></Alert>}
|
||||||
|
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||||
|
|
||||||
|
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
|
||||||
|
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
|
||||||
|
<Field id={`d${i}`} label="Beschreibung"><input id={`d${i}`} value={r.description} onChange={(e) => setRow(i, 'description', e.target.value)} /></Field>
|
||||||
|
<Field id={`q${i}`} label="Menge"><input id={`q${i}`} value={r.quantity} onChange={(e) => setRow(i, 'quantity', e.target.value)} /></Field>
|
||||||
|
<Field id={`u${i}`} label="Einzelpreis netto (€)"><input id={`u${i}`} value={r.unitPrice} onChange={(e) => setRow(i, 'unitPrice', e.target.value)} placeholder="0,00" /></Field>
|
||||||
|
<Field id={`t${i}`} label="USt. %"><input id={`t${i}`} value={r.taxBp} onChange={(e) => setRow(i, 'taxBp', e.target.value)} /></Field>
|
||||||
|
<button className="btn small" type="button" onClick={() => setRows((rs) => rs.filter((_, idx) => idx !== i))} disabled={rows.length === 1}>Entfernen</button>
|
||||||
|
</div>))}
|
||||||
|
<div className="row" style={{ margin: '8px 0 16px' }}><button className="btn small" type="button" onClick={() => setRows((rs) => [...rs, emptyRow()])}>+ Position</button></div>
|
||||||
|
<div className="row"><button className="btn primary" type="submit" disabled={busy}>Positionen speichern</button>
|
||||||
|
<button className="btn" type="button" disabled={busy} onClick={() => run(() => api('POST', `/invoices/${id}/issue`, {}), 'Ausgestellt.')}>Ausstellen</button>
|
||||||
|
<button className="btn" type="button" disabled={busy} onClick={() => confirm('Entwurf wirklich löschen?') && run(async () => { await api('DELETE', `/invoices/${id}`); location.href = '/rechnungen'; })}>Entwurf löschen</button>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">Solange die Rechnung ein Entwurf ist, lassen sich Positionen beliebig ändern. Nach dem Ausstellen ist sie unveränderlich und nur noch per Storno korrigierbar.</p>
|
||||||
|
</form>) : (
|
||||||
|
<div className="card"><table style={{ width: '100%' }}><thead><tr><th style={{ textAlign: 'left' }}>Beschreibung</th><th>Menge</th><th>Einzelpreis</th><th>USt.</th><th style={{ textAlign: 'right' }}>Netto</th></tr></thead><tbody>
|
||||||
|
{inv.items.map((it) => (<tr key={it.id}><td>{it.description}</td><td style={{ textAlign: 'center' }}>{it.quantity}</td><td style={{ textAlign: 'center' }}>{eur(it.unitPriceNetCents)}</td><td style={{ textAlign: 'center' }}>{it.taxBp / 100} %</td><td style={{ textAlign: 'right' }}>{eur(it.netCents)}</td></tr>))}
|
||||||
|
</tbody></table></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ maxWidth: 320, marginLeft: 'auto' }}>
|
||||||
|
<div className="row between"><span>Netto</span><span>{eur(inv.totalNetCents)}</span></div>
|
||||||
|
<div className="row between"><span>USt.</span><span>{eur(inv.totalTaxCents)}</span></div>
|
||||||
|
<div className="row between" style={{ fontWeight: 600 }}><span>Gesamt</span><span>{eur(inv.totalGrossCents)}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isDraft && <div className="row" style={{ marginTop: 16 }}>
|
||||||
|
<a className="btn" href={`/api/invoices/${id}/pdf`} target="_blank" rel="noreferrer">PDF ansehen</a>
|
||||||
|
{inv.canWrite && inv.status === 'open' && <button className="btn" disabled={busy} onClick={() => run(() => api('POST', `/invoices/${id}/mark-paid`), 'Als bezahlt markiert.')}>Als bezahlt markieren</button>}
|
||||||
|
{inv.canWrite && inv.status === 'open' && <button className="btn" disabled={busy} onClick={() => { const reason = prompt('Grund für den Storno (optional):') ?? undefined; run(() => api('POST', `/invoices/${id}/cancel`, reason ? { reason } : {}), 'Storniert.'); }}>Stornieren</button>}
|
||||||
|
</div>}
|
||||||
|
{inv.note && <p className="muted small" style={{ marginTop: 16 }}>Notiz: {inv.note}</p>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
3
apps/web/src/app/(app)/rechnungen/page.tsx
Normal file
3
apps/web/src/app/(app)/rechnungen/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
'use client';
|
||||||
|
import { InvoiceList } from '@/components/InvoiceList';
|
||||||
|
export default function Rechnungen() { return (<><h1>Rechnungen</h1><InvoiceList /></>); }
|
||||||
42
apps/web/src/components/InvoiceList.tsx
Normal file
42
apps/web/src/components/InvoiceList.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
'use client';
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { Alert, Empty, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Inv { id: string; number: string | null; orgId: string; orgName: string; customerNumber: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; totalGrossCents: number; createdAt: string }
|
||||||
|
interface Cust { id: string; name: string; customerNumber: string }
|
||||||
|
|
||||||
|
export function InvoiceList({ orgId }: { orgId?: string }) {
|
||||||
|
const { can } = useSession(); const w = can('invoices.write');
|
||||||
|
const [list, setList] = useState<Inv[] | null>(null); const [status, setStatus] = useState(''); const [err, setErr] = useState('');
|
||||||
|
const [open, setOpen] = useState(false); const [custs, setCusts] = useState<Cust[]>([]); const [newOrg, setNewOrg] = useState(orgId ?? '');
|
||||||
|
const load = useCallback(() => api<Inv[]>('GET', `/invoices?${new URLSearchParams({ ...(orgId ? { org: orgId } : {}), ...(status ? { status } : {}) })}`).then(setList).catch((e) => setErr(errMsg(e))), [orgId, status]);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
useEffect(() => { if (w && !orgId) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setNewOrg(l[0]?.id ?? ''); }).catch(() => undefined); }, [w, orgId]);
|
||||||
|
|
||||||
|
async function create(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget);
|
||||||
|
try { const inv = await api<{ id: string }>('POST', '/invoices', { orgId: newOrg, paymentMethod: f.get('paymentMethod') || undefined, note: f.get('note') || undefined }); location.href = `/rechnungen/${inv.id}`; }
|
||||||
|
catch (x) { setErr(errMsg(x)); }
|
||||||
|
}
|
||||||
|
return (<>
|
||||||
|
{err && <Alert kind="err">{err}</Alert>}
|
||||||
|
{w && <div className="row between" style={{ marginBottom: 16 }}><h2 style={{ margin: 0 }}>Rechnungen</h2><button className="btn primary" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Rechnung (Entwurf)</button></div>}
|
||||||
|
{open && <form className="card" onSubmit={create}>
|
||||||
|
{!orgId && <Field id="org" label="Kunde"><select id="org" value={newOrg} onChange={(e) => setNewOrg(e.target.value)}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>}
|
||||||
|
<Field id="paymentMethod" label="Zahlungsart (optional)"><input id="paymentMethod" name="paymentMethod" /></Field>
|
||||||
|
<Field id="note" label="Notiz (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
||||||
|
<div className="row"><button className="btn primary" type="submit" disabled={!newOrg}>Anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div>
|
||||||
|
</form>}
|
||||||
|
<div className="card">
|
||||||
|
{w && <Field id="ifs" label="Status"><select id="ifs" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option><option value="draft">Entwurf</option><option value="open">Offen</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option></select></Field>}
|
||||||
|
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Rechnungen" /> :
|
||||||
|
<div className="tablewrap"><table><thead><tr><th>Nr.</th>{!orgId && <th>Kunde</th>}<th>Status</th><th>Datum</th><th>Fällig</th><th>Betrag</th></tr></thead><tbody>
|
||||||
|
{list.map((i) => (<tr key={i.id}><td className="mono"><Link href={`/rechnungen/${i.id}`}>{i.number ?? 'Entwurf'}</Link></td>{!orgId && <td>{i.orgName}</td>}
|
||||||
|
<td><InvoiceStatusBadge value={i.status} overdue={i.overdue} /></td><td className="small">{i.issueDate ? fmt(i.issueDate) : '–'}</td><td className="small">{i.dueDate ? fmt(i.dueDate) : '–'}</td><td>{eur(i.totalGrossCents)}</td></tr>))}
|
||||||
|
</tbody></table></div>}
|
||||||
|
</div>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,8 @@ const CONTRACT: Record<string, [string, string, string]> = {
|
||||||
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
|
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
49
docs/rechnungen.md
Normal 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.
|
||||||
54
migrations/016_invoices.sql
Normal file
54
migrations/016_invoices.sql
Normal 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
132
pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue