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>
This commit is contained in:
duffyduck
2026-08-03 21:45:52 +02:00
co-authored by Claude Opus 5
commit bf371c8118
59 changed files with 12913 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
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');
}