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>
226 lines
8.1 KiB
TypeScript
226 lines
8.1 KiB
TypeScript
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');
|
||
}
|