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:
co-authored by
Claude Opus 5
parent
49ddaeccff
commit
b343e89fbf
@@ -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
|
sonst könnte der DATEV-Export einen Rahmen melden, den die Buchungszeilen nicht
|
||||||
verwenden.
|
verwenden.
|
||||||
|
|
||||||
|
## Belege
|
||||||
|
|
||||||
|
Zu jeder Buchung lässt sich ein Beleg hinterlegen – per Durchsuchen-Schaltfläche,
|
||||||
|
Ziehen-und-Ablegen oder <kbd>Strg</kbd>+<kbd>V</kbd>. 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/<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): 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
|
## Anlagevermögen oder Sofortabzug
|
||||||
|
|
||||||
Ob eine Ausgabe abzuschreiben ist, entscheidet das **Konto** (Kontotyp `anlage`),
|
Ob eine Ausgabe abzuschreiben ist, entscheidet das **Konto** (Kontotyp `anlage`),
|
||||||
|
|||||||
Generated
+62
@@ -832,6 +832,12 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
|
||||||
@@ -852,6 +858,22 @@
|
|||||||
"mnemonist": "0.40.0"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
@@ -922,6 +944,45 @@
|
|||||||
"dequal": "^2.0.3"
|
"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": {
|
"node_modules/@fastify/proxy-addr": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||||
@@ -4256,6 +4317,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
|
"@fastify/multipart": "^10.1.0",
|
||||||
"@fastify/static": "^8.0.4",
|
"@fastify/static": "^8.0.4",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"fastify": "^5.2.1"
|
"fastify": "^5.2.1"
|
||||||
|
|||||||
+2
-1
@@ -10,11 +10,12 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"seed": "tsx src/seed.ts",
|
"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"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
|
"@fastify/multipart": "^10.1.0",
|
||||||
"@fastify/static": "^8.0.4",
|
"@fastify/static": "^8.0.4",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"fastify": "^5.2.1"
|
"fastify": "^5.2.1"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -64,6 +64,33 @@ export function ausProjektwurzel(pfad: string): string {
|
|||||||
return isAbsolute(pfad) ? pfad : resolve(projektWurzel, pfad);
|
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. */
|
/** Öffnet eine Mandantendatei und legt das Schema an, falls es noch fehlt. */
|
||||||
export function oeffneDatenbank(datei: string): Database.Database {
|
export function oeffneDatenbank(datei: string): Database.Database {
|
||||||
const pfad = ausProjektwurzel(datei);
|
const pfad = ausProjektwurzel(datei);
|
||||||
@@ -72,6 +99,7 @@ export function oeffneDatenbank(datei: string): Database.Database {
|
|||||||
verbindung.pragma('journal_mode = WAL');
|
verbindung.pragma('journal_mode = WAL');
|
||||||
verbindung.pragma('foreign_keys = ON');
|
verbindung.pragma('foreign_keys = ON');
|
||||||
verbindung.exec(SCHEMA);
|
verbindung.exec(SCHEMA);
|
||||||
|
ergaenzeSpalten(verbindung);
|
||||||
verbindung.prepare('INSERT OR IGNORE INTO mandant (id) VALUES (1)').run();
|
verbindung.prepare('INSERT OR IGNORE INTO mandant (id) VALUES (1)').run();
|
||||||
return verbindung;
|
return verbindung;
|
||||||
}
|
}
|
||||||
@@ -172,7 +200,11 @@ CREATE TABLE IF NOT EXISTS buchungen (
|
|||||||
partner TEXT NOT NULL DEFAULT '',
|
partner TEXT NOT NULL DEFAULT '',
|
||||||
privatanteil_bp INTEGER NOT NULL DEFAULT 0,
|
privatanteil_bp INTEGER NOT NULL DEFAULT 0,
|
||||||
anlagegut_id INTEGER REFERENCES anlagegueter(id),
|
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
|
-- GoBD: keine Löschung, nur Storno; festgeschriebene Sätze sind unveränderbar
|
||||||
storniert INTEGER NOT NULL DEFAULT 0,
|
storniert INTEGER NOT NULL DEFAULT 0,
|
||||||
storno_von INTEGER REFERENCES buchungen(id),
|
storno_von INTEGER REFERENCES buchungen(id),
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface Buchung {
|
|||||||
privatanteil_bp: number;
|
privatanteil_bp: number;
|
||||||
anlagegut_id: number | null;
|
anlagegut_id: number | null;
|
||||||
beleg_datei: string | null;
|
beleg_datei: string | null;
|
||||||
|
beleg_name: string;
|
||||||
|
beleg_typ: string;
|
||||||
|
beleg_hash: string;
|
||||||
|
beleg_groesse: number;
|
||||||
storniert: number;
|
storniert: number;
|
||||||
storno_von: number | null;
|
storno_von: number | null;
|
||||||
festgeschrieben: number;
|
festgeschrieben: number;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { envDatei } from './env.js';
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { projektWurzel } from './pfade.js';
|
import { projektWurzel } from './pfade.js';
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
schliesseAlleVerbindungen,
|
schliesseAlleVerbindungen,
|
||||||
standardMandant,
|
standardMandant,
|
||||||
} from './mandanten.js';
|
} from './mandanten.js';
|
||||||
|
import { MAX_GROESSE } from './belege.js';
|
||||||
import { ericStatus } from './elster/eric.js';
|
import { ericStatus } from './elster/eric.js';
|
||||||
import { mandantenRouten, mandantenScoping } from './routes/mandanten.js';
|
import { mandantenRouten, mandantenScoping } from './routes/mandanten.js';
|
||||||
import { stammdatenRouten } from './routes/stammdaten.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 });
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'warn' }, bodyLimit: 10 * 1024 * 1024 });
|
||||||
|
|
||||||
await app.register(cors, { origin: true, exposedHeaders: ['X-Mandant'] });
|
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);
|
mandantenScoping(app);
|
||||||
await app.register(mandantenRouten);
|
await app.register(mandantenRouten);
|
||||||
await app.register(stammdatenRouten);
|
await app.register(stammdatenRouten);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, protokolliere } from '../db.js';
|
|||||||
import { berechneUst, regimeAm } from '../domain/regime.js';
|
import { berechneUst, regimeAm } from '../domain/regime.js';
|
||||||
import { afaFuer, methodeVorschlag } from '../domain/afa.js';
|
import { afaFuer, methodeVorschlag } from '../domain/afa.js';
|
||||||
import { standardGegenkonto } from '../kontenrahmen/index.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';
|
import type { Anlagegut, Buchung, Konto, Mandant, UstBehandlung } from '../domain/typen.js';
|
||||||
|
|
||||||
/** Bankkonto des aktiven Kontenrahmens – 1800 ist NICHT rahmenübergreifend die Bank. */
|
/** 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 };
|
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 -----------------------------------------------------
|
// ---- Anlagevermögen -----------------------------------------------------
|
||||||
app.get('/api/anlagen', async (req) => {
|
app.get('/api/anlagen', async (req) => {
|
||||||
const { jahr } = req.query as { jahr?: string };
|
const { jahr } = req.query as { jahr?: string };
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [eingefuegt, setEingefuegt] = useState(false);
|
||||||
|
const eingabe = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="feld" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label>Beleg</label>
|
||||||
|
|
||||||
|
{!datei && (
|
||||||
|
<div
|
||||||
|
className={`ablagezone${ueberZone ? ' aktiv' : ''}`}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<strong>Datei hierher ziehen</strong>
|
||||||
|
<span>
|
||||||
|
oder <u>durchsuchen</u> · mit <kbd>Strg</kbd>+<kbd>V</kbd> einfügen
|
||||||
|
</span>
|
||||||
|
<span className="gedimmt" style={{ fontSize: 11 }}>
|
||||||
|
PDF, JPEG, PNG, WebP, TIFF, HEIC · bis 20 MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={eingabe}
|
||||||
|
type="file"
|
||||||
|
accept={BELEG_TYPEN.join(',')}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => onDatei(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{datei && (
|
||||||
|
<div className="belegvorschau">
|
||||||
|
{vorschau ? (
|
||||||
|
<img src={vorschau} alt="Vorschau des Belegs" />
|
||||||
|
) : (
|
||||||
|
<div className="belegsymbol" aria-hidden="true">
|
||||||
|
{datei.type === 'application/pdf' ? 'PDF' : 'DAT'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{datei.name}
|
||||||
|
</div>
|
||||||
|
<div className="gedimmt" style={{ fontSize: 12 }}>
|
||||||
|
{lesbareGroesse(datei.size)}
|
||||||
|
{eingefuegt && ' · aus der Zwischenablage übernommen'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDatei(null);
|
||||||
|
if (eingabe.current) eingabe.current.value = '';
|
||||||
|
}}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && <div className={`hinweis ${status.art}`} style={{ marginTop: 8 }}>{status.text}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { api, useDaten } from '../lib/api';
|
import { api, useDaten } from '../lib/api';
|
||||||
import { useJahr } from '../lib/jahr';
|
import { useJahr } from '../lib/jahr';
|
||||||
import { BEHANDLUNG_LABEL, centAus, datum, eur, REGIME_LABEL } from '../lib/format';
|
import { BEHANDLUNG_LABEL, centAus, datum, eur, REGIME_LABEL } from '../lib/format';
|
||||||
|
import { mitMandant } from '../lib/mandant';
|
||||||
|
import BelegFeld from '../components/BelegFeld';
|
||||||
|
|
||||||
interface Konto {
|
interface Konto {
|
||||||
nummer: string;
|
nummer: string;
|
||||||
@@ -30,6 +32,9 @@ interface Buchung {
|
|||||||
storniert: number;
|
storniert: number;
|
||||||
festgeschrieben: number;
|
festgeschrieben: number;
|
||||||
anlagegut_id: number | null;
|
anlagegut_id: number | null;
|
||||||
|
beleg_datei: string | null;
|
||||||
|
beleg_name: string;
|
||||||
|
beleg_typ: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Aktivierung {
|
interface Aktivierung {
|
||||||
@@ -84,6 +89,8 @@ export default function Buchungen() {
|
|||||||
const [form, setForm] = useState(LEER);
|
const [form, setForm] = useState(LEER);
|
||||||
const [vorschau, setVorschau] = useState<Vorschau | null>(null);
|
const [vorschau, setVorschau] = useState<Vorschau | null>(null);
|
||||||
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
|
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
|
||||||
|
const [beleg, setBeleg] = useState<File | null>(null);
|
||||||
|
const [belegStatus, setBelegStatus] = useState<{ art: 'gut' | 'kritisch'; text: string } | null>(null);
|
||||||
|
|
||||||
const { daten: konten } = useDaten<Konto[]>('/api/konten');
|
const { daten: konten } = useDaten<Konto[]>('/api/konten');
|
||||||
// 1800 ist NUR im SKR04 die Bank – im SKR03 sind das Privatentnahmen.
|
// 1800 ist NUR im SKR04 die Bank – im SKR03 sind das Privatentnahmen.
|
||||||
@@ -156,17 +163,67 @@ export default function Buchungen() {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: 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({
|
setMeldung({
|
||||||
art: 'gut',
|
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 });
|
setForm({ ...LEER, art: form.art, konto: form.konto, gegenkonto: form.gegenkonto });
|
||||||
|
setBeleg(null);
|
||||||
neuLaden();
|
neuLaden();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMeldung({ art: 'kritisch', text: (err as Error).message });
|
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) {
|
async function stornieren(id: number) {
|
||||||
if (!confirm('Die Buchung wird per Gegenbuchung storniert. Fortfahren?')) return;
|
if (!confirm('Die Buchung wird per Gegenbuchung storniert. Fortfahren?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -304,6 +361,8 @@ export default function Buchungen() {
|
|||||||
onChange={(e) => setForm({ ...form, privatanteilFeld: e.target.value })}
|
onChange={(e) => setForm({ ...form, privatanteilFeld: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BelegFeld datei={beleg} onDatei={setBeleg} status={belegStatus} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.art === 'ausgabe' && (
|
{form.art === 'ausgabe' && (
|
||||||
@@ -426,6 +485,7 @@ export default function Buchungen() {
|
|||||||
<th>Text</th>
|
<th>Text</th>
|
||||||
<th>Konto</th>
|
<th>Konto</th>
|
||||||
<th>Behandlung</th>
|
<th>Behandlung</th>
|
||||||
|
<th>Beleg</th>
|
||||||
<th className="rechts">Netto</th>
|
<th className="rechts">Netto</th>
|
||||||
<th className="rechts">USt</th>
|
<th className="rechts">USt</th>
|
||||||
<th className="rechts">Brutto</th>
|
<th className="rechts">Brutto</th>
|
||||||
@@ -447,6 +507,29 @@ export default function Buchungen() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="zahl">{b.konto}</td>
|
<td className="zahl">{b.konto}</td>
|
||||||
<td style={{ fontSize: 12 }}>{BEHANDLUNG_LABEL[b.ust_behandlung] ?? b.ust_behandlung}</td>
|
<td style={{ fontSize: 12 }}>{BEHANDLUNG_LABEL[b.ust_behandlung] ?? b.ust_behandlung}</td>
|
||||||
|
<td>
|
||||||
|
{b.beleg_datei ? (
|
||||||
|
<a
|
||||||
|
className="belegverweis"
|
||||||
|
href={mitMandant(`/api/buchungen/${b.id}/beleg`)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={b.beleg_name}
|
||||||
|
>
|
||||||
|
{b.beleg_typ === 'application/pdf' ? 'PDF' : 'Bild'} ansehen
|
||||||
|
</a>
|
||||||
|
) : b.storniert ? (
|
||||||
|
<span className="gedimmt">–</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => belegNachtragen(b)}
|
||||||
|
style={{ padding: '2px 8px', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
+ Beleg
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="rechts zahl">{eur(b.betrag_netto)}</td>
|
<td className="rechts zahl">{eur(b.betrag_netto)}</td>
|
||||||
<td className="rechts zahl">{b.betrag_ust ? eur(b.betrag_ust) : '–'}</td>
|
<td className="rechts zahl">{b.betrag_ust ? eur(b.betrag_ust) : '–'}</td>
|
||||||
<td className={`rechts zahl ${b.art === 'einnahme' ? 'gut' : ''}`}>
|
<td className={`rechts zahl ${b.art === 'einnahme' ? 'gut' : ''}`}>
|
||||||
@@ -471,7 +554,7 @@ export default function Buchungen() {
|
|||||||
))}
|
))}
|
||||||
{buchungen?.length === 0 && (
|
{buchungen?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="gedimmt">Keine Buchungen für diese Auswahl.</td>
|
<td colSpan={10} className="gedimmt">Keine Buchungen für diese Auswahl.</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -273,3 +273,79 @@ pre.xml {
|
|||||||
.mandant-neu input[type='text'], .mandant-neu input:not([type]) { font-size: 13px; }
|
.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 { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); }
|
||||||
.ankreuz input { width: auto; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user