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

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