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(); /** 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(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); } /** * Nachträglich ergänzte Spalten. * * `CREATE TABLE IF NOT EXISTS` lässt bestehende Tabellen unverändert – ohne * diesen Schritt fehlten in bereits angelegten Mandantendateien alle später * hinzugekommenen Spalten. Die Ergänzung ist bewusst additiv: es wird nie eine * Spalte entfernt oder umbenannt, damit ältere Stände lesbar bleiben. */ const NACHTRAEGLICHE_SPALTEN: { tabelle: string; spalte: string; definition: string }[] = [ { tabelle: 'buchungen', spalte: 'beleg_name', definition: "TEXT NOT NULL DEFAULT ''" }, { tabelle: 'buchungen', spalte: 'beleg_typ', definition: "TEXT NOT NULL DEFAULT ''" }, { tabelle: 'buchungen', spalte: 'beleg_hash', definition: "TEXT NOT NULL DEFAULT ''" }, { tabelle: 'buchungen', spalte: 'beleg_groesse', definition: 'INTEGER NOT NULL DEFAULT 0' }, { tabelle: 'konten', spalte: 'ustva_kz', definition: 'TEXT' }, ]; function ergaenzeSpalten(verbindung: Database.Database): void { for (const { tabelle, spalte, definition } of NACHTRAEGLICHE_SPALTEN) { const vorhanden = ( verbindung.prepare(`PRAGMA table_info(${tabelle})`).all() as { name: string }[] ).some((s) => s.name === spalte); if (!vorhanden) { verbindung.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${definition}`); } } } /** Ö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); ergaenzeSpalten(verbindung); 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, -- Pfad relativ zu data/, siehe belege.ts beleg_name TEXT NOT NULL DEFAULT '', -- ursprünglicher Dateiname beleg_typ TEXT NOT NULL DEFAULT '', -- erkannter Medientyp beleg_hash TEXT NOT NULL DEFAULT '', -- SHA-256, GoBD-Unveränderbarkeit beleg_groesse INTEGER NOT NULL DEFAULT 0, -- 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), ); }