diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 3195c26..9adb18f 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -1,6 +1,10 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { PoolConnection } from 'mysql2/promise'; import { calculatePrice } from '@kc/platform/pricing'; import { one, query, run, tx } from '../../core/db.js'; @@ -37,6 +41,21 @@ async function settings(): Promise { } const complete = (s: CompanySettings) => !!(s.name && s.street && s.zip && s.city && (s.taxNumber || s.vatId)); +export interface DatevSettings { + beraterNr: number | null; mandantNr: number | null; skr: string | null; sachkontenlaenge: number; fiscalYearStart: string; + erloeskonto19: number | null; erloeskonto7: number | null; erloeskonto0: number | null; diktatkuerzel: string | null; +} +async function datevSettings(): Promise { + const s = await one('SELECT * FROM company_settings WHERE id = 1'); + return { + beraterNr: s?.datev_berater_nr ?? null, mandantNr: s?.datev_mandant_nr ?? null, skr: s?.datev_skr ?? null, + sachkontenlaenge: Number(s?.datev_sachkontenlaenge ?? 4), fiscalYearStart: s?.datev_fiscal_year_start ?? '01-01', + erloeskonto19: s?.datev_erloeskonto_19 ?? null, erloeskonto7: s?.datev_erloeskonto_7 ?? null, erloeskonto0: s?.datev_erloeskonto_0 ?? null, + diktatkuerzel: s?.datev_diktatkuerzel ?? null, + }; +} +const datevComplete = (d: DatevSettings) => !!(d.beraterNr && d.mandantNr); + const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents }); const invoiceView = (v: any) => ({ id: v.id, number: v.number, orgId: v.org_id, orgName: v.org_name, customerNumber: v.customer_number, status: v.status, @@ -57,6 +76,107 @@ const notify = (event: string, extra: Record, key: string, corr const mailTo = (to: string, template: string, vars: Record, key: string, correlationId: string) => enqueue('mail.template', { to, key: template, vars }, { idempotencyKey: key, correlationId }); const deDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE'); const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }); +const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x); + +// ---- Export für den Steuerberater (Rechnungsjournal als CSV, Belege als ZIP) ------------------ +const exportQuery = z.object({ + from: z.string().date().optional(), to: z.string().date().optional(), + status: z.enum(['open', 'paid', 'cancelled']).optional(), org: z.string().uuid().optional(), +}); +/** Nie Entwürfe (keine steuerlich relevanten Belege); weitere Filter optional. */ +function exportFilter(q: z.infer): { sql: string; params: unknown[] } { + const conds: string[] = ["v.status != 'draft'"]; const params: unknown[] = []; + if (q.from) { conds.push('v.issue_date >= ?'); params.push(q.from); } + if (q.to) { conds.push('v.issue_date <= ?'); params.push(q.to); } + if (q.status) { conds.push('v.status = ?'); params.push(q.status); } + if (q.org) { conds.push('v.org_id = ?'); params.push(q.org); } + return { sql: conds.join(' AND '), params }; +} +const csvNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); +const csvCell = (s: string): string => (/[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s); +const csvDate = (d: unknown) => (d ? new Date(d as string).toISOString().slice(0, 10) : ''); +const STATUS_LABEL: Record = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' }; + +// ---- DATEV-Buchungsstapel-Export (EXTF, Format 700/21) ----------------------------------------- +// Feldlisten und Reihenfolge gegen die offizielle DATEV-Formatbeschreibung sowie ein reales Beispiel +// aus der etablierten Open-Source-Bibliothek github.com/ledermann/datev verifiziert (nicht aus dem +// Gedächtnis geraten), da ein falsches Spaltenlayout die Datei beim Steuerberater unbrauchbar macht. +const DATEV_BOOKING_HEADER = [ + 'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs', 'Basisumsatz', 'WKZ Basisumsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum', + 'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext', 'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt', 'Zinssperre', 'Beleglink', + 'Beleginfo – Art 1', 'Beleginfo – Inhalt 1', 'Beleginfo – Art 2', 'Beleginfo – Inhalt 2', 'Beleginfo – Art 3', 'Beleginfo – Inhalt 3', 'Beleginfo – Art 4', 'Beleginfo – Inhalt 4', + 'Beleginfo – Art 5', 'Beleginfo – Inhalt 5', 'Beleginfo – Art 6', 'Beleginfo – Inhalt 6', 'Beleginfo – Art 7', 'Beleginfo – Inhalt 7', 'Beleginfo – Art 8', 'Beleginfo – Inhalt 8', + 'KOST1 – Kostenstelle', 'KOST2 – Kostenstelle', 'Kost Menge', 'EU-Land u. USt-IdNr.', 'EU-Steuersatz', 'Abw. Versteuerungsart', 'Sachverhalt L+L', 'Funktionsergänzung L+L', + 'BU 49 Hauptfunktionstyp', 'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung', + 'Zusatzinformation – Art 1', 'Zusatzinformation – Inhalt 1', 'Zusatzinformation – Art 2', 'Zusatzinformation – Inhalt 2', 'Zusatzinformation – Art 3', 'Zusatzinformation – Inhalt 3', + 'Zusatzinformation – Art 4', 'Zusatzinformation – Inhalt 4', 'Zusatzinformation – Art 5', 'Zusatzinformation – Inhalt 5', 'Zusatzinformation – Art 6', 'Zusatzinformation – Inhalt 6', + 'Zusatzinformation – Art 7', 'Zusatzinformation – Inhalt 7', 'Zusatzinformation – Art 8', 'Zusatzinformation – Inhalt 8', 'Zusatzinformation – Art 9', 'Zusatzinformation – Inhalt 9', + 'Zusatzinformation – Art 10', 'Zusatzinformation – Inhalt 10', 'Zusatzinformation – Art 11', 'Zusatzinformation – Inhalt 11', 'Zusatzinformation – Art 12', 'Zusatzinformation – Inhalt 12', + 'Zusatzinformation – Art 13', 'Zusatzinformation – Inhalt 13', 'Zusatzinformation – Art 14', 'Zusatzinformation – Inhalt 14', 'Zusatzinformation – Art 15', 'Zusatzinformation – Inhalt 15', + 'Zusatzinformation – Art 16', 'Zusatzinformation – Inhalt 16', 'Zusatzinformation – Art 17', 'Zusatzinformation – Inhalt 17', 'Zusatzinformation – Art 18', 'Zusatzinformation – Inhalt 18', + 'Zusatzinformation – Art 19', 'Zusatzinformation – Inhalt 19', 'Zusatzinformation – Art 20', 'Zusatzinformation – Inhalt 20', + 'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr', 'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp', 'USt-Schlüssel (Anzahlungen)', + 'EU-Mitgliedstaat (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)', 'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz', 'Leerfeld', 'KOST-Datum', 'SEPA-Mandatsreferenz', + 'Skontosperre', 'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer', 'Postensperre bis', 'Bezeichnung', 'Kennzeichen', 'Festschreibung', 'Leistungsdatum', + 'Datum Zuord.', 'Fälligkeit', 'Generalumkehr', 'Steuersatz', 'Land', 'Abrechnungsreferent', 'BVV-Position', 'EU-Mitgliedstaat u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto', +]; // 125 Felder +const pad2 = (n: number) => String(n).padStart(2, '0'); +const datevYmd = (d: Date) => `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`; +const datevDm = (d: Date) => `${pad2(d.getUTCDate())}${pad2(d.getUTCMonth() + 1)}`; +const datevQ = (s: string) => `"${s.replace(/"/g, '""')}"`; +const datevNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); +/** Eine Buchungszeile mit genau 125 Feldern; nur die übergebenen (1-basierten) Spaltennummern werden gesetzt, der Rest bleibt leer. */ +function datevRow(vals: Record): string { + const arr = new Array(DATEV_BOOKING_HEADER.length).fill(''); + for (const [idx, val] of Object.entries(vals)) arr[Number(idx) - 1] = val; + return arr.join(';'); +} +// Zeichen außerhalb 0x00–0xFF (z. B. der Halbgeviertstrich – in den DATEV-Spaltennamen), die es in ISO-8859-1 +// nicht gibt, aber im von DATEV traditionell erwarteten Windows-1252 auf einem eigenen Byte liegen. +const WIN1252_HIGH: Record = { + 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e, + 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f, +}; +/** Node kennt kein natives Windows-1252 (nur strikt ISO-8859-1 als "latin1"); Zeichen >0xFF würden sonst stillschweigend verstümmelt. */ +function toWin1252(str: string): Buffer { + const bytes: number[] = []; + for (const ch of str) { const cp = ch.codePointAt(0)!; bytes.push(cp <= 0xff ? cp : (WIN1252_HIGH[cp] ?? 0x3f)); } + return Buffer.from(bytes); +} + +/** Absender-/Empfängerdaten und Positionen für das PDF – mit demselben Rückfall wie /invoices/:id/pdf + * für Rechnungen, die vor der Absender-Einfrierung (Migration 020) ausgestellt wurden. */ +async function pdfInput(v: Record): Promise<{ inv: InvoiceForPdf; seller: CompanySettings }> { + const items = await query('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order', [v.id]); + let seller = jparse(v.seller_snapshot_json) as CompanySettings | null; + let customer = jparse(v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null; + if (!seller || !customer) { + 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 = ?', [v.org_id]); + seller ??= await settings(); + customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null }; + } + const inv: InvoiceForPdf = { + number: v.number, issueDate: v.issue_date, dueDate: v.due_date, status: v.status, paymentMethod: v.payment_method, note: v.note, + totalNetCents: v.total_net_cents, totalTaxCents: v.total_tax_cents, totalGrossCents: v.total_gross_cents, + customer, items: items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), + }; + return { inv, seller }; +} +function streamToBuffer(s: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + s.on('data', (c) => chunks.push(c)); s.on('end', () => resolve(Buffer.concat(chunks))); s.on('error', reject); + }); +} +/** Externes Programm ohne Shell starten (wie in ops/backup.ts, hier lokal, um dort keine Export-Belange zu vermischen). */ +function runBin(cmd: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; p.stderr!.on('data', (d) => { err += d; }); + p.on('error', (e) => reject(new Error(`${cmd} konnte nicht gestartet werden: ${e.message}`))); + p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} Fehler (Exit ${code}): ${err.trim().slice(-500)}`)))); + }); +} export const invoicesModule: KcModule = { name: 'invoices', @@ -66,7 +186,11 @@ export const invoicesModule: KcModule = { }, 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.get('/admin/company-settings', async (req) => { + requirePermission(req, 'invoices.read'); + const s = await settings(); const d = await datevSettings(); + return { ...s, complete: complete(s), datev: { ...d, complete: datevComplete(d) } }; + }); app.put('/admin/company-settings', async (req) => { const a = requirePermission(req, 'settings.write'); const b = z.object({ @@ -74,8 +198,17 @@ export const invoicesModule: KcModule = { 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(), + datevBeraterNr: z.number().int().min(1001).max(9999999).nullable().optional(), datevMandantNr: z.number().int().min(1).max(99999).nullable().optional(), + datevSkr: z.enum(['03', '04']).nullable().optional(), datevSachkontenlaenge: z.number().int().min(4).max(8).optional(), + datevFiscalYearStart: z.string().regex(/^\d{2}-\d{2}$/).optional(), + datevErloeskonto19: z.number().int().min(0).max(999999999).nullable().optional(), datevErloeskonto7: z.number().int().min(0).max(999999999).nullable().optional(), datevErloeskonto0: z.number().int().min(0).max(999999999).nullable().optional(), + datevDiktatkuerzel: z.string().trim().max(2).nullable().optional(), }).parse(req.body); - const cols: Record = { name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text' }; + const cols: Record = { + name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text', + datevBeraterNr: 'datev_berater_nr', datevMandantNr: 'datev_mandant_nr', datevSkr: 'datev_skr', datevSachkontenlaenge: 'datev_sachkontenlaenge', datevFiscalYearStart: 'datev_fiscal_year_start', + datevErloeskonto19: 'datev_erloeskonto_19', datevErloeskonto7: 'datev_erloeskonto_7', datevErloeskonto0: 'datev_erloeskonto_0', datevDiktatkuerzel: 'datev_diktatkuerzel', + }; const sets: string[] = []; const params: unknown[] = []; for (const [k, col] of Object.entries(cols)) if ((b as Record)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record)[k]); } if (b.paymentMethods) { sets.push('payment_methods = ?'); params.push(JSON.stringify(b.paymentMethods)); } @@ -285,21 +418,118 @@ export const invoicesModule: KcModule = { const res = await loadInvoice(id); if (!res || !canInOrg(a.principal, res.v.org_id, 'invoices.read', 'invoices.read') || (!staff && res.v.status === 'draft')) throw notFound(); if (res.v.status === 'draft') throw badRequest('Für Entwürfe gibt es noch kein PDF. Bitte zuerst ausstellen.', 'INVOICE_DRAFT'); - const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x); - let seller = jparse(res.v.seller_snapshot_json) as CompanySettings | null; - let customer = jparse(res.v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null; - if (!seller || !customer) { // vor Migration 020 ausgestellt: kein eingefrorener Stand vorhanden, Rückfall auf die damals übliche Live-Anzeige - const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [res.v.org_id]); - seller ??= await settings(); - customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null }; - } - const inv: InvoiceForPdf = { - number: res.v.number, issueDate: res.v.issue_date, dueDate: res.v.due_date, status: res.v.status, paymentMethod: res.v.payment_method, note: res.v.note, - totalNetCents: res.v.total_net_cents, totalTaxCents: res.v.total_tax_cents, totalGrossCents: res.v.total_gross_cents, - customer, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), - }; + const { inv, seller } = await pdfInput(res.v); reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`); return reply.send(renderInvoicePdf(inv, seller)); }); + + /** Rechnungsjournal als CSV (Semikolon, Dezimalkomma, UTF-8-BOM – öffnet sich in Excel ohne Umweg). + * Für den Steuerberater/die Buchhaltung: eine Zeile je ausgestellter Rechnung/Stornorechnung, nie Entwürfe. */ + app.get('/admin/invoices/export.csv', async (req, reply) => { + const a = requirePermission(req, 'invoices.read'); + const q = exportQuery.parse(req.query); + const { sql, params } = exportFilter(q); + const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 10000`, params); + const numberOf = new Map(rows.map((v) => [v.id as string, v.number as string])); + const missing = [...new Set(rows.flatMap((v) => [v.cancels_invoice_id, v.cancelled_by_invoice_id]).filter((id): id is string => !!id && !numberOf.has(id)))]; + if (missing.length) for (const r of await query(`SELECT id, number FROM invoices WHERE id IN (${missing.map(() => '?').join(',')})`, missing)) numberOf.set(r.id, r.number); + const header = ['Rechnungsnummer', 'Rechnungsdatum', 'Faelligkeitsdatum', 'Kundennummer', 'Kunde', 'Land', 'USt-IdNr', 'Netto', 'USt effektiv %', 'USt-Betrag', 'Brutto', 'Waehrung', 'Status', 'Bezahlt am', 'Zahlungsart', 'Storno zu', 'Storniert durch', 'Notiz']; + const lines = [header.join(';')]; + for (const v of rows) { + const buyer = jparse(v.buyer_snapshot_json) as { name?: string; country?: string; vatId?: string } | null; + const rate = v.total_net_cents !== 0 ? Math.round((v.total_tax_cents / v.total_net_cents) * 10000) / 100 : 0; + lines.push([ + v.number ?? '', csvDate(v.issue_date), csvDate(v.due_date), v.customer_number ?? '', csvCell(buyer?.name || v.org_name || ''), + buyer?.country ?? 'DE', buyer?.vatId ?? '', csvNum(v.total_net_cents), rate.toString().replace('.', ','), csvNum(v.total_tax_cents), csvNum(v.total_gross_cents), + v.currency ?? 'EUR', STATUS_LABEL[v.status] ?? v.status, v.paid_at ? csvDate(v.paid_at) : '', v.payment_method ?? '', + v.cancels_invoice_id ? (numberOf.get(v.cancels_invoice_id) ?? '') : '', v.cancelled_by_invoice_id ? (numberOf.get(v.cancelled_by_invoice_id) ?? '') : '', csvCell(v.note ?? ''), + ].join(';')); + } + await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_csv', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } }); + reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', `attachment; filename="rechnungsjournal-${q.from ?? 'alle'}_${q.to ?? 'alle'}.csv"`); + return reply.send('' + lines.join('\r\n') + '\r\n'); + }); + + /** Alle passenden Rechnungs-PDFs als ZIP (die eigentlichen Belege, wie sie GoBD neben dem Journal verlangt). */ + app.get('/admin/invoices/export.zip', async (req, reply) => { + const a = requirePermission(req, 'invoices.read'); + const q = exportQuery.parse(req.query); + const { sql, params } = exportFilter(q); + const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 500`, params); + if (rows.length === 0) throw notFound(); + const work = await mkdtemp(join(tmpdir(), 'kc-invoice-export-')); + try { + const names: string[] = []; + const seen = new Set(); + for (const v of rows) { + const { inv, seller } = await pdfInput(v); + const buf = await streamToBuffer(renderInvoicePdf(inv, seller)); + let base = (v.number ?? v.id).replace(/[^A-Za-z0-9._-]/g, '_'); + let name = `${base}.pdf`; let n = 2; + while (seen.has(name)) name = `${base}_${n++}.pdf`; // theoretisch eindeutig durch die Rechnungsnummer, doppelte Absicherung + seen.add(name); names.push(name); + await writeFile(join(work, name), buf); + } + await runBin('zip', ['-q', '-X', '_export.zip', ...names], work); + const zip = await readFile(join(work, '_export.zip')); + await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_zip', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } }); + reply.header('content-type', 'application/zip').header('content-disposition', `attachment; filename="rechnungen-${q.from ?? 'alle'}_${q.to ?? 'alle'}.zip"`); + return reply.send(zip); + } finally { await rm(work, { recursive: true, force: true }); } + }); + + /** DATEV-Buchungsstapel (EXTF, Format 700/21) zum direkten Import beim Steuerberater. Pro Rechnung eine + * Buchungszeile je USt-Satz-Gruppe (Konto = aus der Kundennummer abgeleitetes Debitorenkonto, Gegenkonto = + * konfiguriertes Erlöskonto des jeweiligen Satzes) – das "Automatikkonten"-Verfahren, bei dem der + * Steuerschlüssel durch das Erlöskonto selbst festgelegt ist (kein geratener BU-Schlüssel nötig). */ + app.get('/admin/invoices/export.datev', async (req, reply) => { + const a = requirePermission(req, 'invoices.read'); + const q = exportQuery.parse(req.query); + const d = await datevSettings(); + if (!datevComplete(d)) throw badRequest('DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer müssen unter Einstellungen → Firma → DATEV-Export hinterlegt sein.', 'DATEV_SETTINGS_INCOMPLETE'); + const { sql, params } = exportFilter(q); + const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 10000`, params); + if (rows.length === 0) throw notFound(); + + const erloeskontoFor = (taxBp: number): number | null => (taxBp === 1900 ? d.erloeskonto19 : taxBp === 700 ? d.erloeskonto7 : taxBp === 0 ? d.erloeskonto0 : null); + const errors: string[] = []; + const bookings: string[] = []; + let minDate: Date | null = null; let maxDate: Date | null = null; + for (const v of rows) { + const issueDate = new Date(v.issue_date); + if (!minDate || issueDate < minDate) minDate = issueDate; if (!maxDate || issueDate > maxDate) maxDate = issueDate; + const digits = String(v.customer_number ?? '').replace(/\D/g, ''); + if (!digits) { errors.push(`${v.number}: Kundennummer "${v.customer_number}" enthält keine Ziffern – Debitorenkonto nicht ableitbar`); continue; } + const groups = await query('SELECT tax_bp, SUM(gross_cents) AS gross FROM invoice_items WHERE invoice_id = ? GROUP BY tax_bp', [v.id]); + for (const g of groups) { + const gross = Number(g.gross); if (gross === 0) continue; + const konto = erloeskontoFor(g.tax_bp); + if (konto == null) { errors.push(`${v.number}: kein Erlöskonto für ${(g.tax_bp / 100).toLocaleString('de-DE')} % USt konfiguriert (unter Einstellungen → Firma → DATEV-Export ergänzen)`); continue; } + bookings.push(datevRow({ + 1: datevNum(Math.abs(gross)), 2: datevQ(gross >= 0 ? 'S' : 'H'), 7: digits, 8: String(konto), + 10: datevDm(issueDate), 11: datevQ(v.number), 14: datevQ(`Rechnung ${v.number} ${v.org_name}`.slice(0, 60)), + })); + } + } + if (errors.length) throw badRequest(`DATEV-Export nicht möglich – bitte zuerst beheben:\n${errors.slice(0, 20).join('\n')}${errors.length > 20 ? `\n… und ${errors.length - 20} weitere` : ''}`, 'DATEV_MAPPING_INCOMPLETE'); + if (bookings.length === 0) throw notFound(); + + const from = q.from ? new Date(q.from) : minDate!; const to = q.to ? new Date(q.to) : maxDate!; + const [fyMonth, fyDay] = d.fiscalYearStart.split('-').map(Number); + const wjBeginn = new Date(Date.UTC(from.getUTCFullYear(), fyMonth! - 1, fyDay)); + const now = new Date(); + const erzeugtAm = `${datevYmd(now)}${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}${String(now.getUTCMilliseconds()).padStart(3, '0')}`; + const s = await settings(); + const header = [ + datevQ('EXTF'), '700', '21', datevQ('Buchungsstapel'), '13', erzeugtAm, '', datevQ('KC'), datevQ((s.name ?? 'Kundencenter').slice(0, 25)), '', + String(d.beraterNr), String(d.mandantNr), datevYmd(wjBeginn), String(d.sachkontenlaenge), datevYmd(from), datevYmd(to), + datevQ(`Rechnungen ${datevYmd(from)}-${datevYmd(to)}`.slice(0, 30)), d.diktatkuerzel ? datevQ(d.diktatkuerzel) : '', '1', '', '', datevQ('EUR'), + '', '', '', '', d.skr ? datevQ(d.skr) : '', '', '', '', '', + ].join(';'); + const lines = [header, DATEV_BOOKING_HEADER.join(';'), ...bookings]; + await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_datev', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length, bookings: bookings.length } }); + reply.header('content-type', 'text/csv; charset=windows-1252').header('content-disposition', `attachment; filename="EXTF_Buchungsstapel_${datevYmd(from)}_${datevYmd(to)}.csv"`); + return reply.send(toWin1252(lines.join('\r\n') + '\r\n')); + }); }, }; diff --git a/apps/web/src/app/(app)/einstellungen/firma/page.tsx b/apps/web/src/app/(app)/einstellungen/firma/page.tsx index e8024f9..47e5def 100644 --- a/apps/web/src/app/(app)/einstellungen/firma/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/firma/page.tsx @@ -4,7 +4,8 @@ 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 } +interface Datev { beraterNr: number | null; mandantNr: number | null; skr: string | null; sachkontenlaenge: number; fiscalYearStart: string; erloeskonto19: number | null; erloeskonto7: number | null; erloeskonto0: number | null; diktatkuerzel: string | null; complete: boolean } +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; datev: Datev } export default function Firma() { const { can } = useSession(); const w = can('settings.write'); @@ -15,12 +16,17 @@ export default function Firma() { async function save(e: FormEvent) { e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined); try { + const n = (k: string) => { const x = v(k); return x === undefined ? null : Number(x); }; 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, + datevBeraterNr: n('datevBeraterNr'), datevMandantNr: n('datevMandantNr'), datevSkr: (v('datevSkr') as '03' | '04' | undefined) ?? null, + datevSachkontenlaenge: Number(f.get('datevSachkontenlaenge') ?? 4), datevFiscalYearStart: v('datevFiscalYearStart') ?? '01-01', + datevErloeskonto19: n('datevErloeskonto19'), datevErloeskonto7: n('datevErloeskonto7'), datevErloeskonto0: n('datevErloeskonto0'), + datevDiktatkuerzel: v('datevDiktatkuerzel') ?? null, }); setOk('Gespeichert.'); void load(); } catch (x) { setErr(errMsg(x)); } @@ -54,6 +60,27 @@ export default function Firma() { +

DATEV-Export

+

Für den Buchungsstapel-Export unter Rechnungen → Export. Werte vom Steuerberater erfragen; ohne Beraternummer und Mandantennummer ist der DATEV-Export nicht möglich (das generische CSV/ZIP funktioniert unabhängig davon immer).

+ {!s.datev.complete && DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer fehlen noch.} +
+ + + + + + + + +
+

Erlöskonten je USt-Satz (Automatikkonten-Verfahren: das Erlöskonto selbst legt den Steuersatz fest, z. B. SKR04 4400/4300 oder SKR03 8400/8300 – beim Steuerberater erfragen).

+
+ + + +
{w ? :

Nur Superadministratoren können diese Angaben ändern.

} ); diff --git a/apps/web/src/app/(app)/rechnungen/export/page.tsx b/apps/web/src/app/(app)/rechnungen/export/page.tsx new file mode 100644 index 0000000..ae45c85 --- /dev/null +++ b/apps/web/src/app/(app)/rechnungen/export/page.tsx @@ -0,0 +1,44 @@ +'use client'; +import { useState } from 'react'; +import Link from 'next/link'; +import { Field } from '@/components/ui'; + +export default function RechnungsExport() { + const today = new Date(); + const jahresanfang = `${today.getFullYear()}-01-01`; + const heute = today.toISOString().slice(0, 10); + const [from, setFrom] = useState(jahresanfang); + const [to, setTo] = useState(heute); + const [status, setStatus] = useState(''); + + const qs = new URLSearchParams({ ...(from ? { from } : {}), ...(to ? { to } : {}), ...(status ? { status } : {}) }).toString(); + + return (<> +

Export für den Steuerberater

← Rechnungen
+

Rechnungsjournal (CSV, für Excel/DATEV) und die zugehörigen Rechnungs-PDFs als Sammel-ZIP – für den gewählten Zeitraum. Entwürfe sind nie enthalten, nur tatsächlich ausgestellte Rechnungen und Stornorechnungen.

+
+
+ setFrom(e.target.value)} /> + setTo(e.target.value)} /> + + + +
+ +

+ Das Journal enthält je Rechnung Nummer, Datum, Kunde, Netto/USt/Brutto, effektiven USt-Satz, Status und Zahlungsdatum – Semikolon-getrennt mit Dezimalkomma, öffnet sich direkt in Excel. + Das ZIP enthält bis zu 500 Rechnungs-PDFs für den gewählten Zeitraum; bei mehr Rechnungen bitte den Zeitraum eingrenzen. + Der DATEV-Buchungsstapel (EXTF, direkt importierbar) braucht vorher hinterlegte Stammdaten unter Einstellungen → Firma; fehlen sie, öffnet der Link statt der Datei eine Fehlermeldung mit den fehlenden Angaben. +

+
+ ); +} diff --git a/apps/web/src/app/(app)/rechnungen/page.tsx b/apps/web/src/app/(app)/rechnungen/page.tsx index 32da919..9a74a3c 100644 --- a/apps/web/src/app/(app)/rechnungen/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/page.tsx @@ -4,5 +4,5 @@ import { useSession } from '@/lib/session'; import { InvoiceList } from '@/components/InvoiceList'; export default function Rechnungen() { const { can } = useSession(); - return (<>

Rechnungen

{can('invoices.read') && Fällig}
); + return (<>

Rechnungen

{can('invoices.read') &&
Export (Steuerberater)Fällig
}
); } diff --git a/migrations/031_datev_export.sql b/migrations/031_datev_export.sql new file mode 100644 index 0000000..f86db12 --- /dev/null +++ b/migrations/031_datev_export.sql @@ -0,0 +1,13 @@ +-- Firmenstammdaten für den DATEV-Buchungsstapel-Export (EXTF, Format 700/21). Alle Felder optional/NULL, +-- solange niemand den DATEV-Export nutzt; der CSV-/ZIP-Export (Migration keine nötig, reine Anwendungslogik) +-- funktioniert unabhängig davon bereits. +ALTER TABLE company_settings + ADD COLUMN datev_berater_nr INT NULL, -- Beraternummer (>= 1001), vom Steuerberater + ADD COLUMN datev_mandant_nr INT NULL, -- Mandantennummer, vom Steuerberater + ADD COLUMN datev_skr CHAR(2) NULL, -- Kontenrahmen, z. B. '03' oder '04' + ADD COLUMN datev_sachkontenlaenge TINYINT NOT NULL DEFAULT 4, -- Länge der Sachkontonummern (4-8) + ADD COLUMN datev_fiscal_year_start CHAR(5) NOT NULL DEFAULT '01-01', -- Wirtschaftsjahresbeginn als MM-DD + ADD COLUMN datev_erloeskonto_19 INT NULL, -- Erlöskonto für 19 % USt (z. B. SKR04 4400) + ADD COLUMN datev_erloeskonto_7 INT NULL, -- Erlöskonto für 7 % USt (z. B. SKR04 4300) + ADD COLUMN datev_erloeskonto_0 INT NULL, -- Erlöskonto für 0 % / steuerfreie Umsätze + ADD COLUMN datev_diktatkuerzel VARCHAR(2) NULL; -- optionales Bearbeiterkürzel (Kopfzeile)