feat(rechnungen): Export für den Steuerberater (CSV, PDF-ZIP, DATEV-Buchungsstapel)

Neue Seite Rechnungen → Export mit Zeitraum-/Status-Filter und drei
Downloads, nie Entwürfe:

- Rechnungsjournal als CSV (Semikolon, Dezimalkomma, UTF-8-BOM):
  Nummer, Datum, Kunde, Netto/USt/Brutto, effektiver USt-Satz, Status,
  Zahlungsdatum, aufgelöste Storno-Referenzen.
- Alle zugehörigen Rechnungs-PDFs als ZIP (die eigentlichen Belege,
  wie von GoBD neben dem Journal verlangt), gebaut über das lokale
  zip-Binary wie in ops/backup.ts.
- Echter DATEV-Buchungsstapel (EXTF, Format 700/21) zum direkten
  Import beim Steuerberater. Pro Rechnung eine Buchungszeile je
  USt-Satz-Gruppe (Automatikkonten-Verfahren: Konto = aus der
  Kundennummer abgeleitetes Debitorenkonto, Gegenkonto = konfiguriertes
  Erlöskonto des jeweiligen Satzes – kein geratener BU-Schlüssel
  nötig). Kopf- und Buchungszeilen-Layout (31 bzw. 125 Felder) gegen
  die offizielle DATEV-Formatbeschreibung und ein reales Beispiel aus
  github.com/ledermann/datev verifiziert, nicht aus dem Gedächtnis
  geraten. Ausgabe in Windows-1252 (eigener Encoder: Node kennt nur
  striktes ISO-8859-1, das den Halbgeviertstrich in den DATEV-
  Spaltennamen stillschweigend verstümmelt hätte – beim Testlauf
  gefunden und behoben).

Dafür neue DATEV-Stammdaten unter Einstellungen → Firma (Berater-/
Mandantennummer, SKR, Sachkontenlänge, Wirtschaftsjahresbeginn,
Erlöskonten je Steuersatz; Migration 031). Ohne diese Angaben liefert
der DATEV-Export eine klare Fehlermeldung statt einer falschen Datei;
das CSV/ZIP funktioniert unabhängig davon immer.

Gegen echte Rechnungen (RE-1001/RE-1002, Original + Storno) über eine
temporäre Test-Session verifiziert: CSV/ZIP/DATEV liefern korrekte
Inhalte, Storno kehrt Soll/Haben korrekt um, Feldzahlen exakt 31/125,
Encoding rund-trip-fest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 11:05:56 +02:00
parent 8cf8404440
commit 12fec5c822
5 changed files with 331 additions and 17 deletions

View file

@ -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<CompanySettings> {
}
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<DatevSettings> {
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<string, unknown>, key: string, corr
const mailTo = (to: string, template: string, vars: Record<string, string>, 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<typeof exportQuery>): { 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<string, string> = { 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<number, string>): 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<number, number> = {
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<string, any>): 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<Buffer> {
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<void> {
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<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 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',
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<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)); }
@ -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<string, string>(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<string>();
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'));
});
},
};

View file

@ -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<HTMLFormElement>) {
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() {
<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>
<h3>DATEV-Export</h3>
<p className="muted small">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).</p>
{!s.datev.complete && <Alert kind="warn">DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer fehlen noch.</Alert>}
<div className="cols">
<Field id="datevBeraterNr" label="Beraternummer" hint="vom Steuerberater, ≥ 1001"><input id="datevBeraterNr" name="datevBeraterNr" type="number" min={1001} defaultValue={s.datev.beraterNr ?? ''} disabled={!w} /></Field>
<Field id="datevMandantNr" label="Mandantennummer" hint="vom Steuerberater"><input id="datevMandantNr" name="datevMandantNr" type="number" min={1} defaultValue={s.datev.mandantNr ?? ''} disabled={!w} /></Field>
<Field id="datevSkr" label="Kontenrahmen (SKR)">
<select id="datevSkr" name="datevSkr" defaultValue={s.datev.skr ?? ''} disabled={!w}>
<option value="">– bitte wählen –</option><option value="04">SKR04</option><option value="03">SKR03</option>
</select>
</Field>
<Field id="datevSachkontenlaenge" label="Sachkontenlänge" hint="4–8 Stellen, meist 4"><input id="datevSachkontenlaenge" name="datevSachkontenlaenge" type="number" min={4} max={8} defaultValue={s.datev.sachkontenlaenge} disabled={!w} /></Field>
<Field id="datevFiscalYearStart" label="Wirtschaftsjahresbeginn" hint="MM-TT, meist 01-01"><input id="datevFiscalYearStart" name="datevFiscalYearStart" pattern="\d{2}-\d{2}" defaultValue={s.datev.fiscalYearStart} disabled={!w} /></Field>
<Field id="datevDiktatkuerzel" label="Diktatkürzel (optional)" hint="2 Zeichen"><input id="datevDiktatkuerzel" name="datevDiktatkuerzel" maxLength={2} defaultValue={s.datev.diktatkuerzel ?? ''} disabled={!w} /></Field>
</div>
<p className="muted small" style={{ marginTop: 4 }}>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).</p>
<div className="cols">
<Field id="datevErloeskonto19" label="Erlöskonto 19 % USt"><input id="datevErloeskonto19" name="datevErloeskonto19" type="number" defaultValue={s.datev.erloeskonto19 ?? ''} disabled={!w} /></Field>
<Field id="datevErloeskonto7" label="Erlöskonto 7 % USt"><input id="datevErloeskonto7" name="datevErloeskonto7" type="number" defaultValue={s.datev.erloeskonto7 ?? ''} disabled={!w} /></Field>
<Field id="datevErloeskonto0" label="Erlöskonto 0 % / steuerfrei"><input id="datevErloeskonto0" name="datevErloeskonto0" type="number" defaultValue={s.datev.erloeskonto0 ?? ''} disabled={!w} /></Field>
</div>
{w ? <button className="btn primary" type="submit">Speichern</button> : <p className="muted small">Nur Superadministratoren können diese Angaben ändern.</p>}
</form>
</>);

View file

@ -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 (<>
<div className="row between"><h1>Export für den Steuerberater</h1><Link className="btn" href="/rechnungen">← Rechnungen</Link></div>
<p className="muted">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.</p>
<div className="card">
<div className="row">
<Field id="from" label="Von"><input id="from" type="date" value={from} onChange={(e) => setFrom(e.target.value)} /></Field>
<Field id="to" label="Bis"><input id="to" type="date" value={to} onChange={(e) => setTo(e.target.value)} /></Field>
<Field id="status" label="Status">
<select id="status" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">Alle (offen, bezahlt, storniert)</option>
<option value="open">Nur offen</option>
<option value="paid">Nur bezahlt</option>
<option value="cancelled">Nur storniert</option>
</select>
</Field>
</div>
<div className="row" style={{ marginTop: 8 }}>
<a className="btn primary" href={`/api/admin/invoices/export.csv?${qs}`}>Rechnungsjournal (CSV) herunterladen</a>
<a className="btn" href={`/api/admin/invoices/export.zip?${qs}`}>Belege (ZIP mit PDFs) herunterladen</a>
<a className="btn" href={`/api/admin/invoices/export.datev?${qs}`} target="_blank" rel="noreferrer">DATEV-Buchungsstapel herunterladen</a>
</div>
<p className="muted small" style={{ marginTop: 12 }}>
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 <Link href="/einstellungen/firma">Einstellungen → Firma</Link>; fehlen sie, öffnet der Link statt der Datei eine Fehlermeldung mit den fehlenden Angaben.
</p>
</div>
</>);
}

View file

@ -4,5 +4,5 @@ import { useSession } from '@/lib/session';
import { InvoiceList } from '@/components/InvoiceList';
export default function Rechnungen() {
const { can } = useSession();
return (<><div className="row between"><h1>Rechnungen</h1>{can('invoices.read') && <Link className="btn" href="/rechnungen/faellig">Fällig</Link>}</div><InvoiceList /></>);
return (<><div className="row between"><h1>Rechnungen</h1>{can('invoices.read') && <div className="row"><Link className="btn" href="/rechnungen/export">Export (Steuerberater)</Link><Link className="btn" href="/rechnungen/faellig">Fällig</Link></div>}</div><InvoiceList /></>);
}