Files
open-eur-buchhaltung/server/src/db.ts
T
duffyduckandClaude Opus 5 b343e89fbf Belege zu Buchungen: Durchsuchen, Ziehen-und-Ablegen, Einfügen
Zu jeder Buchung lässt sich ein Beleg hinterlegen, im Formular oder
nachträglich über die Buchungsliste.

Der Medientyp wird aus der Dateisignatur bestimmt, nicht aus Endung oder
Browserangabe: eine als rechnung.pdf benannte Textdatei wird abgewiesen. SVG ist
ausgeschlossen, weil es Skripte enthalten kann, die beim Anzeigen ausgeführt
würden. Ausgeliefert wird mit nosniff und enger Content-Security-Policy.

Zur Aufbewahrungspflicht nach §147 AO werden Belege nie überschrieben und nie
gelöscht. Beim Ersetzen bleibt die alte Datei liegen; das Lösen der Verknüpfung
entfernt nur den Verweis. Der Dateiname trägt den SHA-256-Anfang, die Buchung
den vollständigen Hash.

Ablage unter data/belege/<mandant>/<jahr>/ – je Mandant getrennt und im selben
Bind-Mount wie die Datenbanken, sodass ein Backup des data-Ordners Buchungen und
Belege gemeinsam erfasst.

Bisher fehlte jede Möglichkeit, bestehende Mandantendateien um neue Spalten zu
erweitern: CREATE TABLE IF NOT EXISTS lässt vorhandene Tabellen unverändert. Neu
hinzugekommene Spalten werden jetzt beim Öffnen additiv ergänzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 22:28:16 +02:00

281 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
/**
* 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),
);
}