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
+248
View File
@@ -0,0 +1,248 @@
import Database from 'better-sqlite3';
import { AsyncLocalStorage } from 'node:async_hooks';
import { mkdirSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { projektWurzel } from './pfade.js';
/**
* Mandantentrennung: jeder Mandant hat eine eigene SQLite-Datei.
*
* Damit ist es strukturell unmöglich, dass Testbuchungen in der echten EÜR oder
* in einer ELSTER-Übermittlung auftauchen anders als bei einer gemeinsamen
* Datenbank mit mandant_id-Spalte, wo ein einziges vergessenes WHERE genügt.
*
* Welcher Mandant gilt, hängt am laufenden Request und wird über
* AsyncLocalStorage geführt. Der Export `db` ist ein Proxy, der jeden Zugriff an
* die Verbindung des aktiven Mandanten weiterreicht die Fachmodule bleiben
* dadurch unverändert und können gar nicht erst am falschen Mandanten arbeiten.
*/
export interface MandantKontext {
slug: string;
name: string;
testmandant: boolean;
verbindung: Database.Database;
}
const speicher = new AsyncLocalStorage<MandantKontext>();
/** Fallback für Skripte und Tests, die außerhalb eines Requests laufen. */
let standardKontext: MandantKontext | null = null;
export function aktuellerMandant(): MandantKontext {
const kontext = speicher.getStore() ?? standardKontext;
if (!kontext) {
throw new Error(
'Kein Mandant im Kontext. Innerhalb eines Requests setzt der Server ihn automatisch; ' +
'in Skripten zuvor initDb() oder setzeStandardKontext() aufrufen.',
);
}
return kontext;
}
/** Führt fn im Kontext des Mandanten aus alle db-Zugriffe darin gehen an dessen Datei. */
export function imMandantKontext<T>(kontext: MandantKontext, fn: () => T): T {
return speicher.run(kontext, fn);
}
export function setzeStandardKontext(kontext: MandantKontext | null): void {
standardKontext = kontext;
}
export const db = new Proxy({} as Database.Database, {
get(_ziel, eigenschaft) {
const verbindung = aktuellerMandant().verbindung as unknown as Record<
string | symbol,
unknown
>;
const wert = verbindung[eigenschaft];
return typeof wert === 'function' ? (wert as (...a: unknown[]) => unknown).bind(verbindung) : wert;
},
});
/** Relative Pfadangaben beziehen sich auf die Projektwurzel, nicht auf das Arbeitsverzeichnis. */
export function ausProjektwurzel(pfad: string): string {
return isAbsolute(pfad) ? pfad : resolve(projektWurzel, pfad);
}
/** Öffnet eine Mandantendatei und legt das Schema an, falls es noch fehlt. */
export function oeffneDatenbank(datei: string): Database.Database {
const pfad = ausProjektwurzel(datei);
mkdirSync(dirname(pfad), { recursive: true });
const verbindung = new Database(pfad);
verbindung.pragma('journal_mode = WAL');
verbindung.pragma('foreign_keys = ON');
verbindung.exec(SCHEMA);
verbindung.prepare('INSERT OR IGNORE INTO mandant (id) VALUES (1)').run();
return verbindung;
}
/**
* Alle Geldbeträge werden als ganzzahlige Cent gespeichert, alle Steuersätze als
* Basispunkte (1900 = 19,00 %). Damit gibt es keine Fließkomma-Rundungsfehler in
* der Steuerberechnung.
*/
const SCHEMA = `
CREATE TABLE IF NOT EXISTS mandant (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL DEFAULT '',
rechtsform TEXT NOT NULL DEFAULT 'einzelunternehmen',
strasse TEXT NOT NULL DEFAULT '',
plz TEXT NOT NULL DEFAULT '',
ort TEXT NOT NULL DEFAULT '',
bundesland TEXT NOT NULL DEFAULT 'BY',
steuernummer TEXT NOT NULL DEFAULT '',
ust_idnr TEXT NOT NULL DEFAULT '',
finanzamt_nr TEXT NOT NULL DEFAULT '',
taetigkeit TEXT NOT NULL DEFAULT '',
betriebsbeginn TEXT,
-- 'ist' = Ist-Versteuerung (§20 UStG), 'soll' = Soll-Versteuerung (§16 UStG)
besteuerungsart TEXT NOT NULL DEFAULT 'ist' CHECK (besteuerungsart IN ('ist','soll')),
-- 'monatlich' | 'vierteljaehrlich' | 'jaehrlich'
voranmeldungszeitraum TEXT NOT NULL DEFAULT 'vierteljaehrlich',
dauerfristverlaengerung INTEGER NOT NULL DEFAULT 0,
kontenrahmen TEXT NOT NULL DEFAULT 'SKR04',
-- Stammdaten für DATEV
datev_berater_nr TEXT NOT NULL DEFAULT '',
datev_mandanten_nr TEXT NOT NULL DEFAULT '',
-- Kontaktdaten für den ELSTER-Datenlieferant-Block
telefon TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT ''
);
/*
* Zeitachse der umsatzsteuerlichen Regime. Statt einer globalen Ja/Nein-Flagge
* wird das Regime je Datum aufgelöst -> unterjähriger Wechsel möglich
* (z.B. Überschreiten der 100.000-EUR-Grenze nach §19 Abs. 1 Satz 3 UStG).
*/
CREATE TABLE IF NOT EXISTS steuer_regime (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gueltig_ab TEXT NOT NULL UNIQUE, -- YYYY-MM-DD
modus TEXT NOT NULL CHECK (modus IN ('kleinunternehmer','regelbesteuerung')),
grund TEXT NOT NULL DEFAULT '',
notiz TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS konten (
nummer TEXT PRIMARY KEY,
bezeichnung TEXT NOT NULL,
-- erloes | aufwand | anlage | privat | neutral | bestand
typ TEXT NOT NULL,
euer_zeile TEXT, -- Schlüssel in domain/euerZeilen.ts
bwa_gruppe TEXT, -- Schlüssel in domain/bwa.ts
ustva_kz TEXT, -- optionale Kennzahl-Zuordnung für die UStVA
ust_satz_bp INTEGER NOT NULL DEFAULT 1900,
ust_behandlung TEXT NOT NULL DEFAULT 'regel',
aktiv INTEGER NOT NULL DEFAULT 1,
sortierung INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS anlagegueter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bezeichnung TEXT NOT NULL,
anschaffungsdatum TEXT NOT NULL,
akosten_netto INTEGER NOT NULL, -- Cent, ohne abziehbare Vorsteuer
akosten_brutto INTEGER NOT NULL,
nutzungsdauer INTEGER NOT NULL, -- Jahre
-- 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine'
afa_methode TEXT NOT NULL DEFAULT 'linear',
konto TEXT REFERENCES konten(nummer),
abgang_datum TEXT,
abgang_erloes INTEGER,
notiz TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS buchungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
art TEXT NOT NULL CHECK (art IN ('einnahme','ausgabe')),
belegdatum TEXT NOT NULL, -- Datum der Rechnung / des Belegs
leistungsdatum TEXT NOT NULL, -- maßgeblich für das USt-Regime + Soll-Versteuerung
zahlungsdatum TEXT, -- maßgeblich für EÜR (§11 EStG) + Ist-Versteuerung
betrag_brutto INTEGER NOT NULL, -- Cent, immer positiv
betrag_netto INTEGER NOT NULL,
betrag_ust INTEGER NOT NULL,
ust_satz_bp INTEGER NOT NULL DEFAULT 0,
/* regel | kleinunternehmer | steuerfrei_mit_vst | steuerfrei_ohne_vst
| ig_lieferung | ig_erwerb | reverse_charge_13b | nicht_steuerbar */
ust_behandlung TEXT NOT NULL DEFAULT 'regel',
vorsteuer_abziehbar INTEGER NOT NULL DEFAULT 1,
konto TEXT NOT NULL REFERENCES konten(nummer),
gegenkonto TEXT NOT NULL DEFAULT '1800',
beleg_nr TEXT NOT NULL DEFAULT '',
buchungstext TEXT NOT NULL DEFAULT '',
partner TEXT NOT NULL DEFAULT '',
privatanteil_bp INTEGER NOT NULL DEFAULT 0,
anlagegut_id INTEGER REFERENCES anlagegueter(id),
beleg_datei TEXT,
-- GoBD: keine Löschung, nur Storno; festgeschriebene Sätze sind unveränderbar
storniert INTEGER NOT NULL DEFAULT 0,
storno_von INTEGER REFERENCES buchungen(id),
festgeschrieben INTEGER NOT NULL DEFAULT 0,
erstellt_am TEXT NOT NULL DEFAULT (datetime('now')),
geaendert_am TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_buchungen_zahlung ON buchungen(zahlungsdatum);
CREATE INDEX IF NOT EXISTS idx_buchungen_leistung ON buchungen(leistungsdatum);
CREATE INDEX IF NOT EXISTS idx_buchungen_konto ON buchungen(konto);
/* Änderungsprotokoll (GoBD-Verfahrensdokumentation) */
CREATE TABLE IF NOT EXISTS aenderungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tabelle TEXT NOT NULL,
datensatz_id TEXT NOT NULL,
aktion TEXT NOT NULL,
vorher TEXT,
nachher TEXT,
zeitpunkt TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ustva_meldungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jahr INTEGER NOT NULL,
zeitraum TEXT NOT NULL, -- '01'..'12' oder '41'..'44' (Quartale)
kennzahlen TEXT NOT NULL, -- JSON
zahllast INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'entwurf', -- entwurf | validiert | uebermittelt | fehler
berichtigung INTEGER NOT NULL DEFAULT 0,
xml TEXT,
transferticket TEXT,
eric_protokoll TEXT,
erstellt_am TEXT NOT NULL DEFAULT (datetime('now')),
uebermittelt_am TEXT,
UNIQUE (jahr, zeitraum, berichtigung)
);
`;
/**
* Für Skripte und Tests: öffnet die über EUER_DB angegebene Datei (Standard
* data/euer.sqlite) und macht sie zum Standardmandanten. Der Server benutzt
* stattdessen das Mandantenregister.
*/
export function initDb(): MandantKontext {
const kontext: MandantKontext = {
slug: 'standard',
name: 'Standard',
testmandant: false,
verbindung: oeffneDatenbank(process.env.EUER_DB ?? 'data/euer.sqlite'),
};
setzeStandardKontext(kontext);
return kontext;
}
export function protokolliere(
tabelle: string,
datensatzId: string | number | bigint,
aktion: string,
vorher: unknown,
nachher: unknown,
): void {
db.prepare(
'INSERT INTO aenderungen (tabelle, datensatz_id, aktion, vorher, nachher) VALUES (?,?,?,?,?)',
).run(
tabelle,
String(datensatzId),
aktion,
vorher === undefined ? null : JSON.stringify(vorher),
nachher === undefined ? null : JSON.stringify(nachher),
);
}