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
|
|
@ -1,6 +1,10 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { randomUUID } from 'node:crypto';
|
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 type { PoolConnection } from 'mysql2/promise';
|
||||||
import { calculatePrice } from '@kc/platform/pricing';
|
import { calculatePrice } from '@kc/platform/pricing';
|
||||||
import { one, query, run, tx } from '../../core/db.js';
|
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));
|
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 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) => ({
|
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,
|
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 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 deDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE');
|
||||||
const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
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 = {
|
export const invoicesModule: KcModule = {
|
||||||
name: 'invoices',
|
name: 'invoices',
|
||||||
|
|
@ -66,7 +186,11 @@ export const invoicesModule: KcModule = {
|
||||||
},
|
},
|
||||||
register(app: FastifyInstance) {
|
register(app: FastifyInstance) {
|
||||||
// ---- Firmenstammdaten (für den Rechnungskopf) ---------------------------
|
// ---- 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) => {
|
app.put('/admin/company-settings', async (req) => {
|
||||||
const a = requirePermission(req, 'settings.write');
|
const a = requirePermission(req, 'settings.write');
|
||||||
const b = z.object({
|
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(),
|
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(),
|
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(),
|
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);
|
}).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[] = [];
|
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]); }
|
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 (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);
|
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 || !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');
|
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);
|
const { inv, seller } = await pdfInput(res.v);
|
||||||
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 })),
|
|
||||||
};
|
|
||||||
reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`);
|
reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`);
|
||||||
return reply.send(renderInvoicePdf(inv, seller));
|
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'));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { api, errMsg } from '@/lib/api';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { Alert, Field } from '@/components/ui';
|
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() {
|
export default function Firma() {
|
||||||
const { can } = useSession(); const w = can('settings.write');
|
const { can } = useSession(); const w = can('settings.write');
|
||||||
|
|
@ -15,12 +16,17 @@ export default function Firma() {
|
||||||
async function save(e: FormEvent<HTMLFormElement>) {
|
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);
|
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined);
|
||||||
try {
|
try {
|
||||||
|
const n = (k: string) => { const x = v(k); return x === undefined ? null : Number(x); };
|
||||||
await api('PUT', '/admin/company-settings', {
|
await api('PUT', '/admin/company-settings', {
|
||||||
name: v('name'), street: v('street'), zip: v('zip'), city: v('city'), country: v('country') ?? 'DE',
|
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'),
|
taxNumber: v('taxNumber'), vatId: v('vatId'), bankName: v('bankName'), iban: v('iban'), bic: v('bic'),
|
||||||
invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14),
|
invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14),
|
||||||
paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean),
|
paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean),
|
||||||
footerText: v('footerText') ?? null,
|
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();
|
setOk('Gespeichert.'); void load();
|
||||||
} catch (x) { setErr(errMsg(x)); }
|
} 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>
|
<Field id="paymentMethods" label="Zahlungsarten" hint="kommagetrennt"><input id="paymentMethods" name="paymentMethods" defaultValue={s.paymentMethods.join(', ')} disabled={!w} /></Field>
|
||||||
</div>
|
</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>
|
<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>}
|
{w ? <button className="btn primary" type="submit">Speichern</button> : <p className="muted small">Nur Superadministratoren können diese Angaben ändern.</p>}
|
||||||
</form>
|
</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';
|
import { InvoiceList } from '@/components/InvoiceList';
|
||||||
export default function Rechnungen() {
|
export default function Rechnungen() {
|
||||||
const { can } = useSession();
|
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 /></>);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
migrations/031_datev_export.sql
Normal file
13
migrations/031_datev_export.sql
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue