From b343e89fbf2c7595757c8bb48f2c05d77d9da96a Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 3 Aug 2026 22:28:16 +0200 Subject: [PATCH] =?UTF-8?q?Belege=20zu=20Buchungen:=20Durchsuchen,=20Ziehe?= =?UTF-8?q?n-und-Ablegen,=20Einf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/// – 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) --- README.md | 25 +++++ package-lock.json | 62 +++++++++++ server/package.json | 3 +- server/src/belege.test.ts | 46 +++++++++ server/src/belege.ts | 171 +++++++++++++++++++++++++++++++ server/src/db.ts | 34 +++++- server/src/domain/typen.ts | 4 + server/src/index.ts | 4 + server/src/routes/buchungen.ts | 112 ++++++++++++++++++++ web/src/components/BelegFeld.tsx | 145 ++++++++++++++++++++++++++ web/src/pages/Buchungen.tsx | 87 +++++++++++++++- web/src/styles.css | 76 ++++++++++++++ 12 files changed, 765 insertions(+), 4 deletions(-) create mode 100644 server/src/belege.test.ts create mode 100644 server/src/belege.ts create mode 100644 web/src/components/BelegFeld.tsx diff --git a/README.md b/README.md index d99ea7b..63bad34 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,31 @@ SKR04 `1800`) und das Feld `kontenrahmen` in den Stammdaten schreibgeschützt sonst könnte der DATEV-Export einen Rahmen melden, den die Buchungszeilen nicht verwenden. +## Belege + +Zu jeder Buchung lässt sich ein Beleg hinterlegen – per Durchsuchen-Schaltfläche, +Ziehen-und-Ablegen oder Strg+V. Nachträglich geht es über +die Schaltfläche „+ Beleg" in der Buchungsliste. + +**Zum Einfügen mit Strg+V:** Screenshots liegen als Bild in der Zwischenablage +und funktionieren überall. Beim Kopieren einer *Datei* im Dateimanager hängt es +vom System ab: Windows und macOS legen die Datei selbst ab, dann klappt es auch +für PDFs. Unter Linux liefern viele Dateimanager nur einen `file://`-Verweis, den +der Browser aus Sicherheitsgründen nicht lesen darf – dort bleibt +Ziehen-und-Ablegen der verlässliche Weg für PDFs. + +Zulässig sind PDF, JPEG, PNG, WebP, TIFF und HEIC bis 20 MB. **Der Typ 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. + +Die Dateien liegen unter `data/belege///` und damit im selben +Bind-Mount wie die Datenbanken; ein Backup des `data`-Ordners erfasst Buchungen +und Belege gemeinsam. Zur Aufbewahrungspflicht (§147 AO): Belege werden nie +überschrieben und nie gelöscht. Wird ein Beleg ersetzt oder die Verknüpfung +gelöst, bleibt die Datei erhalten – der Dateiname enthält den SHA-256-Anfang, die +Buchung den vollständigen Hash. + ## Anlagevermögen oder Sofortabzug Ob eine Ausgabe abzuschreiben ist, entscheidet das **Konto** (Kontotyp `anlage`), diff --git a/package-lock.json b/package-lock.json index d898f63..4a4be27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -832,6 +832,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cors": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", @@ -852,6 +858,22 @@ "mnemonist": "0.40.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -922,6 +944,45 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.0.tgz", + "integrity": "sha512-b2r6CovmLQvLFJ5HJDtxVigZjcO9TwkRGslhiNQxsGJwilm5B3eqvXPOVLudC44zXMXJITpQ/iEATIE7zoXqcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^6.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/multipart/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", @@ -4256,6 +4317,7 @@ "version": "0.1.0", "dependencies": { "@fastify/cors": "^10.0.2", + "@fastify/multipart": "^10.1.0", "@fastify/static": "^8.0.4", "better-sqlite3": "^11.8.1", "fastify": "^5.2.1" diff --git a/server/package.json b/server/package.json index 36fb1b7..8f9e9bd 100644 --- a/server/package.json +++ b/server/package.json @@ -10,11 +10,12 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "seed": "tsx src/seed.ts", - "test": "tsx --test src/berechnung.test.ts", + "test": "tsx --test src/berechnung.test.ts src/belege.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@fastify/cors": "^10.0.2", + "@fastify/multipart": "^10.1.0", "@fastify/static": "^8.0.4", "better-sqlite3": "^11.8.1", "fastify": "^5.2.1" diff --git a/server/src/belege.test.ts b/server/src/belege.test.ts new file mode 100644 index 0000000..8003cc7 --- /dev/null +++ b/server/src/belege.test.ts @@ -0,0 +1,46 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { BelegFehler, MAX_GROESSE, pruefeBeleg, saubererName } from './belege.js'; + +describe('Belegprüfung', () => { + test('erkennt zulässige Formate an der Dateisignatur', () => { + assert.equal(pruefeBeleg(Buffer.from('%PDF-1.7\nInhalt')).typ, 'application/pdf'); + assert.equal( + pruefeBeleg(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])).typ, + 'image/png', + ); + assert.equal(pruefeBeleg(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2])).typ, 'image/jpeg'); + }); + + test('weist getarnte Dateien ab statt der Endung zu glauben', () => { + // Als rechnung.pdf benannt, tatsächlich Text. + assert.throws(() => pruefeBeleg(Buffer.from('nur text')), BelegFehler); + // SVG kann Skripte enthalten und ist deshalb ausgeschlossen. + assert.throws(() => pruefeBeleg(Buffer.from('')), BelegFehler); + assert.throws(() => pruefeBeleg(Buffer.alloc(0)), BelegFehler); + }); + + test('begrenzt die Dateigröße', () => { + const zuGross = Buffer.concat([Buffer.from('%PDF'), Buffer.alloc(MAX_GROESSE)]); + assert.throws(() => pruefeBeleg(zuGross), BelegFehler); + }); + + test('bildet denselben Hash für denselben Inhalt', () => { + const a = pruefeBeleg(Buffer.from('%PDF-1.4 abc')); + const b = pruefeBeleg(Buffer.from('%PDF-1.4 abc')); + assert.equal(a.hash, b.hash); + assert.notEqual(a.hash, pruefeBeleg(Buffer.from('%PDF-1.4 abd')).hash); + }); + + test('entschärft Dateinamen', () => { + // Pfadanteile dürfen nicht überleben, sonst wäre ein Ausbruch aus der Ablage möglich. + assert.equal(saubererName('../../etc/passwd', 'pdf'), 'passwd.pdf'); + assert.equal(saubererName('Rechnung Mai.pdf', 'pdf'), 'Rechnung Mai.pdf'); + assert.equal(saubererName('..', 'pdf'), 'beleg.pdf'); + assert.equal(saubererName('', 'png'), 'beleg.png'); + assert.ok(!saubererName('a/b.png', 'png').includes('/')); + // Endung wird ergänzt, wenn sie fehlt oder nicht zum erkannten Typ passt + assert.equal(saubererName('scan', 'png'), 'scan.png'); + assert.equal(saubererName('bild.jpeg', 'png'), 'bild.jpeg.png'); + }); +}); diff --git a/server/src/belege.ts b/server/src/belege.ts new file mode 100644 index 0000000..fc0a3a6 --- /dev/null +++ b/server/src/belege.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { datenVerzeichnis } from './pfade.js'; + +/** + * Ablage der Belege im Dateisystem. + * + * Die Dateien liegen je Mandant getrennt unter data/belege/// und + * damit im selben Bind-Mount wie die Datenbanken – ein Backup des data-Ordners + * erfasst Buchungen und Belege gemeinsam. + * + * Zur Aufbewahrungspflicht (§147 AO, GoBD): Belege werden im Originalformat + * gespeichert, nie überschrieben und nie gelöscht. Wird ein Beleg ersetzt, + * bleibt die alte Datei liegen; die Buchung verweist nur nicht mehr darauf. + */ + +export const MAX_GROESSE = 20 * 1024 * 1024; + +export class BelegFehler extends Error {} + +interface Signatur { + typ: string; + endung: string; + bezeichnung: string; + passt: (b: Buffer) => boolean; +} + +const beginntMit = (b: Buffer, bytes: number[], versatz = 0): boolean => + b.length >= versatz + bytes.length && bytes.every((x, i) => b[versatz + i] === x); + +/** + * Erkennung anhand der Dateisignatur statt anhand des vom Browser gemeldeten + * Typs: Letzterer stammt aus der Zwischenablage oder vom Dateinamen und ist + * beliebig fälschbar. SVG fehlt bewusst – es kann Skripte enthalten und würde + * beim Anzeigen im Browser ausgeführt. + */ +const SIGNATUREN: Signatur[] = [ + { + typ: 'application/pdf', + endung: 'pdf', + bezeichnung: 'PDF', + passt: (b) => beginntMit(b, [0x25, 0x50, 0x44, 0x46]), // %PDF + }, + { + typ: 'image/jpeg', + endung: 'jpg', + bezeichnung: 'JPEG-Bild', + passt: (b) => beginntMit(b, [0xff, 0xd8, 0xff]), + }, + { + typ: 'image/png', + endung: 'png', + bezeichnung: 'PNG-Bild', + passt: (b) => beginntMit(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + }, + { + typ: 'image/webp', + endung: 'webp', + bezeichnung: 'WebP-Bild', + passt: (b) => + beginntMit(b, [0x52, 0x49, 0x46, 0x46]) && beginntMit(b, [0x57, 0x45, 0x42, 0x50], 8), + }, + { + typ: 'image/tiff', + endung: 'tif', + bezeichnung: 'TIFF-Bild', + passt: (b) => + beginntMit(b, [0x49, 0x49, 0x2a, 0x00]) || beginntMit(b, [0x4d, 0x4d, 0x00, 0x2a]), + }, + { + typ: 'image/heic', + endung: 'heic', + bezeichnung: 'HEIC-Bild', + // ISO-BMFF: "ftyp" an Position 4, Marke heic/heif/mif1 + passt: (b) => + beginntMit(b, [0x66, 0x74, 0x79, 0x70], 4) && + ['heic', 'heix', 'heif', 'mif1'].includes(b.subarray(8, 12).toString('latin1')), + }, +]; + +export const ERLAUBTE_TYPEN = SIGNATUREN.map((s) => s.typ); + +export interface ErkannterBeleg { + typ: string; + endung: string; + bezeichnung: string; + hash: string; + groesse: number; +} + +export function pruefeBeleg(inhalt: Buffer): ErkannterBeleg { + if (inhalt.length === 0) throw new BelegFehler('Die Datei ist leer.'); + if (inhalt.length > MAX_GROESSE) { + throw new BelegFehler( + `Die Datei ist ${(inhalt.length / 1024 / 1024).toFixed(1)} MB groß; ` + + `zulässig sind bis zu ${MAX_GROESSE / 1024 / 1024} MB.`, + ); + } + + const treffer = SIGNATUREN.find((s) => s.passt(inhalt)); + if (!treffer) { + throw new BelegFehler( + 'Dateiformat nicht erkannt. Zulässig sind PDF sowie JPEG-, PNG-, WebP-, TIFF- und ' + + 'HEIC-Bilder. Office-Dateien bitte vorher als PDF ausgeben.', + ); + } + + return { + typ: treffer.typ, + endung: treffer.endung, + bezeichnung: treffer.bezeichnung, + hash: createHash('sha256').update(inhalt).digest('hex'), + groesse: inhalt.length, + }; +} + +/** Dateinamen entschärfen: keine Pfadanteile, keine Steuerzeichen. */ +export function saubererName(name: string, endung: string): string { + const basis = (name.split(/[\\/]/).pop() ?? '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[^\p{L}\p{N}._ -]/gu, '_') + .replace(/^\.+/, '') + .trim() + .slice(0, 120); + if (!basis) return `beleg.${endung}`; + return basis.toLowerCase().endsWith(`.${endung}`) ? basis : `${basis}.${endung}`; +} + +export interface GespeicherterBeleg extends ErkannterBeleg { + /** Pfad relativ zum data-Verzeichnis, so wie er in der Datenbank steht */ + pfad: string; + name: string; +} + +export function speichereBeleg(opt: { + mandant: string; + buchungId: number; + jahr: string; + dateiname: string; + inhalt: Buffer; +}): GespeicherterBeleg { + const erkannt = pruefeBeleg(opt.inhalt); + const name = saubererName(opt.dateiname, erkannt.endung); + + // Der Hash im Dateinamen macht die Ablage eindeutig und erkennbar + // unverändert; ein zweiter Upload derselben Datei überschreibt sich selbst. + const relativ = `belege/${opt.mandant}/${opt.jahr}/${opt.buchungId}-${erkannt.hash.slice(0, 16)}.${erkannt.endung}`; + const ziel = resolve(datenVerzeichnis, relativ); + + mkdirSync(dirname(ziel), { recursive: true }); + if (!existsSync(ziel)) writeFileSync(ziel, opt.inhalt, { mode: 0o640 }); + + return { ...erkannt, pfad: relativ, name }; +} + +export function leseBeleg(relativerPfad: string): Buffer { + const ziel = resolve(datenVerzeichnis, relativerPfad); + // Absicherung gegen ../-Anteile in einem manipulierten Datenbankeintrag + if (!ziel.startsWith(resolve(datenVerzeichnis) + '/')) { + throw new BelegFehler('Der Belegpfad zeigt aus dem Datenverzeichnis heraus.'); + } + if (!existsSync(ziel)) { + throw new BelegFehler('Die Belegdatei fehlt im Datenverzeichnis.'); + } + return readFileSync(ziel); +} + +export function belegVorhanden(relativerPfad: string): boolean { + return existsSync(resolve(datenVerzeichnis, relativerPfad)); +} diff --git a/server/src/db.ts b/server/src/db.ts index 841ee49..da954ea 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -64,6 +64,33 @@ 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); @@ -72,6 +99,7 @@ export function oeffneDatenbank(datei: string): Database.Database { 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; } @@ -172,7 +200,11 @@ CREATE TABLE IF NOT EXISTS buchungen ( partner TEXT NOT NULL DEFAULT '', privatanteil_bp INTEGER NOT NULL DEFAULT 0, anlagegut_id INTEGER REFERENCES anlagegueter(id), - beleg_datei TEXT, + 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), diff --git a/server/src/domain/typen.ts b/server/src/domain/typen.ts index 38d87d8..8691c2d 100644 --- a/server/src/domain/typen.ts +++ b/server/src/domain/typen.ts @@ -32,6 +32,10 @@ export interface Buchung { privatanteil_bp: number; anlagegut_id: number | null; beleg_datei: string | null; + beleg_name: string; + beleg_typ: string; + beleg_hash: string; + beleg_groesse: number; storniert: number; storno_von: number | null; festgeschrieben: number; diff --git a/server/src/index.ts b/server/src/index.ts index 87aa30b..a234a4d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import { envDatei } from './env.js'; import Fastify from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; +import multipart from '@fastify/multipart'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { projektWurzel } from './pfade.js'; @@ -15,6 +16,7 @@ import { schliesseAlleVerbindungen, standardMandant, } from './mandanten.js'; +import { MAX_GROESSE } from './belege.js'; import { ericStatus } from './elster/eric.js'; import { mandantenRouten, mandantenScoping } from './routes/mandanten.js'; import { stammdatenRouten } from './routes/stammdaten.js'; @@ -33,6 +35,8 @@ for (const eintrag of alleMandanten()) { const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'warn' }, bodyLimit: 10 * 1024 * 1024 }); await app.register(cors, { origin: true, exposedHeaders: ['X-Mandant'] }); +// Belege werden als multipart/form-data hochgeladen; die Obergrenze steht in belege.ts. +await app.register(multipart, { limits: { fileSize: MAX_GROESSE, files: 1 } }); mandantenScoping(app); await app.register(mandantenRouten); await app.register(stammdatenRouten); diff --git a/server/src/routes/buchungen.ts b/server/src/routes/buchungen.ts index 121083a..cb9d79b 100644 --- a/server/src/routes/buchungen.ts +++ b/server/src/routes/buchungen.ts @@ -3,6 +3,7 @@ import { db, protokolliere } from '../db.js'; import { berechneUst, regimeAm } from '../domain/regime.js'; import { afaFuer, methodeVorschlag } from '../domain/afa.js'; import { standardGegenkonto } from '../kontenrahmen/index.js'; +import { BelegFehler, leseBeleg, MAX_GROESSE, speichereBeleg } from '../belege.js'; import type { Anlagegut, Buchung, Konto, Mandant, UstBehandlung } from '../domain/typen.js'; /** Bankkonto des aktiven Kontenrahmens – 1800 ist NICHT rahmenübergreifend die Bank. */ @@ -341,6 +342,117 @@ export async function buchungsRouten(app: FastifyInstance): Promise { return { anzahl: info.changes, bis }; }); + // ---- Belege ------------------------------------------------------------- + + /** + * Beleg zu einer Buchung hinterlegen. Der Medientyp wird aus der + * Dateisignatur bestimmt, nicht aus der Angabe des Browsers. + */ + app.post('/api/buchungen/:id/beleg', async (req, reply) => { + const { id } = req.params as { id: string }; + const buchung = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id) as + | Buchung + | undefined; + if (!buchung) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' }); + if (buchung.festgeschrieben) { + return reply.code(409).send({ + fehler: + 'Die Buchung ist festgeschrieben. Ein Beleg lässt sich nachträglich nicht mehr ' + + 'austauschen – bitte stornieren und neu erfassen.', + }); + } + + try { + const teil = await req.file({ limits: { fileSize: MAX_GROESSE, files: 1 } }); + if (!teil) return reply.code(400).send({ fehler: 'Es wurde keine Datei übertragen.' }); + + const inhalt = await teil.toBuffer(); + const gespeichert = speichereBeleg({ + mandant: req.mandant?.slug ?? 'standard', + buchungId: buchung.id, + jahr: (buchung.zahlungsdatum ?? buchung.leistungsdatum).slice(0, 4), + dateiname: teil.filename ?? 'beleg', + inhalt, + }); + + db.prepare( + `UPDATE buchungen SET beleg_datei = ?, beleg_name = ?, beleg_typ = ?, beleg_hash = ?, + beleg_groesse = ?, geaendert_am = datetime('now') WHERE id = ?`, + ).run( + gespeichert.pfad, gespeichert.name, gespeichert.typ, + gespeichert.hash, gespeichert.groesse, id, + ); + protokolliere('buchungen', id, 'beleg-hinterlegt', buchung.beleg_datei, gespeichert.pfad); + + return { + beleg_name: gespeichert.name, + beleg_typ: gespeichert.typ, + beleg_groesse: gespeichert.groesse, + beleg_hash: gespeichert.hash, + bezeichnung: gespeichert.bezeichnung, + ersetzt: !!buchung.beleg_datei, + }; + } catch (e) { + if (e instanceof BelegFehler) return reply.code(415).send({ fehler: e.message }); + const meldung = (e as Error).message; + const zuGross = /file size limit|FST_REQ_FILE_TOO_LARGE|request file too large/i.test(meldung); + return reply.code(zuGross ? 413 : 400).send({ + fehler: zuGross + ? `Die Datei überschreitet die Grenze von ${MAX_GROESSE / 1024 / 1024} MB.` + : meldung, + }); + } + }); + + app.get('/api/buchungen/:id/beleg', async (req, reply) => { + const { id } = req.params as { id: string }; + const { download } = req.query as { download?: string }; + const b = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id) as Buchung | undefined; + if (!b?.beleg_datei) return reply.code(404).send({ fehler: 'Zu dieser Buchung ist kein Beleg hinterlegt.' }); + + try { + const inhalt = leseBeleg(b.beleg_datei); + return reply + .header('Content-Type', b.beleg_typ || 'application/octet-stream') + // Kein Erraten des Typs durch den Browser und keine Einbettung fremder Inhalte. + .header('X-Content-Type-Options', 'nosniff') + .header('Content-Security-Policy', "default-src 'none'; object-src 'self'; img-src 'self'") + .header( + 'Content-Disposition', + `${download ? 'attachment' : 'inline'}; filename="${encodeURIComponent(b.beleg_name || 'beleg')}"`, + ) + .send(inhalt); + } catch (e) { + return reply.code(410).send({ fehler: (e as Error).message }); + } + }); + + /** + * Entfernt nur die Verknüpfung. Die Datei bleibt im Datenverzeichnis liegen – + * Belege unterliegen der Aufbewahrungspflicht nach §147 AO. + */ + app.delete('/api/buchungen/:id/beleg', async (req, reply) => { + const { id } = req.params as { id: string }; + const b = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id) as Buchung | undefined; + if (!b) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' }); + if (b.festgeschrieben) { + return reply.code(409).send({ fehler: 'Die Buchung ist festgeschrieben und unveränderbar.' }); + } + + db.prepare( + `UPDATE buchungen SET beleg_datei = NULL, beleg_name = '', beleg_typ = '', beleg_hash = '', + beleg_groesse = 0, geaendert_am = datetime('now') WHERE id = ?`, + ).run(id); + protokolliere('buchungen', id, 'beleg-verknuepfung-geloest', b.beleg_datei, null); + + return { + ok: true, + hinweis: + 'Die Verknüpfung wurde gelöst. Die Belegdatei bleibt aus Aufbewahrungsgründen im ' + + 'Datenverzeichnis erhalten.', + }; + }); + // ---- Anlagevermögen ----------------------------------------------------- app.get('/api/anlagen', async (req) => { const { jahr } = req.query as { jahr?: string }; diff --git a/web/src/components/BelegFeld.tsx b/web/src/components/BelegFeld.tsx new file mode 100644 index 0000000..d5ca758 --- /dev/null +++ b/web/src/components/BelegFeld.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Auswahl eines Belegs über Durchsuchen, Ziehen-und-Ablegen oder Einfügen. + * + * Zum Einfügen mit Strg+V: Screenshots liegen als Bild in der Zwischenablage und + * funktionieren überall. Beim Kopieren einer Datei im Dateimanager hängt es vom + * System ab, ob die Datei selbst oder nur ein file://-Verweis abgelegt wird – + * Letzteren darf der Browser aus Sicherheitsgründen nicht lesen. Deshalb wird + * hier typunabhängig alles übernommen, was als Datei ankommt (also auch PDFs), + * und für den Rest bleibt Ziehen-und-Ablegen der verlässliche Weg. + */ + +export const BELEG_TYPEN = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/tiff', + 'image/heic', +]; + +interface Props { + datei: File | null; + onDatei: (datei: File | null) => void; + /** Meldung des Servers nach dem Hochladen */ + status?: { art: 'gut' | 'kritisch'; text: string } | null; +} + +function lesbareGroesse(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} kB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export default function BelegFeld({ datei, onDatei, status }: Props) { + const [ueberZone, setUeberZone] = useState(false); + const [vorschau, setVorschau] = useState(null); + const [eingefuegt, setEingefuegt] = useState(false); + const eingabe = useRef(null); + + // Vorschaubild nur für Bilder; die Objekt-URL muss wieder freigegeben werden. + useEffect(() => { + if (!datei || !datei.type.startsWith('image/')) { + setVorschau(null); + return; + } + const url = URL.createObjectURL(datei); + setVorschau(url); + return () => URL.revokeObjectURL(url); + }, [datei]); + + // Strg+V global aufnehmen, aber nur wenn tatsächlich eine Datei mitkommt – + // sonst würde das Einfügen von Text in die übrigen Felder gestört. + useEffect(() => { + function beiEinfuegen(e: ClipboardEvent) { + const dateien = Array.from(e.clipboardData?.files ?? []); + if (dateien.length === 0) return; + e.preventDefault(); + onDatei(dateien[0]!); + setEingefuegt(true); + setTimeout(() => setEingefuegt(false), 2500); + } + window.addEventListener('paste', beiEinfuegen); + return () => window.removeEventListener('paste', beiEinfuegen); + }, [onDatei]); + + function beiAblegen(e: React.DragEvent) { + e.preventDefault(); + setUeberZone(false); + const f = e.dataTransfer.files?.[0]; + if (f) onDatei(f); + } + + return ( +
+ + + {!datei && ( +
{ + e.preventDefault(); + setUeberZone(true); + }} + onDragLeave={() => setUeberZone(false)} + onDrop={beiAblegen} + onClick={() => eingabe.current?.click()} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && eingabe.current?.click()} + role="button" + tabIndex={0} + > + Datei hierher ziehen + + oder durchsuchen · mit Strg+V einfügen + + + PDF, JPEG, PNG, WebP, TIFF, HEIC · bis 20 MB + +
+ )} + + onDatei(e.target.files?.[0] ?? null)} + /> + + {datei && ( +
+ {vorschau ? ( + Vorschau des Belegs + ) : ( + + )} +
+
+ {datei.name} +
+
+ {lesbareGroesse(datei.size)} + {eingefuegt && ' · aus der Zwischenablage übernommen'} +
+
+ +
+ )} + + {status &&
{status.text}
} +
+ ); +} diff --git a/web/src/pages/Buchungen.tsx b/web/src/pages/Buchungen.tsx index 1ee613e..87c296b 100644 --- a/web/src/pages/Buchungen.tsx +++ b/web/src/pages/Buchungen.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { api, useDaten } from '../lib/api'; import { useJahr } from '../lib/jahr'; import { BEHANDLUNG_LABEL, centAus, datum, eur, REGIME_LABEL } from '../lib/format'; +import { mitMandant } from '../lib/mandant'; +import BelegFeld from '../components/BelegFeld'; interface Konto { nummer: string; @@ -30,6 +32,9 @@ interface Buchung { storniert: number; festgeschrieben: number; anlagegut_id: number | null; + beleg_datei: string | null; + beleg_name: string; + beleg_typ: string; } interface Aktivierung { @@ -84,6 +89,8 @@ export default function Buchungen() { const [form, setForm] = useState(LEER); const [vorschau, setVorschau] = useState(null); const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null); + const [beleg, setBeleg] = useState(null); + const [belegStatus, setBelegStatus] = useState<{ art: 'gut' | 'kritisch'; text: string } | null>(null); const { daten: konten } = useDaten('/api/konten'); // 1800 ist NUR im SKR04 die Bank – im SKR03 sind das Privatentnahmen. @@ -156,17 +163,67 @@ export default function Buchungen() { } : undefined, }); + let belegHinweis = ''; + if (beleg) { + // Der Upload braucht die Buchungsnummer, läuft also erst nach dem Speichern. + // Schlägt er fehl, bleibt die Buchung erhalten und der Beleg kann in der + // Liste nachgereicht werden – doppeltes Erfassen wäre schlimmer. + try { + await belegHochladen(antwort.id, beleg); + belegHinweis = ' Beleg hinterlegt.'; + } catch (err) { + setBelegStatus({ + art: 'kritisch', + text: `Die Buchung wurde gespeichert, der Beleg jedoch nicht: ${(err as Error).message} ` + + 'Er lässt sich in der Liste nachtragen.', + }); + } + } setMeldung({ art: 'gut', - text: `Buchung gespeichert (${eur(antwort.betrag_brutto)}).${antwort.hinweise.length ? ' ' + antwort.hinweise.join(' ') : ''}`, + text: `Buchung gespeichert (${eur(antwort.betrag_brutto)}).${belegHinweis}${antwort.hinweise.length ? ' ' + antwort.hinweise.join(' ') : ''}`, }); setForm({ ...LEER, art: form.art, konto: form.konto, gegenkonto: form.gegenkonto }); + setBeleg(null); neuLaden(); } catch (err) { setMeldung({ art: 'kritisch', text: (err as Error).message }); } } + /** multipart/form-data – deshalb nicht über den JSON-Helfer in lib/api. */ + async function belegHochladen(buchungId: number, datei: File) { + const daten = new FormData(); + daten.append('datei', datei); + const antwort = await fetch(`/api/buchungen/${buchungId}/beleg`, { + method: 'POST', + headers: { ...(localStorage.getItem('euer.mandant') ? { 'X-Mandant': localStorage.getItem('euer.mandant')! } : {}) }, + body: daten, + }); + const text = await antwort.text(); + const inhalt = text ? JSON.parse(text) : null; + if (!antwort.ok) throw new Error(inhalt?.fehler ?? `Fehler ${antwort.status}`); + return inhalt; + } + + async function belegNachtragen(b: Buchung) { + const wahl = document.createElement('input'); + wahl.type = 'file'; + wahl.accept = 'application/pdf,image/jpeg,image/png,image/webp,image/tiff,image/heic'; + wahl.onchange = async () => { + const datei = wahl.files?.[0]; + if (!datei) return; + try { + await belegHochladen(b.id, datei); + setMeldung({ art: 'gut', text: `Beleg zu Buchung ${b.id} hinterlegt.` }); + neuLaden(); + } catch (err) { + setMeldung({ art: 'kritisch', text: (err as Error).message }); + } + }; + wahl.click(); + } + async function stornieren(id: number) { if (!confirm('Die Buchung wird per Gegenbuchung storniert. Fortfahren?')) return; try { @@ -304,6 +361,8 @@ export default function Buchungen() { onChange={(e) => setForm({ ...form, privatanteilFeld: e.target.value })} /> + + {form.art === 'ausgabe' && ( @@ -426,6 +485,7 @@ export default function Buchungen() { Text Konto Behandlung + Beleg Netto USt Brutto @@ -447,6 +507,29 @@ export default function Buchungen() { {b.konto} {BEHANDLUNG_LABEL[b.ust_behandlung] ?? b.ust_behandlung} + + {b.beleg_datei ? ( + + {b.beleg_typ === 'application/pdf' ? 'PDF' : 'Bild'} ansehen + + ) : b.storniert ? ( + + ) : ( + + )} + {eur(b.betrag_netto)} {b.betrag_ust ? eur(b.betrag_ust) : '–'} @@ -471,7 +554,7 @@ export default function Buchungen() { ))} {buchungen?.length === 0 && ( - Keine Buchungen für diese Auswahl. + Keine Buchungen für diese Auswahl. )} diff --git a/web/src/styles.css b/web/src/styles.css index d4616b2..794a08c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -273,3 +273,79 @@ pre.xml { .mandant-neu input[type='text'], .mandant-neu input:not([type]) { font-size: 13px; } .ankreuz { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); } .ankreuz input { width: auto; } + +/* ---- Belegfeld ---------------------------------------------------------- */ +.ablagezone { + border: 1.5px dashed var(--border-stark); + border-radius: var(--radius); + padding: 18px 14px; + text-align: center; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 3px; + color: var(--text-secondary); + font-size: 13px; + background: var(--surface-2); +} +.ablagezone:hover, +.ablagezone:focus-visible { border-color: var(--akzent); color: var(--text-primary); } +.ablagezone.aktiv { + border-color: var(--akzent); + background: var(--akzent-weich); + color: var(--akzent); +} +.ablagezone strong { font-weight: 600; font-size: 14px; color: var(--text-primary); } + +kbd { + font-family: var(--schrift-zahl); + font-size: 11px; + padding: 1px 5px; + border: 1px solid var(--border-stark); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--surface-1); +} + +.belegvorschau { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); +} +.belegvorschau img { + width: 52px; + height: 52px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface-1); +} +.belegsymbol { + width: 52px; + height: 52px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface-1); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +/* Beleg-Verweis in der Buchungsliste */ +.belegverweis { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + text-decoration: none; + color: var(--akzent); +} +.belegverweis:hover { text-decoration: underline; }