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