Files
open-eur-buchhaltung/server/src/export/datev.ts
T
duffyduckandClaude Opus 5 bf371c8118 Einnahmen-Überschuss-Rechnung mit ELSTER, DATEV und Mandantenfähigkeit
Gewinnermittlung nach §4 Abs. 3 EStG für Einzelunternehmen und Freiberufler.
Node/Fastify mit SQLite im Backend, React/Vite im Frontend; die Anwendung
läuft lokal ohne Cloud-Anbindung.

Fachlicher Kern ist eine Zeitachse der Besteuerungsform: das umsatzsteuerliche
Regime wird je Buchung aus dem Leistungsdatum aufgelöst, statt aus einer
globalen Ja/Nein-Flagge. Dadurch ist auch ein unterjähriger Wechsel von der
Kleinunternehmerregelung zur Regelbesteuerung abbildbar, wie ihn §19 Abs. 1
Satz 3 UStG seit 2025 beim Überschreiten der 100.000-EUR-Grenze vorsieht.

Enthalten:
- Buchungserfassung nach dem Zu-/Abflussprinzip mit Storno statt Löschung
- Anlagevermögen mit AfA (linear, GWG, Sammelposten); die Aktivierung
  entscheidet das Konto, die Methode der Betrag
- Anlage EÜR, BWA im Aufbau der Form 01, Umsatzsteuer-Voranmeldung
- ELSTER-Anbindung über ERiC mit validierendem Simulationsmodus
- DATEV-Buchungsstapel (EXTF 700) und GoBD-Datenträgerüberlassung (Z3)
- Kontenrahmen SKR03 und SKR04, je Mandant beim Anlegen wählbar
- Mandantenfähigkeit mit einer SQLite-Datei je Mandant; Testmandanten sind
  von der Übermittlung ans Finanzamt ausgeschlossen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:45:52 +02:00

226 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}