import { db } from '../db.js'; import { dezimal } from '../domain/geld.js'; import type { Buchung, Konto, Mandant } from '../domain/typen.js'; /** * DATEV-Format ("EXTF") – Buchungsstapel und Kontenbeschriftungen. * Das ist das Format, das ein Steuerberater in DATEV Rechnungswesen einliest. * Spezifikation: DATEV-Format Version 700 / Formatversion 13. * * Zeichensatz: DATEV erwartet ANSI (Windows-1252). Node kennt cp1252 nicht * direkt; latin1 deckt den deutschen Zeichenvorrat ab, lediglich das * Euro-Zeichen wird ersetzt (siehe `alsAnsi`). */ const FORMAT_VERSION = 700; const FORMAT_KATEGORIE_BUCHUNGSSTAPEL = 21; const FORMATVERSION_BUCHUNGSSTAPEL = 13; const FORMAT_KATEGORIE_KONTENBESCHRIFTUNG = 20; const FORMATVERSION_KONTENBESCHRIFTUNG = 3; /** Offizielle Spaltenüberschriften des Buchungsstapels (125 Felder). */ const SPALTEN_BUCHUNGSSTAPEL = [ 'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs', 'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum', 'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext', 'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt', 'Zinssperre', 'Beleglink', ...pärchen('Beleginfo - Art', 'Beleginfo - Inhalt', 8), 'KOST1 - Kostenstelle', 'KOST2 - Kostenstelle', 'KOST-Menge', 'EU-Land u. UStID (Bestimmung)', 'EU-Steuersatz (Bestimmung)', 'Abw. Versteuerungsart', 'Sachverhalt L+L', 'Funktionsergänzung L+L', 'BU 49 Hauptfunktionstyp', 'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung', ...pärchen('Zusatzinformation - Art', 'Zusatzinformation- Inhalt', 20), 'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr', 'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp', 'USt-Schlüssel (Anzahlungen)', 'EU-Land (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)', 'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz', 'Buchungs GUID', 'KOST-Datum', 'SEPA-Mandatsreferenz', 'Skontosperre', 'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer', 'Postensperre bis', 'Bezeichnung SoBil-Sachverhalt', 'Kennzeichen SoBil-Buchung', 'Festschreibung', 'Leistungsdatum', 'Datum Zuord. Steuerperiode', 'Fälligkeit', 'Generalumkehr (GU)', 'Steuersatz', 'Land', 'Abrechnungsreferenz', 'BVV-Position', 'EU-Land u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto', ]; function pärchen(a: string, b: string, n: number): string[] { return Array.from({ length: n }, (_, i) => [`${a} ${i + 1}`, `${b} ${i + 1}`]).flat(); } function q(v: string | number | null | undefined): string { if (v === null || v === undefined || v === '') return ''; return `"${String(v).replace(/"/g, '""')}"`; } function zahl(v: string | number | null | undefined): string { return v === null || v === undefined || v === '' ? '' : String(v); } function ttmm(datum: string): string { return `${datum.slice(8, 10)}${datum.slice(5, 7)}`; } function ttmmjjjj(datum: string): string { return `${datum.slice(8, 10)}${datum.slice(5, 7)}${datum.slice(0, 4)}`; } function zeitstempel(d: Date): string { const p = (n: number, l = 2) => String(n).padStart(l, '0'); return ( `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` + `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}${p(d.getMilliseconds(), 3)}` ); } export interface DatevOptionen { jahr: number; von?: string; bis?: string; /** Buchungen als festgeschrieben kennzeichnen (Feld 114) */ festschreibung?: boolean; bezeichnung?: string; /** Ohne Automatikkonten kann der Steuersatz per BU-Schlüssel mitgegeben werden. */ mitBuSchluessel?: boolean; jetzt?: Date; } export interface DatevDatei { dateiname: string; inhalt: Buffer; } /** DATEV-BU-Schlüssel für die gängigen Steuersätze. */ function buSchluessel(b: Buchung): string { if (b.ust_satz_bp === 0) return ''; if (b.art === 'einnahme') return b.ust_satz_bp === 700 ? '2' : '3'; return b.ust_satz_bp === 700 ? '8' : '9'; } export function buchungsstapel(opt: DatevOptionen): DatevDatei { const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant; const von = opt.von ?? `${opt.jahr}-01-01`; const bis = opt.bis ?? `${opt.jahr}-12-31`; const jetzt = opt.jetzt ?? new Date(); const buchungen = db .prepare( `SELECT * FROM buchungen WHERE storniert = 0 AND COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ? ORDER BY COALESCE(zahlungsdatum, leistungsdatum), id`, ) .all(von, bis) as Buchung[]; const kopf = [ q('EXTF'), FORMAT_VERSION, FORMAT_KATEGORIE_BUCHUNGSSTAPEL, q('Buchungsstapel'), FORMATVERSION_BUCHUNGSSTAPEL, zeitstempel(jetzt), '', // importiert q('RE'), // Herkunfts-Kennzeichen q(m.name.slice(0, 25)), '', // importiert von zahl(m.datev_berater_nr || '0'), zahl(m.datev_mandanten_nr || '0'), `${opt.jahr}0101`, 4, // Sachkontenlänge von.replace(/-/g, ''), bis.replace(/-/g, ''), q(opt.bezeichnung ?? `EÜR ${opt.jahr}`), q(''), // Diktatkürzel 1, // Buchungstyp: Finanzbuchführung 0, // Rechnungslegungszweck opt.festschreibung ? 1 : 0, q('EUR'), '', '', '', '', q(m.kontenrahmen), '', '', '', q('open-eur-webapp'), ].join(';'); const spalten = SPALTEN_BUCHUNGSSTAPEL.map(q).join(';'); const zeilen = buchungen.map((b) => { const einnahme = b.art === 'einnahme'; const konto = einnahme ? b.gegenkonto : b.konto; const gegenkonto = einnahme ? b.konto : b.gegenkonto; const datum = b.zahlungsdatum ?? b.leistungsdatum; const felder: (string | number)[] = new Array(SPALTEN_BUCHUNGSSTAPEL.length).fill(''); felder[0] = q(dezimal(b.betrag_brutto)); // Umsatz immer positiv felder[1] = q('S'); // Soll auf `Konto` felder[2] = q('EUR'); felder[6] = zahl(konto); felder[7] = zahl(gegenkonto); felder[8] = opt.mitBuSchluessel ? q(buSchluessel(b)) : ''; felder[9] = ttmm(datum); felder[10] = q(b.beleg_nr.slice(0, 36)); felder[11] = q(b.partner.slice(0, 12)); felder[13] = q(b.buchungstext.slice(0, 60)); felder[113] = opt.festschreibung || b.festgeschrieben ? 1 : 0; felder[114] = ttmmjjjj(b.leistungsdatum); // Leistungsdatum felder[115] = ttmmjjjj(b.leistungsdatum); // Zuordnung Steuerperiode return felder.join(';'); }); const inhalt = [kopf, spalten, ...zeilen].join('\r\n') + '\r\n'; return { dateiname: `EXTF_Buchungsstapel_${opt.jahr}_${von.replace(/-/g, '')}-${bis.replace(/-/g, '')}.csv`, inhalt: alsAnsi(inhalt), }; } export function kontenbeschriftungen(opt: DatevOptionen): DatevDatei { const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant; const konten = db.prepare('SELECT * FROM konten ORDER BY nummer').all() as Konto[]; const jetzt = opt.jetzt ?? new Date(); const kopf = [ q('EXTF'), FORMAT_VERSION, FORMAT_KATEGORIE_KONTENBESCHRIFTUNG, q('Kontenbeschriftungen'), FORMATVERSION_KONTENBESCHRIFTUNG, zeitstempel(jetzt), '', q('RE'), q(m.name.slice(0, 25)), '', zahl(m.datev_berater_nr || '0'), zahl(m.datev_mandanten_nr || '0'), `${opt.jahr}0101`, 4, `${opt.jahr}0101`, `${opt.jahr}1231`, q(`Kontenbeschriftungen ${opt.jahr}`), '', '', '', 0, q('EUR'), '', '', '', '', q(m.kontenrahmen), '', '', '', q('open-eur-webapp'), ].join(';'); const spalten = ['Konto', 'Kontenbeschriftung', 'Sprach-ID', 'Kontenbeschriftung lang'] .map(q) .join(';'); const zeilen = konten.map((k) => [zahl(k.nummer), q(k.bezeichnung.slice(0, 40)), q('de-DE'), q(k.bezeichnung)].join(';'), ); return { dateiname: `EXTF_Kontenbeschriftungen_${opt.jahr}.csv`, inhalt: alsAnsi([kopf, spalten, ...zeilen].join('\r\n') + '\r\n'), }; } /** * Windows-1252-Näherung: latin1 deckt alle deutschen Umlaute ab; das * Euro-Zeichen liegt in cp1252 auf 0x80 und wird ersetzt, damit keine * fehlerhaften Bytes im Import landen. */ export function alsAnsi(s: string): Buffer { return Buffer.from(s.replace(/€/g, 'EUR').replace(/[^\x00-\xFF]/g, '?'), 'latin1'); }