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>
This commit is contained in:
duffyduck
2026-08-03 22:28:16 +02:00
co-authored by Claude Opus 5
parent 49ddaeccff
commit b343e89fbf
12 changed files with 765 additions and 4 deletions
+46
View File
@@ -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('<svg><script>x</script></svg>')), 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');
});
});
+171
View File
@@ -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/<mandant>/<jahr>/ 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));
}
+33 -1
View File
@@ -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),
+4
View File
@@ -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;
+4
View File
@@ -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);
+112
View File
@@ -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<void> {
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 };