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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user