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>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
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),
|
||
);
|
||
}
|