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:
parent
8cf8404440
commit
12fec5c822
5 changed files with 331 additions and 17 deletions
|
|
@ -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>
|
||||
</>);
|
||||
|
|
|
|||
44
apps/web/src/app/(app)/rechnungen/export/page.tsx
Normal file
44
apps/web/src/app/(app)/rechnungen/export/page.tsx
Normal 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>
|
||||
</>);
|
||||
}
|
||||
|
|
@ -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 /></>);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue