Einnahmen-Überschuss-Rechnung mit ELSTER, DATEV und Mandantenfähigkeit
Gewinnermittlung nach §4 Abs. 3 EStG für Einzelunternehmen und Freiberufler. Node/Fastify mit SQLite im Backend, React/Vite im Frontend; die Anwendung läuft lokal ohne Cloud-Anbindung. Fachlicher Kern ist eine Zeitachse der Besteuerungsform: das umsatzsteuerliche Regime wird je Buchung aus dem Leistungsdatum aufgelöst, statt aus einer globalen Ja/Nein-Flagge. Dadurch ist auch ein unterjähriger Wechsel von der Kleinunternehmerregelung zur Regelbesteuerung abbildbar, wie ihn §19 Abs. 1 Satz 3 UStG seit 2025 beim Überschreiten der 100.000-EUR-Grenze vorsieht. Enthalten: - Buchungserfassung nach dem Zu-/Abflussprinzip mit Storno statt Löschung - Anlagevermögen mit AfA (linear, GWG, Sammelposten); die Aktivierung entscheidet das Konto, die Methode der Betrag - Anlage EÜR, BWA im Aufbau der Form 01, Umsatzsteuer-Voranmeldung - ELSTER-Anbindung über ERiC mit validierendem Simulationsmodus - DATEV-Buchungsstapel (EXTF 700) und GoBD-Datenträgerüberlassung (Z3) - Kontenrahmen SKR03 und SKR04, je Mandant beim Anlegen wählbar - Mandantenfähigkeit mit einer SQLite-Datei je Mandant; Testmandanten sind von der Übermittlung ans Finanzamt ausgeschlossen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Kopie als .env anlegen und die Werte eintragen.
|
||||
# Ohne diese Variablen läuft die Anwendung im Simulationsmodus: die
|
||||
# Voranmeldung wird erzeugt und geprüft, aber nichts übermittelt.
|
||||
#
|
||||
# Relative Pfade beziehen sich immer auf dieses Verzeichnis (die Projektwurzel),
|
||||
# unabhängig davon, aus welchem Ordner der Prozess gestartet wurde.
|
||||
|
||||
# --- ERiC (ELSTER Rich Client) ------------------------------------------
|
||||
# Nach Registrierung unter https://www.elster.de/elsterweb/entwickler
|
||||
# herunterladen. Die Bibliothek darf nicht weitergegeben werden.
|
||||
ERIC_LIB_PATH=/opt/eric/lib/libericapi.so
|
||||
ERIC_PLUGIN_PATH=/opt/eric/lib/plugins2
|
||||
ERIC_LOG_PATH=./data/eric-log
|
||||
|
||||
# --- ELSTER-Zugang -------------------------------------------------------
|
||||
ELSTER_ZERTIFIKAT=/pfad/zum/zertifikat.pfx
|
||||
ELSTER_PIN=
|
||||
# Bei ELSTER registrierte Hersteller-ID; ohne sie weist ERiC die Sendung ab.
|
||||
ELSTER_HERSTELLER_ID=
|
||||
|
||||
# Testübermittlung ohne steuerliche Wirkung (leer lassen für den Echtbetrieb).
|
||||
# ELSTER_TESTMERKER=700000004
|
||||
|
||||
# --- Anwendung -----------------------------------------------------------
|
||||
PORT=3311
|
||||
EUER_DB=./data/euer.sqlite
|
||||
LOG_LEVEL=warn
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# --- Abhängigkeiten und Build -------------------------------------------
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# --- Buchhaltungsdaten ---------------------------------------------------
|
||||
# Der gesamte data-Ordner enthält echte Mandantendaten – auch der
|
||||
# Unterordner data/mandanten/, den ein Muster wie data/*.sqlite nicht erfasst.
|
||||
/data/*
|
||||
!/data/.gitkeep
|
||||
|
||||
# Auch außerhalb von /data nichts an Datenbanken einchecken
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
*.db
|
||||
|
||||
# --- Zugangsdaten --------------------------------------------------------
|
||||
# .env enthält den ELSTER-PIN. Nur die Vorlage gehört ins Repository.
|
||||
.env
|
||||
.env.*
|
||||
!.env.beispiel
|
||||
|
||||
# ELSTER-Zertifikate und Schlüsselmaterial
|
||||
*.pfx
|
||||
*.p12
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# --- Erzeugte Ausgaben ---------------------------------------------------
|
||||
# Übertragungsprotokolle und Steueranmeldungen enthalten Steuernummer und Umsätze.
|
||||
elster-ausgang/
|
||||
eric-log/
|
||||
*.log
|
||||
|
||||
# --- Werkzeuge und Betriebssystem ---------------------------------------
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,277 @@
|
||||
# open-eur-webapp
|
||||
|
||||
Einnahmen-Überschuss-Rechnung nach §4 Abs. 3 EStG für Einzelunternehmen und
|
||||
Freiberufler – mit Kleinunternehmerregelung, Umsatzsteuer-Voranmeldung,
|
||||
BWA, ELSTER-Anbindung und DATEV-/GoBD-Export.
|
||||
|
||||
Die Anwendung läuft lokal: ein Node-Prozess, eine SQLite-Datei, keine
|
||||
Cloud-Anbindung.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run seed # Beispieldaten mit Regimewechsel 2025 -> 2026
|
||||
npm run dev # Backend (3311) + Frontend (5173) im Entwicklungsmodus
|
||||
```
|
||||
|
||||
Für den Dauerbetrieb:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start # liefert API und Oberfläche auf http://127.0.0.1:3311
|
||||
```
|
||||
|
||||
`npm test` prüft die Steuerlogik (Regimewechsel, EÜR, UStVA, AfA,
|
||||
Steuernummernumsetzung).
|
||||
|
||||
`npm run seed` bricht ab, wenn bereits Buchungen vorhanden sind – sonst würden
|
||||
die Beispieldaten ein zweites Mal eingespielt und alle Auswertungen verdoppeln.
|
||||
`npm run seed -- reset` setzt die Bewegungsdaten zurück und legt sie neu an;
|
||||
dabei gehen auch selbst erfasste Buchungen verloren.
|
||||
|
||||
## Mandanten
|
||||
|
||||
Jeder Mandant hat **eine eigene SQLite-Datei** unter `data/mandanten/`. Das ist
|
||||
Absicht: bei einer gemeinsamen Datenbank mit `mandant_id`-Spalte genügt ein
|
||||
einziges vergessenes `WHERE`, damit Testbuchungen in der echten EÜR oder in
|
||||
einer ELSTER-Übermittlung auftauchen. Getrennte Dateien machen das strukturell
|
||||
unmöglich.
|
||||
|
||||
Umgeschaltet wird oben in der Seitenleiste. Die Anwendung lädt dabei neu, damit
|
||||
keine Formularstände des vorherigen Mandanten stehen bleiben.
|
||||
|
||||
**Testmandanten** sind als solche markiert, auf jeder Seite sichtbar
|
||||
gekennzeichnet und **können nichts an das Finanzamt übermitteln** – es sei denn,
|
||||
`ELSTER_TESTMERKER` ist gesetzt, dann sind Probeübermittlungen ohne steuerliche
|
||||
Wirkung möglich.
|
||||
|
||||
```bash
|
||||
npm run seed -- --mandant=test # legt einen Testmandanten an und befüllt ihn
|
||||
```
|
||||
|
||||
Technisch hängt der Mandant am Request: Header `X-Mandant`, für Download-Links
|
||||
(die keine Header setzen können) alternativ `?mandant=<bezeichner>`. Der Server
|
||||
führt den Handler in einem AsyncLocalStorage-Kontext aus, sodass jeder
|
||||
Datenbankzugriff automatisch auf der richtigen Datei landet.
|
||||
|
||||
Beim Entfernen eines Mandanten wird die Datei **nicht gelöscht**, sondern mit
|
||||
Zeitstempel beiseitegelegt (`….sqlite.entfernt-<zeit>`).
|
||||
|
||||
## Kontenrahmen: SKR03 und SKR04
|
||||
|
||||
Beide stehen zur Wahl – **beim Anlegen eines Mandanten**, danach nicht mehr.
|
||||
Kontonummern und Bezeichnungen stammen aus den amtlichen DATEV-Kontenrahmen
|
||||
(Stand 2026); die Zuordnung zu EÜR-Posten, BWA-Gruppen und UStVA-Kennzahlen ist
|
||||
fachliche Auslegung dieser Anwendung und gehört vor dem ersten Export mit der
|
||||
Steuerkanzlei abgestimmt.
|
||||
|
||||
Der Wechsel ist gesperrt, weil dieselbe Kontonummer in beiden Rahmen etwas
|
||||
anderes bedeutet:
|
||||
|
||||
| Konto | SKR03 | SKR04 |
|
||||
|---|---|---|
|
||||
| 1800 | Privatentnahmen allgemein | **Bank** |
|
||||
| 2100 | Zinsen und ähnliche Aufwendungen | Privatentnahmen allgemein |
|
||||
| 4830 | Abschreibungen auf Sachanlagen | Sonstige betriebliche Erträge |
|
||||
| 4970 | Nebenkosten des Geldverkehrs | Zinserträge |
|
||||
|
||||
Aus demselben Grund ist das Standard-Gegenkonto rahmenabhängig (SKR03 `1200`,
|
||||
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.
|
||||
|
||||
## Anlagevermögen oder Sofortabzug
|
||||
|
||||
Ob eine Ausgabe abzuschreiben ist, entscheidet das **Konto** (Kontotyp `anlage`),
|
||||
nicht der Betrag – eine Werbekampagne für 3.000 € bleibt sofort abziehbar, ein
|
||||
Drucker für 900 € nicht. Eine Buchung auf ein Anlagekonto wird deshalb immer
|
||||
aktiviert; ein stiller Vollabzug ist nicht möglich.
|
||||
|
||||
Der Betrag entscheidet erst über die **Methode** (bei Kleinunternehmern zählt der
|
||||
Bruttobetrag, weil die Umsatzsteuer dann zu den Anschaffungskosten gehört):
|
||||
|
||||
| Anschaffungskosten | Behandlung |
|
||||
|---|---|
|
||||
| ≤ 800 € | GWG, im Anschaffungsjahr voll abgesetzt (§6 Abs. 2 EStG) |
|
||||
| ≤ 1.000 € | Sammelposten, über 5 Jahre aufgelöst (§6 Abs. 2a EStG) |
|
||||
| > 1.000 € | linear über die Nutzungsdauer (§7 Abs. 1 EStG) – Nutzungsdauer ist Pflichtangabe |
|
||||
|
||||
Buchungen auf Anlagekonten ohne hinterlegtes Anlagegut (etwa aus einer früheren
|
||||
Fassung) werden in der EÜR gesondert als prüfbedürftig ausgewiesen. Die Zahlen
|
||||
werden dabei bewusst *nicht* stillschweigend korrigiert.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
`.env.beispiel` nach `.env` kopieren und anpassen. Die Datei wird beim Start
|
||||
automatisch gelesen; bereits gesetzte Umgebungsvariablen haben Vorrang.
|
||||
**Relative Pfade darin beziehen sich immer auf das Projektverzeichnis**, nicht
|
||||
auf das Verzeichnis, aus dem der Prozess gestartet wurde – sonst entstünden je
|
||||
nach Startort verschiedene Datenbanken.
|
||||
|
||||
Ohne `.env` läuft alles außer der ELSTER-Übermittlung; diese arbeitet dann im
|
||||
Simulationsmodus.
|
||||
|
||||
## Wenn der Start fehlschlägt
|
||||
|
||||
**`EMFILE: too many open files, watch ...`** (Linux, betrifft `tsx watch` und
|
||||
Vite gleichermaßen)
|
||||
|
||||
Nicht das Dateideskriptor-Limit, sondern die Zahl der erlaubten
|
||||
inotify-*Instanzen* ist erschöpft – typischerweise, weil VS Code und andere
|
||||
Werkzeuge sie bereits aufgebraucht haben:
|
||||
|
||||
```bash
|
||||
cat /proc/sys/fs/inotify/max_user_instances # meist 128
|
||||
find /proc/*/fd -lname anon_inode:inotify | wc -l # tatsächlich belegt
|
||||
```
|
||||
|
||||
Dauerhafte Abhilfe:
|
||||
|
||||
```bash
|
||||
sudo sysctl -w fs.inotify.max_user_instances=1024
|
||||
echo 'fs.inotify.max_user_instances=1024' | sudo tee /etc/sysctl.d/60-inotify.conf
|
||||
```
|
||||
|
||||
Ohne Root-Rechte stattdessen:
|
||||
|
||||
```bash
|
||||
npm run dev:polling # Server ohne Watcher, Vite mit Polling statt inotify
|
||||
```
|
||||
|
||||
Der Server startet dann nicht automatisch neu – nach Änderungen am Backend
|
||||
manuell neu starten.
|
||||
|
||||
---
|
||||
|
||||
## Der Kern: die Zeitachse der Besteuerungsform
|
||||
|
||||
Der Wechsel von der Kleinunternehmerregelung zur Regelbesteuerung ist nicht als
|
||||
Schalter umgesetzt, sondern als **Zeitachse**:
|
||||
|
||||
| Gültig ab | Besteuerungsform |
|
||||
|---|---|
|
||||
| 15.01.2024 | Kleinunternehmer (§19 UStG) |
|
||||
| 01.01.2026 | Regelbesteuerung |
|
||||
|
||||
Jede Buchung wird nach dem Regime behandelt, das **zu ihrem Leistungsdatum**
|
||||
galt. Daraus folgt:
|
||||
|
||||
- Altbuchungen bleiben unverändert, wenn ein Wechsel eingetragen wird.
|
||||
- Ein **unterjähriger** Wechsel ist möglich – nötig etwa beim Überschreiten der
|
||||
100.000-EUR-Grenze, seit 2025 endet die Kleinunternehmerregelung dann sofort
|
||||
mit dem überschreitenden Umsatz (§19 Abs. 1 Satz 3 UStG).
|
||||
- Ein Jahr mit Wechsel weist Umsätze davor und danach in der EÜR getrennt aus.
|
||||
|
||||
Die Umsatzgrenzen werden laufend überwacht (25.000 EUR Vorjahr / 100.000 EUR
|
||||
laufendes Jahr ab 2025; 22.000 / 50.000 bis 2024) und melden sich, bevor der
|
||||
Wechsel fällig wird.
|
||||
|
||||
Die Regeln stehen an einer Stelle: [`server/src/domain/regime.ts`](server/src/domain/regime.ts).
|
||||
|
||||
## Was die Anwendung kann
|
||||
|
||||
**Erfassung**
|
||||
- Buchungen mit Beleg-, Leistungs- und Zahlungsdatum; jedes Datum hat eine
|
||||
eigene steuerliche Aufgabe (Regime, Soll-Versteuerung, Zuflussprinzip).
|
||||
- Live-Vorschau: das Backend entscheidet über Steuersatz, Behandlung und
|
||||
Vorsteuerabzug – nicht das Formular.
|
||||
- Reverse Charge (§13b), innergemeinschaftliche Erwerbe und Lieferungen,
|
||||
steuerfreie und nicht steuerbare Umsätze.
|
||||
- Privatanteile, Bewirtung (70 %), offene Posten.
|
||||
- Anlagevermögen mit linearer AfA zeitanteilig, GWG-Sofortabschreibung und
|
||||
Sammelposten; Kleinunternehmer schreiben vom Bruttobetrag ab.
|
||||
- GoBD: keine Löschung, sondern Storno per Gegenbuchung; Festschreibung;
|
||||
Änderungsprotokoll.
|
||||
|
||||
**Auswertungen**
|
||||
- **EÜR** in der Gliederung der Anlage EÜR, mit Anlagenverzeichnis.
|
||||
- **UStVA** mit den amtlichen Kennzahlen, Ist- oder Soll-Versteuerung,
|
||||
monatlich oder vierteljährlich, mit Aufriss auf die Einzelbuchungen.
|
||||
- **BWA** im Aufbau der BWA Form 01: Monat, kumuliert, Vorjahresvergleich,
|
||||
Quoten – wahlweise nach Zahlungs- oder Leistungszeitpunkt.
|
||||
|
||||
**Ausgabe**
|
||||
- **ELSTER** über ERiC (siehe unten).
|
||||
- **DATEV** Buchungsstapel im EXTF-Format (Version 700, Formatversion 13) plus
|
||||
Kontenbeschriftungen – das Paket für die Steuerkanzlei.
|
||||
- **GoBD-Datenträgerüberlassung** (GDPdU, Format Z3) mit `index.xml` und CSV –
|
||||
das Paket für die Betriebsprüfung.
|
||||
- EÜR und BWA zusätzlich als CSV.
|
||||
|
||||
## ELSTER-Übermittlung
|
||||
|
||||
**Ohne ERiC ist keine Übermittlung möglich – auch nicht mit einem eigenen
|
||||
XML-Versand.** ERiC ist die einzige zugelassene Schnittstelle zur
|
||||
Finanzverwaltung. Die Bibliothek ist proprietär, darf nicht weitergegeben
|
||||
werden und ist deshalb hier nicht enthalten.
|
||||
|
||||
So wird sie eingebunden:
|
||||
|
||||
1. Im [ELSTER-Entwicklerportal](https://www.elster.de/elsterweb/entwickler)
|
||||
registrieren und ERiC für die eigene Plattform herunterladen.
|
||||
2. `.env.beispiel` nach `.env` kopieren und ausfüllen:
|
||||
`ERIC_LIB_PATH`, `ERIC_PLUGIN_PATH`, `ELSTER_ZERTIFIKAT`, `ELSTER_PIN`,
|
||||
`ELSTER_HERSTELLER_ID`.
|
||||
3. Erste Versuche mit `ELSTER_TESTMERKER=700000004` – solche Übermittlungen
|
||||
haben keine steuerliche Wirkung.
|
||||
|
||||
Ist ERiC nicht eingerichtet, läuft die Anwendung im **Simulationsmodus**: das
|
||||
XML wird erzeugt, auf Wohlgeformtheit und Vollständigkeit geprüft und unter
|
||||
`data/elster-ausgang/` abgelegt. Es wird nichts gesendet, und die Oberfläche
|
||||
sagt das ausdrücklich.
|
||||
|
||||
Die Übermittlung ist verbindlich und nicht zurückzunehmen. Der Endpunkt
|
||||
verlangt deshalb `"bestaetigt": true`, die Oberfläche eine Rückfrage.
|
||||
|
||||
Die Steuernummer wird automatisch vom Länderformat (`123/456/78901`) in das
|
||||
13-stellige Bundesschema umgesetzt, das ELSTER erwartet – für alle 16
|
||||
Bundesländer, siehe [`server/src/elster/steuernummer.ts`](server/src/elster/steuernummer.ts).
|
||||
|
||||
## Was vor dem Produktiveinsatz zu prüfen ist
|
||||
|
||||
Die Rechenwege sind mit Tests abgesichert, aber einige Angaben ändern sich
|
||||
jährlich und müssen gegen die amtlichen Vorgaben abgeglichen werden:
|
||||
|
||||
- **Zeilennummern der Anlage EÜR** – sie ändern sich fast jedes Jahr. Der
|
||||
fachliche Aufbau ist stabil, nur die Nummerierung nicht. Gepflegt werden sie
|
||||
in `ZEILEN_NUMMERN` in
|
||||
[`server/src/domain/euerZeilen.ts`](server/src/domain/euerZeilen.ts), getrennt
|
||||
von der Berechnung. Stand: an der Fassung 2024/2025 orientiert.
|
||||
- **ELSTER-Schemaversion** – die Datenart-Version (`UStVA_<Jahr>`) und der
|
||||
Aufbau des Nutzdatenblocks müssen zu der Version passen, die im installierten
|
||||
ERiC steckt. Der erste Lauf sollte mit gesetztem Testmerker erfolgen.
|
||||
- **DATEV-Formatversion** – der Export folgt Version 700 / Formatversion 13.
|
||||
Vor der ersten Übergabe eine Probedatei mit der Kanzlei einlesen.
|
||||
- **Kontenrahmen** – `server/src/kontenrahmen/skr04.ts` ist ein praxisnaher
|
||||
Auszug aus dem SKR04, kein vollständiger Kontenrahmen. Konten lassen sich in
|
||||
der Oberfläche ergänzen und umwidmen.
|
||||
|
||||
Die Anwendung ersetzt keine steuerliche Beratung. Für die Richtigkeit der
|
||||
Erklärung bleibt die abgebende Person verantwortlich.
|
||||
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
server/src/
|
||||
db.ts SQLite-Schema, Änderungsprotokoll
|
||||
domain/
|
||||
regime.ts Zeitachse der Besteuerungsform, USt-Ermittlung, §19-Grenzen
|
||||
euer.ts Einnahmen-Überschuss-Rechnung
|
||||
euerZeilen.ts Gliederung + Zeilennummern je Veranlagungsjahr
|
||||
ustva.ts Voranmeldung, Kennzahlen, Ist/Soll
|
||||
bwa.ts BWA Form 01
|
||||
afa.ts lineare AfA, GWG, Sammelposten
|
||||
geld.ts Cent-Arithmetik, kaufmännische Rundung
|
||||
elster/
|
||||
steuernummer.ts Länderformat -> Bundesschema (alle 16 Länder)
|
||||
ustvaXml.ts ELSTER-Datenteil für die UStVA
|
||||
eric.ts ERiC-Anbindung über koffi + Simulationsmodus
|
||||
export/
|
||||
datev.ts EXTF-Buchungsstapel und Kontenbeschriftungen
|
||||
gdpdu.ts GoBD-Datenträgerüberlassung
|
||||
zip.ts minimaler ZIP-Writer
|
||||
routes/ HTTP-Schnittstelle
|
||||
web/src/ Oberfläche (React, Vite)
|
||||
```
|
||||
|
||||
Alle Beträge werden als ganzzahlige Cent geführt, Steuersätze als Basispunkte
|
||||
(1900 = 19 %). Damit gibt es keine Fließkommafehler in der Steuerberechnung.
|
||||
Generated
+4289
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "open-eur-webapp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Einnahmen-Überschuss-Rechnung (§4 Abs. 3 EStG) mit Kleinunternehmerregelung, UStVA, BWA, ELSTER und DATEV-Export",
|
||||
"workspaces": ["server", "web"],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"dev:polling": "concurrently -n server,web -c blue,green \"npm run dev:nowatch -w server\" \"npm run dev:polling -w web\"",
|
||||
"build": "npm run build -w server && npm run build -w web",
|
||||
"start": "npm run start -w server",
|
||||
"seed": "npm run seed -w server",
|
||||
"test": "npm run test -w server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:nowatch": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"test": "tsx --test src/berechnung.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/static": "^8.0.4",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"fastify": "^5.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"koffi": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.13.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { test, describe, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Jeder Testlauf bekommt eine eigene Datenbank – vor dem ersten Import von db.ts setzen.
|
||||
process.env['EUER_DB'] = join(mkdtempSync(join(tmpdir(), 'euer-test-')), 'test.sqlite');
|
||||
|
||||
const { ustAusBrutto, ustAusNetto, volleEuroAbgerundet } = await import('./domain/geld.js');
|
||||
const { bundeseinheitlich, SteuernummerFehler } = await import('./elster/steuernummer.js');
|
||||
const { db, initDb } = await import('./db.js');
|
||||
const { SKR03, SKR04, standardGegenkonto } = await import('./kontenrahmen/index.js');
|
||||
const { berechneUst, regimeAm } = await import('./domain/regime.js');
|
||||
const { berechneEuer } = await import('./domain/euer.js');
|
||||
const { berechneUstva } = await import('./domain/ustva.js');
|
||||
const { afaFuer } = await import('./domain/afa.js');
|
||||
const { istAnlagekonto } = await import('./routes/buchungen.js');
|
||||
const { methodeVorschlag } = await import('./domain/afa.js');
|
||||
const { oeffneDatenbank, imMandantKontext } = await import('./db.js');
|
||||
const { kontenrahmenAnlegen } = await import('./mandanten.js');
|
||||
|
||||
describe('Geldrechnung', () => {
|
||||
test('rechnet die Umsatzsteuer aus dem Bruttobetrag heraus', () => {
|
||||
assert.equal(ustAusBrutto(11900, 1900), 1900);
|
||||
assert.equal(ustAusBrutto(10700, 700), 700);
|
||||
assert.equal(ustAusBrutto(10000, 0), 0);
|
||||
});
|
||||
|
||||
test('rundet kaufmännisch auf ganze Cent', () => {
|
||||
// 33,33 EUR brutto bei 19 % -> 5,32 EUR Steuer
|
||||
assert.equal(ustAusBrutto(3333, 1900), 532);
|
||||
assert.equal(ustAusNetto(3333, 1900), 633);
|
||||
});
|
||||
|
||||
test('rundet Bemessungsgrundlagen für ELSTER auf volle Euro ab', () => {
|
||||
assert.equal(volleEuroAbgerundet(199999), 1999);
|
||||
assert.equal(volleEuroAbgerundet(-199999), -1999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Steuernummer nach Bundesschema', () => {
|
||||
test('Bayern: 11 Stellen mit vorangestellter 9', () => {
|
||||
assert.equal(bundeseinheitlich('123/456/78901', 'BY'), '9123045678901');
|
||||
});
|
||||
|
||||
test('Nordrhein-Westfalen hat einen vierstelligen Bezirk', () => {
|
||||
assert.equal(bundeseinheitlich('133/8150/8159', 'NW'), '5133081508159');
|
||||
});
|
||||
|
||||
test('Baden-Württemberg: zweistelliges Landespräfix', () => {
|
||||
assert.equal(bundeseinheitlich('93815/08152', 'BW'), '2893081508152');
|
||||
});
|
||||
|
||||
test('Hessen: führende Füllziffer wird entfernt', () => {
|
||||
assert.equal(bundeseinheitlich('01381508152', 'HE'), '2613081508152');
|
||||
});
|
||||
|
||||
test('meldet eine falsche Stellenzahl statt still zu raten', () => {
|
||||
assert.throws(() => bundeseinheitlich('123', 'BY'), SteuernummerFehler);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regimewechsel Kleinunternehmer -> Regelbesteuerung', () => {
|
||||
before(() => {
|
||||
initDb();
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR IGNORE INTO konten (nummer, bezeichnung, typ, euer_zeile, bwa_gruppe, ustva_kz,
|
||||
ust_satz_bp, ust_behandlung, aktiv, sortierung)
|
||||
VALUES (@nummer, @bezeichnung, @typ, @euer_zeile, @bwa_gruppe, @ustva_kz,
|
||||
@ust_satz_bp, @ust_behandlung, @aktiv, @sortierung)`,
|
||||
);
|
||||
db.transaction(() => SKR04.forEach((k) => stmt.run(k)))();
|
||||
db.exec('DELETE FROM steuer_regime');
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus) VALUES (?,?)').run('2025-01-01', 'kleinunternehmer');
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus) VALUES (?,?)').run('2026-01-01', 'regelbesteuerung');
|
||||
});
|
||||
|
||||
test('löst das Regime nach dem Leistungsdatum auf', () => {
|
||||
assert.equal(regimeAm('2025-12-31'), 'kleinunternehmer');
|
||||
assert.equal(regimeAm('2026-01-01'), 'regelbesteuerung');
|
||||
// vor dem ersten Eintrag gilt der konservative Default
|
||||
assert.equal(regimeAm('2024-06-01'), 'kleinunternehmer');
|
||||
});
|
||||
|
||||
test('erzwingt USt-Freiheit für Umsätze vor dem Wechsel', () => {
|
||||
const r = berechneUst({
|
||||
art: 'einnahme', leistungsdatum: '2025-11-30', brutto: 119_00,
|
||||
satzBp: 1900, behandlung: 'regel',
|
||||
});
|
||||
assert.equal(r.ust, 0);
|
||||
assert.equal(r.netto, 119_00);
|
||||
assert.equal(r.behandlung, 'kleinunternehmer');
|
||||
assert.ok(r.hinweise.length > 0);
|
||||
});
|
||||
|
||||
test('weist nach dem Wechsel Umsatzsteuer aus', () => {
|
||||
const r = berechneUst({
|
||||
art: 'einnahme', leistungsdatum: '2026-01-02', brutto: 119_00,
|
||||
satzBp: 1900, behandlung: 'regel',
|
||||
});
|
||||
assert.equal(r.ust, 19_00);
|
||||
assert.equal(r.netto, 100_00);
|
||||
assert.equal(r.behandlung, 'regel');
|
||||
});
|
||||
|
||||
test('versagt dem Kleinunternehmer den Vorsteuerabzug', () => {
|
||||
const r = berechneUst({
|
||||
art: 'ausgabe', leistungsdatum: '2025-05-05', brutto: 119_00,
|
||||
satzBp: 1900, behandlung: 'regel',
|
||||
});
|
||||
assert.equal(r.vorsteuerAbziehbar, false);
|
||||
assert.equal(r.ust, 0);
|
||||
});
|
||||
|
||||
test('behält die Steuerschuld nach §13b auch für Kleinunternehmer', () => {
|
||||
const r = berechneUst({
|
||||
art: 'ausgabe', leistungsdatum: '2025-05-05', netto: 1_000_00,
|
||||
satzBp: 1900, behandlung: 'reverse_charge_13b',
|
||||
});
|
||||
assert.equal(r.behandlung, 'reverse_charge_13b');
|
||||
assert.equal(r.ust, 190_00);
|
||||
assert.equal(r.vorsteuerAbziehbar, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EÜR und Voranmeldung über den Wechsel hinweg', () => {
|
||||
before(() => {
|
||||
db.exec('DELETE FROM buchungen; DELETE FROM anlagegueter;');
|
||||
const ein = db.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
// 2025: Kleinunternehmer – 1.000 EUR Erlös, 119 EUR Ausgabe brutto
|
||||
ein.run('einnahme', '2025-03-01', '2025-03-01', '2025-03-05', 1_000_00, 1_000_00, 0, 0, 'kleinunternehmer', 0, '4200', '1800');
|
||||
ein.run('ausgabe', '2025-03-02', '2025-03-02', '2025-03-02', 119_00, 119_00, 0, 0, 'kleinunternehmer', 0, '6815', '1800');
|
||||
// 2026: Regelbesteuerung – 1.190 EUR brutto Erlös, 119 EUR brutto Ausgabe
|
||||
ein.run('einnahme', '2026-02-01', '2026-02-01', '2026-02-10', 1_190_00, 1_000_00, 190_00, 1900, 'regel', 1, '4400', '1800');
|
||||
ein.run('ausgabe', '2026-02-03', '2026-02-03', '2026-02-03', 119_00, 100_00, 19_00, 1900, 'regel', 1, '6815', '1800');
|
||||
});
|
||||
|
||||
test('bucht 2025 brutto, weil kein Vorsteuerabzug besteht', () => {
|
||||
const e = berechneEuer(2025);
|
||||
assert.equal(e.summeEinnahmen, 1_000_00);
|
||||
assert.equal(e.summeAusgaben, 119_00);
|
||||
assert.equal(e.gewinn, 881_00);
|
||||
assert.ok(e.einnahmen.some((z) => z.key === 'be_ku'));
|
||||
});
|
||||
|
||||
test('trennt 2026 Netto, Umsatzsteuer und Vorsteuer', () => {
|
||||
const e = berechneEuer(2026);
|
||||
const be = (k: string) => e.einnahmen.find((z) => z.key === k)?.betrag ?? 0;
|
||||
const ba = (k: string) => e.ausgaben.find((z) => z.key === k)?.betrag ?? 0;
|
||||
assert.equal(be('be_pflichtig'), 1_000_00);
|
||||
assert.equal(be('be_ust'), 190_00);
|
||||
assert.equal(ba('ba_buero'), 100_00);
|
||||
assert.equal(ba('ba_vorsteuer'), 19_00);
|
||||
assert.equal(e.gewinn, 1_190_00 - 119_00);
|
||||
});
|
||||
|
||||
test('meldet für den Kleinunternehmer-Zeitraum keine Abgabepflicht', () => {
|
||||
const u = berechneUstva(2025, '41');
|
||||
assert.equal(u.abgabepflichtig, false);
|
||||
assert.equal(u.zahllast, 0);
|
||||
});
|
||||
|
||||
test('ermittelt Zahllast und Kennzahlen nach dem Wechsel', () => {
|
||||
const u = berechneUstva(2026, '41');
|
||||
assert.equal(u.abgabepflichtig, true);
|
||||
assert.equal(u.kennzahlen['81'], 1_000_00);
|
||||
assert.equal(u.kennzahlen['66'], 19_00);
|
||||
assert.equal(u.zahllast, 190_00 - 19_00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abschreibung', () => {
|
||||
test('rechnet linear zeitanteilig ab dem Anschaffungsmonat', () => {
|
||||
const gut = {
|
||||
id: 1, bezeichnung: 'Notebook', anschaffungsdatum: '2026-04-15',
|
||||
akosten_netto: 3_600_00, akosten_brutto: 4_284_00, nutzungsdauer: 3,
|
||||
afa_methode: 'linear' as const, konto: '0670', abgang_datum: null,
|
||||
abgang_erloes: null, notiz: '',
|
||||
};
|
||||
// 3.600 / 3 Jahre = 1.200 pro Jahr, davon 9/12 im Anschaffungsjahr
|
||||
assert.equal(afaFuer(gut, 2026, false).afa, 900_00);
|
||||
assert.equal(afaFuer(gut, 2027, false).afa, 1_200_00);
|
||||
// Kleinunternehmer schreiben vom Bruttobetrag ab
|
||||
assert.equal(afaFuer(gut, 2027, true).afa, 1_428_00);
|
||||
});
|
||||
|
||||
test('setzt geringwertige Wirtschaftsgüter sofort ab', () => {
|
||||
const gwg = {
|
||||
id: 2, bezeichnung: 'Bürostuhl', anschaffungsdatum: '2026-11-20',
|
||||
akosten_netto: 600_00, akosten_brutto: 714_00, nutzungsdauer: 13,
|
||||
afa_methode: 'gwg_sofort' as const, konto: '0675', abgang_datum: null,
|
||||
abgang_erloes: null, notiz: '',
|
||||
};
|
||||
assert.equal(afaFuer(gwg, 2026, false).afa, 600_00);
|
||||
assert.equal(afaFuer(gwg, 2027, false).afa, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anlagevermögen statt Sofortabzug', () => {
|
||||
test('erkennt Anlagekonten am Kontotyp, nicht am Betrag', () => {
|
||||
const anlage = { nummer: '0670', typ: 'anlage' } as never;
|
||||
const aufwand = { nummer: '6600', typ: 'aufwand' } as never;
|
||||
assert.equal(istAnlagekonto(anlage), true);
|
||||
// Eine teure Werbekampagne bleibt sofort abziehbar
|
||||
assert.equal(istAnlagekonto(aufwand), false);
|
||||
});
|
||||
|
||||
test('leitet die Abschreibungsmethode aus den Anschaffungskosten ab', () => {
|
||||
assert.equal(methodeVorschlag(800_00), 'gwg_sofort');
|
||||
assert.equal(methodeVorschlag(800_01), 'sammelposten');
|
||||
assert.equal(methodeVorschlag(1_000_00), 'sammelposten');
|
||||
assert.equal(methodeVorschlag(1_000_01), 'linear');
|
||||
});
|
||||
|
||||
test('meldet Buchungen auf Anlagekonten ohne Anlagegut als prüfbedürftig', () => {
|
||||
db.exec('DELETE FROM buchungen; DELETE FROM anlagegueter;');
|
||||
db.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES ('ausgabe','2026-03-01','2026-03-01','2026-03-01',357000,300000,57000,1900,'regel',1,'0670','1800')`,
|
||||
).run();
|
||||
|
||||
const e = berechneEuer(2026);
|
||||
assert.equal(e.fehlbuchungen.length, 1);
|
||||
assert.equal(e.fehlbuchungen[0]?.konto, '0670');
|
||||
assert.ok(e.hinweise.some((h) => h.includes('Anlagekonto')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mandantentrennung', () => {
|
||||
test('führt Buchungen strikt getrennt', () => {
|
||||
const ordner = mkdtempSync(join(tmpdir(), 'euer-mandanten-'));
|
||||
const anlegen = (name: string) => {
|
||||
const verbindung = oeffneDatenbank(join(ordner, `${name}.sqlite`));
|
||||
kontenrahmenAnlegen(verbindung); // buchungen.konto verweist per Fremdschlüssel auf konten
|
||||
return { slug: name, name, testmandant: false, verbindung };
|
||||
};
|
||||
const a = anlegen('alpha');
|
||||
const b = anlegen('beta');
|
||||
|
||||
const schreibe = (betrag: number) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES ('ausgabe','2026-01-01','2026-01-01','2026-01-01',?,?,0,0,'regel',1,'6815','1800')`,
|
||||
)
|
||||
.run(betrag, betrag);
|
||||
const zaehle = () =>
|
||||
(db.prepare('SELECT COUNT(*) AS n FROM buchungen').get() as { n: number }).n;
|
||||
|
||||
imMandantKontext(a, () => { schreibe(100); schreibe(200); });
|
||||
imMandantKontext(b, () => schreibe(999));
|
||||
|
||||
assert.equal(imMandantKontext(a, zaehle), 2);
|
||||
assert.equal(imMandantKontext(b, zaehle), 1);
|
||||
// Verschachtelung: der innere Kontext darf den äußeren nicht überschreiben
|
||||
imMandantKontext(a, () => {
|
||||
assert.equal(imMandantKontext(b, zaehle), 1);
|
||||
assert.equal(zaehle(), 2);
|
||||
});
|
||||
|
||||
a.verbindung.close();
|
||||
b.verbindung.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kontenrahmen SKR03', () => {
|
||||
test('deckt dieselben fachlichen Posten ab wie der SKR04', () => {
|
||||
const posten = (ks: typeof SKR03) => new Set(ks.map((k) => k.euer_zeile).filter(Boolean));
|
||||
const gruppen = (ks: typeof SKR03) => new Set(ks.map((k) => k.bwa_gruppe).filter(Boolean));
|
||||
for (const p of posten(SKR04)) assert.ok(posten(SKR03).has(p), `EÜR-Posten fehlt im SKR03: ${p}`);
|
||||
for (const g of gruppen(SKR04)) assert.ok(gruppen(SKR03).has(g), `BWA-Gruppe fehlt im SKR03: ${g}`);
|
||||
});
|
||||
|
||||
test('belegt gleiche Kontonummern unterschiedlich – deshalb ist ein Wechsel gesperrt', () => {
|
||||
const skr04 = new Map(SKR04.map((k) => [k.nummer, k]));
|
||||
// Vergleich über Kontotyp UND EÜR-Posten: 1800 hat in beiden Rahmen keinen
|
||||
// EÜR-Posten, ist aber einmal Bank und einmal Privatentnahme.
|
||||
const kennung = (k: (typeof SKR03)[number]) => `${k.typ}/${k.euer_zeile ?? '-'}`;
|
||||
const kollisionen = SKR03.filter((k) => skr04.has(k.nummer)).map((k) => ({
|
||||
nummer: k.nummer,
|
||||
skr03: kennung(k),
|
||||
skr04: kennung(skr04.get(k.nummer)!),
|
||||
}));
|
||||
|
||||
// Es MUSS Kollisionen geben: 4830 ist im SKR03 "Abschreibungen auf Sachanlagen",
|
||||
// im SKR04 dagegen "Sonstige betriebliche Erträge". Würde man den Kontenrahmen
|
||||
// eines Mandanten nachträglich umstellen, würde aus Aufwand ein Ertrag.
|
||||
assert.ok(kollisionen.length > 0, 'Erwartet: überlappende Nummern mit anderer Bedeutung');
|
||||
|
||||
// 1800 ist der gefährlichste Fall: im SKR04 die Bank, im SKR03 Privatentnahmen.
|
||||
// Ein fest verdrahtetes Gegenkonto würde im SKR03 jede Zahlung als Entnahme buchen.
|
||||
const skr03_1800 = SKR03.find((k) => k.nummer === '1800');
|
||||
assert.equal(skr03_1800?.typ, 'privat');
|
||||
assert.equal(skr04.get('1800')?.typ, 'bestand');
|
||||
assert.equal(standardGegenkonto('SKR03'), '1200');
|
||||
assert.equal(standardGegenkonto('SKR04'), '1800');
|
||||
|
||||
// Konten, die in beiden Rahmen dieselbe Nummer UND Bedeutung haben (z.B. 1360
|
||||
// Geldtransit), sind unkritisch; alle übrigen müssen sich unterscheiden.
|
||||
const gleicheBedeutung = kollisionen.filter((k) => k.skr03 === k.skr04);
|
||||
assert.deepEqual(gleicheBedeutung.map((k) => k.nummer), ['1360']);
|
||||
});
|
||||
|
||||
test('hat je Kontenrahmen eindeutige Nummern', () => {
|
||||
for (const [name, ks] of [['SKR03', SKR03], ['SKR04', SKR04]] as const) {
|
||||
assert.equal(new Set(ks.map((k) => k.nummer)).size, ks.length, `doppelte Nummer in ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('rechnet die EÜR mit SKR03-Konten identisch', () => {
|
||||
const ordner = mkdtempSync(join(tmpdir(), 'euer-skr03-'));
|
||||
const v = oeffneDatenbank(join(ordner, 'skr03.sqlite'));
|
||||
kontenrahmenAnlegen(v, 'SKR03');
|
||||
const kontext = { slug: 's3', name: 'SKR03', testmandant: false, verbindung: v };
|
||||
|
||||
imMandantKontext(kontext, () => {
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus) VALUES (?,?)').run('2026-01-01', 'regelbesteuerung');
|
||||
const ein = db.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
// 8400 = Erlöse 19 % USt, 4930 = Bürobedarf, 1200 = Bank
|
||||
ein.run('einnahme', '2026-02-01', '2026-02-01', '2026-02-10', 1_190_00, 1_000_00, 190_00, 1900, 'regel', 1, '8400', '1200');
|
||||
ein.run('ausgabe', '2026-02-03', '2026-02-03', '2026-02-03', 119_00, 100_00, 19_00, 1900, 'regel', 1, '4930', '1200');
|
||||
|
||||
const e = berechneEuer(2026);
|
||||
assert.equal(e.einnahmen.find((z) => z.key === 'be_pflichtig')?.betrag, 1_000_00);
|
||||
assert.equal(e.ausgaben.find((z) => z.key === 'ba_buero')?.betrag, 100_00);
|
||||
assert.equal(e.gewinn, 1_190_00 - 119_00);
|
||||
assert.equal(e.fehlbuchungen.length, 0);
|
||||
|
||||
const u = berechneUstva(2026, '41');
|
||||
assert.equal(u.kennzahlen['81'], 1_000_00);
|
||||
assert.equal(u.kennzahlen['66'], 19_00);
|
||||
});
|
||||
v.close();
|
||||
});
|
||||
|
||||
test('führt Kleinunternehmer-Erlöse auf dem SKR03-Konto 8192', () => {
|
||||
const konto = SKR03.find((k) => k.nummer === '8192');
|
||||
assert.equal(konto?.ust_behandlung, 'kleinunternehmer');
|
||||
assert.equal(konto?.euer_zeile, 'be_ku');
|
||||
assert.equal(konto?.ust_satz_bp, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
import { projektWurzel } from './pfade.js';
|
||||
|
||||
/**
|
||||
* Mandantentrennung: jeder Mandant hat eine eigene SQLite-Datei.
|
||||
*
|
||||
* Damit ist es strukturell unmöglich, dass Testbuchungen in der echten EÜR oder
|
||||
* in einer ELSTER-Übermittlung auftauchen – anders als bei einer gemeinsamen
|
||||
* Datenbank mit mandant_id-Spalte, wo ein einziges vergessenes WHERE genügt.
|
||||
*
|
||||
* Welcher Mandant gilt, hängt am laufenden Request und wird über
|
||||
* AsyncLocalStorage geführt. Der Export `db` ist ein Proxy, der jeden Zugriff an
|
||||
* die Verbindung des aktiven Mandanten weiterreicht – die Fachmodule bleiben
|
||||
* dadurch unverändert und können gar nicht erst am falschen Mandanten arbeiten.
|
||||
*/
|
||||
export interface MandantKontext {
|
||||
slug: string;
|
||||
name: string;
|
||||
testmandant: boolean;
|
||||
verbindung: Database.Database;
|
||||
}
|
||||
|
||||
const speicher = new AsyncLocalStorage<MandantKontext>();
|
||||
|
||||
/** Fallback für Skripte und Tests, die außerhalb eines Requests laufen. */
|
||||
let standardKontext: MandantKontext | null = null;
|
||||
|
||||
export function aktuellerMandant(): MandantKontext {
|
||||
const kontext = speicher.getStore() ?? standardKontext;
|
||||
if (!kontext) {
|
||||
throw new Error(
|
||||
'Kein Mandant im Kontext. Innerhalb eines Requests setzt der Server ihn automatisch; ' +
|
||||
'in Skripten zuvor initDb() oder setzeStandardKontext() aufrufen.',
|
||||
);
|
||||
}
|
||||
return kontext;
|
||||
}
|
||||
|
||||
/** Führt fn im Kontext des Mandanten aus – alle db-Zugriffe darin gehen an dessen Datei. */
|
||||
export function imMandantKontext<T>(kontext: MandantKontext, fn: () => T): T {
|
||||
return speicher.run(kontext, fn);
|
||||
}
|
||||
|
||||
export function setzeStandardKontext(kontext: MandantKontext | null): void {
|
||||
standardKontext = kontext;
|
||||
}
|
||||
|
||||
export const db = new Proxy({} as Database.Database, {
|
||||
get(_ziel, eigenschaft) {
|
||||
const verbindung = aktuellerMandant().verbindung as unknown as Record<
|
||||
string | symbol,
|
||||
unknown
|
||||
>;
|
||||
const wert = verbindung[eigenschaft];
|
||||
return typeof wert === 'function' ? (wert as (...a: unknown[]) => unknown).bind(verbindung) : wert;
|
||||
},
|
||||
});
|
||||
|
||||
/** Relative Pfadangaben beziehen sich auf die Projektwurzel, nicht auf das Arbeitsverzeichnis. */
|
||||
export function ausProjektwurzel(pfad: string): string {
|
||||
return isAbsolute(pfad) ? pfad : resolve(projektWurzel, pfad);
|
||||
}
|
||||
|
||||
/** Öffnet eine Mandantendatei und legt das Schema an, falls es noch fehlt. */
|
||||
export function oeffneDatenbank(datei: string): Database.Database {
|
||||
const pfad = ausProjektwurzel(datei);
|
||||
mkdirSync(dirname(pfad), { recursive: true });
|
||||
const verbindung = new Database(pfad);
|
||||
verbindung.pragma('journal_mode = WAL');
|
||||
verbindung.pragma('foreign_keys = ON');
|
||||
verbindung.exec(SCHEMA);
|
||||
verbindung.prepare('INSERT OR IGNORE INTO mandant (id) VALUES (1)').run();
|
||||
return verbindung;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Geldbeträge werden als ganzzahlige Cent gespeichert, alle Steuersätze als
|
||||
* Basispunkte (1900 = 19,00 %). Damit gibt es keine Fließkomma-Rundungsfehler in
|
||||
* der Steuerberechnung.
|
||||
*/
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS mandant (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
rechtsform TEXT NOT NULL DEFAULT 'einzelunternehmen',
|
||||
strasse TEXT NOT NULL DEFAULT '',
|
||||
plz TEXT NOT NULL DEFAULT '',
|
||||
ort TEXT NOT NULL DEFAULT '',
|
||||
bundesland TEXT NOT NULL DEFAULT 'BY',
|
||||
steuernummer TEXT NOT NULL DEFAULT '',
|
||||
ust_idnr TEXT NOT NULL DEFAULT '',
|
||||
finanzamt_nr TEXT NOT NULL DEFAULT '',
|
||||
taetigkeit TEXT NOT NULL DEFAULT '',
|
||||
betriebsbeginn TEXT,
|
||||
-- 'ist' = Ist-Versteuerung (§20 UStG), 'soll' = Soll-Versteuerung (§16 UStG)
|
||||
besteuerungsart TEXT NOT NULL DEFAULT 'ist' CHECK (besteuerungsart IN ('ist','soll')),
|
||||
-- 'monatlich' | 'vierteljaehrlich' | 'jaehrlich'
|
||||
voranmeldungszeitraum TEXT NOT NULL DEFAULT 'vierteljaehrlich',
|
||||
dauerfristverlaengerung INTEGER NOT NULL DEFAULT 0,
|
||||
kontenrahmen TEXT NOT NULL DEFAULT 'SKR04',
|
||||
-- Stammdaten für DATEV
|
||||
datev_berater_nr TEXT NOT NULL DEFAULT '',
|
||||
datev_mandanten_nr TEXT NOT NULL DEFAULT '',
|
||||
-- Kontaktdaten für den ELSTER-Datenlieferant-Block
|
||||
telefon TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
/*
|
||||
* Zeitachse der umsatzsteuerlichen Regime. Statt einer globalen Ja/Nein-Flagge
|
||||
* wird das Regime je Datum aufgelöst -> unterjähriger Wechsel möglich
|
||||
* (z.B. Überschreiten der 100.000-EUR-Grenze nach §19 Abs. 1 Satz 3 UStG).
|
||||
*/
|
||||
CREATE TABLE IF NOT EXISTS steuer_regime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gueltig_ab TEXT NOT NULL UNIQUE, -- YYYY-MM-DD
|
||||
modus TEXT NOT NULL CHECK (modus IN ('kleinunternehmer','regelbesteuerung')),
|
||||
grund TEXT NOT NULL DEFAULT '',
|
||||
notiz TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS konten (
|
||||
nummer TEXT PRIMARY KEY,
|
||||
bezeichnung TEXT NOT NULL,
|
||||
-- erloes | aufwand | anlage | privat | neutral | bestand
|
||||
typ TEXT NOT NULL,
|
||||
euer_zeile TEXT, -- Schlüssel in domain/euerZeilen.ts
|
||||
bwa_gruppe TEXT, -- Schlüssel in domain/bwa.ts
|
||||
ustva_kz TEXT, -- optionale Kennzahl-Zuordnung für die UStVA
|
||||
ust_satz_bp INTEGER NOT NULL DEFAULT 1900,
|
||||
ust_behandlung TEXT NOT NULL DEFAULT 'regel',
|
||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||
sortierung INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS anlagegueter (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bezeichnung TEXT NOT NULL,
|
||||
anschaffungsdatum TEXT NOT NULL,
|
||||
akosten_netto INTEGER NOT NULL, -- Cent, ohne abziehbare Vorsteuer
|
||||
akosten_brutto INTEGER NOT NULL,
|
||||
nutzungsdauer INTEGER NOT NULL, -- Jahre
|
||||
-- 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine'
|
||||
afa_methode TEXT NOT NULL DEFAULT 'linear',
|
||||
konto TEXT REFERENCES konten(nummer),
|
||||
abgang_datum TEXT,
|
||||
abgang_erloes INTEGER,
|
||||
notiz TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS buchungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
art TEXT NOT NULL CHECK (art IN ('einnahme','ausgabe')),
|
||||
belegdatum TEXT NOT NULL, -- Datum der Rechnung / des Belegs
|
||||
leistungsdatum TEXT NOT NULL, -- maßgeblich für das USt-Regime + Soll-Versteuerung
|
||||
zahlungsdatum TEXT, -- maßgeblich für EÜR (§11 EStG) + Ist-Versteuerung
|
||||
betrag_brutto INTEGER NOT NULL, -- Cent, immer positiv
|
||||
betrag_netto INTEGER NOT NULL,
|
||||
betrag_ust INTEGER NOT NULL,
|
||||
ust_satz_bp INTEGER NOT NULL DEFAULT 0,
|
||||
/* regel | kleinunternehmer | steuerfrei_mit_vst | steuerfrei_ohne_vst
|
||||
| ig_lieferung | ig_erwerb | reverse_charge_13b | nicht_steuerbar */
|
||||
ust_behandlung TEXT NOT NULL DEFAULT 'regel',
|
||||
vorsteuer_abziehbar INTEGER NOT NULL DEFAULT 1,
|
||||
konto TEXT NOT NULL REFERENCES konten(nummer),
|
||||
gegenkonto TEXT NOT NULL DEFAULT '1800',
|
||||
beleg_nr TEXT NOT NULL DEFAULT '',
|
||||
buchungstext TEXT NOT NULL DEFAULT '',
|
||||
partner TEXT NOT NULL DEFAULT '',
|
||||
privatanteil_bp INTEGER NOT NULL DEFAULT 0,
|
||||
anlagegut_id INTEGER REFERENCES anlagegueter(id),
|
||||
beleg_datei TEXT,
|
||||
-- GoBD: keine Löschung, nur Storno; festgeschriebene Sätze sind unveränderbar
|
||||
storniert INTEGER NOT NULL DEFAULT 0,
|
||||
storno_von INTEGER REFERENCES buchungen(id),
|
||||
festgeschrieben INTEGER NOT NULL DEFAULT 0,
|
||||
erstellt_am TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
geaendert_am TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buchungen_zahlung ON buchungen(zahlungsdatum);
|
||||
CREATE INDEX IF NOT EXISTS idx_buchungen_leistung ON buchungen(leistungsdatum);
|
||||
CREATE INDEX IF NOT EXISTS idx_buchungen_konto ON buchungen(konto);
|
||||
|
||||
/* Änderungsprotokoll (GoBD-Verfahrensdokumentation) */
|
||||
CREATE TABLE IF NOT EXISTS aenderungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tabelle TEXT NOT NULL,
|
||||
datensatz_id TEXT NOT NULL,
|
||||
aktion TEXT NOT NULL,
|
||||
vorher TEXT,
|
||||
nachher TEXT,
|
||||
zeitpunkt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ustva_meldungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jahr INTEGER NOT NULL,
|
||||
zeitraum TEXT NOT NULL, -- '01'..'12' oder '41'..'44' (Quartale)
|
||||
kennzahlen TEXT NOT NULL, -- JSON
|
||||
zahllast INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'entwurf', -- entwurf | validiert | uebermittelt | fehler
|
||||
berichtigung INTEGER NOT NULL DEFAULT 0,
|
||||
xml TEXT,
|
||||
transferticket TEXT,
|
||||
eric_protokoll TEXT,
|
||||
erstellt_am TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
uebermittelt_am TEXT,
|
||||
UNIQUE (jahr, zeitraum, berichtigung)
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Für Skripte und Tests: öffnet die über EUER_DB angegebene Datei (Standard
|
||||
* data/euer.sqlite) und macht sie zum Standardmandanten. Der Server benutzt
|
||||
* stattdessen das Mandantenregister.
|
||||
*/
|
||||
export function initDb(): MandantKontext {
|
||||
const kontext: MandantKontext = {
|
||||
slug: 'standard',
|
||||
name: 'Standard',
|
||||
testmandant: false,
|
||||
verbindung: oeffneDatenbank(process.env.EUER_DB ?? 'data/euer.sqlite'),
|
||||
};
|
||||
setzeStandardKontext(kontext);
|
||||
return kontext;
|
||||
}
|
||||
|
||||
export function protokolliere(
|
||||
tabelle: string,
|
||||
datensatzId: string | number | bigint,
|
||||
aktion: string,
|
||||
vorher: unknown,
|
||||
nachher: unknown,
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT INTO aenderungen (tabelle, datensatz_id, aktion, vorher, nachher) VALUES (?,?,?,?,?)',
|
||||
).run(
|
||||
tabelle,
|
||||
String(datensatzId),
|
||||
aktion,
|
||||
vorher === undefined ? null : JSON.stringify(vorher),
|
||||
nachher === undefined ? null : JSON.stringify(nachher),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { db } from '../db.js';
|
||||
import type { Anlagegut } from './typen.js';
|
||||
import { runde } from './geld.js';
|
||||
|
||||
/** GWG-Grenze (§6 Abs. 2 EStG) und Sammelposten-Spanne (§6 Abs. 2a EStG), netto in Cent. */
|
||||
export const GWG_GRENZE = 800_00;
|
||||
export const SAMMELPOSTEN_VON = 250_00;
|
||||
export const SAMMELPOSTEN_BIS = 1_000_00;
|
||||
export const SAMMELPOSTEN_JAHRE = 5;
|
||||
|
||||
export interface AfaZeile {
|
||||
anlagegut: Anlagegut;
|
||||
jahr: number;
|
||||
/** Bemessungsgrundlage: netto bei Regelbesteuerung, brutto beim Kleinunternehmer */
|
||||
bemessung: number;
|
||||
buchwertAnfang: number;
|
||||
afa: number;
|
||||
buchwertEnde: number;
|
||||
/** Restbuchwert bei Abgang im Jahr – geht als Betriebsausgabe in die EÜR */
|
||||
restbuchwertAbgang: number;
|
||||
monate: number;
|
||||
hinweis: string;
|
||||
}
|
||||
|
||||
function monateImJahr(anschaffung: string, jahr: number, abgang: string | null): number {
|
||||
const aJahr = Number(anschaffung.slice(0, 4));
|
||||
const aMonat = Number(anschaffung.slice(5, 7));
|
||||
let von = 1;
|
||||
let bis = 12;
|
||||
if (aJahr > jahr) return 0;
|
||||
if (aJahr === jahr) von = aMonat;
|
||||
if (abgang) {
|
||||
const bJahr = Number(abgang.slice(0, 4));
|
||||
const bMonat = Number(abgang.slice(5, 7));
|
||||
if (bJahr < jahr) return 0;
|
||||
// Im Abgangsjahr wird bis einschließlich des Abgangsmonats abgeschrieben.
|
||||
if (bJahr === jahr) bis = bMonat;
|
||||
}
|
||||
return Math.max(0, bis - von + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineare AfA (§7 Abs. 1 EStG) zeitanteilig ab dem Monat der Anschaffung.
|
||||
* GWG werden im Anschaffungsjahr voll abgesetzt, Sammelposten über 5 Jahre
|
||||
* zu je 20 % – unabhängig vom Monat der Anschaffung.
|
||||
*/
|
||||
export function afaFuer(a: Anlagegut, jahr: number, kleinunternehmerBeiAnschaffung: boolean): AfaZeile {
|
||||
const bemessung = kleinunternehmerBeiAnschaffung ? a.akosten_brutto : a.akosten_netto;
|
||||
const aJahr = Number(a.anschaffungsdatum.slice(0, 4));
|
||||
const monate = monateImJahr(a.anschaffungsdatum, jahr, a.abgang_datum);
|
||||
|
||||
let afa = 0;
|
||||
let hinweis = '';
|
||||
let kumuliertBisVorjahr = 0;
|
||||
|
||||
if (a.afa_methode === 'gwg_sofort') {
|
||||
afa = jahr === aJahr ? bemessung : 0;
|
||||
kumuliertBisVorjahr = jahr > aJahr ? bemessung : 0;
|
||||
hinweis = 'Sofortabschreibung GWG (§6 Abs. 2 EStG)';
|
||||
} else if (a.afa_methode === 'sammelposten') {
|
||||
const jahresrate = runde(bemessung / SAMMELPOSTEN_JAHRE);
|
||||
const idx = jahr - aJahr;
|
||||
afa = idx >= 0 && idx < SAMMELPOSTEN_JAHRE ? jahresrate : 0;
|
||||
if (idx === SAMMELPOSTEN_JAHRE - 1) afa = bemessung - jahresrate * (SAMMELPOSTEN_JAHRE - 1);
|
||||
kumuliertBisVorjahr = Math.min(Math.max(idx, 0), SAMMELPOSTEN_JAHRE) * jahresrate;
|
||||
hinweis = `Sammelposten ${SAMMELPOSTEN_JAHRE} Jahre (§6 Abs. 2a EStG)`;
|
||||
} else if (a.afa_methode === 'keine') {
|
||||
hinweis = 'nicht abnutzbar – keine AfA';
|
||||
} else {
|
||||
const jahresAfa = a.nutzungsdauer > 0 ? bemessung / a.nutzungsdauer : 0;
|
||||
for (let j = aJahr; j < jahr; j++) {
|
||||
kumuliertBisVorjahr += runde((jahresAfa * monateImJahr(a.anschaffungsdatum, j, a.abgang_datum)) / 12);
|
||||
}
|
||||
kumuliertBisVorjahr = Math.min(kumuliertBisVorjahr, bemessung);
|
||||
afa = runde((jahresAfa * monate) / 12);
|
||||
// letzte Rate: exakt auf 0 bzw. Erinnerungswert auslaufen lassen
|
||||
if (kumuliertBisVorjahr + afa > bemessung) afa = bemessung - kumuliertBisVorjahr;
|
||||
hinweis = `linear über ${a.nutzungsdauer} Jahre, ${monate}/12 Monate`;
|
||||
}
|
||||
|
||||
const buchwertAnfang = Math.max(0, bemessung - kumuliertBisVorjahr);
|
||||
const buchwertEnde = Math.max(0, buchwertAnfang - afa);
|
||||
|
||||
let restbuchwertAbgang = 0;
|
||||
if (a.abgang_datum && Number(a.abgang_datum.slice(0, 4)) === jahr) {
|
||||
restbuchwertAbgang = buchwertEnde;
|
||||
}
|
||||
|
||||
return {
|
||||
anlagegut: a,
|
||||
jahr,
|
||||
bemessung,
|
||||
buchwertAnfang,
|
||||
afa,
|
||||
buchwertEnde: restbuchwertAbgang > 0 ? 0 : buchwertEnde,
|
||||
restbuchwertAbgang,
|
||||
monate,
|
||||
hinweis,
|
||||
};
|
||||
}
|
||||
|
||||
export function anlagenverzeichnis(jahr: number, regimeAm: (d: string) => string): AfaZeile[] {
|
||||
const gueter = db
|
||||
.prepare('SELECT * FROM anlagegueter ORDER BY anschaffungsdatum')
|
||||
.all() as Anlagegut[];
|
||||
return gueter
|
||||
.map((a) => afaFuer(a, jahr, regimeAm(a.anschaffungsdatum) === 'kleinunternehmer'))
|
||||
.filter((z) => z.afa !== 0 || z.buchwertAnfang > 0 || z.restbuchwertAbgang > 0);
|
||||
}
|
||||
|
||||
/** Schlägt anhand der Netto-Anschaffungskosten die passende AfA-Methode vor. */
|
||||
export function methodeVorschlag(nettoCent: number): Anlagegut['afa_methode'] {
|
||||
if (nettoCent <= GWG_GRENZE) return 'gwg_sofort';
|
||||
if (nettoCent <= SAMMELPOSTEN_BIS) return 'sammelposten';
|
||||
return 'linear';
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { db } from '../db.js';
|
||||
import { anteil } from './geld.js';
|
||||
import { anlagenverzeichnis } from './afa.js';
|
||||
import { regimeAm } from './regime.js';
|
||||
import { POSTEN_NACH_KEY } from './euerZeilen.js';
|
||||
import type { Buchung, Konto } from './typen.js';
|
||||
|
||||
/**
|
||||
* Betriebswirtschaftliche Auswertung im Aufbau der klassischen "BWA Form 01"
|
||||
* (kurzfristige Erfolgsrechnung). Zeilen mit `formel` werden aus den
|
||||
* Einzelgruppen berechnet, alle übrigen aus den Kontensalden.
|
||||
*/
|
||||
export type BwaGruppenId =
|
||||
| 'umsatzerloese' | 'bestandsveraenderung' | 'akt_eigenleistungen'
|
||||
| 'mat_wareneinkauf' | 'so_betr_erloese'
|
||||
| 'personalkosten' | 'raumkosten' | 'betriebliche_steuern'
|
||||
| 'versicherungen_beitraege' | 'besondere_kosten' | 'kfz_kosten'
|
||||
| 'werbe_reisekosten' | 'kosten_warenabgabe' | 'abschreibungen'
|
||||
| 'reparatur_instandhaltung' | 'sonstige_kosten'
|
||||
| 'zinsaufwand' | 'so_neutraler_aufwand'
|
||||
| 'zinsertraege' | 'so_neutrale_ertraege'
|
||||
| 'steuern_eink_ertrag';
|
||||
|
||||
interface BwaDefinition {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
art: 'erloes' | 'kosten' | 'summe' | 'neutral';
|
||||
/** Aufbau als Summe/Differenz anderer Zeilen */
|
||||
formel?: { id: string; faktor: 1 | -1 }[];
|
||||
/** true = fett/abgesetzt darstellen */
|
||||
zwischensumme?: boolean;
|
||||
}
|
||||
|
||||
export const BWA_AUFBAU: BwaDefinition[] = [
|
||||
{ id: 'umsatzerloese', bezeichnung: 'Umsatzerlöse', art: 'erloes' },
|
||||
{ id: 'bestandsveraenderung', bezeichnung: 'Bestandsveränderungen FE/UE', art: 'erloes' },
|
||||
{ id: 'akt_eigenleistungen', bezeichnung: 'Aktivierte Eigenleistungen', art: 'erloes' },
|
||||
{
|
||||
id: 'gesamtleistung',
|
||||
bezeichnung: 'Gesamtleistung',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'umsatzerloese', faktor: 1 },
|
||||
{ id: 'bestandsveraenderung', faktor: 1 },
|
||||
{ id: 'akt_eigenleistungen', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{ id: 'mat_wareneinkauf', bezeichnung: 'Material-/Wareneinkauf', art: 'kosten' },
|
||||
{
|
||||
id: 'rohertrag',
|
||||
bezeichnung: 'Rohertrag',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'gesamtleistung', faktor: 1 },
|
||||
{ id: 'mat_wareneinkauf', faktor: -1 },
|
||||
],
|
||||
},
|
||||
{ id: 'so_betr_erloese', bezeichnung: 'Sonstige betriebliche Erlöse', art: 'erloes' },
|
||||
{
|
||||
id: 'betrieblicher_rohertrag',
|
||||
bezeichnung: 'Betrieblicher Rohertrag',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'rohertrag', faktor: 1 },
|
||||
{ id: 'so_betr_erloese', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{ id: 'personalkosten', bezeichnung: 'Personalkosten', art: 'kosten' },
|
||||
{ id: 'raumkosten', bezeichnung: 'Raumkosten', art: 'kosten' },
|
||||
{ id: 'betriebliche_steuern', bezeichnung: 'Betriebliche Steuern', art: 'kosten' },
|
||||
{ id: 'versicherungen_beitraege', bezeichnung: 'Versicherungen/Beiträge', art: 'kosten' },
|
||||
{ id: 'besondere_kosten', bezeichnung: 'Besondere Kosten', art: 'kosten' },
|
||||
{ id: 'kfz_kosten', bezeichnung: 'Fahrzeugkosten (ohne Steuer)', art: 'kosten' },
|
||||
{ id: 'werbe_reisekosten', bezeichnung: 'Werbe-/Reisekosten', art: 'kosten' },
|
||||
{ id: 'kosten_warenabgabe', bezeichnung: 'Kosten der Warenabgabe', art: 'kosten' },
|
||||
{ id: 'abschreibungen', bezeichnung: 'Abschreibungen', art: 'kosten' },
|
||||
{ id: 'reparatur_instandhaltung', bezeichnung: 'Reparatur/Instandhaltung', art: 'kosten' },
|
||||
{ id: 'sonstige_kosten', bezeichnung: 'Sonstige Kosten', art: 'kosten' },
|
||||
{
|
||||
id: 'gesamtkosten',
|
||||
bezeichnung: 'Gesamtkosten',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
'personalkosten', 'raumkosten', 'betriebliche_steuern', 'versicherungen_beitraege',
|
||||
'besondere_kosten', 'kfz_kosten', 'werbe_reisekosten', 'kosten_warenabgabe',
|
||||
'abschreibungen', 'reparatur_instandhaltung', 'sonstige_kosten',
|
||||
].map((id) => ({ id, faktor: 1 as const })),
|
||||
},
|
||||
{
|
||||
id: 'betriebsergebnis',
|
||||
bezeichnung: 'Betriebsergebnis',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'betrieblicher_rohertrag', faktor: 1 },
|
||||
{ id: 'gesamtkosten', faktor: -1 },
|
||||
],
|
||||
},
|
||||
{ id: 'zinsaufwand', bezeichnung: 'Zinsaufwand', art: 'kosten' },
|
||||
{ id: 'so_neutraler_aufwand', bezeichnung: 'Sonstiger neutraler Aufwand', art: 'kosten' },
|
||||
{
|
||||
id: 'neutraler_aufwand',
|
||||
bezeichnung: 'Neutraler Aufwand',
|
||||
art: 'summe',
|
||||
formel: [
|
||||
{ id: 'zinsaufwand', faktor: 1 },
|
||||
{ id: 'so_neutraler_aufwand', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{ id: 'zinsertraege', bezeichnung: 'Zinserträge', art: 'erloes' },
|
||||
{ id: 'so_neutrale_ertraege', bezeichnung: 'Sonstige neutrale Erträge', art: 'erloes' },
|
||||
{
|
||||
id: 'neutraler_ertrag',
|
||||
bezeichnung: 'Neutraler Ertrag',
|
||||
art: 'summe',
|
||||
formel: [
|
||||
{ id: 'zinsertraege', faktor: 1 },
|
||||
{ id: 'so_neutrale_ertraege', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ergebnis_vor_steuern',
|
||||
bezeichnung: 'Ergebnis vor Steuern',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'betriebsergebnis', faktor: 1 },
|
||||
{ id: 'neutraler_ertrag', faktor: 1 },
|
||||
{ id: 'neutraler_aufwand', faktor: -1 },
|
||||
],
|
||||
},
|
||||
{ id: 'steuern_eink_ertrag', bezeichnung: 'Steuern Einkommen und Ertrag', art: 'kosten' },
|
||||
{
|
||||
id: 'vorlaeufiges_ergebnis',
|
||||
bezeichnung: 'Vorläufiges Ergebnis',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'ergebnis_vor_steuern', faktor: 1 },
|
||||
{ id: 'steuern_eink_ertrag', faktor: -1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface BwaZeile {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
art: BwaDefinition['art'];
|
||||
zwischensumme: boolean;
|
||||
/** Werte des ausgewählten Monats */
|
||||
monat: number;
|
||||
/** Kumuliert seit Jahresbeginn */
|
||||
kumuliert: number;
|
||||
/** Kumuliert im Vorjahreszeitraum */
|
||||
vorjahrKumuliert: number;
|
||||
/** Anteil an der Gesamtleistung in Prozent (kumuliert) */
|
||||
quoteKumuliert: number;
|
||||
}
|
||||
|
||||
export interface BwaErgebnis {
|
||||
jahr: number;
|
||||
monat: number;
|
||||
basis: 'zahlung' | 'leistung';
|
||||
zeilen: BwaZeile[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
type Salden = Map<string, number>;
|
||||
|
||||
/**
|
||||
* @param basis 'zahlung' = Ist-Sicht passend zur EÜR (§11 EStG),
|
||||
* 'leistung' = periodengerechte Sicht.
|
||||
*/
|
||||
export function berechneBwa(
|
||||
jahr: number,
|
||||
monat: number,
|
||||
basis: 'zahlung' | 'leistung' = 'zahlung',
|
||||
): BwaErgebnis {
|
||||
const konten = new Map(
|
||||
(db.prepare('SELECT * FROM konten').all() as Konto[]).map((k) => [k.nummer, k]),
|
||||
);
|
||||
|
||||
const monatSalden = salden(jahr, monat, monat, basis, konten);
|
||||
const kumSalden = salden(jahr, 1, monat, basis, konten);
|
||||
const vjSalden = salden(jahr - 1, 1, monat, basis, konten);
|
||||
|
||||
const werte = new Map<string, { m: number; k: number; v: number }>();
|
||||
for (const def of BWA_AUFBAU) {
|
||||
if (def.formel) {
|
||||
const acc = { m: 0, k: 0, v: 0 };
|
||||
for (const t of def.formel) {
|
||||
const q = werte.get(t.id);
|
||||
if (!q) continue;
|
||||
acc.m += t.faktor * q.m;
|
||||
acc.k += t.faktor * q.k;
|
||||
acc.v += t.faktor * q.v;
|
||||
}
|
||||
werte.set(def.id, acc);
|
||||
} else {
|
||||
werte.set(def.id, {
|
||||
m: monatSalden.get(def.id) ?? 0,
|
||||
k: kumSalden.get(def.id) ?? 0,
|
||||
v: vjSalden.get(def.id) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gesamtleistung = werte.get('gesamtleistung')?.k ?? 0;
|
||||
|
||||
const zeilen: BwaZeile[] = BWA_AUFBAU.map((def) => {
|
||||
const w = werte.get(def.id) ?? { m: 0, k: 0, v: 0 };
|
||||
return {
|
||||
id: def.id,
|
||||
bezeichnung: def.bezeichnung,
|
||||
art: def.art,
|
||||
zwischensumme: !!def.zwischensumme,
|
||||
monat: w.m,
|
||||
kumuliert: w.k,
|
||||
vorjahrKumuliert: w.v,
|
||||
quoteKumuliert: gesamtleistung !== 0 ? (w.k / gesamtleistung) * 100 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const hinweise: string[] = [];
|
||||
if (basis === 'zahlung') {
|
||||
hinweise.push(
|
||||
'Basis: Zahlungszeitpunkt (Ist-Prinzip) – deckungsgleich mit der EÜR. ' +
|
||||
'Offene Rechnungen sind nicht enthalten.',
|
||||
);
|
||||
} else {
|
||||
hinweise.push('Basis: Leistungszeitpunkt – periodengerecht, weicht bewusst von der EÜR ab.');
|
||||
}
|
||||
hinweise.push(
|
||||
'Abschreibungen werden monatsanteilig aus dem Anlagenverzeichnis umgelegt (1/12 der Jahres-AfA).',
|
||||
);
|
||||
|
||||
return { jahr, monat, basis, zeilen, hinweise };
|
||||
}
|
||||
|
||||
function salden(
|
||||
jahr: number,
|
||||
vonMonat: number,
|
||||
bisMonat: number,
|
||||
basis: 'zahlung' | 'leistung',
|
||||
konten: Map<string, Konto>,
|
||||
): Salden {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
const von = `${jahr}-${p(vonMonat)}-01`;
|
||||
const bis = new Date(Date.UTC(jahr, bisMonat, 0)).toISOString().slice(0, 10);
|
||||
const feld = basis === 'zahlung' ? 'zahlungsdatum' : 'leistungsdatum';
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE storniert = 0 AND ${feld} IS NOT NULL AND ${feld} BETWEEN ? AND ?`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
|
||||
const out: Salden = new Map();
|
||||
const add = (g: string | null | undefined, cent: number) => {
|
||||
if (!g || cent === 0) return;
|
||||
out.set(g, (out.get(g) ?? 0) + cent);
|
||||
};
|
||||
|
||||
for (const b of rows) {
|
||||
const konto = konten.get(b.konto);
|
||||
if (konto?.typ === 'bestand' || konto?.typ === 'privat') continue;
|
||||
// Anschaffungen von Anlagegütern sind kein Aufwand – nur die AfA.
|
||||
if (b.anlagegut_id) continue;
|
||||
|
||||
if (b.art === 'einnahme') {
|
||||
add(konto?.bwa_gruppe ?? 'umsatzerloese', b.betrag_netto);
|
||||
} else {
|
||||
const posten = konto?.euer_zeile ? POSTEN_NACH_KEY.get(konto.euer_zeile) : undefined;
|
||||
let betrag = b.vorsteuer_abziehbar ? b.betrag_netto : b.betrag_brutto;
|
||||
if (b.privatanteil_bp > 0) betrag -= anteil(betrag, b.privatanteil_bp);
|
||||
if (posten?.abziehbarBp) betrag = anteil(betrag, posten.abziehbarBp);
|
||||
add(konto?.bwa_gruppe ?? 'sonstige_kosten', betrag);
|
||||
}
|
||||
}
|
||||
|
||||
// AfA monatsanteilig
|
||||
const monate = bisMonat - vonMonat + 1;
|
||||
for (const z of anlagenverzeichnis(jahr, regimeAm)) {
|
||||
if (z.afa === 0) continue;
|
||||
add('abschreibungen', Math.round((z.afa / 12) * monate));
|
||||
if (z.restbuchwertAbgang > 0 && bisMonat === 12) {
|
||||
add('so_neutraler_aufwand', z.restbuchwertAbgang);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { db } from '../db.js';
|
||||
import { anteil } from './geld.js';
|
||||
import { EUER_POSTEN, POSTEN_NACH_KEY, zeilenNummer } from './euerZeilen.js';
|
||||
import { anlagenverzeichnis } from './afa.js';
|
||||
import { regimeAm, regimeWechselIm } from './regime.js';
|
||||
import type { Buchung, Konto } from './typen.js';
|
||||
|
||||
export interface EuerZeile {
|
||||
key: string;
|
||||
zeile: number | null;
|
||||
bezeichnung: string;
|
||||
betrag: number;
|
||||
hinweis?: string;
|
||||
/** Belegte Buchungen für die Aufriss-Ansicht */
|
||||
anzahl: number;
|
||||
}
|
||||
|
||||
export interface EuerErgebnis {
|
||||
jahr: number;
|
||||
von: string;
|
||||
bis: string;
|
||||
regimeStart: string;
|
||||
regimeEnde: string;
|
||||
wechselImJahr: { gueltig_ab: string; modus: string; grund: string }[];
|
||||
einnahmen: EuerZeile[];
|
||||
ausgaben: EuerZeile[];
|
||||
summeEinnahmen: number;
|
||||
summeAusgaben: number;
|
||||
gewinn: number;
|
||||
offenePosten: { einnahmen: number; ausgaben: number };
|
||||
/** Ausgaben auf Anlagekonten, zu denen kein Anlagegut existiert – zu prüfen */
|
||||
fehlbuchungen: { id: number; datum: string; konto: string; text: string; betrag: number }[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Einnahmen-Überschuss-Rechnung nach §4 Abs. 3 EStG.
|
||||
* Maßgeblich ist das Zu-/Abflussprinzip (§11 EStG): eine Buchung zählt in dem
|
||||
* Jahr, in dem sie bezahlt wurde – unbezahlte Rechnungen bleiben außen vor.
|
||||
*/
|
||||
export function berechneEuer(jahr: number): EuerErgebnis {
|
||||
const von = `${jahr}-01-01`;
|
||||
const bis = `${jahr}-12-31`;
|
||||
|
||||
const konten = new Map(
|
||||
(db.prepare('SELECT * FROM konten').all() as Konto[]).map((k) => [k.nummer, k]),
|
||||
);
|
||||
|
||||
const buchungen = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE storniert = 0 AND zahlungsdatum IS NOT NULL
|
||||
AND zahlungsdatum BETWEEN ? AND ?
|
||||
ORDER BY zahlungsdatum`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
|
||||
const summen = new Map<string, { betrag: number; anzahl: number }>();
|
||||
const addiere = (key: string, betrag: number) => {
|
||||
if (betrag === 0) return;
|
||||
const s = summen.get(key) ?? { betrag: 0, anzahl: 0 };
|
||||
s.betrag += betrag;
|
||||
s.anzahl += 1;
|
||||
summen.set(key, s);
|
||||
};
|
||||
|
||||
const hinweise: string[] = [];
|
||||
const fehlbuchungen: {
|
||||
id: number;
|
||||
datum: string;
|
||||
konto: string;
|
||||
text: string;
|
||||
betrag: number;
|
||||
}[] = [];
|
||||
|
||||
for (const b of buchungen) {
|
||||
const konto = konten.get(b.konto);
|
||||
|
||||
// Umsatzsteuer-Zahlungen an das / Erstattungen vom Finanzamt sind in der
|
||||
// EÜR eigene Posten (Bruttoverfahren der Anlage EÜR).
|
||||
if (b.konto === '3820' || b.konto === '3806' || b.konto === '3801') {
|
||||
addiere(b.art === 'ausgabe' ? 'ba_ust_zahlung' : 'be_ust_erstattung', b.betrag_brutto);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (b.art === 'einnahme') {
|
||||
const kuBuchung = b.ust_behandlung === 'kleinunternehmer';
|
||||
const zielKey = konto?.euer_zeile ?? (kuBuchung ? 'be_ku' : 'be_pflichtig');
|
||||
// Ein Konto kann auf "be_pflichtig" zeigen – wenn die Buchung aber unter
|
||||
// §19 fiel, gehört sie in die Kleinunternehmer-Zeile und umgekehrt.
|
||||
const key =
|
||||
zielKey === 'be_pflichtig' && kuBuchung
|
||||
? 'be_ku'
|
||||
: zielKey === 'be_ku' && !kuBuchung
|
||||
? 'be_pflichtig'
|
||||
: zielKey;
|
||||
addiere(key, b.betrag_netto);
|
||||
addiere('be_ust', b.betrag_ust);
|
||||
} else {
|
||||
// Anlagegüter: die Anschaffung selbst ist keine Betriebsausgabe,
|
||||
// nur die AfA. Die Vorsteuer ist dagegen sofort abziehbar.
|
||||
if (b.anlagegut_id) {
|
||||
if (b.vorsteuer_abziehbar) addiere('ba_vorsteuer', b.betrag_ust);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Buchung auf einem Anlagekonto ohne zugehöriges Anlagegut: die Anschaffung
|
||||
// würde hier in voller Höhe als Betriebsausgabe abgezogen. Der Betrag bleibt
|
||||
// unverändert – stillschweigend korrigierte Vorjahreszahlen wären schlimmer –,
|
||||
// die Buchung wird aber ausdrücklich zur Prüfung gemeldet.
|
||||
if (konto?.typ === 'anlage') {
|
||||
fehlbuchungen.push({
|
||||
id: b.id,
|
||||
datum: b.zahlungsdatum ?? b.leistungsdatum,
|
||||
konto: b.konto,
|
||||
text: b.buchungstext || b.beleg_nr,
|
||||
betrag: b.betrag_brutto,
|
||||
});
|
||||
}
|
||||
|
||||
const key = konto?.euer_zeile ?? 'ba_sonstige';
|
||||
const posten = POSTEN_NACH_KEY.get(key);
|
||||
|
||||
let basis = b.vorsteuer_abziehbar ? b.betrag_netto : b.betrag_brutto;
|
||||
if (b.privatanteil_bp > 0) basis -= anteil(basis, b.privatanteil_bp);
|
||||
if (posten?.abziehbarBp) basis = anteil(basis, posten.abziehbarBp);
|
||||
|
||||
addiere(key, basis);
|
||||
|
||||
if (b.vorsteuer_abziehbar && b.betrag_ust > 0) {
|
||||
// Vorsteuer bleibt auch bei Bewirtung zu 100 % abziehbar, wird aber um
|
||||
// einen privaten Nutzungsanteil gekürzt.
|
||||
let vst = b.betrag_ust;
|
||||
if (b.privatanteil_bp > 0) vst -= anteil(vst, b.privatanteil_bp);
|
||||
addiere('ba_vorsteuer', vst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abschreibungen aus dem Anlagenverzeichnis ---------------------------
|
||||
for (const z of anlagenverzeichnis(jahr, regimeAm)) {
|
||||
const konto = z.anlagegut.konto ? konten.get(z.anlagegut.konto) : undefined;
|
||||
let key = 'ba_afa_beweglich';
|
||||
if (z.anlagegut.afa_methode === 'gwg_sofort') key = 'ba_gwg';
|
||||
else if (z.anlagegut.afa_methode === 'sammelposten') key = 'ba_sammelposten';
|
||||
else if (konto?.nummer === '0135') key = 'ba_afa_immateriell';
|
||||
addiere(key, z.afa);
|
||||
addiere('ba_restbuchwert', z.restbuchwertAbgang);
|
||||
}
|
||||
|
||||
const regimeStart = regimeAm(von);
|
||||
const regimeEnde = regimeAm(bis);
|
||||
const wechsel = regimeWechselIm(von, bis).map((w) => ({
|
||||
gueltig_ab: w.gueltig_ab,
|
||||
modus: w.modus,
|
||||
grund: w.grund,
|
||||
}));
|
||||
|
||||
if (wechsel.length > 0) {
|
||||
hinweise.push(
|
||||
`Im Jahr ${jahr} liegt ein Wechsel der Besteuerungsform. Umsätze vor dem Wechsel sind ` +
|
||||
'in der Kleinunternehmer-Zeile ausgewiesen, danach in den umsatzsteuerpflichtigen Zeilen.',
|
||||
);
|
||||
}
|
||||
if (regimeEnde === 'kleinunternehmer') {
|
||||
hinweise.push(
|
||||
'Als Kleinunternehmer sind Betriebsausgaben mit dem Bruttobetrag angesetzt, ' +
|
||||
'da kein Vorsteuerabzug besteht (§19 Abs. 1 Satz 4 UStG).',
|
||||
);
|
||||
}
|
||||
if (fehlbuchungen.length > 0) {
|
||||
hinweise.push(
|
||||
`${fehlbuchungen.length} Buchung(en) liegen auf einem Anlagekonto, ohne dass ein Anlagegut ` +
|
||||
'hinterlegt ist. Sie sind hier in voller Höhe als Betriebsausgabe enthalten, statt über die ' +
|
||||
'Nutzungsdauer abgeschrieben zu werden. Bitte stornieren und mit Nutzungsdauer neu erfassen.',
|
||||
);
|
||||
}
|
||||
|
||||
const zeilenAus = (bereich: 'einnahmen' | 'ausgaben'): EuerZeile[] =>
|
||||
EUER_POSTEN.filter((p) => p.bereich === bereich)
|
||||
.map((p) => {
|
||||
const s = summen.get(p.key);
|
||||
return {
|
||||
key: p.key,
|
||||
zeile: zeilenNummer(jahr, p.key),
|
||||
bezeichnung: p.bezeichnung,
|
||||
betrag: s?.betrag ?? 0,
|
||||
anzahl: s?.anzahl ?? 0,
|
||||
hinweis: p.hinweis,
|
||||
};
|
||||
})
|
||||
.filter((z) => z.betrag !== 0);
|
||||
|
||||
const einnahmen = zeilenAus('einnahmen');
|
||||
const ausgaben = zeilenAus('ausgaben');
|
||||
const summeEinnahmen = einnahmen.reduce((s, z) => s + z.betrag, 0);
|
||||
const summeAusgaben = ausgaben.reduce((s, z) => s + z.betrag, 0);
|
||||
|
||||
const offen = db
|
||||
.prepare(
|
||||
`SELECT art, COALESCE(SUM(betrag_brutto),0) AS s FROM buchungen
|
||||
WHERE storniert = 0 AND zahlungsdatum IS NULL
|
||||
AND leistungsdatum BETWEEN ? AND ? GROUP BY art`,
|
||||
)
|
||||
.all(von, bis) as { art: string; s: number }[];
|
||||
|
||||
return {
|
||||
jahr,
|
||||
von,
|
||||
bis,
|
||||
regimeStart,
|
||||
regimeEnde,
|
||||
wechselImJahr: wechsel,
|
||||
einnahmen,
|
||||
ausgaben,
|
||||
summeEinnahmen,
|
||||
summeAusgaben,
|
||||
gewinn: summeEinnahmen - summeAusgaben,
|
||||
offenePosten: {
|
||||
einnahmen: offen.find((o) => o.art === 'einnahme')?.s ?? 0,
|
||||
ausgaben: offen.find((o) => o.art === 'ausgabe')?.s ?? 0,
|
||||
},
|
||||
fehlbuchungen,
|
||||
hinweise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Gliederung der Anlage EÜR.
|
||||
*
|
||||
* WICHTIG: Die amtlichen Zeilennummern der Anlage EÜR ändern sich fast jedes
|
||||
* Jahr. Der fachliche Aufbau (welcher Posten wohin gehört) ist stabil, die
|
||||
* Nummerierung nicht. Deshalb sind hier fachliche Schlüssel definiert und die
|
||||
* Zeilennummern pro Veranlagungsjahr in ZEILEN_NUMMERN gepflegt. Vor der ersten
|
||||
* Abgabe eines Jahres die Nummern gegen das aktuelle amtliche Formular prüfen
|
||||
* und ggf. hier nachziehen – die Berechnung selbst bleibt davon unberührt.
|
||||
*/
|
||||
|
||||
export type EuerBereich = 'einnahmen' | 'ausgaben';
|
||||
|
||||
export interface EuerPosten {
|
||||
key: string;
|
||||
bezeichnung: string;
|
||||
bereich: EuerBereich;
|
||||
/** Nur bei Regelbesteuerung relevant */
|
||||
nurRegelbesteuerung?: boolean;
|
||||
/** Nur bei Kleinunternehmern relevant */
|
||||
nurKleinunternehmer?: boolean;
|
||||
/** Anteil, der steuerlich abziehbar ist (Basispunkte); z.B. Bewirtung 70 % */
|
||||
abziehbarBp?: number;
|
||||
hinweis?: string;
|
||||
}
|
||||
|
||||
export const EUER_POSTEN: EuerPosten[] = [
|
||||
// ---- Betriebseinnahmen -------------------------------------------------
|
||||
{
|
||||
key: 'be_ku',
|
||||
bezeichnung: 'Betriebseinnahmen als umsatzsteuerlicher Kleinunternehmer (§19 UStG)',
|
||||
bereich: 'einnahmen',
|
||||
nurKleinunternehmer: true,
|
||||
},
|
||||
{
|
||||
key: 'be_pflichtig',
|
||||
bezeichnung: 'Umsatzsteuerpflichtige Betriebseinnahmen (netto)',
|
||||
bereich: 'einnahmen',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'be_frei',
|
||||
bezeichnung: 'Umsatzsteuerfreie und nicht steuerbare Betriebseinnahmen',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{
|
||||
key: 'be_ust',
|
||||
bezeichnung: 'Vereinnahmte Umsatzsteuer',
|
||||
bereich: 'einnahmen',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'be_ust_erstattung',
|
||||
bezeichnung: 'Vom Finanzamt erstattete und ggf. verrechnete Umsatzsteuer',
|
||||
bereich: 'einnahmen',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'be_anlagenabgang',
|
||||
bezeichnung: 'Veräußerung oder Entnahme von Anlagevermögen',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{
|
||||
key: 'be_kfz_privat',
|
||||
bezeichnung: 'Private Kfz-Nutzung',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{
|
||||
key: 'be_entnahmen',
|
||||
bezeichnung: 'Sonstige Sach-, Nutzungs- und Leistungsentnahmen',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{ key: 'be_sonstige', bezeichnung: 'Übrige Betriebseinnahmen', bereich: 'einnahmen' },
|
||||
|
||||
// ---- Betriebsausgaben --------------------------------------------------
|
||||
{
|
||||
key: 'ba_waren',
|
||||
bezeichnung: 'Waren, Roh- und Hilfsstoffe einschließlich Nebenkosten',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_fremdleistung', bezeichnung: 'Bezogene Fremdleistungen', bereich: 'ausgaben' },
|
||||
{ key: 'ba_personal', bezeichnung: 'Ausgaben für eigenes Personal', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_afa_beweglich',
|
||||
bezeichnung: 'AfA auf bewegliche Wirtschaftsgüter',
|
||||
bereich: 'ausgaben',
|
||||
hinweis: 'wird automatisch aus dem Anlagenverzeichnis ermittelt',
|
||||
},
|
||||
{
|
||||
key: 'ba_afa_immateriell',
|
||||
bezeichnung: 'AfA auf immaterielle Wirtschaftsgüter',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_gwg',
|
||||
bezeichnung: 'Sofortabschreibung geringwertiger Wirtschaftsgüter (GWG, §6 Abs. 2 EStG)',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_sammelposten',
|
||||
bezeichnung: 'Auflösung Sammelposten (§6 Abs. 2a EStG)',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_restbuchwert',
|
||||
bezeichnung: 'Restbuchwert ausgeschiedener Anlagegüter',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_miete', bezeichnung: 'Miete/Pacht für Geschäftsräume', bereich: 'ausgaben' },
|
||||
{ key: 'ba_raum_sonstige', bezeichnung: 'Sonstige Raumkosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_arbeitszimmer',
|
||||
bezeichnung: 'Häusliches Arbeitszimmer / Tagespauschale',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_versicherung',
|
||||
bezeichnung: 'Beiträge, Gebühren, Abgaben und Versicherungen',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_werbung', bezeichnung: 'Werbekosten', bereich: 'ausgaben' },
|
||||
{ key: 'ba_reise', bezeichnung: 'Reisekosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_bewirtung',
|
||||
bezeichnung: 'Bewirtungsaufwendungen',
|
||||
bereich: 'ausgaben',
|
||||
abziehbarBp: 7000,
|
||||
hinweis: 'nur zu 70 % abziehbar (§4 Abs. 5 Nr. 2 EStG); die Vorsteuer bleibt zu 100 % abziehbar',
|
||||
},
|
||||
{
|
||||
key: 'ba_geschenke',
|
||||
bezeichnung: 'Geschenke an Geschäftsfreunde',
|
||||
bereich: 'ausgaben',
|
||||
hinweis: 'nur abziehbar bis 50 EUR je Empfänger und Jahr (§4 Abs. 5 Nr. 1 EStG)',
|
||||
},
|
||||
{ key: 'ba_kfz', bezeichnung: 'Kfz-Kosten und andere Fahrtkosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_buero',
|
||||
bezeichnung: 'Porto, Telefon, Bürobedarf und sonstige Betriebsmittel',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_fortbildung', bezeichnung: 'Fortbildungskosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_beratung',
|
||||
bezeichnung: 'Rechts- und Steuerberatung, Buchführung',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_zinsen', bezeichnung: 'Übrige Schuldzinsen', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_vorsteuer',
|
||||
bezeichnung: 'Gezahlte Vorsteuerbeträge',
|
||||
bereich: 'ausgaben',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'ba_ust_zahlung',
|
||||
bezeichnung: 'An das Finanzamt gezahlte und ggf. verrechnete Umsatzsteuer',
|
||||
bereich: 'ausgaben',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'ba_sonstige',
|
||||
bezeichnung: 'Übrige unbeschränkt abziehbare Betriebsausgaben',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
];
|
||||
|
||||
export const POSTEN_NACH_KEY = new Map(EUER_POSTEN.map((p) => [p.key, p]));
|
||||
|
||||
/**
|
||||
* Amtliche Zeilennummern je Veranlagungsjahr. Bewusst separat gehalten, damit
|
||||
* ein neues Formularjahr eine reine Datenänderung ist.
|
||||
* Status: an der Formularfassung 2024/2025 orientiert – vor Abgabe verifizieren.
|
||||
*/
|
||||
export const ZEILEN_NUMMERN: Record<number, Record<string, number>> = {
|
||||
2025: {
|
||||
be_ku: 11,
|
||||
be_pflichtig: 12,
|
||||
be_frei: 13,
|
||||
be_ust: 16,
|
||||
be_ust_erstattung: 17,
|
||||
be_anlagenabgang: 18,
|
||||
be_kfz_privat: 19,
|
||||
be_entnahmen: 20,
|
||||
be_sonstige: 15,
|
||||
ba_waren: 26,
|
||||
ba_fremdleistung: 27,
|
||||
ba_personal: 28,
|
||||
ba_afa_beweglich: 31,
|
||||
ba_afa_immateriell: 30,
|
||||
ba_gwg: 34,
|
||||
ba_sammelposten: 35,
|
||||
ba_restbuchwert: 36,
|
||||
ba_miete: 38,
|
||||
ba_raum_sonstige: 39,
|
||||
ba_arbeitszimmer: 40,
|
||||
ba_versicherung: 47,
|
||||
ba_werbung: 49,
|
||||
ba_reise: 50,
|
||||
ba_bewirtung: 51,
|
||||
ba_geschenke: 52,
|
||||
ba_kfz: 61,
|
||||
ba_buero: 55,
|
||||
ba_fortbildung: 56,
|
||||
ba_beratung: 57,
|
||||
ba_zinsen: 44,
|
||||
ba_vorsteuer: 63,
|
||||
ba_ust_zahlung: 64,
|
||||
ba_sonstige: 66,
|
||||
},
|
||||
};
|
||||
|
||||
export function zeilenNummer(jahr: number, key: string): number | null {
|
||||
const jahre = Object.keys(ZEILEN_NUMMERN)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
const passend = jahre.find((j) => j <= jahr) ?? jahre[jahre.length - 1];
|
||||
if (passend === undefined) return null;
|
||||
return ZEILEN_NUMMERN[passend]?.[key] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Rechnen in ganzzahligen Cent; Steuersätze in Basispunkten (1900 = 19 %). */
|
||||
|
||||
/** Kaufmännische Rundung auf ganze Cent (halbe Cent immer aufrunden, wie §? Handelsbrauch). */
|
||||
export function runde(cent: number): number {
|
||||
return cent < 0 ? -Math.round(-cent) : Math.round(cent);
|
||||
}
|
||||
|
||||
/** Umsatzsteuer aus einem Bruttobetrag herausrechnen. */
|
||||
export function ustAusBrutto(bruttoCent: number, satzBp: number): number {
|
||||
if (satzBp <= 0) return 0;
|
||||
return runde((bruttoCent * satzBp) / (10000 + satzBp));
|
||||
}
|
||||
|
||||
/** Umsatzsteuer auf einen Nettobetrag aufschlagen. */
|
||||
export function ustAusNetto(nettoCent: number, satzBp: number): number {
|
||||
if (satzBp <= 0) return 0;
|
||||
return runde((nettoCent * satzBp) / 10000);
|
||||
}
|
||||
|
||||
export function anteil(cent: number, bp: number): number {
|
||||
return runde((cent * bp) / 10000);
|
||||
}
|
||||
|
||||
export function eur(cent: number): string {
|
||||
return (cent / 100).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
});
|
||||
}
|
||||
|
||||
/** Für ELSTER/DATEV: Betrag ohne Tausendertrennung, Komma als Dezimaltrenner. */
|
||||
export function dezimal(cent: number): string {
|
||||
return (cent / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
/** ELSTER erwartet in den UStVA-Kennzahlen Bemessungsgrundlagen als volle Euro (abgerundet). */
|
||||
export function volleEuroAbgerundet(cent: number): number {
|
||||
return cent >= 0 ? Math.floor(cent / 100) : -Math.floor(-cent / 100);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { db } from '../db.js';
|
||||
import type { Regime, RegimeEintrag, UstBehandlung } from './typen.js';
|
||||
import { ustAusBrutto, ustAusNetto } from './geld.js';
|
||||
|
||||
/**
|
||||
* Grenzen der Kleinunternehmerregelung (§19 UStG) ab dem Besteuerungszeitraum 2025.
|
||||
* Bis einschließlich 2024 galten 22.000 EUR (Vorjahr, brutto) / 50.000 EUR (Prognose).
|
||||
*/
|
||||
export const KU_GRENZEN = {
|
||||
bis2024: { vorjahr: 22_000_00, laufend: 50_000_00, laufendIstPrognose: true },
|
||||
ab2025: { vorjahr: 25_000_00, laufend: 100_000_00, laufendIstPrognose: false },
|
||||
} as const;
|
||||
|
||||
export function grenzenFuer(jahr: number) {
|
||||
return jahr >= 2025 ? KU_GRENZEN.ab2025 : KU_GRENZEN.bis2024;
|
||||
}
|
||||
|
||||
export function alleRegime(): RegimeEintrag[] {
|
||||
return db
|
||||
.prepare('SELECT * FROM steuer_regime ORDER BY gueltig_ab ASC')
|
||||
.all() as RegimeEintrag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst das umsatzsteuerliche Regime für ein Datum auf: der jüngste Eintrag,
|
||||
* dessen `gueltig_ab` nicht in der Zukunft des Datums liegt. Ohne passenden
|
||||
* Eintrag gilt Kleinunternehmer (konservativer Default für Gründer).
|
||||
*/
|
||||
export function regimeAm(datum: string): Regime {
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT modus FROM steuer_regime WHERE gueltig_ab <= ? ORDER BY gueltig_ab DESC LIMIT 1',
|
||||
)
|
||||
.get(datum) as { modus: Regime } | undefined;
|
||||
return row?.modus ?? 'kleinunternehmer';
|
||||
}
|
||||
|
||||
/** Gibt es innerhalb des Zeitraums einen Regimewechsel? Wichtig für UStVA-Hinweise. */
|
||||
export function regimeWechselIm(von: string, bis: string): RegimeEintrag[] {
|
||||
return db
|
||||
.prepare('SELECT * FROM steuer_regime WHERE gueltig_ab > ? AND gueltig_ab <= ? ORDER BY gueltig_ab')
|
||||
.all(von, bis) as RegimeEintrag[];
|
||||
}
|
||||
|
||||
export interface UstErgebnis {
|
||||
brutto: number;
|
||||
netto: number;
|
||||
ust: number;
|
||||
satzBp: number;
|
||||
behandlung: UstBehandlung;
|
||||
vorsteuerAbziehbar: boolean;
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
export interface UstEingabe {
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
/** Maßgeblich für das Regime: der Zeitpunkt der Leistungsausführung. */
|
||||
leistungsdatum: string;
|
||||
/** Genau eines von beiden angeben. */
|
||||
brutto?: number;
|
||||
netto?: number;
|
||||
satzBp: number;
|
||||
behandlung: UstBehandlung;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentrale USt-Ermittlung. Sie erzwingt die Regeln des zum Leistungsdatum
|
||||
* geltenden Regimes – dadurch bleibt ein unterjähriger Wechsel automatisch
|
||||
* konsistent, ohne dass Altbuchungen angefasst werden müssen.
|
||||
*/
|
||||
export function berechneUst(e: UstEingabe): UstErgebnis {
|
||||
const regime = regimeAm(e.leistungsdatum);
|
||||
const hinweise: string[] = [];
|
||||
let behandlung = e.behandlung;
|
||||
let satzBp = e.satzBp;
|
||||
let vorsteuerAbziehbar = true;
|
||||
|
||||
if (regime === 'kleinunternehmer') {
|
||||
if (e.art === 'einnahme') {
|
||||
if (behandlung === 'regel' && satzBp > 0) {
|
||||
hinweise.push(
|
||||
'Zum Leistungsdatum galt die Kleinunternehmerregelung (§19 UStG): ' +
|
||||
'der Umsatz ist steuerfrei, es wurde keine Umsatzsteuer angesetzt.',
|
||||
);
|
||||
}
|
||||
behandlung = 'kleinunternehmer';
|
||||
satzBp = 0;
|
||||
} else {
|
||||
// Vorsteuerabzug ist nach §19 Abs. 1 Satz 4 UStG ausgeschlossen ->
|
||||
// die Ausgabe geht brutto in die EÜR ein.
|
||||
if (satzBp > 0 && behandlung === 'regel') {
|
||||
hinweise.push(
|
||||
'Kein Vorsteuerabzug als Kleinunternehmer – die Betriebsausgabe wird mit dem Bruttobetrag erfasst.',
|
||||
);
|
||||
}
|
||||
vorsteuerAbziehbar = false;
|
||||
// Reverse-Charge/i.g. Erwerb bleiben auch für Kleinunternehmer steuerpflichtig.
|
||||
if (behandlung !== 'reverse_charge_13b' && behandlung !== 'ig_erwerb') {
|
||||
behandlung = 'kleinunternehmer';
|
||||
satzBp = 0;
|
||||
} else {
|
||||
hinweise.push(
|
||||
'Steuerschuld des Leistungsempfängers besteht auch für Kleinunternehmer – ' +
|
||||
'eine USt-Voranmeldung ist für diesen Zeitraum abzugeben (§18 Abs. 4a UStG), ' +
|
||||
'ein Vorsteuerabzug daraus jedoch nicht möglich.',
|
||||
);
|
||||
vorsteuerAbziehbar = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (behandlung === 'kleinunternehmer') {
|
||||
hinweise.push(
|
||||
'Zum Leistungsdatum galt bereits die Regelbesteuerung – Behandlung auf "regel" gesetzt.',
|
||||
);
|
||||
behandlung = 'regel';
|
||||
}
|
||||
if (
|
||||
behandlung === 'steuerfrei_ohne_vst' ||
|
||||
behandlung === 'nicht_steuerbar'
|
||||
) {
|
||||
vorsteuerAbziehbar = behandlung === 'nicht_steuerbar';
|
||||
satzBp = 0;
|
||||
}
|
||||
if (behandlung === 'steuerfrei_mit_vst' || behandlung === 'ig_lieferung') {
|
||||
satzBp = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let brutto: number;
|
||||
let netto: number;
|
||||
let ust: number;
|
||||
|
||||
if (behandlung === 'reverse_charge_13b' || behandlung === 'ig_erwerb') {
|
||||
// Der Rechnungsbetrag ist netto; die Steuer schuldet der Leistungsempfänger.
|
||||
netto = e.netto ?? e.brutto ?? 0;
|
||||
ust = ustAusNetto(netto, satzBp);
|
||||
brutto = netto; // gezahlt wird nur der Nettobetrag
|
||||
} else if (e.brutto !== undefined) {
|
||||
brutto = e.brutto;
|
||||
ust = ustAusBrutto(brutto, satzBp);
|
||||
netto = brutto - ust;
|
||||
} else {
|
||||
netto = e.netto ?? 0;
|
||||
ust = ustAusNetto(netto, satzBp);
|
||||
brutto = netto + ust;
|
||||
}
|
||||
|
||||
return { brutto, netto, ust, satzBp, behandlung, vorsteuerAbziehbar, hinweise };
|
||||
}
|
||||
|
||||
export interface GrenzenStatus {
|
||||
jahr: number;
|
||||
vorjahresumsatz: number;
|
||||
laufenderUmsatz: number;
|
||||
grenzeVorjahr: number;
|
||||
grenzeLaufend: number;
|
||||
regimeJetzt: Regime;
|
||||
vorjahrUeberschritten: boolean;
|
||||
laufendUeberschritten: boolean;
|
||||
/** Kleinunternehmerregelung entfällt zum 1. Januar des Folgejahres */
|
||||
wechselImFolgejahr: boolean;
|
||||
warnungen: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Überwacht die §19-Grenzen. Maßgeblich ist der Gesamtumsatz nach §19 Abs. 2 UStG
|
||||
* (vereinnahmte Entgelte); hier näherungsweise die Summe der steuerbaren
|
||||
* Betriebseinnahmen nach Zuflussprinzip.
|
||||
*/
|
||||
export function grenzenStatus(jahr: number): GrenzenStatus {
|
||||
const umsatz = (j: number): number => {
|
||||
const r = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(betrag_brutto),0) AS s FROM buchungen
|
||||
WHERE art='einnahme' AND storniert=0
|
||||
AND ust_behandlung NOT IN ('nicht_steuerbar')
|
||||
AND zahlungsdatum BETWEEN ? AND ?`,
|
||||
)
|
||||
.get(`${j}-01-01`, `${j}-12-31`) as { s: number };
|
||||
return r.s;
|
||||
};
|
||||
|
||||
const g = grenzenFuer(jahr);
|
||||
const vorjahresumsatz = umsatz(jahr - 1);
|
||||
const laufenderUmsatz = umsatz(jahr);
|
||||
const regimeJetzt = regimeAm(`${jahr}-12-31`);
|
||||
const warnungen: string[] = [];
|
||||
|
||||
const vorjahrUeberschritten = vorjahresumsatz > g.vorjahr;
|
||||
const laufendUeberschritten = laufenderUmsatz > g.laufend;
|
||||
|
||||
const folgejahr = grenzenFuer(jahr + 1);
|
||||
const wechselImFolgejahr =
|
||||
regimeJetzt === 'kleinunternehmer' && laufenderUmsatz > folgejahr.vorjahr;
|
||||
|
||||
if (regimeJetzt === 'kleinunternehmer') {
|
||||
if (wechselImFolgejahr) {
|
||||
warnungen.push(
|
||||
`Der Umsatz ${jahr} liegt mit ${(laufenderUmsatz / 100).toLocaleString('de-DE')} EUR über der ` +
|
||||
`Vorjahresgrenze von ${(folgejahr.vorjahr / 100).toLocaleString('de-DE')} EUR. ` +
|
||||
`Die Kleinunternehmerregelung entfällt damit ab dem 01.01.${jahr + 1} – bitte zu diesem ` +
|
||||
'Datum einen Wechsel zur Regelbesteuerung anlegen und ab dann Umsatzsteuer ausweisen.',
|
||||
);
|
||||
}
|
||||
if (vorjahrUeberschritten) {
|
||||
warnungen.push(
|
||||
`Der Vorjahresumsatz ${jahr - 1} liegt über ${(g.vorjahr / 100).toLocaleString('de-DE')} EUR. ` +
|
||||
`Ab dem 01.01.${jahr} greift die Regelbesteuerung – bitte einen Regimewechsel zum 01.01.${jahr} anlegen.`,
|
||||
);
|
||||
}
|
||||
if (laufendUeberschritten && jahr >= 2025) {
|
||||
warnungen.push(
|
||||
`Die laufende Grenze von ${(g.laufend / 100).toLocaleString('de-DE')} EUR wurde in ${jahr} überschritten. ` +
|
||||
'Seit 2025 endet die Kleinunternehmerregelung sofort mit dem Umsatz, der die Grenze übersteigt ' +
|
||||
'(§19 Abs. 1 Satz 3 UStG) – der Regimewechsel ist unterjährig zu diesem Datum einzutragen.',
|
||||
);
|
||||
}
|
||||
if (!laufendUeberschritten && laufenderUmsatz > (g.laufend * 8) / 10) {
|
||||
warnungen.push(
|
||||
`Achtung: ${((laufenderUmsatz / g.laufend) * 100).toFixed(0)} % der laufenden Umsatzgrenze erreicht.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
jahr,
|
||||
vorjahresumsatz,
|
||||
laufenderUmsatz,
|
||||
grenzeVorjahr: g.vorjahr,
|
||||
grenzeLaufend: g.laufend,
|
||||
regimeJetzt,
|
||||
vorjahrUeberschritten,
|
||||
laufendUeberschritten,
|
||||
wechselImFolgejahr,
|
||||
warnungen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type Regime = 'kleinunternehmer' | 'regelbesteuerung';
|
||||
|
||||
export type UstBehandlung =
|
||||
| 'regel' // steuerpflichtiger Umsatz / Vorsteuerabzug
|
||||
| 'kleinunternehmer' // §19 UStG – steuerfrei, kein Vorsteuerabzug
|
||||
| 'steuerfrei_mit_vst' // z.B. §4 Nr. 1-7 UStG (Ausfuhr) – Vorsteuerabzug bleibt
|
||||
| 'steuerfrei_ohne_vst' // z.B. §4 Nr. 8-28 UStG (Heilbehandlung, Vermietung)
|
||||
| 'ig_lieferung' // innergemeinschaftliche Lieferung (Kz 41)
|
||||
| 'ig_erwerb' // innergemeinschaftlicher Erwerb (Kz 89/93 + Kz 61)
|
||||
| 'reverse_charge_13b' // Leistungsempfänger schuldet die Steuer (Kz 46/47 + Kz 67)
|
||||
| 'nicht_steuerbar'; // z.B. Leistungsort im Ausland (§3a Abs. 2), Kz 21/45
|
||||
|
||||
export type BuchungsArt = 'einnahme' | 'ausgabe';
|
||||
|
||||
export interface Buchung {
|
||||
id: number;
|
||||
art: BuchungsArt;
|
||||
belegdatum: string;
|
||||
leistungsdatum: string;
|
||||
zahlungsdatum: string | null;
|
||||
betrag_brutto: number;
|
||||
betrag_netto: number;
|
||||
betrag_ust: number;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: UstBehandlung;
|
||||
vorsteuer_abziehbar: number;
|
||||
konto: string;
|
||||
gegenkonto: string;
|
||||
beleg_nr: string;
|
||||
buchungstext: string;
|
||||
partner: string;
|
||||
privatanteil_bp: number;
|
||||
anlagegut_id: number | null;
|
||||
beleg_datei: string | null;
|
||||
storniert: number;
|
||||
storno_von: number | null;
|
||||
festgeschrieben: number;
|
||||
erstellt_am: string;
|
||||
geaendert_am: string;
|
||||
}
|
||||
|
||||
export interface Konto {
|
||||
nummer: string;
|
||||
bezeichnung: string;
|
||||
typ: 'erloes' | 'aufwand' | 'anlage' | 'privat' | 'neutral' | 'bestand';
|
||||
euer_zeile: string | null;
|
||||
bwa_gruppe: string | null;
|
||||
ustva_kz: string | null;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: UstBehandlung;
|
||||
aktiv: number;
|
||||
sortierung: number;
|
||||
}
|
||||
|
||||
export interface Anlagegut {
|
||||
id: number;
|
||||
bezeichnung: string;
|
||||
anschaffungsdatum: string;
|
||||
akosten_netto: number;
|
||||
akosten_brutto: number;
|
||||
nutzungsdauer: number;
|
||||
afa_methode: 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine';
|
||||
konto: string | null;
|
||||
abgang_datum: string | null;
|
||||
abgang_erloes: number | null;
|
||||
notiz: string;
|
||||
}
|
||||
|
||||
export interface Mandant {
|
||||
id: number;
|
||||
name: string;
|
||||
rechtsform: string;
|
||||
strasse: string;
|
||||
plz: string;
|
||||
ort: string;
|
||||
bundesland: string;
|
||||
steuernummer: string;
|
||||
ust_idnr: string;
|
||||
finanzamt_nr: string;
|
||||
taetigkeit: string;
|
||||
betriebsbeginn: string | null;
|
||||
besteuerungsart: 'ist' | 'soll';
|
||||
voranmeldungszeitraum: 'monatlich' | 'vierteljaehrlich' | 'jaehrlich';
|
||||
dauerfristverlaengerung: number;
|
||||
kontenrahmen: string;
|
||||
datev_berater_nr: string;
|
||||
datev_mandanten_nr: string;
|
||||
telefon: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RegimeEintrag {
|
||||
id: number;
|
||||
gueltig_ab: string;
|
||||
modus: Regime;
|
||||
grund: string;
|
||||
notiz: string;
|
||||
}
|
||||
|
||||
/** Zeitraum-Filter für Auswertungen */
|
||||
export interface Zeitraum {
|
||||
von: string; // YYYY-MM-DD inklusive
|
||||
bis: string; // YYYY-MM-DD inklusive
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { db } from '../db.js';
|
||||
import { volleEuroAbgerundet, ustAusNetto } from './geld.js';
|
||||
import { regimeAm, regimeWechselIm } from './regime.js';
|
||||
import type { Buchung, Konto, Mandant } from './typen.js';
|
||||
|
||||
/**
|
||||
* Umsatzsteuer-Voranmeldung (§18 UStG).
|
||||
*
|
||||
* Zeitliche Zuordnung:
|
||||
* - Ausgangsumsätze bei Soll-Versteuerung -> Leistungsdatum
|
||||
* - Ausgangsumsätze bei Ist-Versteuerung -> Zahlungsdatum (§20 UStG)
|
||||
* - Vorsteuer -> späteres von Rechnungs- und
|
||||
* Leistungsdatum, denn der Abzug setzt beides voraus (§15 Abs. 1 Nr. 1 UStG)
|
||||
* und ist von der Ist-Versteuerung unabhängig.
|
||||
*/
|
||||
|
||||
export interface UstvaZeitraum {
|
||||
jahr: number;
|
||||
/** '01'..'12' für Monate, '41'..'44' für Quartale, '00' für das Kalenderjahr */
|
||||
code: string;
|
||||
von: string;
|
||||
bis: string;
|
||||
bezeichnung: string;
|
||||
}
|
||||
|
||||
export function zeitraum(jahr: number, code: string): UstvaZeitraum {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
if (code === '00') {
|
||||
return { jahr, code, von: `${jahr}-01-01`, bis: `${jahr}-12-31`, bezeichnung: `Jahr ${jahr}` };
|
||||
}
|
||||
const n = Number(code);
|
||||
if (n >= 41 && n <= 44) {
|
||||
const q = n - 40;
|
||||
const m1 = (q - 1) * 3 + 1;
|
||||
const m3 = m1 + 2;
|
||||
return {
|
||||
jahr,
|
||||
code,
|
||||
von: `${jahr}-${p(m1)}-01`,
|
||||
bis: letzterTag(jahr, m3),
|
||||
bezeichnung: `${q}. Quartal ${jahr}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
jahr,
|
||||
code,
|
||||
von: `${jahr}-${p(n)}-01`,
|
||||
bis: letzterTag(jahr, n),
|
||||
bezeichnung: `${MONATE[n - 1]} ${jahr}`,
|
||||
};
|
||||
}
|
||||
|
||||
const MONATE = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
function letzterTag(jahr: number, monat: number): string {
|
||||
const d = new Date(Date.UTC(jahr, monat, 0));
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function moeglicheZeitraeume(m: Mandant): string[] {
|
||||
if (m.voranmeldungszeitraum === 'monatlich') {
|
||||
return Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'));
|
||||
}
|
||||
if (m.voranmeldungszeitraum === 'vierteljaehrlich') return ['41', '42', '43', '44'];
|
||||
return ['00'];
|
||||
}
|
||||
|
||||
export type Kennzahlen = Record<string, number>;
|
||||
|
||||
export interface UstvaErgebnis {
|
||||
zeitraum: UstvaZeitraum;
|
||||
besteuerungsart: 'ist' | 'soll';
|
||||
regimeStart: string;
|
||||
regimeEnde: string;
|
||||
abgabepflichtig: boolean;
|
||||
kennzahlen: Kennzahlen;
|
||||
umsatzsteuer: number;
|
||||
vorsteuer: number;
|
||||
/** Kz 83 – positiv = Zahllast, negativ = Erstattungsanspruch (in Cent) */
|
||||
zahllast: number;
|
||||
hinweise: string[];
|
||||
positionen: UstvaPosition[];
|
||||
}
|
||||
|
||||
export interface UstvaPosition {
|
||||
buchungId: number;
|
||||
datum: string;
|
||||
text: string;
|
||||
kz: string;
|
||||
bemessung: number;
|
||||
steuer: number;
|
||||
}
|
||||
|
||||
const STEUERSATZ_KZ: Record<number, { bmg: string; steuer?: string }> = {
|
||||
1900: { bmg: '81' },
|
||||
700: { bmg: '86' },
|
||||
};
|
||||
const ERWERB_KZ: Record<number, { bmg: string; steuer?: string }> = {
|
||||
1900: { bmg: '89' },
|
||||
700: { bmg: '93' },
|
||||
};
|
||||
|
||||
export function berechneUstva(jahr: number, code: string): UstvaErgebnis {
|
||||
const zr = zeitraum(jahr, code);
|
||||
const mandant = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const konten = new Map(
|
||||
(db.prepare('SELECT * FROM konten').all() as Konto[]).map((k) => [k.nummer, k]),
|
||||
);
|
||||
|
||||
const kz: Kennzahlen = {};
|
||||
const positionen: UstvaPosition[] = [];
|
||||
const hinweise: string[] = [];
|
||||
const add = (k: string, cent: number) => {
|
||||
if (cent === 0) return;
|
||||
kz[k] = (kz[k] ?? 0) + cent;
|
||||
};
|
||||
|
||||
const alle = db
|
||||
.prepare('SELECT * FROM buchungen WHERE storniert = 0')
|
||||
.all() as Buchung[];
|
||||
|
||||
const imZeitraum = (d: string | null) => !!d && d >= zr.von && d <= zr.bis;
|
||||
|
||||
for (const b of alle) {
|
||||
const konto = konten.get(b.konto);
|
||||
if (b.art === 'einnahme') {
|
||||
const stichtag =
|
||||
mandant.besteuerungsart === 'ist' ? b.zahlungsdatum : b.leistungsdatum;
|
||||
if (!imZeitraum(stichtag)) continue;
|
||||
erfasseUmsatz(b, konto);
|
||||
} else {
|
||||
// Vorsteuerabzug: Rechnung UND Leistung müssen vorliegen.
|
||||
const stichtag = b.belegdatum > b.leistungsdatum ? b.belegdatum : b.leistungsdatum;
|
||||
if (!imZeitraum(stichtag)) continue;
|
||||
erfasseVorsteuer(b, konto);
|
||||
}
|
||||
}
|
||||
|
||||
function push(b: Buchung, kzName: string, bemessung: number, steuer: number) {
|
||||
positionen.push({
|
||||
buchungId: b.id,
|
||||
datum: b.leistungsdatum,
|
||||
text: b.buchungstext || b.partner || b.beleg_nr,
|
||||
kz: kzName,
|
||||
bemessung,
|
||||
steuer,
|
||||
});
|
||||
}
|
||||
|
||||
function erfasseUmsatz(b: Buchung, konto: Konto | undefined) {
|
||||
switch (b.ust_behandlung) {
|
||||
case 'regel': {
|
||||
const map = STEUERSATZ_KZ[b.ust_satz_bp];
|
||||
if (map) {
|
||||
add(map.bmg, b.betrag_netto);
|
||||
push(b, map.bmg, b.betrag_netto, b.betrag_ust);
|
||||
} else {
|
||||
// andere Steuersätze: Bemessungsgrundlage Kz 35, Steuer Kz 36
|
||||
add('35', b.betrag_netto);
|
||||
add('36', b.betrag_ust);
|
||||
push(b, '35', b.betrag_netto, b.betrag_ust);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ig_lieferung':
|
||||
add('41', b.betrag_netto);
|
||||
push(b, '41', b.betrag_netto, 0);
|
||||
break;
|
||||
case 'steuerfrei_mit_vst':
|
||||
add('43', b.betrag_netto);
|
||||
push(b, '43', b.betrag_netto, 0);
|
||||
break;
|
||||
case 'steuerfrei_ohne_vst':
|
||||
add('48', b.betrag_netto);
|
||||
push(b, '48', b.betrag_netto, 0);
|
||||
break;
|
||||
case 'nicht_steuerbar': {
|
||||
const ziel = konto?.ustva_kz ?? '45';
|
||||
add(ziel, b.betrag_netto);
|
||||
push(b, ziel, b.betrag_netto, 0);
|
||||
break;
|
||||
}
|
||||
case 'kleinunternehmer':
|
||||
// Umsätze nach §19 UStG werden in der Voranmeldung nicht erklärt.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function erfasseVorsteuer(b: Buchung, _konto: Konto | undefined) {
|
||||
if (b.ust_behandlung === 'ig_erwerb') {
|
||||
const map = ERWERB_KZ[b.ust_satz_bp];
|
||||
const steuer = ustAusNetto(b.betrag_netto, b.ust_satz_bp);
|
||||
if (map) {
|
||||
add(map.bmg, b.betrag_netto);
|
||||
} else {
|
||||
add('95', b.betrag_netto);
|
||||
add('98', steuer);
|
||||
}
|
||||
if (b.vorsteuer_abziehbar) add('61', steuer);
|
||||
push(b, map?.bmg ?? '95', b.betrag_netto, steuer);
|
||||
return;
|
||||
}
|
||||
if (b.ust_behandlung === 'reverse_charge_13b') {
|
||||
const steuer = ustAusNetto(b.betrag_netto, b.ust_satz_bp);
|
||||
add('46', b.betrag_netto);
|
||||
add('47', steuer);
|
||||
if (b.vorsteuer_abziehbar) add('67', steuer);
|
||||
push(b, '46', b.betrag_netto, steuer);
|
||||
return;
|
||||
}
|
||||
if (b.vorsteuer_abziehbar && b.betrag_ust > 0) {
|
||||
add('66', b.betrag_ust);
|
||||
push(b, '66', b.betrag_netto, b.betrag_ust);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Steuerbeträge zusammenrechnen ---------------------------------------
|
||||
const ust19 = ustAusNetto(kz['81'] ?? 0, 1900);
|
||||
const ust7 = ustAusNetto(kz['86'] ?? 0, 700);
|
||||
const erwerb19 = ustAusNetto(kz['89'] ?? 0, 1900);
|
||||
const erwerb7 = ustAusNetto(kz['93'] ?? 0, 700);
|
||||
|
||||
const umsatzsteuer =
|
||||
ust19 + ust7 + (kz['36'] ?? 0) + erwerb19 + erwerb7 + (kz['98'] ?? 0) + (kz['47'] ?? 0);
|
||||
const vorsteuer =
|
||||
(kz['66'] ?? 0) + (kz['61'] ?? 0) + (kz['62'] ?? 0) + (kz['67'] ?? 0) + (kz['63'] ?? 0);
|
||||
|
||||
const zahllast = umsatzsteuer - vorsteuer;
|
||||
kz['83'] = zahllast;
|
||||
|
||||
const regimeStart = regimeAm(zr.von);
|
||||
const regimeEnde = regimeAm(zr.bis);
|
||||
const wechsel = regimeWechselIm(zr.von, zr.bis);
|
||||
|
||||
// §19 Abs. 1 Satz 4 UStG: Kleinunternehmer geben grundsätzlich keine
|
||||
// Voranmeldung ab – außer bei Steuerschuld nach §13b / i.g. Erwerb.
|
||||
const nurKleinunternehmer = regimeStart === 'kleinunternehmer' && regimeEnde === 'kleinunternehmer';
|
||||
const schuldetTrotzdem = (kz['47'] ?? 0) > 0 || (kz['89'] ?? 0) > 0 || (kz['93'] ?? 0) > 0;
|
||||
const abgabepflichtig = !nurKleinunternehmer || schuldetTrotzdem;
|
||||
|
||||
if (nurKleinunternehmer && !schuldetTrotzdem) {
|
||||
hinweise.push(
|
||||
'Für diesen Zeitraum galt durchgehend die Kleinunternehmerregelung. ' +
|
||||
'Eine Umsatzsteuer-Voranmeldung ist nach §19 Abs. 1 Satz 4 UStG regelmäßig nicht abzugeben.',
|
||||
);
|
||||
}
|
||||
if (nurKleinunternehmer && schuldetTrotzdem) {
|
||||
hinweise.push(
|
||||
'Trotz Kleinunternehmerregelung besteht eine Steuerschuld als Leistungsempfänger ' +
|
||||
'(§13b UStG bzw. innergemeinschaftlicher Erwerb). Die Voranmeldung ist abzugeben, ' +
|
||||
'ein Vorsteuerabzug daraus besteht nicht.',
|
||||
);
|
||||
}
|
||||
for (const w of wechsel) {
|
||||
hinweise.push(
|
||||
`Wechsel der Besteuerungsform zum ${w.gueltig_ab} (${w.modus}). ` +
|
||||
'Es sind nur die Umsätze ab diesem Datum in der Voranmeldung enthalten.',
|
||||
);
|
||||
}
|
||||
if (mandant.besteuerungsart === 'ist') {
|
||||
hinweise.push('Ist-Versteuerung (§20 UStG): maßgeblich ist der Zahlungseingang.');
|
||||
}
|
||||
if (!mandant.steuernummer) {
|
||||
hinweise.push('Es ist keine Steuernummer hinterlegt – ohne sie ist keine Übermittlung möglich.');
|
||||
}
|
||||
|
||||
return {
|
||||
zeitraum: zr,
|
||||
besteuerungsart: mandant.besteuerungsart,
|
||||
regimeStart,
|
||||
regimeEnde,
|
||||
abgabepflichtig,
|
||||
kennzahlen: kz,
|
||||
umsatzsteuer,
|
||||
vorsteuer,
|
||||
zahllast,
|
||||
hinweise,
|
||||
positionen,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bemessungsgrundlagen werden in der UStVA in vollen Euro erklärt, Steuerbeträge mit Cent. */
|
||||
export const BEMESSUNG_KZ = new Set([
|
||||
'41', '44', '49', '43', '48', '21', '45', '81', '86', '35', '77', '76', '91', '89', '93', '95', '46',
|
||||
]);
|
||||
|
||||
export function kennzahlenFuerElster(kz: Kennzahlen): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, cent] of Object.entries(kz)) {
|
||||
if (cent === 0 && k !== '83') continue;
|
||||
out[k] = BEMESSUNG_KZ.has(k)
|
||||
? String(volleEuroAbgerundet(cent))
|
||||
: (cent / 100).toFixed(2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname, isAbsolute } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { alsLatin1 } from './ustvaXml.js';
|
||||
import { datenVerzeichnis, projektWurzel } from '../pfade.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Anbindung an ERiC (ELSTER Rich Client).
|
||||
*
|
||||
* ERiC ist die einzige zugelassene Schnittstelle, um Steueranmeldungen an die
|
||||
* Finanzverwaltung zu übermitteln. Die Bibliothek ist proprietär, darf nicht
|
||||
* weitergegeben werden und muss nach Registrierung im ELSTER-Entwicklerportal
|
||||
* selbst heruntergeladen werden. Sie wird über Umgebungsvariablen eingebunden:
|
||||
*
|
||||
* ERIC_LIB_PATH Pfad zu libericapi.so / ericapi.dll / libericapi.dylib
|
||||
* ERIC_PLUGIN_PATH Pfad zum Verzeichnis "plugins2"
|
||||
* ERIC_LOG_PATH Verzeichnis für eric.log (Standard: ./data/eric-log)
|
||||
* ELSTER_ZERTIFIKAT Pfad zur .pfx-Zertifikatsdatei (ELSTER-Zertifikat)
|
||||
* ELSTER_PIN PIN zum Zertifikat
|
||||
* ELSTER_HERSTELLER_ID bei ELSTER registrierte Hersteller-ID
|
||||
* ELSTER_TESTMERKER z.B. 700000004 für Testübermittlungen ohne Wirkung
|
||||
*
|
||||
* Ist ERiC nicht eingerichtet, arbeitet die Anwendung im Simulationsmodus:
|
||||
* das XML wird erzeugt und geprüft, aber ausdrücklich nicht übermittelt.
|
||||
*/
|
||||
|
||||
const ERIC_OK = 0;
|
||||
const FLAG_VALIDIERE = 1 << 1; // 2
|
||||
const FLAG_SENDE = 1 << 2; // 4
|
||||
const FLAG_DRUCKE = 1 << 5; // 32
|
||||
|
||||
export interface EricAntwort {
|
||||
erfolg: boolean;
|
||||
simulation: boolean;
|
||||
rueckgabecode: number;
|
||||
transferticket: string | null;
|
||||
/** Von ERiC gelieferte Validierungs-/Serverantwort */
|
||||
protokoll: string;
|
||||
fehler: string[];
|
||||
hinweise: string[];
|
||||
pdfPfad?: string;
|
||||
}
|
||||
|
||||
export interface SendeOptionen {
|
||||
/** Nur prüfen, nicht übermitteln */
|
||||
nurValidieren: boolean;
|
||||
/** Zusätzlich ein Übertragungsprotokoll als PDF erzeugen */
|
||||
pdfPfad?: string;
|
||||
}
|
||||
|
||||
interface EricBindings {
|
||||
Initialise(plugin: string, log: string): number;
|
||||
Beende(): number;
|
||||
BearbeiteVorgang(
|
||||
daten: Buffer,
|
||||
datenartVersion: string,
|
||||
flags: number,
|
||||
druck: unknown,
|
||||
crypto: unknown,
|
||||
rueckgabe: unknown,
|
||||
serverantwort: unknown,
|
||||
): number;
|
||||
PufferErzeugen(): unknown;
|
||||
PufferInhalt(h: unknown): string;
|
||||
PufferFreigeben(h: unknown): number;
|
||||
ZertifikatOeffnen(pfad: string): { handle: number; pinSupport: number };
|
||||
ZertifikatSchliessen(handle: number): number;
|
||||
FehlerText(code: number): string;
|
||||
}
|
||||
|
||||
let bindings: EricBindings | null = null;
|
||||
let ladeFehler: string | null = null;
|
||||
let initialisiert = false;
|
||||
|
||||
/** Relative Angaben aus der .env beziehen sich auf die Projektwurzel. */
|
||||
function ausWurzel(pfad: string): string {
|
||||
return isAbsolute(pfad) ? pfad : resolve(projektWurzel, pfad);
|
||||
}
|
||||
|
||||
function logVerzeichnis(): string {
|
||||
const konfiguriert = process.env.ERIC_LOG_PATH;
|
||||
const p = konfiguriert ? ausWurzel(konfiguriert) : resolve(datenVerzeichnis, 'eric-log');
|
||||
mkdirSync(p, { recursive: true });
|
||||
return p;
|
||||
}
|
||||
|
||||
function ladeBindings(): EricBindings | null {
|
||||
if (bindings) return bindings;
|
||||
if (ladeFehler) return null;
|
||||
|
||||
const libPfad = process.env.ERIC_LIB_PATH;
|
||||
if (!libPfad || !existsSync(libPfad)) {
|
||||
ladeFehler = libPfad
|
||||
? `ERIC_LIB_PATH zeigt auf "${libPfad}", dort liegt keine Datei.`
|
||||
: 'ERIC_LIB_PATH ist nicht gesetzt – ERiC ist nicht eingebunden.';
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// koffi ist eine optionale Abhängigkeit; ohne ERiC wird sie nicht gebraucht.
|
||||
const koffi = require('koffi') as typeof import('koffi');
|
||||
const lib = koffi.load(libPfad);
|
||||
|
||||
const druckParameter = koffi.struct('eric_druck_parameter_t', {
|
||||
version: 'uint32',
|
||||
vorschau: 'uint32',
|
||||
ersteSeite: 'uint32',
|
||||
duplexDruck: 'uint32',
|
||||
pdfName: 'const char*',
|
||||
fussText: 'const char*',
|
||||
});
|
||||
const cryptoParameter = koffi.struct('eric_verschluesselungs_parameter_t', {
|
||||
version: 'uint32',
|
||||
zertifikatHandle: 'uint32',
|
||||
pin: 'const char*',
|
||||
abrufCode: 'const char*',
|
||||
});
|
||||
|
||||
const fnInitialise = lib.func('int EricInitialise(const char* pluginPfad, const char* logPfad)');
|
||||
const fnBeende = lib.func('int EricBeende()');
|
||||
const fnPufferErzeugen = lib.func('void* EricRueckgabepufferErzeugen()');
|
||||
const fnPufferInhalt = lib.func('const char* EricRueckgabepufferInhalt(void* handle)');
|
||||
const fnPufferFreigeben = lib.func('int EricRueckgabepufferFreigeben(void* handle)');
|
||||
const fnBearbeite = lib.func(
|
||||
'int EricBearbeiteVorgang(const char* daten, const char* datenartVersion, uint32 flags, ' +
|
||||
'const eric_druck_parameter_t* druck, ' +
|
||||
'const eric_verschluesselungs_parameter_t* crypto, void* transferHandle, ' +
|
||||
'void* rueckgabe, void* serverantwort)',
|
||||
);
|
||||
const fnZertifikat = lib.func(
|
||||
'int EricGetHandleToCertificate(_Out_ uint32* handle, _Out_ uint32* pinSupport, const char* pfad)',
|
||||
);
|
||||
const fnZertifikatZu = lib.func('int EricCloseHandleToCertificate(uint32 handle)');
|
||||
const fnFehlertext = lib.func('int EricHoleFehlerText(int fehlercode, void* puffer)');
|
||||
|
||||
void druckParameter;
|
||||
void cryptoParameter;
|
||||
|
||||
bindings = {
|
||||
Initialise: (plugin, log) => fnInitialise(plugin, log) as number,
|
||||
Beende: () => fnBeende() as number,
|
||||
BearbeiteVorgang: (daten, dv, flags, druck, crypto, rueckgabe, serverantwort) =>
|
||||
fnBearbeite(daten, dv, flags, druck, crypto, null, rueckgabe, serverantwort) as number,
|
||||
PufferErzeugen: () => fnPufferErzeugen(),
|
||||
PufferInhalt: (h) => (fnPufferInhalt(h) as string) ?? '',
|
||||
PufferFreigeben: (h) => fnPufferFreigeben(h) as number,
|
||||
ZertifikatOeffnen: (pfad) => {
|
||||
const handle: number[] = [0];
|
||||
const pinSupport: number[] = [0];
|
||||
const rc = fnZertifikat(handle, pinSupport, pfad) as number;
|
||||
if (rc !== ERIC_OK) throw new Error(`Zertifikat konnte nicht geöffnet werden (ERiC ${rc}).`);
|
||||
return { handle: handle[0]!, pinSupport: pinSupport[0]! };
|
||||
},
|
||||
ZertifikatSchliessen: (h) => fnZertifikatZu(h) as number,
|
||||
FehlerText: (code) => {
|
||||
const puffer = fnPufferErzeugen();
|
||||
try {
|
||||
fnFehlertext(code, puffer);
|
||||
return (fnPufferInhalt(puffer) as string) ?? '';
|
||||
} finally {
|
||||
fnPufferFreigeben(puffer);
|
||||
}
|
||||
},
|
||||
};
|
||||
return bindings;
|
||||
} catch (e) {
|
||||
ladeFehler = `ERiC konnte nicht geladen werden: ${(e as Error).message}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function initialisiere(b: EricBindings): void {
|
||||
if (initialisiert) return;
|
||||
const plugin = process.env.ERIC_PLUGIN_PATH ?? '';
|
||||
const rc = b.Initialise(plugin, logVerzeichnis());
|
||||
if (rc !== ERIC_OK) {
|
||||
throw new Error(`EricInitialise fehlgeschlagen (Code ${rc}). ERIC_PLUGIN_PATH prüfen.`);
|
||||
}
|
||||
initialisiert = true;
|
||||
process.once('exit', () => {
|
||||
try {
|
||||
b.Beende();
|
||||
} catch {
|
||||
/* beim Herunterfahren ignorieren */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function ericStatus(): {
|
||||
verfuegbar: boolean;
|
||||
meldung: string;
|
||||
zertifikat: boolean;
|
||||
herstellerId: boolean;
|
||||
testmodus: boolean;
|
||||
} {
|
||||
const b = ladeBindings();
|
||||
const zertPfad = process.env.ELSTER_ZERTIFIKAT;
|
||||
return {
|
||||
verfuegbar: b !== null,
|
||||
meldung: b ? `ERiC geladen von ${process.env.ERIC_LIB_PATH}` : (ladeFehler ?? 'unbekannt'),
|
||||
zertifikat: !!zertPfad && existsSync(zertPfad),
|
||||
herstellerId: !!process.env.ELSTER_HERSTELLER_ID,
|
||||
testmodus: !!process.env.ELSTER_TESTMERKER,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uebermittle(
|
||||
xml: string,
|
||||
datenartVersion: string,
|
||||
opt: SendeOptionen,
|
||||
): Promise<EricAntwort> {
|
||||
const b = ladeBindings();
|
||||
if (!b) return simuliere(xml, opt, ladeFehler ?? 'ERiC nicht verfügbar');
|
||||
|
||||
initialisiere(b);
|
||||
|
||||
const zertPfad = process.env.ELSTER_ZERTIFIKAT;
|
||||
const pin = process.env.ELSTER_PIN ?? '';
|
||||
if (!opt.nurValidieren && (!zertPfad || !existsSync(zertPfad))) {
|
||||
return {
|
||||
erfolg: false,
|
||||
simulation: false,
|
||||
rueckgabecode: -1,
|
||||
transferticket: null,
|
||||
protokoll: '',
|
||||
fehler: [
|
||||
'Für die Übermittlung wird ein ELSTER-Zertifikat benötigt. ' +
|
||||
'Bitte ELSTER_ZERTIFIKAT auf die .pfx-Datei und ELSTER_PIN setzen.',
|
||||
],
|
||||
hinweise: [],
|
||||
};
|
||||
}
|
||||
|
||||
let zertHandle = 0;
|
||||
const rueckgabe = b.PufferErzeugen();
|
||||
const serverantwort = b.PufferErzeugen();
|
||||
|
||||
try {
|
||||
let flags = FLAG_VALIDIERE;
|
||||
if (!opt.nurValidieren) flags |= FLAG_SENDE;
|
||||
if (opt.pdfPfad) flags |= FLAG_DRUCKE;
|
||||
|
||||
let crypto: unknown = null;
|
||||
if (!opt.nurValidieren && zertPfad) {
|
||||
const z = b.ZertifikatOeffnen(zertPfad);
|
||||
zertHandle = z.handle;
|
||||
crypto = { version: 2, zertifikatHandle: zertHandle, pin, abrufCode: null };
|
||||
}
|
||||
|
||||
let druck: unknown = null;
|
||||
if (opt.pdfPfad) {
|
||||
mkdirSync(dirname(opt.pdfPfad), { recursive: true });
|
||||
druck = {
|
||||
version: 4,
|
||||
vorschau: opt.nurValidieren ? 1 : 0,
|
||||
ersteSeite: 0,
|
||||
duplexDruck: 0,
|
||||
pdfName: opt.pdfPfad,
|
||||
fussText: 'Erstellt mit open-eur-webapp',
|
||||
};
|
||||
}
|
||||
|
||||
const rc = b.BearbeiteVorgang(
|
||||
alsLatin1(xml),
|
||||
datenartVersion,
|
||||
flags,
|
||||
druck,
|
||||
crypto,
|
||||
rueckgabe,
|
||||
serverantwort,
|
||||
);
|
||||
|
||||
const protokoll = b.PufferInhalt(rueckgabe);
|
||||
const antwort = b.PufferInhalt(serverantwort);
|
||||
const gesamt = [protokoll, antwort].filter(Boolean).join('\n');
|
||||
|
||||
return {
|
||||
erfolg: rc === ERIC_OK,
|
||||
simulation: false,
|
||||
rueckgabecode: rc,
|
||||
transferticket: transferticketAus(antwort),
|
||||
protokoll: gesamt,
|
||||
fehler: rc === ERIC_OK ? [] : [`ERiC-Code ${rc}: ${sicherFehlertext(b, rc)}`, ...fehlerAus(gesamt)],
|
||||
hinweise: hinweiseAus(gesamt),
|
||||
pdfPfad: opt.pdfPfad,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
erfolg: false,
|
||||
simulation: false,
|
||||
rueckgabecode: -1,
|
||||
transferticket: null,
|
||||
protokoll: '',
|
||||
fehler: [(e as Error).message],
|
||||
hinweise: [],
|
||||
};
|
||||
} finally {
|
||||
if (zertHandle) {
|
||||
try {
|
||||
b.ZertifikatSchliessen(zertHandle);
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
b.PufferFreigeben(rueckgabe);
|
||||
b.PufferFreigeben(serverantwort);
|
||||
}
|
||||
}
|
||||
|
||||
function sicherFehlertext(b: EricBindings, code: number): string {
|
||||
try {
|
||||
return b.FehlerText(code) || 'kein Klartext verfügbar';
|
||||
} catch {
|
||||
return 'kein Klartext verfügbar';
|
||||
}
|
||||
}
|
||||
|
||||
function transferticketAus(xml: string): string | null {
|
||||
return /<TransferTicket>([^<]+)<\/TransferTicket>/.exec(xml)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function fehlerAus(xml: string): string[] {
|
||||
return [...xml.matchAll(/<Text>([^<]+)<\/Text>/g)].map((m) => m[1]!).slice(0, 20);
|
||||
}
|
||||
|
||||
function hinweiseAus(xml: string): string[] {
|
||||
return [...xml.matchAll(/<Hinweis>([\s\S]*?)<\/Hinweis>/g)].map((m) => m[1]!.trim()).slice(0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulationsmodus ohne ERiC: prüft das erzeugte XML auf Wohlgeformtheit und
|
||||
* offensichtliche Lücken und legt es zur Ansicht ab. Es findet ausdrücklich
|
||||
* keine Übermittlung an die Finanzverwaltung statt.
|
||||
*/
|
||||
function simuliere(xml: string, opt: SendeOptionen, grund: string): EricAntwort {
|
||||
const fehler: string[] = [];
|
||||
const hinweise = [
|
||||
`ERiC ist nicht eingebunden (${grund}).`,
|
||||
'Es wurde NICHTS an das Finanzamt übermittelt. Die Anmeldung wurde lediglich erzeugt und geprüft.',
|
||||
'Zum echten Versand: ERiC im ELSTER-Entwicklerportal herunterladen und ERIC_LIB_PATH, ' +
|
||||
'ERIC_PLUGIN_PATH, ELSTER_ZERTIFIKAT, ELSTER_PIN sowie ELSTER_HERSTELLER_ID setzen.',
|
||||
];
|
||||
|
||||
// einfache Wohlgeformtheitsprüfung: alle Tags müssen sich schließen
|
||||
const stack: string[] = [];
|
||||
for (const m of xml.matchAll(/<(\/?)([A-Za-z][\w.-]*)[^>]*?(\/?)>/g)) {
|
||||
const [, schliessend, name, selbst] = m;
|
||||
if (selbst === '/' || name === 'xml') continue;
|
||||
if (schliessend === '/') {
|
||||
if (stack.pop() !== name) fehler.push(`XML nicht wohlgeformt bei </${name}>.`);
|
||||
} else {
|
||||
stack.push(name!);
|
||||
}
|
||||
}
|
||||
if (stack.length > 0) fehler.push(`Nicht geschlossene Elemente: ${stack.join(', ')}.`);
|
||||
if (!/<Steuernummer>\d{13}<\/Steuernummer>/.test(xml)) {
|
||||
fehler.push('Die Steuernummer liegt nicht im 13-stelligen Bundesformat vor.');
|
||||
}
|
||||
if (!/<Kz83>/.test(xml)) fehler.push('Kennzahl 83 (Zahllast) fehlt.');
|
||||
|
||||
const ablage = resolve(datenVerzeichnis, 'elster-ausgang');
|
||||
mkdirSync(ablage, { recursive: true });
|
||||
const datei = resolve(ablage, `ustva-simulation-${Date.now()}.xml`);
|
||||
writeFileSync(datei, alsLatin1(xml));
|
||||
|
||||
return {
|
||||
erfolg: fehler.length === 0,
|
||||
simulation: true,
|
||||
rueckgabecode: fehler.length === 0 ? 0 : -1,
|
||||
transferticket: null,
|
||||
protokoll: `Simulationsmodus. XML abgelegt unter ${datei}.`,
|
||||
fehler,
|
||||
hinweise: opt.nurValidieren ? hinweise.slice(0, 1).concat(hinweise[2]!) : hinweise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Umrechnung der auf dem Steuerbescheid gedruckten Landes-Steuernummer in das
|
||||
* 13-stellige bundeseinheitliche Format, das ELSTER erwartet.
|
||||
*
|
||||
* Aufbau: <2- bis 3-stelliges Landespräfix><Finanzamtsnummer><0><Bezirk><Unterscheidungsnr.><Prüfziffer>
|
||||
*/
|
||||
|
||||
export type Bundesland =
|
||||
| 'BW' | 'BY' | 'BE' | 'BB' | 'HB' | 'HH' | 'HE' | 'MV'
|
||||
| 'NI' | 'NW' | 'RP' | 'SL' | 'SN' | 'ST' | 'SH' | 'TH';
|
||||
|
||||
export const BUNDESLAENDER: { code: Bundesland; name: string }[] = [
|
||||
{ code: 'BW', name: 'Baden-Württemberg' },
|
||||
{ code: 'BY', name: 'Bayern' },
|
||||
{ code: 'BE', name: 'Berlin' },
|
||||
{ code: 'BB', name: 'Brandenburg' },
|
||||
{ code: 'HB', name: 'Bremen' },
|
||||
{ code: 'HH', name: 'Hamburg' },
|
||||
{ code: 'HE', name: 'Hessen' },
|
||||
{ code: 'MV', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'NI', name: 'Niedersachsen' },
|
||||
{ code: 'NW', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'RP', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'SL', name: 'Saarland' },
|
||||
{ code: 'SN', name: 'Sachsen' },
|
||||
{ code: 'ST', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'SH', name: 'Schleswig-Holstein' },
|
||||
{ code: 'TH', name: 'Thüringen' },
|
||||
];
|
||||
|
||||
type Regel =
|
||||
/** 10-stellige Landesnummer FFBBBUUUUP -> <praefix>FF0BBBUUUUP */
|
||||
| { typ: 'kurz'; praefix: string }
|
||||
/** 11-stellige Landesnummer mit führender Füllziffer -> wie 'kurz' nach Abschneiden */
|
||||
| { typ: 'fuellziffer'; praefix: string }
|
||||
/** Bayern: FFFBBBUUUUP -> 9FFF0BBBUUUUP */
|
||||
| { typ: 'bayern' }
|
||||
/** NRW: FFFBBBBUUUP -> 5FFF0BBBBUUUP */
|
||||
| { typ: 'nrw' };
|
||||
|
||||
const REGELN: Record<Bundesland, Regel> = {
|
||||
BW: { typ: 'kurz', praefix: '28' },
|
||||
BY: { typ: 'bayern' },
|
||||
BE: { typ: 'kurz', praefix: '11' },
|
||||
BB: { typ: 'fuellziffer', praefix: '30' },
|
||||
HB: { typ: 'kurz', praefix: '24' },
|
||||
HH: { typ: 'kurz', praefix: '22' },
|
||||
HE: { typ: 'fuellziffer', praefix: '26' },
|
||||
MV: { typ: 'fuellziffer', praefix: '40' },
|
||||
NI: { typ: 'kurz', praefix: '23' },
|
||||
NW: { typ: 'nrw' },
|
||||
RP: { typ: 'kurz', praefix: '27' },
|
||||
SL: { typ: 'kurz', praefix: '10' },
|
||||
SN: { typ: 'fuellziffer', praefix: '32' },
|
||||
ST: { typ: 'fuellziffer', praefix: '31' },
|
||||
SH: { typ: 'kurz', praefix: '21' },
|
||||
TH: { typ: 'fuellziffer', praefix: '41' },
|
||||
};
|
||||
|
||||
export class SteuernummerFehler extends Error {}
|
||||
|
||||
export function nurZiffern(s: string): string {
|
||||
return s.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function bundeseinheitlich(steuernummer: string, land: Bundesland): string {
|
||||
const z = nurZiffern(steuernummer);
|
||||
if (!z) throw new SteuernummerFehler('Es ist keine Steuernummer hinterlegt.');
|
||||
|
||||
const regel = REGELN[land];
|
||||
let ergebnis: string;
|
||||
|
||||
if (regel.typ === 'bayern') {
|
||||
if (z.length !== 11) {
|
||||
throw new SteuernummerFehler(
|
||||
`Bayerische Steuernummern haben 11 Ziffern (Format 123/456/78901), erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
ergebnis = `9${z.slice(0, 3)}0${z.slice(3)}`;
|
||||
} else if (regel.typ === 'nrw') {
|
||||
if (z.length !== 11) {
|
||||
throw new SteuernummerFehler(
|
||||
`Nordrhein-westfälische Steuernummern haben 11 Ziffern (Format 123/4567/8901), erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
ergebnis = `5${z.slice(0, 3)}0${z.slice(3)}`;
|
||||
} else {
|
||||
let kurz = z;
|
||||
if (regel.typ === 'fuellziffer') {
|
||||
if (z.length === 11) kurz = z.slice(1);
|
||||
else if (z.length !== 10) {
|
||||
throw new SteuernummerFehler(
|
||||
`Steuernummer für ${land} muss 10 oder 11 Ziffern haben, erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
} else if (z.length !== 10) {
|
||||
throw new SteuernummerFehler(
|
||||
`Steuernummer für ${land} muss 10 Ziffern haben (Format 12/345/67890), erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
ergebnis = `${regel.praefix}${kurz.slice(0, 2)}0${kurz.slice(2)}`;
|
||||
}
|
||||
|
||||
if (ergebnis.length !== 13) {
|
||||
throw new SteuernummerFehler(
|
||||
`Umrechnung ergab ${ergebnis.length} statt 13 Stellen – bitte Bundesland und Steuernummer prüfen.`,
|
||||
);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/** Die ersten vier Stellen sind die Bundesfinanzamtsnummer – ELSTER-Empfänger-ID. */
|
||||
export function finanzamtNummer(bundesSteuernummer: string): string {
|
||||
return bundesSteuernummer.slice(0, 4);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Mandant } from '../domain/typen.js';
|
||||
import type { UstvaErgebnis } from '../domain/ustva.js';
|
||||
import { kennzahlenFuerElster } from '../domain/ustva.js';
|
||||
import { bundeseinheitlich, finanzamtNummer, type Bundesland } from './steuernummer.js';
|
||||
|
||||
export const HERSTELLER = {
|
||||
name: 'open-eur-webapp',
|
||||
version: '0.1.0',
|
||||
/**
|
||||
* ERiC verlangt eine bei ELSTER registrierte Hersteller-ID. Ohne gültige ID
|
||||
* lehnt ERiC die Übermittlung ab – hier über die Umgebung konfigurierbar.
|
||||
*/
|
||||
herstellerId: process.env.ELSTER_HERSTELLER_ID ?? '00000',
|
||||
};
|
||||
|
||||
/**
|
||||
* Datenart-Version für EricBearbeiteVorgang, z.B. "UStVA_2026".
|
||||
* Muss zu der im installierten ERiC enthaltenen Version passen.
|
||||
*/
|
||||
export function datenartVersion(jahr: number): string {
|
||||
return `UStVA_${jahr}`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export interface XmlOptionen {
|
||||
berichtigt?: boolean;
|
||||
/** Kz 26: Verrechnung des Erstattungsbetrags erwünscht */
|
||||
verrechnungErwuenscht?: boolean;
|
||||
/** Kz 29: Belege werden gesondert eingereicht */
|
||||
belegnachreichung?: boolean;
|
||||
/** Kz 22/23: Einzugsermächtigung soll ausnahmsweise nicht genutzt werden */
|
||||
keinLastschriftEinzug?: boolean;
|
||||
erstellungsdatum?: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface XmlErgebnis {
|
||||
xml: string;
|
||||
bundesSteuernummer: string;
|
||||
datenartVersion: string;
|
||||
warnungen: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt den ELSTER-Datenteil für eine Umsatzsteuer-Voranmeldung.
|
||||
* Den TransferHeader (Verschlüsselung, Signatur, Routing) ergänzt ERiC selbst –
|
||||
* übergeben wird nur dieses Dokument.
|
||||
*/
|
||||
export function ustvaXml(
|
||||
ergebnis: UstvaErgebnis,
|
||||
mandant: Mandant,
|
||||
opt: XmlOptionen = {},
|
||||
): XmlErgebnis {
|
||||
const warnungen: string[] = [];
|
||||
const stnr = bundeseinheitlich(mandant.steuernummer, mandant.bundesland as Bundesland);
|
||||
const jahr = ergebnis.zeitraum.jahr;
|
||||
const kz = kennzahlenFuerElster(ergebnis.kennzahlen);
|
||||
|
||||
if (HERSTELLER.herstellerId === '00000') {
|
||||
warnungen.push(
|
||||
'Es ist keine ELSTER-Hersteller-ID gesetzt (ELSTER_HERSTELLER_ID). ' +
|
||||
'ERiC weist die Übermittlung ohne registrierte ID zurück.',
|
||||
);
|
||||
}
|
||||
|
||||
const erstellung =
|
||||
opt.erstellungsdatum ?? new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
const kzZeilen: string[] = [];
|
||||
if (opt.berichtigt) kzZeilen.push(' <Kz10>1</Kz10>');
|
||||
if (opt.belegnachreichung) kzZeilen.push(' <Kz22>1</Kz22>');
|
||||
if (opt.keinLastschriftEinzug) kzZeilen.push(' <Kz26>1</Kz26>');
|
||||
if (opt.verrechnungErwuenscht) kzZeilen.push(' <Kz29>1</Kz29>');
|
||||
|
||||
// Kennzahlen in aufsteigender numerischer Reihenfolge – so erwartet es das Schema.
|
||||
for (const k of Object.keys(kz).sort((a, b) => Number(a) - Number(b))) {
|
||||
kzZeilen.push(` <Kz${k}>${kz[k]}</Kz${k}>`);
|
||||
}
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="ISO-8859-15"?>
|
||||
<Elster xmlns="http://www.elster.de/elsterxml/schema/v11">
|
||||
<TransferHeader version="11">
|
||||
<Verfahren>ElsterAnmeldung</Verfahren>
|
||||
<DatenArt>UStVA</DatenArt>
|
||||
<Vorgang>send-Auth</Vorgang>
|
||||
<Testmerker>${process.env.ELSTER_TESTMERKER ?? ''}</Testmerker>
|
||||
<HerstellerID>${esc(HERSTELLER.herstellerId)}</HerstellerID>
|
||||
<DatenLieferant>${esc(mandant.name)}</DatenLieferant>
|
||||
<Datei>
|
||||
<Verschluesselung>PKCS#7v1.5</Verschluesselung>
|
||||
<Kompression>GZIP</Kompression>
|
||||
<TransportSchluessel/>
|
||||
<Erstellung><Eric/></Erstellung>
|
||||
</Datei>
|
||||
<VersionClient>${esc(HERSTELLER.version)}</VersionClient>
|
||||
</TransferHeader>
|
||||
<DatenTeil>
|
||||
<Nutzdatenblock>
|
||||
<NutzdatenHeader version="11">
|
||||
<NutzdatenTicket>1</NutzdatenTicket>
|
||||
<Empfaenger id="F">${finanzamtNummer(stnr)}</Empfaenger>
|
||||
<Hersteller>
|
||||
<ProduktName>${esc(HERSTELLER.name)}</ProduktName>
|
||||
<ProduktVersion>${esc(HERSTELLER.version)}</ProduktVersion>
|
||||
</Hersteller>
|
||||
</NutzdatenHeader>
|
||||
<Nutzdaten>
|
||||
<Anmeldungssteuern art="UStVA" version="${jahr}">
|
||||
<DatenLieferant>
|
||||
<Name>${esc(mandant.name)}</Name>
|
||||
<Strasse>${esc(mandant.strasse)}</Strasse>
|
||||
<PLZ>${esc(mandant.plz)}</PLZ>
|
||||
<Ort>${esc(mandant.ort)}</Ort>
|
||||
<Telefon>${esc(mandant.telefon)}</Telefon>
|
||||
<Email>${esc(mandant.email)}</Email>
|
||||
</DatenLieferant>
|
||||
<Erstellungsdatum>${erstellung}</Erstellungsdatum>
|
||||
<Steuerfall>
|
||||
<Umsatzsteuervoranmeldung>
|
||||
<Jahr>${jahr}</Jahr>
|
||||
<Zeitraum>${ergebnis.zeitraum.code}</Zeitraum>
|
||||
<Steuernummer>${stnr}</Steuernummer>
|
||||
${kzZeilen.join('\n')}
|
||||
</Umsatzsteuervoranmeldung>
|
||||
</Steuerfall>
|
||||
</Anmeldungssteuern>
|
||||
</Nutzdaten>
|
||||
</Nutzdatenblock>
|
||||
</DatenTeil>
|
||||
</Elster>
|
||||
`;
|
||||
|
||||
return { xml, bundesSteuernummer: stnr, datenartVersion: datenartVersion(jahr), warnungen };
|
||||
}
|
||||
|
||||
/** ELSTER erwartet ISO-8859-15; Umlaute müssen als latin1-Bytes übergeben werden. */
|
||||
export function alsLatin1(xml: string): Buffer {
|
||||
return Buffer.from(xml, 'latin1');
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { projektWurzel } from './pfade.js';
|
||||
|
||||
/**
|
||||
* Lädt `.env` in process.env – ohne Abhängigkeit und unabhängig davon, ob der
|
||||
* Prozess über `tsx`, `node` oder `npm start` gestartet wurde.
|
||||
*
|
||||
* Dieses Modul muss als ERSTES importiert werden, weil andere Module (z.B. db.ts)
|
||||
* ihre Umgebungsvariablen beim Import auswerten. ESM wertet Importe in der
|
||||
* angegebenen Reihenfolge aus, deshalb genügt die erste Import-Zeile.
|
||||
*
|
||||
* Bereits gesetzte Variablen gewinnen: was in der Shell steht, überschreibt die
|
||||
* Datei – so lässt sich ein Wert für einen einzelnen Lauf gezielt übersteuern.
|
||||
*/
|
||||
|
||||
const KANDIDATEN = [
|
||||
process.env['EUER_ENV_DATEI'],
|
||||
resolve(projektWurzel, '.env'),
|
||||
resolve(projektWurzel, 'server/.env'),
|
||||
].filter((p): p is string => !!p);
|
||||
|
||||
function parse(inhalt: string): Record<string, string> {
|
||||
const werte: Record<string, string> = {};
|
||||
for (const rohzeile of inhalt.split(/\r?\n/)) {
|
||||
const zeile = rohzeile.trim();
|
||||
if (!zeile || zeile.startsWith('#')) continue;
|
||||
|
||||
const trenner = zeile.indexOf('=');
|
||||
if (trenner < 1) continue;
|
||||
|
||||
const schluessel = zeile.slice(0, trenner).trim().replace(/^export\s+/, '');
|
||||
let wert = zeile.slice(trenner + 1).trim();
|
||||
|
||||
// Anführungszeichen entfernen; nur außerhalb von Quotes gibt es Kommentare.
|
||||
if (
|
||||
(wert.startsWith('"') && wert.endsWith('"') && wert.length > 1) ||
|
||||
(wert.startsWith("'") && wert.endsWith("'") && wert.length > 1)
|
||||
) {
|
||||
wert = wert.slice(1, -1);
|
||||
} else {
|
||||
wert = wert.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
if (schluessel) werte[schluessel] = wert;
|
||||
}
|
||||
return werte;
|
||||
}
|
||||
|
||||
let geladeneDatei: string | null = null;
|
||||
|
||||
for (const pfad of KANDIDATEN) {
|
||||
if (!existsSync(pfad)) continue;
|
||||
try {
|
||||
for (const [k, v] of Object.entries(parse(readFileSync(pfad, 'utf8')))) {
|
||||
if (process.env[k] === undefined) process.env[k] = v;
|
||||
}
|
||||
geladeneDatei = pfad;
|
||||
break;
|
||||
} catch (e) {
|
||||
console.warn(`Warnung: ${pfad} konnte nicht gelesen werden – ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const envDatei = geladeneDatei;
|
||||
@@ -0,0 +1,225 @@
|
||||
import { db } from '../db.js';
|
||||
import { dezimal } from '../domain/geld.js';
|
||||
import type { Buchung, Konto, Mandant } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* DATEV-Format ("EXTF") – Buchungsstapel und Kontenbeschriftungen.
|
||||
* Das ist das Format, das ein Steuerberater in DATEV Rechnungswesen einliest.
|
||||
* Spezifikation: DATEV-Format Version 700 / Formatversion 13.
|
||||
*
|
||||
* Zeichensatz: DATEV erwartet ANSI (Windows-1252). Node kennt cp1252 nicht
|
||||
* direkt; latin1 deckt den deutschen Zeichenvorrat ab, lediglich das
|
||||
* Euro-Zeichen wird ersetzt (siehe `alsAnsi`).
|
||||
*/
|
||||
|
||||
const FORMAT_VERSION = 700;
|
||||
const FORMAT_KATEGORIE_BUCHUNGSSTAPEL = 21;
|
||||
const FORMATVERSION_BUCHUNGSSTAPEL = 13;
|
||||
const FORMAT_KATEGORIE_KONTENBESCHRIFTUNG = 20;
|
||||
const FORMATVERSION_KONTENBESCHRIFTUNG = 3;
|
||||
|
||||
/** Offizielle Spaltenüberschriften des Buchungsstapels (125 Felder). */
|
||||
const SPALTEN_BUCHUNGSSTAPEL = [
|
||||
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs',
|
||||
'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)',
|
||||
'BU-Schlüssel', 'Belegdatum', 'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext',
|
||||
'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt',
|
||||
'Zinssperre', 'Beleglink',
|
||||
...pärchen('Beleginfo - Art', 'Beleginfo - Inhalt', 8),
|
||||
'KOST1 - Kostenstelle', 'KOST2 - Kostenstelle', 'KOST-Menge',
|
||||
'EU-Land u. UStID (Bestimmung)', 'EU-Steuersatz (Bestimmung)', 'Abw. Versteuerungsart',
|
||||
'Sachverhalt L+L', 'Funktionsergänzung L+L', 'BU 49 Hauptfunktionstyp',
|
||||
'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung',
|
||||
...pärchen('Zusatzinformation - Art', 'Zusatzinformation- Inhalt', 20),
|
||||
'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr',
|
||||
'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp',
|
||||
'USt-Schlüssel (Anzahlungen)', 'EU-Land (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)',
|
||||
'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz',
|
||||
'Buchungs GUID', 'KOST-Datum', 'SEPA-Mandatsreferenz', 'Skontosperre',
|
||||
'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer',
|
||||
'Postensperre bis', 'Bezeichnung SoBil-Sachverhalt', 'Kennzeichen SoBil-Buchung',
|
||||
'Festschreibung', 'Leistungsdatum', 'Datum Zuord. Steuerperiode', 'Fälligkeit',
|
||||
'Generalumkehr (GU)', 'Steuersatz', 'Land', 'Abrechnungsreferenz', 'BVV-Position',
|
||||
'EU-Land u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto',
|
||||
];
|
||||
|
||||
function pärchen(a: string, b: string, n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) => [`${a} ${i + 1}`, `${b} ${i + 1}`]).flat();
|
||||
}
|
||||
|
||||
function q(v: string | number | null | undefined): string {
|
||||
if (v === null || v === undefined || v === '') return '';
|
||||
return `"${String(v).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function zahl(v: string | number | null | undefined): string {
|
||||
return v === null || v === undefined || v === '' ? '' : String(v);
|
||||
}
|
||||
|
||||
function ttmm(datum: string): string {
|
||||
return `${datum.slice(8, 10)}${datum.slice(5, 7)}`;
|
||||
}
|
||||
function ttmmjjjj(datum: string): string {
|
||||
return `${datum.slice(8, 10)}${datum.slice(5, 7)}${datum.slice(0, 4)}`;
|
||||
}
|
||||
function zeitstempel(d: Date): string {
|
||||
const p = (n: number, l = 2) => String(n).padStart(l, '0');
|
||||
return (
|
||||
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
||||
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}${p(d.getMilliseconds(), 3)}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface DatevOptionen {
|
||||
jahr: number;
|
||||
von?: string;
|
||||
bis?: string;
|
||||
/** Buchungen als festgeschrieben kennzeichnen (Feld 114) */
|
||||
festschreibung?: boolean;
|
||||
bezeichnung?: string;
|
||||
/** Ohne Automatikkonten kann der Steuersatz per BU-Schlüssel mitgegeben werden. */
|
||||
mitBuSchluessel?: boolean;
|
||||
jetzt?: Date;
|
||||
}
|
||||
|
||||
export interface DatevDatei {
|
||||
dateiname: string;
|
||||
inhalt: Buffer;
|
||||
}
|
||||
|
||||
/** DATEV-BU-Schlüssel für die gängigen Steuersätze. */
|
||||
function buSchluessel(b: Buchung): string {
|
||||
if (b.ust_satz_bp === 0) return '';
|
||||
if (b.art === 'einnahme') return b.ust_satz_bp === 700 ? '2' : '3';
|
||||
return b.ust_satz_bp === 700 ? '8' : '9';
|
||||
}
|
||||
|
||||
export function buchungsstapel(opt: DatevOptionen): DatevDatei {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const von = opt.von ?? `${opt.jahr}-01-01`;
|
||||
const bis = opt.bis ?? `${opt.jahr}-12-31`;
|
||||
const jetzt = opt.jetzt ?? new Date();
|
||||
|
||||
const buchungen = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE storniert = 0
|
||||
AND COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ?
|
||||
ORDER BY COALESCE(zahlungsdatum, leistungsdatum), id`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
|
||||
const kopf = [
|
||||
q('EXTF'),
|
||||
FORMAT_VERSION,
|
||||
FORMAT_KATEGORIE_BUCHUNGSSTAPEL,
|
||||
q('Buchungsstapel'),
|
||||
FORMATVERSION_BUCHUNGSSTAPEL,
|
||||
zeitstempel(jetzt),
|
||||
'', // importiert
|
||||
q('RE'), // Herkunfts-Kennzeichen
|
||||
q(m.name.slice(0, 25)),
|
||||
'', // importiert von
|
||||
zahl(m.datev_berater_nr || '0'),
|
||||
zahl(m.datev_mandanten_nr || '0'),
|
||||
`${opt.jahr}0101`,
|
||||
4, // Sachkontenlänge
|
||||
von.replace(/-/g, ''),
|
||||
bis.replace(/-/g, ''),
|
||||
q(opt.bezeichnung ?? `EÜR ${opt.jahr}`),
|
||||
q(''), // Diktatkürzel
|
||||
1, // Buchungstyp: Finanzbuchführung
|
||||
0, // Rechnungslegungszweck
|
||||
opt.festschreibung ? 1 : 0,
|
||||
q('EUR'),
|
||||
'', '', '', '',
|
||||
q(m.kontenrahmen),
|
||||
'', '', '',
|
||||
q('open-eur-webapp'),
|
||||
].join(';');
|
||||
|
||||
const spalten = SPALTEN_BUCHUNGSSTAPEL.map(q).join(';');
|
||||
|
||||
const zeilen = buchungen.map((b) => {
|
||||
const einnahme = b.art === 'einnahme';
|
||||
const konto = einnahme ? b.gegenkonto : b.konto;
|
||||
const gegenkonto = einnahme ? b.konto : b.gegenkonto;
|
||||
const datum = b.zahlungsdatum ?? b.leistungsdatum;
|
||||
|
||||
const felder: (string | number)[] = new Array(SPALTEN_BUCHUNGSSTAPEL.length).fill('');
|
||||
felder[0] = q(dezimal(b.betrag_brutto)); // Umsatz immer positiv
|
||||
felder[1] = q('S'); // Soll auf `Konto`
|
||||
felder[2] = q('EUR');
|
||||
felder[6] = zahl(konto);
|
||||
felder[7] = zahl(gegenkonto);
|
||||
felder[8] = opt.mitBuSchluessel ? q(buSchluessel(b)) : '';
|
||||
felder[9] = ttmm(datum);
|
||||
felder[10] = q(b.beleg_nr.slice(0, 36));
|
||||
felder[11] = q(b.partner.slice(0, 12));
|
||||
felder[13] = q(b.buchungstext.slice(0, 60));
|
||||
felder[113] = opt.festschreibung || b.festgeschrieben ? 1 : 0;
|
||||
felder[114] = ttmmjjjj(b.leistungsdatum); // Leistungsdatum
|
||||
felder[115] = ttmmjjjj(b.leistungsdatum); // Zuordnung Steuerperiode
|
||||
return felder.join(';');
|
||||
});
|
||||
|
||||
const inhalt = [kopf, spalten, ...zeilen].join('\r\n') + '\r\n';
|
||||
return {
|
||||
dateiname: `EXTF_Buchungsstapel_${opt.jahr}_${von.replace(/-/g, '')}-${bis.replace(/-/g, '')}.csv`,
|
||||
inhalt: alsAnsi(inhalt),
|
||||
};
|
||||
}
|
||||
|
||||
export function kontenbeschriftungen(opt: DatevOptionen): DatevDatei {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const konten = db.prepare('SELECT * FROM konten ORDER BY nummer').all() as Konto[];
|
||||
const jetzt = opt.jetzt ?? new Date();
|
||||
|
||||
const kopf = [
|
||||
q('EXTF'),
|
||||
FORMAT_VERSION,
|
||||
FORMAT_KATEGORIE_KONTENBESCHRIFTUNG,
|
||||
q('Kontenbeschriftungen'),
|
||||
FORMATVERSION_KONTENBESCHRIFTUNG,
|
||||
zeitstempel(jetzt),
|
||||
'',
|
||||
q('RE'),
|
||||
q(m.name.slice(0, 25)),
|
||||
'',
|
||||
zahl(m.datev_berater_nr || '0'),
|
||||
zahl(m.datev_mandanten_nr || '0'),
|
||||
`${opt.jahr}0101`,
|
||||
4,
|
||||
`${opt.jahr}0101`,
|
||||
`${opt.jahr}1231`,
|
||||
q(`Kontenbeschriftungen ${opt.jahr}`),
|
||||
'', '', '', 0,
|
||||
q('EUR'),
|
||||
'', '', '', '',
|
||||
q(m.kontenrahmen),
|
||||
'', '', '',
|
||||
q('open-eur-webapp'),
|
||||
].join(';');
|
||||
|
||||
const spalten = ['Konto', 'Kontenbeschriftung', 'Sprach-ID', 'Kontenbeschriftung lang']
|
||||
.map(q)
|
||||
.join(';');
|
||||
|
||||
const zeilen = konten.map((k) =>
|
||||
[zahl(k.nummer), q(k.bezeichnung.slice(0, 40)), q('de-DE'), q(k.bezeichnung)].join(';'),
|
||||
);
|
||||
|
||||
return {
|
||||
dateiname: `EXTF_Kontenbeschriftungen_${opt.jahr}.csv`,
|
||||
inhalt: alsAnsi([kopf, spalten, ...zeilen].join('\r\n') + '\r\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows-1252-Näherung: latin1 deckt alle deutschen Umlaute ab; das
|
||||
* Euro-Zeichen liegt in cp1252 auf 0x80 und wird ersetzt, damit keine
|
||||
* fehlerhaften Bytes im Import landen.
|
||||
*/
|
||||
export function alsAnsi(s: string): Buffer {
|
||||
return Buffer.from(s.replace(/€/g, 'EUR').replace(/[^\x00-\xFF]/g, '?'), 'latin1');
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { db } from '../db.js';
|
||||
import { dezimal } from '../domain/geld.js';
|
||||
import { zip, type ZipEintrag } from './zip.js';
|
||||
import type { Anlagegut, Buchung, Konto, Mandant } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* GDPdU-/GoBD-Datenträgerüberlassung (Format Z3, "Beschreibungsstandard für die
|
||||
* Datenträgerüberlassung" 1.0). Erzeugt wird ein ZIP mit index.xml und den
|
||||
* zugehörigen CSV-Dateien – das ist das Paket, das die Betriebsprüfung in IDEA
|
||||
* einliest. Für den laufenden Austausch mit dem Steuerberater ist dagegen der
|
||||
* DATEV-Buchungsstapel das übliche Format.
|
||||
*/
|
||||
|
||||
interface SpaltenDef {
|
||||
name: string;
|
||||
typ: 'text' | 'zahl' | 'datum';
|
||||
beschreibung?: string;
|
||||
}
|
||||
|
||||
function csvFeld(v: unknown): string {
|
||||
if (v === null || v === undefined) return '""';
|
||||
return `"${String(v).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function csv(spalten: SpaltenDef[], zeilen: unknown[][]): Buffer {
|
||||
const kopf = spalten.map((s) => csvFeld(s.name)).join(';');
|
||||
const body = zeilen.map((z) => z.map(csvFeld).join(';'));
|
||||
return Buffer.from([kopf, ...body].join('\r\n') + '\r\n', 'latin1');
|
||||
}
|
||||
|
||||
function tabellenXml(
|
||||
datei: string,
|
||||
name: string,
|
||||
beschreibung: string,
|
||||
von: string,
|
||||
bis: string,
|
||||
spalten: SpaltenDef[],
|
||||
primaerschluessel?: string,
|
||||
): string {
|
||||
const spaltenXml = spalten
|
||||
.map((s) => {
|
||||
const typ =
|
||||
s.typ === 'zahl'
|
||||
? '<Numeric><Accuracy>2</Accuracy></Numeric>'
|
||||
: s.typ === 'datum'
|
||||
? '<Date><Format>YYYY-MM-DD</Format></Date>'
|
||||
: '<AlphaNumeric/>';
|
||||
const tag = s.name === primaerschluessel ? 'VariablePrimaryKey' : 'VariableColumn';
|
||||
const beschr = s.beschreibung ? `<Description>${esc(s.beschreibung)}</Description>` : '';
|
||||
return ` <${tag}><Name>${esc(s.name)}</Name>${beschr}${typ}</${tag}>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return ` <Table>
|
||||
<URL>${esc(datei)}</URL>
|
||||
<Name>${esc(name)}</Name>
|
||||
<Description>${esc(beschreibung)}</Description>
|
||||
<Validity>
|
||||
<Range><From>${von}</From><To>${bis}</To></Range>
|
||||
<Format>YYYY-MM-DD</Format>
|
||||
</Validity>
|
||||
<DecimalSymbol>,</DecimalSymbol>
|
||||
<DigitGroupingSymbol>.</DigitGroupingSymbol>
|
||||
<VariableLength>
|
||||
<ColumnDelimiter>;</ColumnDelimiter>
|
||||
<RecordDelimiter> </RecordDelimiter>
|
||||
<TextEncapsulator>"</TextEncapsulator>
|
||||
${spaltenXml}
|
||||
</VariableLength>
|
||||
</Table>`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function gdpduExport(jahr: number): { dateiname: string; inhalt: Buffer } {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const von = `${jahr}-01-01`;
|
||||
const bis = `${jahr}-12-31`;
|
||||
|
||||
const buchungen = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ?
|
||||
ORDER BY COALESCE(zahlungsdatum, leistungsdatum), id`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
const konten = db.prepare('SELECT * FROM konten ORDER BY nummer').all() as Konto[];
|
||||
const anlagen = db.prepare('SELECT * FROM anlagegueter ORDER BY anschaffungsdatum').all() as Anlagegut[];
|
||||
|
||||
const buchungSpalten: SpaltenDef[] = [
|
||||
{ name: 'ID', typ: 'text', beschreibung: 'eindeutige Satznummer' },
|
||||
{ name: 'Belegdatum', typ: 'datum' },
|
||||
{ name: 'Leistungsdatum', typ: 'datum' },
|
||||
{ name: 'Zahlungsdatum', typ: 'datum' },
|
||||
{ name: 'Belegnummer', typ: 'text' },
|
||||
{ name: 'Buchungstext', typ: 'text' },
|
||||
{ name: 'Geschaeftspartner', typ: 'text' },
|
||||
{ name: 'Art', typ: 'text', beschreibung: 'einnahme oder ausgabe' },
|
||||
{ name: 'Konto', typ: 'text' },
|
||||
{ name: 'Gegenkonto', typ: 'text' },
|
||||
{ name: 'Betrag_Brutto', typ: 'zahl' },
|
||||
{ name: 'Betrag_Netto', typ: 'zahl' },
|
||||
{ name: 'Umsatzsteuer', typ: 'zahl' },
|
||||
{ name: 'Steuersatz_Prozent', typ: 'zahl' },
|
||||
{ name: 'USt_Behandlung', typ: 'text' },
|
||||
{ name: 'Vorsteuer_abziehbar', typ: 'text' },
|
||||
{ name: 'Privatanteil_Prozent', typ: 'zahl' },
|
||||
{ name: 'Storniert', typ: 'text' },
|
||||
{ name: 'Festgeschrieben', typ: 'text' },
|
||||
{ name: 'Erfasst_am', typ: 'text' },
|
||||
];
|
||||
|
||||
const buchungZeilen = buchungen.map((b) => [
|
||||
b.id,
|
||||
b.belegdatum,
|
||||
b.leistungsdatum,
|
||||
b.zahlungsdatum ?? '',
|
||||
b.beleg_nr,
|
||||
b.buchungstext,
|
||||
b.partner,
|
||||
b.art,
|
||||
b.konto,
|
||||
b.gegenkonto,
|
||||
dezimal(b.betrag_brutto),
|
||||
dezimal(b.betrag_netto),
|
||||
dezimal(b.betrag_ust),
|
||||
(b.ust_satz_bp / 100).toFixed(2).replace('.', ','),
|
||||
b.ust_behandlung,
|
||||
b.vorsteuer_abziehbar ? 'ja' : 'nein',
|
||||
(b.privatanteil_bp / 100).toFixed(2).replace('.', ','),
|
||||
b.storniert ? 'ja' : 'nein',
|
||||
b.festgeschrieben ? 'ja' : 'nein',
|
||||
b.erstellt_am,
|
||||
]);
|
||||
|
||||
const kontenSpalten: SpaltenDef[] = [
|
||||
{ name: 'Konto', typ: 'text' },
|
||||
{ name: 'Bezeichnung', typ: 'text' },
|
||||
{ name: 'Typ', typ: 'text' },
|
||||
{ name: 'EUER_Posten', typ: 'text' },
|
||||
{ name: 'BWA_Gruppe', typ: 'text' },
|
||||
{ name: 'Steuersatz_Prozent', typ: 'zahl' },
|
||||
];
|
||||
const kontenZeilen = konten.map((k) => [
|
||||
k.nummer,
|
||||
k.bezeichnung,
|
||||
k.typ,
|
||||
k.euer_zeile ?? '',
|
||||
k.bwa_gruppe ?? '',
|
||||
(k.ust_satz_bp / 100).toFixed(2).replace('.', ','),
|
||||
]);
|
||||
|
||||
const anlagenSpalten: SpaltenDef[] = [
|
||||
{ name: 'ID', typ: 'text' },
|
||||
{ name: 'Bezeichnung', typ: 'text' },
|
||||
{ name: 'Anschaffungsdatum', typ: 'datum' },
|
||||
{ name: 'AK_Netto', typ: 'zahl' },
|
||||
{ name: 'AK_Brutto', typ: 'zahl' },
|
||||
{ name: 'Nutzungsdauer_Jahre', typ: 'zahl' },
|
||||
{ name: 'AfA_Methode', typ: 'text' },
|
||||
{ name: 'Abgang', typ: 'datum' },
|
||||
];
|
||||
const anlagenZeilen = anlagen.map((a) => [
|
||||
a.id,
|
||||
a.bezeichnung,
|
||||
a.anschaffungsdatum,
|
||||
dezimal(a.akosten_netto),
|
||||
dezimal(a.akosten_brutto),
|
||||
a.nutzungsdauer,
|
||||
a.afa_methode,
|
||||
a.abgang_datum ?? '',
|
||||
]);
|
||||
|
||||
const indexXml = `<?xml version="1.0" encoding="ISO-8859-15"?>
|
||||
<!DOCTYPE DataSet SYSTEM "gdpdu-01-09-2004.dtd">
|
||||
<DataSet>
|
||||
<Version>1.0</Version>
|
||||
<DataSupplier>
|
||||
<Name>${esc(m.name)}</Name>
|
||||
<Location>${esc(`${m.plz} ${m.ort}`.trim())}</Location>
|
||||
<Comment>Steuernummer ${esc(m.steuernummer)} – Einnahmen-Überschuss-Rechnung ${jahr}</Comment>
|
||||
</DataSupplier>
|
||||
<Media>
|
||||
<Name>Datenträgerüberlassung ${jahr}</Name>
|
||||
${tabellenXml('buchungen.csv', 'Buchungssaetze', `Einzelaufzeichnungen ${jahr}`, von, bis, buchungSpalten, 'ID')}
|
||||
${tabellenXml('konten.csv', 'Kontenplan', 'Sachkonten mit Zuordnung', von, bis, kontenSpalten, 'Konto')}
|
||||
${tabellenXml('anlagen.csv', 'Anlagenverzeichnis', 'Anlagegueter und AfA-Parameter', von, bis, anlagenSpalten, 'ID')}
|
||||
</Media>
|
||||
</DataSet>
|
||||
`;
|
||||
|
||||
const liesmich = `Datenträgerüberlassung nach GoBD (Format Z3)
|
||||
==========================================
|
||||
|
||||
Inhalt
|
||||
index.xml Beschreibungsdatei nach dem "Beschreibungsstandard für die
|
||||
Datenträgerüberlassung" (Version 1.0)
|
||||
buchungen.csv Einzelaufzeichnungen ${jahr}
|
||||
konten.csv verwendeter Kontenplan
|
||||
anlagen.csv Anlagenverzeichnis
|
||||
|
||||
Hinweise
|
||||
- Zeichensatz der CSV-Dateien: ISO-8859-15, Trennzeichen ";", Textbegrenzer '"'.
|
||||
- Beträge in Euro mit Komma als Dezimaltrennzeichen.
|
||||
- Die Datei gdpdu-01-09-2004.dtd wird von den Prüfprogrammen (z.B. IDEA)
|
||||
mitgeliefert und ist deshalb hier nicht enthalten.
|
||||
- Erzeugt mit open-eur-webapp am ${new Date().toISOString().slice(0, 10)}.
|
||||
`;
|
||||
|
||||
const eintraege: ZipEintrag[] = [
|
||||
{ name: 'index.xml', inhalt: Buffer.from(indexXml, 'latin1') },
|
||||
{ name: 'buchungen.csv', inhalt: csv(buchungSpalten, buchungZeilen) },
|
||||
{ name: 'konten.csv', inhalt: csv(kontenSpalten, kontenZeilen) },
|
||||
{ name: 'anlagen.csv', inhalt: csv(anlagenSpalten, anlagenZeilen) },
|
||||
{ name: 'LIESMICH.txt', inhalt: Buffer.from(liesmich, 'latin1') },
|
||||
];
|
||||
|
||||
return { dateiname: `GoBD-Export-${jahr}.zip`, inhalt: zip(eintraege) };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
|
||||
/** Minimaler ZIP-Writer (Deflate), damit für den Export keine weitere Abhängigkeit nötig ist. */
|
||||
|
||||
const CRC_TABELLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABELLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosZeit(d: Date): { zeit: number; datum: number } {
|
||||
return {
|
||||
zeit: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
|
||||
datum: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ZipEintrag {
|
||||
name: string;
|
||||
inhalt: Buffer;
|
||||
}
|
||||
|
||||
export function zip(eintraege: ZipEintrag[], jetzt = new Date()): Buffer {
|
||||
const { zeit, datum } = dosZeit(jetzt);
|
||||
const lokale: Buffer[] = [];
|
||||
const zentrale: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of eintraege) {
|
||||
const name = Buffer.from(e.name, 'utf8');
|
||||
const komprimiert = deflateRawSync(e.inhalt);
|
||||
const crc = crc32(e.inhalt);
|
||||
|
||||
const kopf = Buffer.alloc(30);
|
||||
kopf.writeUInt32LE(0x04034b50, 0);
|
||||
kopf.writeUInt16LE(20, 4); // Version
|
||||
kopf.writeUInt16LE(0x0800, 6); // UTF-8-Flag
|
||||
kopf.writeUInt16LE(8, 8); // Deflate
|
||||
kopf.writeUInt16LE(zeit, 10);
|
||||
kopf.writeUInt16LE(datum, 12);
|
||||
kopf.writeUInt32LE(crc, 14);
|
||||
kopf.writeUInt32LE(komprimiert.length, 18);
|
||||
kopf.writeUInt32LE(e.inhalt.length, 22);
|
||||
kopf.writeUInt16LE(name.length, 26);
|
||||
kopf.writeUInt16LE(0, 28);
|
||||
|
||||
lokale.push(kopf, name, komprimiert);
|
||||
|
||||
const zentral = Buffer.alloc(46);
|
||||
zentral.writeUInt32LE(0x02014b50, 0);
|
||||
zentral.writeUInt16LE(20, 4);
|
||||
zentral.writeUInt16LE(20, 6);
|
||||
zentral.writeUInt16LE(0x0800, 8);
|
||||
zentral.writeUInt16LE(8, 10);
|
||||
zentral.writeUInt16LE(zeit, 12);
|
||||
zentral.writeUInt16LE(datum, 14);
|
||||
zentral.writeUInt32LE(crc, 16);
|
||||
zentral.writeUInt32LE(komprimiert.length, 20);
|
||||
zentral.writeUInt32LE(e.inhalt.length, 24);
|
||||
zentral.writeUInt16LE(name.length, 28);
|
||||
zentral.writeUInt32LE(offset, 42);
|
||||
zentrale.push(zentral, name);
|
||||
|
||||
offset += kopf.length + name.length + komprimiert.length;
|
||||
}
|
||||
|
||||
const zentralBuf = Buffer.concat(zentrale);
|
||||
const ende = Buffer.alloc(22);
|
||||
ende.writeUInt32LE(0x06054b50, 0);
|
||||
ende.writeUInt16LE(eintraege.length, 8);
|
||||
ende.writeUInt16LE(eintraege.length, 10);
|
||||
ende.writeUInt32LE(zentralBuf.length, 12);
|
||||
ende.writeUInt32LE(offset, 16);
|
||||
|
||||
return Buffer.concat([Buffer.concat(lokale), zentralBuf, ende]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// muss vor allen anderen Importen stehen – siehe env.ts
|
||||
import { envDatei } from './env.js';
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { projektWurzel } from './pfade.js';
|
||||
import {
|
||||
alleMandanten,
|
||||
kontenrahmenAnlegen,
|
||||
kontextFuer,
|
||||
registerVorbereiten,
|
||||
schliesseAlleVerbindungen,
|
||||
standardMandant,
|
||||
} from './mandanten.js';
|
||||
import { mandantenRouten, mandantenScoping } from './routes/mandanten.js';
|
||||
import { stammdatenRouten } from './routes/stammdaten.js';
|
||||
import { buchungsRouten } from './routes/buchungen.js';
|
||||
import { auswertungsRouten } from './routes/auswertungen.js';
|
||||
import { elsterRouten } from './routes/elster.js';
|
||||
import { exportRouten } from './routes/export.js';
|
||||
|
||||
const start = registerVorbereiten();
|
||||
for (const eintrag of alleMandanten()) {
|
||||
// Fehlender Kontenrahmen (z.B. in einer von Hand angelegten Datei) wird ergänzt.
|
||||
const anzahl = kontenrahmenAnlegen(kontextFuer(eintrag).verbindung);
|
||||
if (anzahl > 0) console.log(`Mandant "${eintrag.name}": Kontenrahmen SKR04 mit ${anzahl} Konten angelegt.`);
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'warn' }, bodyLimit: 10 * 1024 * 1024 });
|
||||
|
||||
await app.register(cors, { origin: true, exposedHeaders: ['X-Mandant'] });
|
||||
mandantenScoping(app);
|
||||
await app.register(mandantenRouten);
|
||||
await app.register(stammdatenRouten);
|
||||
await app.register(buchungsRouten);
|
||||
await app.register(auswertungsRouten);
|
||||
await app.register(elsterRouten);
|
||||
await app.register(exportRouten);
|
||||
|
||||
app.get('/api/gesundheit', async () => ({ ok: true, version: '0.1.0' }));
|
||||
|
||||
// Im Produktivbetrieb liefert derselbe Prozess auch das gebaute Frontend aus.
|
||||
const webDist = resolve(projektWurzel, 'web/dist');
|
||||
if (existsSync(webDist)) {
|
||||
await app.register(fastifyStatic, { root: webDist });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api/')) return reply.code(404).send({ fehler: 'Unbekannter Endpunkt.' });
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
schliesseAlleVerbindungen();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
const port = Number(process.env.PORT ?? 3311);
|
||||
await app.listen({ port, host: '127.0.0.1' });
|
||||
console.log(`open-eur-webapp läuft auf http://127.0.0.1:${port}`);
|
||||
console.log(
|
||||
envDatei
|
||||
? `Konfiguration aus ${envDatei} geladen.`
|
||||
: 'Keine .env gefunden – ELSTER läuft im Simulationsmodus (Vorlage: .env.beispiel).',
|
||||
);
|
||||
if (start.uebernommen) {
|
||||
console.log('Vorhandene data/euer.sqlite als Mandant "standard" übernommen (Datei unverändert).');
|
||||
}
|
||||
if (start.angelegt) console.log('Ersten Mandanten angelegt.');
|
||||
console.log(
|
||||
`Mandanten: ${alleMandanten()
|
||||
.map((m) => `${m.slug}${m.testmandant ? ' [Test]' : ''}`)
|
||||
.join(', ')} – aktiv ohne Angabe: ${standardMandant().slug}`,
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Konto } from '../domain/typen.js';
|
||||
import { SKR03 } from './skr03.js';
|
||||
import { SKR04 } from './skr04.js';
|
||||
|
||||
export type KontenrahmenId = 'SKR03' | 'SKR04';
|
||||
|
||||
export const KONTENRAHMEN: Record<KontenrahmenId, { bezeichnung: string; konten: Konto[] }> = {
|
||||
SKR03: {
|
||||
bezeichnung: 'SKR03 – Prozessgliederungsprinzip (Erlöse Klasse 8, Aufwand Klasse 4)',
|
||||
konten: SKR03,
|
||||
},
|
||||
SKR04: {
|
||||
bezeichnung: 'SKR04 – Abschlussgliederungsprinzip (Erlöse Klasse 4, Aufwand Klasse 6)',
|
||||
konten: SKR04,
|
||||
},
|
||||
};
|
||||
|
||||
export function istKontenrahmen(wert: string): wert is KontenrahmenId {
|
||||
return wert === 'SKR03' || wert === 'SKR04';
|
||||
}
|
||||
|
||||
/**
|
||||
* Voreingestelltes Gegenkonto (Bank) je Kontenrahmen.
|
||||
*
|
||||
* Achtung, klassische Fehlerquelle: 1800 ist im SKR04 das Bankkonto, im SKR03
|
||||
* dagegen "Privatentnahmen allgemein". Ein fest verdrahtetes Gegenkonto würde
|
||||
* im SKR03 jede Zahlung als Privatentnahme buchen.
|
||||
*/
|
||||
export const STANDARD_GEGENKONTO: Record<KontenrahmenId, string> = {
|
||||
SKR03: '1200',
|
||||
SKR04: '1800',
|
||||
};
|
||||
|
||||
export function standardGegenkonto(kontenrahmen: string): string {
|
||||
return istKontenrahmen(kontenrahmen)
|
||||
? STANDARD_GEGENKONTO[kontenrahmen]
|
||||
: STANDARD_GEGENKONTO.SKR04;
|
||||
}
|
||||
|
||||
export function kontenFuer(id: string): Konto[] {
|
||||
return (istKontenrahmen(id) ? KONTENRAHMEN[id] : KONTENRAHMEN.SKR04).konten;
|
||||
}
|
||||
|
||||
export { SKR03, SKR04 };
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Konto } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* Praxisnaher Auszug aus dem SKR03 (Prozessgliederungsprinzip).
|
||||
*
|
||||
* Kontonummern und Bezeichnungen sind dem amtlichen DATEV-Dokument
|
||||
* "DATEV-Kontenrahmen nach dem Bilanzrichtlinie-Umsetzungsgesetz –
|
||||
* Standardkontenrahmen Prozessgliederungsprinzip (SKR 03), gültig für 2026"
|
||||
* entnommen. Die Zuordnung zu EÜR-Posten, BWA-Gruppen und UStVA-Kennzahlen
|
||||
* stammt dagegen aus dieser Anwendung und ist fachliche Auslegung – sie sollte
|
||||
* vor dem ersten Export mit der Steuerkanzlei abgeglichen werden.
|
||||
*
|
||||
* Anders als der SKR04 (Abschlussgliederung) ordnet der SKR03 nach dem
|
||||
* Geschäftsablauf: Erlöse liegen in Klasse 8, Aufwand in Klasse 4, Wareneingang
|
||||
* in Klasse 3. Ein Konto wie 4400 bedeutet in beiden Rahmen etwas völlig
|
||||
* anderes – deshalb lässt sich der Kontenrahmen nach dem Anlegen eines
|
||||
* Mandanten nicht mehr wechseln.
|
||||
*/
|
||||
export const SKR03: Konto[] = [
|
||||
// --- Finanz- und Bestandskonten (Gegenkonten) ---------------------------
|
||||
k('1000', 'Kasse', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1200', 'Bank', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1360', 'Geldtransit', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1576', 'Abziehbare Vorsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('1571', 'Abziehbare Vorsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('1776', 'Umsatzsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('1771', 'Umsatzsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('1780', 'Umsatzsteuer-Vorauszahlungen', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1800', 'Privatentnahmen allgemein', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1890', 'Privateinlagen', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Anlagevermögen -----------------------------------------------------
|
||||
k('0027', 'EDV-Software', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0320', 'Pkw', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0410', 'Geschäftsausstattung', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0480', 'Geringwertige Wirtschaftsgüter', 'anlage', 'ba_gwg', 'abschreibungen', 1900, 'regel'),
|
||||
k('0485', 'Wirtschaftsgüter (Sammelposten)', 'anlage', 'ba_sammelposten', 'abschreibungen', 1900, 'regel'),
|
||||
|
||||
// --- Erlöse (Klasse 8) --------------------------------------------------
|
||||
k('8192', 'Steuerfreie Erlöse Kleinunternehmer nach § 19 Abs. 1 UStG', 'erloes', 'be_ku', 'umsatzerloese', 0, 'kleinunternehmer'),
|
||||
k('8400', 'Erlöse 19 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 1900, 'regel'),
|
||||
k('8300', 'Erlöse 7 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 700, 'regel'),
|
||||
k('8125', 'Steuerfreie innergemeinschaftliche Lieferungen nach § 4 Nr. 1b UStG', 'erloes', 'be_frei', 'umsatzerloese', 0, 'ig_lieferung'),
|
||||
k('8120', 'Steuerfreie Umsätze nach § 4 Nr. 1a UStG', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_mit_vst'),
|
||||
k('8100', 'Steuerfreie Umsätze § 4 Nr. 8 ff. UStG', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_ohne_vst'),
|
||||
// Kz 21: nicht steuerbare sonstige Leistungen § 18b Satz 1 Nr. 2 UStG
|
||||
k('8336', 'Erlöse aus im anderen EU-Land steuerpflichtigen sonstigen Leistungen', 'erloes', 'be_frei', 'umsatzerloese', 0, 'nicht_steuerbar', '21'),
|
||||
k('2700', 'Andere betriebs- und/oder periodenfremde sonstige Erträge', 'erloes', 'be_sonstige', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8801', 'Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchverlust)', 'erloes', 'be_anlagenabgang', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8820', 'Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchgewinn)', 'erloes', 'be_anlagenabgang', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8924', 'Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens (Kfz-Nutzung)', 'erloes', 'be_kfz_privat', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8905', 'Entnahme von Gegenständen ohne USt', 'erloes', 'be_entnahmen', 'so_betr_erloese', 0, 'nicht_steuerbar'),
|
||||
k('2650', 'Sonstige Zinsen und ähnliche Erträge', 'erloes', 'be_sonstige', 'zinsertraege', 0, 'steuerfrei_ohne_vst'),
|
||||
|
||||
// --- Wareneingang und Fremdleistungen (Klasse 3) ------------------------
|
||||
k('3400', 'Wareneingang 19 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('3300', 'Wareneingang 7 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 700, 'regel'),
|
||||
k('3425', 'Innergemeinschaftlicher Erwerb 19 % Vorsteuer und 19 % Umsatzsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'ig_erwerb'),
|
||||
k('3100', 'Fremdleistungen', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('3123', 'Sonstige Leistungen eines im anderen EU-Land ansässigen Unternehmers (§ 13b UStG)', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'reverse_charge_13b'),
|
||||
|
||||
// --- Personal -----------------------------------------------------------
|
||||
k('4100', 'Löhne und Gehälter', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('4130', 'Gesetzliche soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('4140', 'Freiwillige soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 1900, 'regel'),
|
||||
|
||||
// --- Abschreibungen -----------------------------------------------------
|
||||
k('4830', 'Abschreibungen auf Sachanlagen', 'aufwand', 'ba_afa_beweglich', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('4822', 'Abschreibungen auf immaterielle Vermögensgegenstände', 'aufwand', 'ba_afa_immateriell', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('4855', 'Sofortabschreibung geringwertiger Wirtschaftsgüter', 'aufwand', 'ba_gwg', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('4862', 'Abschreibungen auf den Sammelposten Wirtschaftsgüter', 'aufwand', 'ba_sammelposten', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('2310', 'Anlagenabgänge Sachanlagen (Restbuchwert bei Buchverlust)', 'aufwand', 'ba_restbuchwert', 'so_neutraler_aufwand', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Raum ---------------------------------------------------------------
|
||||
k('4210', 'Miete (unbewegliche Wirtschaftsgüter)', 'aufwand', 'ba_miete', 'raumkosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4240', 'Gas, Strom, Wasser', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('4250', 'Reinigung', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('4260', 'Instandhaltung betrieblicher Räume', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('4288', 'Aufwendungen für ein häusliches Arbeitszimmer (abziehbarer Anteil)', 'aufwand', 'ba_arbeitszimmer', 'raumkosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Versicherungen, Beiträge, Steuern ----------------------------------
|
||||
k('4360', 'Versicherungen', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4380', 'Beiträge', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4320', 'Gewerbesteuer', 'aufwand', 'ba_sonstige', 'steuern_eink_ertrag', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Fahrzeuge ----------------------------------------------------------
|
||||
k('4520', 'Fahrzeug-Versicherungen', 'aufwand', 'ba_kfz', 'kfz_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4530', 'Laufende Fahrzeug-Betriebskosten', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('4540', 'Fahrzeug-Reparaturen', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('4570', 'Mietleasing Kfz', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
|
||||
// --- Werbung und Reisekosten --------------------------------------------
|
||||
k('4600', 'Werbekosten', 'aufwand', 'ba_werbung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4650', 'Bewirtungskosten', 'aufwand', 'ba_bewirtung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4630', 'Geschenke abzugsfähig ohne § 37b EStG', 'aufwand', 'ba_geschenke', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4670', 'Reisekosten Unternehmer', 'aufwand', 'ba_reise', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4674', 'Reisekosten Unternehmer Verpflegungsmehraufwand', 'aufwand', 'ba_reise', 'werbe_reisekosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Sonstige betriebliche Aufwendungen ---------------------------------
|
||||
k('4910', 'Porto', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4920', 'Telefon', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4930', 'Bürobedarf', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4940', 'Zeitschriften, Bücher, digitale Medien', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 700, 'regel'),
|
||||
k('4945', 'Fortbildungskosten', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4950', 'Rechts- und Beratungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4955', 'Buchführungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4805', 'Reparaturen und Instandhaltungen von anderen Anlagen und Betriebs- und Geschäftsausstattung', 'aufwand', 'ba_sonstige', 'reparatur_instandhaltung', 1900, 'regel'),
|
||||
k('4970', 'Nebenkosten des Geldverkehrs', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4980', 'Sonstiger Betriebsbedarf', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('2100', 'Zinsen und ähnliche Aufwendungen', 'aufwand', 'ba_zinsen', 'zinsaufwand', 0, 'steuerfrei_ohne_vst'),
|
||||
];
|
||||
|
||||
function k(
|
||||
nummer: string,
|
||||
bezeichnung: string,
|
||||
typ: Konto['typ'],
|
||||
euer: string | null,
|
||||
bwa: string | null,
|
||||
satz: number,
|
||||
behandlung: Konto['ust_behandlung'],
|
||||
ustvaKz: string | null = null,
|
||||
): Konto {
|
||||
return {
|
||||
nummer,
|
||||
bezeichnung,
|
||||
typ,
|
||||
euer_zeile: euer,
|
||||
bwa_gruppe: bwa,
|
||||
ustva_kz: ustvaKz,
|
||||
ust_satz_bp: satz,
|
||||
ust_behandlung: behandlung,
|
||||
aktiv: 1,
|
||||
sortierung: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Konto } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* Praxisnaher Auszug aus dem SKR04 für Einnahmen-Überschuss-Rechner.
|
||||
* Bewusst kompakt gehalten: Konten lassen sich in der Oberfläche ergänzen,
|
||||
* umbenennen und deaktivieren. Die Spalten `euer_zeile` und `bwa_gruppe`
|
||||
* steuern, wo ein Konto in Anlage EÜR bzw. BWA einfließt.
|
||||
*/
|
||||
export const SKR04: Konto[] = [
|
||||
// --- Finanz- und Bestandskonten (Gegenkonten) ---------------------------
|
||||
k('1600', 'Kasse', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1800', 'Bank', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1810', 'PayPal / Zahlungsdienstleister', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1360', 'Geldtransit', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1406', 'Abziehbare Vorsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('1401', 'Abziehbare Vorsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('3806', 'Umsatzsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('3801', 'Umsatzsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('3820', 'Umsatzsteuer-Vorauszahlungen', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('2100', 'Privatentnahmen allgemein', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
k('2180', 'Privateinlagen', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Anlagevermögen -----------------------------------------------------
|
||||
k('0135', 'Software (immateriell)', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0520', 'Pkw', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0650', 'Betriebs- und Geschäftsausstattung', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0670', 'EDV-Hardware', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0675', 'Geringwertige Wirtschaftsgüter (GWG)', 'anlage', 'ba_gwg', 'abschreibungen', 1900, 'regel'),
|
||||
k('0680', 'Sammelposten bewegliche Anlagegüter', 'anlage', 'ba_sammelposten', 'abschreibungen', 1900, 'regel'),
|
||||
|
||||
// --- Erlöse -------------------------------------------------------------
|
||||
k('4200', 'Erlöse (Kleinunternehmer, §19 UStG)', 'erloes', 'be_ku', 'umsatzerloese', 0, 'kleinunternehmer'),
|
||||
k('4400', 'Erlöse 19 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 1900, 'regel'),
|
||||
k('4300', 'Erlöse 7 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 700, 'regel'),
|
||||
k('4125', 'Steuerfreie innergemeinschaftliche Lieferungen', 'erloes', 'be_frei', 'umsatzerloese', 0, 'ig_lieferung'),
|
||||
k('4120', 'Steuerfreie Umsätze §4 Nr. 1a UStG (Ausfuhr)', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_mit_vst'),
|
||||
// Kz 21: nicht steuerbare sonstige Leistungen §18b Satz 1 Nr. 2 UStG (EU-B2B, Reverse Charge beim Kunden)
|
||||
k('4180', 'Im anderen EU-Land steuerpflichtige sonstige Leistung', 'erloes', 'be_frei', 'umsatzerloese', 0, 'nicht_steuerbar', '21'),
|
||||
k('4186', 'Steuerfreie Umsätze ohne Vorsteuerabzug (§4 Nr. 8 ff. UStG)', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4830', 'Sonstige betriebliche Erträge', 'erloes', 'be_sonstige', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4845', 'Erträge aus dem Abgang von Anlagevermögen', 'erloes', 'be_anlagenabgang', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4639', 'Private Kfz-Nutzung', 'erloes', 'be_kfz_privat', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4640', 'Sach-, Nutzungs- und Leistungsentnahmen', 'erloes', 'be_entnahmen', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4970', 'Zinserträge', 'erloes', 'be_sonstige', 'zinsertraege', 0, 'steuerfrei_ohne_vst'),
|
||||
|
||||
// --- Material / Fremdleistungen -----------------------------------------
|
||||
k('5200', 'Wareneingang 19 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('5300', 'Wareneingang 7 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 700, 'regel'),
|
||||
k('5400', 'Innergemeinschaftlicher Erwerb 19 %', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'ig_erwerb'),
|
||||
k('5900', 'Fremdleistungen', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('5906', 'Leistungen nach §13b UStG (Reverse Charge)', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'reverse_charge_13b'),
|
||||
|
||||
// --- Personal -----------------------------------------------------------
|
||||
k('6000', 'Löhne und Gehälter', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('6110', 'Gesetzliche soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('6130', 'Freiwillige soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 1900, 'regel'),
|
||||
|
||||
// --- Abschreibungen -----------------------------------------------------
|
||||
k('6220', 'Abschreibungen auf Sachanlagen', 'aufwand', 'ba_afa_beweglich', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6200', 'Abschreibungen auf immaterielle Vermögensgegenstände', 'aufwand', 'ba_afa_immateriell', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6260', 'Sofortabschreibung GWG', 'aufwand', 'ba_gwg', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6262', 'Auflösung Sammelposten', 'aufwand', 'ba_sammelposten', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6895', 'Restbuchwert ausgeschiedener Anlagegüter', 'aufwand', 'ba_restbuchwert', 'so_neutraler_aufwand', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Raum ---------------------------------------------------------------
|
||||
k('6310', 'Miete Geschäftsräume', 'aufwand', 'ba_miete', 'raumkosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6325', 'Heizung, Strom, Wasser', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('6330', 'Reinigung', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('6348', 'Häusliches Arbeitszimmer / Tagespauschale', 'aufwand', 'ba_arbeitszimmer', 'raumkosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Versicherungen, Beiträge, Steuern ----------------------------------
|
||||
k('6400', 'Versicherungen', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6420', 'Beiträge (Kammern, Verbände)', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('7685', 'Gewerbesteuer', 'aufwand', 'ba_sonstige', 'steuern_eink_ertrag', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Kfz ----------------------------------------------------------------
|
||||
k('6520', 'Kfz-Versicherungen', 'aufwand', 'ba_kfz', 'kfz_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6530', 'Laufende Kfz-Betriebskosten', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('6540', 'Kfz-Reparaturen', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('6570', 'Kfz-Leasing', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('6650', 'Reisekosten Unternehmer', 'aufwand', 'ba_reise', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('6663', 'Reisekosten Unternehmer – Verpflegungspauschale', 'aufwand', 'ba_reise', 'werbe_reisekosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Werbung / Repräsentation -------------------------------------------
|
||||
k('6600', 'Werbekosten', 'aufwand', 'ba_werbung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('6640', 'Bewirtungsaufwendungen', 'aufwand', 'ba_bewirtung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('6610', 'Geschenke an Geschäftsfreunde bis 50 EUR', 'aufwand', 'ba_geschenke', 'werbe_reisekosten', 1900, 'regel'),
|
||||
|
||||
// --- Sonstige betriebliche Aufwendungen ---------------------------------
|
||||
k('6800', 'Porto', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6805', 'Telefon und Internet', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6815', 'Bürobedarf', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6820', 'Zeitschriften, Bücher, Fachliteratur', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 700, 'regel'),
|
||||
k('6821', 'Fortbildungskosten', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6825', 'Rechts- und Beratungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6827', 'Buchführungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6830', 'Mieten für Einrichtungen / Software-Abos', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6845', 'Werkzeuge und Kleingeräte', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6855', 'Nebenkosten des Geldverkehrs', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6300', 'Sonstige betriebliche Aufwendungen', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6470', 'Reparatur und Instandhaltung', 'aufwand', 'ba_sonstige', 'reparatur_instandhaltung', 1900, 'regel'),
|
||||
k('7310', 'Zinsaufwendungen', 'aufwand', 'ba_zinsen', 'zinsaufwand', 0, 'steuerfrei_ohne_vst'),
|
||||
];
|
||||
|
||||
function k(
|
||||
nummer: string,
|
||||
bezeichnung: string,
|
||||
typ: Konto['typ'],
|
||||
euer: string | null,
|
||||
bwa: string | null,
|
||||
satz: number,
|
||||
behandlung: Konto['ust_behandlung'],
|
||||
ustvaKz: string | null = null,
|
||||
): Konto {
|
||||
return {
|
||||
nummer,
|
||||
bezeichnung,
|
||||
typ,
|
||||
euer_zeile: euer,
|
||||
bwa_gruppe: bwa,
|
||||
ustva_kz: ustvaKz,
|
||||
ust_satz_bp: satz,
|
||||
ust_behandlung: behandlung,
|
||||
aktiv: 1,
|
||||
sortierung: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { datenVerzeichnis } from './pfade.js';
|
||||
import {
|
||||
ausProjektwurzel,
|
||||
oeffneDatenbank,
|
||||
setzeStandardKontext,
|
||||
type MandantKontext,
|
||||
} from './db.js';
|
||||
import { istKontenrahmen, kontenFuer, type KontenrahmenId } from './kontenrahmen/index.js';
|
||||
|
||||
/**
|
||||
* Register aller Mandanten. Es liegt in einer eigenen kleinen Datenbank und
|
||||
* enthält bewusst keine Buchhaltungsdaten – nur, welche Mandantendateien es
|
||||
* gibt. Die Nutzdaten stehen ausschließlich in den Mandantendateien selbst.
|
||||
*/
|
||||
|
||||
export interface MandantEintrag {
|
||||
slug: string;
|
||||
name: string;
|
||||
testmandant: number;
|
||||
datei: string;
|
||||
angelegt_am: string;
|
||||
}
|
||||
|
||||
const REGISTER_DATEI = resolve(datenVerzeichnis, 'register.sqlite');
|
||||
const MANDANTEN_ORDNER = resolve(datenVerzeichnis, 'mandanten');
|
||||
/** Datenbank aus der Zeit vor der Mandantenfähigkeit. */
|
||||
const ALTBESTAND = resolve(datenVerzeichnis, 'euer.sqlite');
|
||||
|
||||
let register: Database.Database | null = null;
|
||||
const verbindungen = new Map<string, Database.Database>();
|
||||
|
||||
function reg(): Database.Database {
|
||||
if (register) return register;
|
||||
mkdirSync(datenVerzeichnis, { recursive: true });
|
||||
register = new Database(REGISTER_DATEI);
|
||||
register.pragma('journal_mode = WAL');
|
||||
register.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mandantenregister (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
testmandant INTEGER NOT NULL DEFAULT 0,
|
||||
datei TEXT NOT NULL,
|
||||
angelegt_am TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
return register;
|
||||
}
|
||||
|
||||
export class MandantFehler extends Error {}
|
||||
|
||||
/** Aus einem Namen einen dateisystemtauglichen, stabilen Bezeichner bilden. */
|
||||
export function slugAus(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
if (!s) throw new MandantFehler('Aus dem Namen lässt sich kein Bezeichner bilden.');
|
||||
return s;
|
||||
}
|
||||
|
||||
export function alleMandanten(): MandantEintrag[] {
|
||||
return reg()
|
||||
.prepare('SELECT * FROM mandantenregister ORDER BY testmandant, name')
|
||||
.all() as MandantEintrag[];
|
||||
}
|
||||
|
||||
export function findeMandant(slug: string): MandantEintrag | undefined {
|
||||
return reg().prepare('SELECT * FROM mandantenregister WHERE slug = ?').get(slug) as
|
||||
| MandantEintrag
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function legeMandantAn(opt: {
|
||||
name: string;
|
||||
slug?: string;
|
||||
testmandant?: boolean;
|
||||
/** SKR03 oder SKR04 – nach dem Anlegen nicht mehr änderbar */
|
||||
kontenrahmen?: KontenrahmenId;
|
||||
/** false unterdrückt das Einspielen von Konten (nur für Sonderfälle) */
|
||||
ohneKonten?: boolean;
|
||||
}): MandantEintrag {
|
||||
const slug = opt.slug ? slugAus(opt.slug) : slugAus(opt.name);
|
||||
if (findeMandant(slug)) {
|
||||
throw new MandantFehler(`Ein Mandant mit dem Bezeichner "${slug}" ist bereits angelegt.`);
|
||||
}
|
||||
|
||||
const datei = `data/mandanten/${slug}.sqlite`;
|
||||
if (existsSync(ausProjektwurzel(datei))) {
|
||||
throw new MandantFehler(
|
||||
`Die Datei ${datei} existiert bereits, ist aber nicht registriert. ` +
|
||||
'Bitte prüfen und gegebenenfalls von Hand entfernen oder umbenennen.',
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(MANDANTEN_ORDNER, { recursive: true });
|
||||
const verbindung = oeffneDatenbank(datei);
|
||||
if (!opt.ohneKonten) kontenrahmenAnlegen(verbindung, opt.kontenrahmen ?? 'SKR04');
|
||||
verbindung.prepare('UPDATE mandant SET name = ? WHERE id = 1').run(opt.name);
|
||||
verbindungen.set(slug, verbindung);
|
||||
|
||||
reg()
|
||||
.prepare('INSERT INTO mandantenregister (slug, name, testmandant, datei) VALUES (?,?,?,?)')
|
||||
.run(slug, opt.name, opt.testmandant ? 1 : 0, datei);
|
||||
|
||||
return findeMandant(slug)!;
|
||||
}
|
||||
|
||||
export function benenneMandantUm(slug: string, name: string): MandantEintrag {
|
||||
const eintrag = findeMandant(slug);
|
||||
if (!eintrag) throw new MandantFehler(`Mandant "${slug}" ist nicht bekannt.`);
|
||||
reg().prepare('UPDATE mandantenregister SET name = ? WHERE slug = ?').run(name, slug);
|
||||
return findeMandant(slug)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Mandanten aus dem Register. Die Datei wird nicht gelöscht,
|
||||
* sondern mit Zeitstempel beiseitegelegt – eine versehentlich entfernte
|
||||
* Buchhaltung wäre sonst unwiederbringlich weg.
|
||||
*/
|
||||
export function entferneMandant(slug: string, zeitstempel: string): { abgelegtAls: string } {
|
||||
const eintrag = findeMandant(slug);
|
||||
if (!eintrag) throw new MandantFehler(`Mandant "${slug}" ist nicht bekannt.`);
|
||||
|
||||
verbindungen.get(slug)?.close();
|
||||
verbindungen.delete(slug);
|
||||
|
||||
const quelle = ausProjektwurzel(eintrag.datei);
|
||||
const ziel = `${quelle}.entfernt-${zeitstempel}`;
|
||||
if (existsSync(quelle)) renameSync(quelle, ziel);
|
||||
// WAL- und Shared-Memory-Dateien mitnehmen, sonst bleiben Reste liegen.
|
||||
for (const endung of ['-wal', '-shm']) {
|
||||
if (existsSync(quelle + endung)) renameSync(quelle + endung, ziel + endung);
|
||||
}
|
||||
|
||||
reg().prepare('DELETE FROM mandantenregister WHERE slug = ?').run(slug);
|
||||
return { abgelegtAls: ziel };
|
||||
}
|
||||
|
||||
/** Offene Verbindung zum Mandanten; wird zwischengespeichert. */
|
||||
export function kontextFuer(eintrag: MandantEintrag): MandantKontext {
|
||||
let verbindung = verbindungen.get(eintrag.slug);
|
||||
if (!verbindung || !verbindung.open) {
|
||||
verbindung = oeffneDatenbank(eintrag.datei);
|
||||
verbindungen.set(eintrag.slug, verbindung);
|
||||
}
|
||||
return {
|
||||
slug: eintrag.slug,
|
||||
name: eintrag.name,
|
||||
testmandant: eintrag.testmandant === 1,
|
||||
verbindung,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt den Kontenrahmen ein, sofern die Datei noch keine Konten hat.
|
||||
* Welcher Rahmen gilt, steht in den Stammdaten des Mandanten – so bleiben
|
||||
* Kontonummern und die Angabe im DATEV-Export zwingend deckungsgleich.
|
||||
*/
|
||||
export function kontenrahmenAnlegen(
|
||||
verbindung: Database.Database,
|
||||
rahmen?: KontenrahmenId,
|
||||
): number {
|
||||
const anzahl = (verbindung.prepare('SELECT COUNT(*) AS n FROM konten').get() as { n: number }).n;
|
||||
if (anzahl > 0) return 0;
|
||||
|
||||
const gespeichert = (
|
||||
verbindung.prepare('SELECT kontenrahmen FROM mandant WHERE id = 1').get() as
|
||||
| { kontenrahmen: string }
|
||||
| undefined
|
||||
)?.kontenrahmen;
|
||||
const id: KontenrahmenId =
|
||||
rahmen ?? (gespeichert && istKontenrahmen(gespeichert) ? gespeichert : 'SKR04');
|
||||
|
||||
const konten = kontenFuer(id);
|
||||
const einfuegen = verbindung.prepare(
|
||||
`INSERT INTO konten (nummer, bezeichnung, typ, euer_zeile, bwa_gruppe, ustva_kz,
|
||||
ust_satz_bp, ust_behandlung, aktiv, sortierung)
|
||||
VALUES (@nummer, @bezeichnung, @typ, @euer_zeile, @bwa_gruppe, @ustva_kz,
|
||||
@ust_satz_bp, @ust_behandlung, @aktiv, @sortierung)`,
|
||||
);
|
||||
verbindung.transaction(() => konten.forEach((k) => einfuegen.run(k)))();
|
||||
verbindung.prepare('UPDATE mandant SET kontenrahmen = ? WHERE id = 1').run(id);
|
||||
return konten.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim ersten Start nach der Umstellung: eine vorhandene data/euer.sqlite wird
|
||||
* als Mandant übernommen. Die Datei bleibt liegen, wo sie ist – kopiert oder
|
||||
* verschoben wird nichts, damit im Fehlerfall nichts verloren geht.
|
||||
*/
|
||||
export function registerVorbereiten(): { uebernommen: boolean; angelegt: boolean } {
|
||||
const vorhanden = alleMandanten();
|
||||
if (vorhanden.length > 0) return { uebernommen: false, angelegt: false };
|
||||
|
||||
if (existsSync(ALTBESTAND)) {
|
||||
reg()
|
||||
.prepare('INSERT INTO mandantenregister (slug, name, testmandant, datei) VALUES (?,?,?,?)')
|
||||
.run('standard', nameAusAltbestand(), 0, 'data/euer.sqlite');
|
||||
return { uebernommen: true, angelegt: false };
|
||||
}
|
||||
|
||||
legeMandantAn({ name: 'Mein Unternehmen' });
|
||||
return { uebernommen: false, angelegt: true };
|
||||
}
|
||||
|
||||
function nameAusAltbestand(): string {
|
||||
try {
|
||||
const alt = new Database(ALTBESTAND, { readonly: true });
|
||||
const zeile = alt.prepare('SELECT name FROM mandant WHERE id = 1').get() as
|
||||
| { name: string }
|
||||
| undefined;
|
||||
alt.close();
|
||||
return zeile?.name?.trim() || 'Bestehende Buchhaltung';
|
||||
} catch {
|
||||
return 'Bestehende Buchhaltung';
|
||||
}
|
||||
}
|
||||
|
||||
/** Standardmandant für Aufrufe ohne ausdrückliche Angabe. */
|
||||
export function standardMandant(): MandantEintrag {
|
||||
const bevorzugt = process.env['EUER_MANDANT'];
|
||||
if (bevorzugt) {
|
||||
const eintrag = findeMandant(bevorzugt);
|
||||
if (eintrag) return eintrag;
|
||||
throw new MandantFehler(
|
||||
`EUER_MANDANT verweist auf "${bevorzugt}", ein solcher Mandant ist nicht registriert.`,
|
||||
);
|
||||
}
|
||||
const echte = alleMandanten().filter((m) => m.testmandant === 0);
|
||||
const eintrag = echte[0] ?? alleMandanten()[0];
|
||||
if (!eintrag) throw new MandantFehler('Es ist kein Mandant angelegt.');
|
||||
return eintrag;
|
||||
}
|
||||
|
||||
/** Für Skripte: einen Mandanten dauerhaft als Kontext setzen. */
|
||||
export function waehleMandantFuerSkript(slug?: string): MandantKontext {
|
||||
registerVorbereiten();
|
||||
const eintrag = slug
|
||||
? (findeMandant(slug) ?? (() => { throw new MandantFehler(`Mandant "${slug}" ist nicht bekannt.`); })())
|
||||
: standardMandant();
|
||||
const kontext = kontextFuer(eintrag);
|
||||
setzeStandardKontext(kontext);
|
||||
return kontext;
|
||||
}
|
||||
|
||||
export function schliesseAlleVerbindungen(): void {
|
||||
for (const v of verbindungen.values()) {
|
||||
try {
|
||||
v.close();
|
||||
} catch {
|
||||
/* beim Herunterfahren ignorieren */
|
||||
}
|
||||
}
|
||||
verbindungen.clear();
|
||||
register?.close();
|
||||
register = null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Die Projektwurzel wird aus dem Ort dieser Datei abgeleitet, nicht aus
|
||||
* process.cwd(). Damit zeigen relative Pfade aus der .env immer auf dieselbe
|
||||
* Stelle – gleich, ob der Prozess aus dem Wurzelverzeichnis, aus server/ oder
|
||||
* aus einem Editor heraus gestartet wurde.
|
||||
*
|
||||
* Quelltext liegt unter <wurzel>/server/src, der Build unter <wurzel>/server/dist.
|
||||
*/
|
||||
const hier = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const projektWurzel = resolve(hier, '../..');
|
||||
|
||||
/** Ablage für Datenbank, ERiC-Protokolle und erzeugte ELSTER-Dateien. */
|
||||
export const datenVerzeichnis = resolve(projektWurzel, 'data');
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db } from '../db.js';
|
||||
import { berechneEuer } from '../domain/euer.js';
|
||||
import { berechneBwa, BWA_AUFBAU } from '../domain/bwa.js';
|
||||
import { berechneUstva, moeglicheZeitraeume, zeitraum } from '../domain/ustva.js';
|
||||
import { anlagenverzeichnis } from '../domain/afa.js';
|
||||
import { grenzenStatus, regimeAm } from '../domain/regime.js';
|
||||
import { EUER_POSTEN } from '../domain/euerZeilen.js';
|
||||
import type { Mandant } from '../domain/typen.js';
|
||||
|
||||
export async function auswertungsRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/euer/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
return berechneEuer(Number(jahr));
|
||||
});
|
||||
|
||||
app.get('/api/euer/:jahr/anlagen', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
return anlagenverzeichnis(Number(jahr), regimeAm);
|
||||
});
|
||||
|
||||
app.get('/api/euer/posten', async () => EUER_POSTEN);
|
||||
|
||||
app.get('/api/bwa/:jahr/:monat', async (req) => {
|
||||
const { jahr, monat } = req.params as { jahr: string; monat: string };
|
||||
const { basis } = req.query as { basis?: 'zahlung' | 'leistung' };
|
||||
return berechneBwa(Number(jahr), Number(monat), basis ?? 'zahlung');
|
||||
});
|
||||
|
||||
app.get('/api/bwa/aufbau', async () => BWA_AUFBAU);
|
||||
|
||||
app.get('/api/ustva/:jahr/:zeitraum', async (req) => {
|
||||
const { jahr, zeitraum: z } = req.params as { jahr: string; zeitraum: string };
|
||||
const ergebnis = berechneUstva(Number(jahr), z);
|
||||
const gemeldet = db
|
||||
.prepare('SELECT id, status, transferticket, uebermittelt_am, berichtigung FROM ustva_meldungen WHERE jahr = ? AND zeitraum = ? ORDER BY berichtigung DESC')
|
||||
.all(Number(jahr), z);
|
||||
return { ...ergebnis, meldungen: gemeldet };
|
||||
});
|
||||
|
||||
app.get('/api/ustva/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const codes = moeglicheZeitraeume(m);
|
||||
return codes.map((c) => {
|
||||
const e = berechneUstva(Number(jahr), c);
|
||||
return {
|
||||
code: c,
|
||||
bezeichnung: zeitraum(Number(jahr), c).bezeichnung,
|
||||
zahllast: e.zahllast,
|
||||
abgabepflichtig: e.abgabepflichtig,
|
||||
umsatzsteuer: e.umsatzsteuer,
|
||||
vorsteuer: e.vorsteuer,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Kennzahlen für die Startseite */
|
||||
app.get('/api/dashboard/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const j = Number(jahr);
|
||||
const euer = berechneEuer(j);
|
||||
const grenzen = grenzenStatus(j);
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
|
||||
const monatlich = db
|
||||
.prepare(
|
||||
`SELECT substr(zahlungsdatum, 1, 7) AS monat, art,
|
||||
COALESCE(SUM(CASE WHEN vorsteuer_abziehbar = 1 THEN betrag_netto ELSE betrag_brutto END), 0) AS summe
|
||||
FROM buchungen
|
||||
WHERE storniert = 0 AND zahlungsdatum BETWEEN ? AND ?
|
||||
GROUP BY monat, art ORDER BY monat`,
|
||||
)
|
||||
.all(`${j}-01-01`, `${j}-12-31`) as { monat: string; art: string; summe: number }[];
|
||||
|
||||
const verlauf = Array.from({ length: 12 }, (_, i) => {
|
||||
const monat = `${j}-${String(i + 1).padStart(2, '0')}`;
|
||||
return {
|
||||
monat,
|
||||
einnahmen: monatlich.find((r) => r.monat === monat && r.art === 'einnahme')?.summe ?? 0,
|
||||
ausgaben: monatlich.find((r) => r.monat === monat && r.art === 'ausgabe')?.summe ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const offen = db
|
||||
.prepare(
|
||||
`SELECT art, COUNT(*) AS anzahl, COALESCE(SUM(betrag_brutto),0) AS summe
|
||||
FROM buchungen WHERE storniert = 0 AND zahlungsdatum IS NULL GROUP BY art`,
|
||||
)
|
||||
.all() as { art: string; anzahl: number; summe: number }[];
|
||||
|
||||
return {
|
||||
jahr: j,
|
||||
regime: regimeAm(new Date().toISOString().slice(0, 10)),
|
||||
besteuerungsart: m.besteuerungsart,
|
||||
voranmeldungszeitraum: m.voranmeldungszeitraum,
|
||||
einnahmen: euer.summeEinnahmen,
|
||||
ausgaben: euer.summeAusgaben,
|
||||
gewinn: euer.gewinn,
|
||||
verlauf,
|
||||
offen,
|
||||
grenzen,
|
||||
hinweise: euer.hinweise,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
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 type { Anlagegut, Buchung, Konto, Mandant, UstBehandlung } from '../domain/typen.js';
|
||||
|
||||
/** Bankkonto des aktiven Kontenrahmens – 1800 ist NICHT rahmenübergreifend die Bank. */
|
||||
function vorbelegtesGegenkonto(): string {
|
||||
const m = db.prepare('SELECT kontenrahmen FROM mandant WHERE id = 1').get() as
|
||||
| Pick<Mandant, 'kontenrahmen'>
|
||||
| undefined;
|
||||
return standardGegenkonto(m?.kontenrahmen ?? 'SKR04');
|
||||
}
|
||||
|
||||
interface BuchungEingabe {
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
belegdatum: string;
|
||||
leistungsdatum?: string;
|
||||
zahlungsdatum?: string | null;
|
||||
brutto?: number;
|
||||
netto?: number;
|
||||
ust_satz_bp?: number;
|
||||
ust_behandlung?: UstBehandlung;
|
||||
konto: string;
|
||||
gegenkonto?: string;
|
||||
beleg_nr?: string;
|
||||
buchungstext?: string;
|
||||
partner?: string;
|
||||
privatanteil_bp?: number;
|
||||
anlagegut_id?: number | null;
|
||||
/** Anlagegut direkt aus der Buchung anlegen */
|
||||
anlage?: { bezeichnung: string; nutzungsdauer: number; afa_methode?: Anlagegut['afa_methode'] };
|
||||
}
|
||||
|
||||
export class AnlageFehler extends Error {}
|
||||
|
||||
function normalisiere(e: BuchungEingabe) {
|
||||
const konto = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(e.konto) as Konto | undefined;
|
||||
if (!konto) throw new Error(`Konto ${e.konto} ist nicht bekannt.`);
|
||||
|
||||
const leistungsdatum = e.leistungsdatum || e.belegdatum;
|
||||
const behandlung = e.ust_behandlung ?? konto.ust_behandlung;
|
||||
const satz = e.ust_satz_bp ?? konto.ust_satz_bp;
|
||||
|
||||
const ust = berechneUst({
|
||||
art: e.art,
|
||||
leistungsdatum,
|
||||
brutto: e.brutto,
|
||||
netto: e.netto,
|
||||
satzBp: satz,
|
||||
behandlung,
|
||||
});
|
||||
|
||||
return { konto, leistungsdatum, ust };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bemessungsgrundlage der Abschreibung: netto bei Vorsteuerabzug, sonst brutto –
|
||||
* ohne Vorsteuerabzug gehört die Umsatzsteuer zu den Anschaffungskosten.
|
||||
*/
|
||||
function bemessungsgrundlage(ust: { netto: number; brutto: number; vorsteuerAbziehbar: boolean }): number {
|
||||
return ust.vorsteuerAbziehbar ? ust.netto : ust.brutto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob eine Ausgabe ins Anlagevermögen gehört, sagt das Konto (Kontotyp 'anlage') –
|
||||
* nicht der Betrag. Ein abnutzbares Wirtschaftsgut bleibt eines, auch wenn es
|
||||
* billig ist, und eine teure Werbekampagne wird dadurch keines. Der Betrag
|
||||
* entscheidet erst über die Abschreibungsmethode.
|
||||
*/
|
||||
export function istAnlagekonto(konto: Konto): boolean {
|
||||
return konto.typ === 'anlage';
|
||||
}
|
||||
|
||||
interface AnlagePlan {
|
||||
bezeichnung: string;
|
||||
nutzungsdauer: number;
|
||||
methode: Anlagegut['afa_methode'];
|
||||
bemessung: number;
|
||||
/** true, wenn die Nutzungsdauer für die gewählte Methode zwingend ist */
|
||||
nutzungsdauerNoetig: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt, wie das Anlagegut abgeschrieben würde. Wirft, wenn für die lineare
|
||||
* AfA die Nutzungsdauer fehlt – sonst würde die Buchung stillschweigend mit
|
||||
* einer geratenen Nutzungsdauer im Anlagenverzeichnis landen.
|
||||
*/
|
||||
function planeAnlage(
|
||||
e: BuchungEingabe,
|
||||
konto: Konto,
|
||||
ust: { netto: number; brutto: number; vorsteuerAbziehbar: boolean },
|
||||
): AnlagePlan {
|
||||
const bemessung = bemessungsgrundlage(ust);
|
||||
const methode = e.anlage?.afa_methode ?? methodeVorschlag(bemessung);
|
||||
const nutzungsdauer = Number(e.anlage?.nutzungsdauer ?? 0);
|
||||
const nutzungsdauerNoetig = methode === 'linear';
|
||||
|
||||
if (nutzungsdauerNoetig && !(nutzungsdauer > 0)) {
|
||||
throw new AnlageFehler(
|
||||
`Konto ${konto.nummer} (${konto.bezeichnung}) ist ein Anlagekonto. Bei Anschaffungskosten von ` +
|
||||
`${(bemessung / 100).toLocaleString('de-DE', { minimumFractionDigits: 2 })} EUR ist linear ` +
|
||||
'über die Nutzungsdauer abzuschreiben – bitte die betriebsgewöhnliche Nutzungsdauer in Jahren ' +
|
||||
'angeben (Feld "anlage.nutzungsdauer"). Für einen Sofortabzug ist ein Aufwandskonto zu wählen.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
bezeichnung: e.anlage?.bezeichnung || e.buchungstext || konto.bezeichnung,
|
||||
// GWG und Sammelposten haben eine feste Abschreibungsdauer; der Wert ist dort ohne Bedeutung.
|
||||
nutzungsdauer: nutzungsdauer > 0 ? nutzungsdauer : methode === 'sammelposten' ? 5 : 1,
|
||||
methode,
|
||||
bemessung,
|
||||
nutzungsdauerNoetig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buchungsRouten(app: FastifyInstance): Promise<void> {
|
||||
/** Live-Vorschau für das Erfassungsformular – rechnet, ohne zu speichern. */
|
||||
app.post('/api/buchungen/vorschau', async (req, reply) => {
|
||||
try {
|
||||
const e = req.body as BuchungEingabe;
|
||||
const { konto, leistungsdatum, ust } = normalisiere(e);
|
||||
|
||||
// Die Vorschau zeigt die Aktivierung an, verlangt aber noch keine
|
||||
// Nutzungsdauer – sonst blinkt beim Tippen dauernd eine Fehlermeldung auf.
|
||||
const aktivierung =
|
||||
e.art === 'ausgabe' && (istAnlagekonto(konto) || !!e.anlage)
|
||||
? (() => {
|
||||
const bemessung = bemessungsgrundlage(ust);
|
||||
const methode = e.anlage?.afa_methode ?? methodeVorschlag(bemessung);
|
||||
return {
|
||||
erforderlich: istAnlagekonto(konto),
|
||||
methode,
|
||||
bemessung,
|
||||
nutzungsdauerNoetig: methode === 'linear',
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
return {
|
||||
regime: regimeAm(leistungsdatum),
|
||||
konto: { nummer: konto.nummer, bezeichnung: konto.bezeichnung, typ: konto.typ },
|
||||
brutto: ust.brutto,
|
||||
netto: ust.netto,
|
||||
ust: ust.ust,
|
||||
ust_satz_bp: ust.satzBp,
|
||||
ust_behandlung: ust.behandlung,
|
||||
vorsteuer_abziehbar: ust.vorsteuerAbziehbar,
|
||||
aktivierung,
|
||||
hinweise: ust.hinweise,
|
||||
};
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ fehler: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/buchungen', async (req) => {
|
||||
const qy = req.query as {
|
||||
jahr?: string; von?: string; bis?: string; art?: string;
|
||||
konto?: string; suche?: string; nurOffen?: string; limit?: string;
|
||||
};
|
||||
const bedingungen: string[] = ['1=1'];
|
||||
const werte: (string | number)[] = [];
|
||||
|
||||
if (qy.jahr) {
|
||||
bedingungen.push("COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ?");
|
||||
werte.push(`${qy.jahr}-01-01`, `${qy.jahr}-12-31`);
|
||||
}
|
||||
if (qy.von) { bedingungen.push('COALESCE(zahlungsdatum, leistungsdatum) >= ?'); werte.push(qy.von); }
|
||||
if (qy.bis) { bedingungen.push('COALESCE(zahlungsdatum, leistungsdatum) <= ?'); werte.push(qy.bis); }
|
||||
if (qy.art) { bedingungen.push('art = ?'); werte.push(qy.art); }
|
||||
if (qy.konto) { bedingungen.push('konto = ?'); werte.push(qy.konto); }
|
||||
if (qy.nurOffen === '1') bedingungen.push('zahlungsdatum IS NULL');
|
||||
if (qy.suche) {
|
||||
bedingungen.push('(buchungstext LIKE ? OR partner LIKE ? OR beleg_nr LIKE ?)');
|
||||
const s = `%${qy.suche}%`;
|
||||
werte.push(s, s, s);
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(qy.limit ?? 500), 5000);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen WHERE ${bedingungen.join(' AND ')}
|
||||
ORDER BY COALESCE(zahlungsdatum, leistungsdatum) DESC, id DESC LIMIT ?`,
|
||||
)
|
||||
.all(...werte, limit) as Buchung[];
|
||||
});
|
||||
|
||||
app.post('/api/buchungen', async (req, reply) => {
|
||||
try {
|
||||
const e = req.body as BuchungEingabe;
|
||||
const { konto, leistungsdatum, ust } = normalisiere(e);
|
||||
|
||||
let anlagegutId = e.anlagegut_id ?? null;
|
||||
// Ein Anlagekonto erzwingt die Aktivierung, auch ohne ausdrückliche Angabe –
|
||||
// sonst würde die Anschaffung stillschweigend voll als Betriebsausgabe abgezogen.
|
||||
const alsAnlage = e.art === 'ausgabe' && !anlagegutId && (!!e.anlage || istAnlagekonto(konto));
|
||||
|
||||
if (alsAnlage) {
|
||||
const plan = planeAnlage(e, konto, ust);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO anlagegueter
|
||||
(bezeichnung, anschaffungsdatum, akosten_netto, akosten_brutto,
|
||||
nutzungsdauer, afa_methode, konto)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
plan.bezeichnung, leistungsdatum, ust.netto, ust.brutto,
|
||||
plan.nutzungsdauer, plan.methode, konto.nummer,
|
||||
);
|
||||
anlagegutId = Number(info.lastInsertRowid);
|
||||
ust.hinweise.push(
|
||||
plan.methode === 'gwg_sofort'
|
||||
? 'Als geringwertiges Wirtschaftsgut erfasst und im Anschaffungsjahr voll abgesetzt (§6 Abs. 2 EStG).'
|
||||
: plan.methode === 'sammelposten'
|
||||
? 'In den Sammelposten eingestellt und über fünf Jahre aufgelöst (§6 Abs. 2a EStG).'
|
||||
: `Im Anlagevermögen aktiviert und linear über ${plan.nutzungsdauer} Jahre abgeschrieben.`,
|
||||
);
|
||||
}
|
||||
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO buchungen
|
||||
(art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto, betrag_netto,
|
||||
betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto,
|
||||
beleg_nr, buchungstext, partner, privatanteil_bp, anlagegut_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
e.art, e.belegdatum, leistungsdatum, e.zahlungsdatum || null,
|
||||
ust.brutto, ust.netto, ust.ust, ust.satzBp, ust.behandlung,
|
||||
ust.vorsteuerAbziehbar ? 1 : 0, konto.nummer,
|
||||
e.gegenkonto || vorbelegtesGegenkonto(), e.beleg_nr ?? '', e.buchungstext ?? '',
|
||||
e.partner ?? '', e.privatanteil_bp ?? 0, anlagegutId,
|
||||
);
|
||||
|
||||
const gespeichert = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(info.lastInsertRowid);
|
||||
protokolliere('buchungen', info.lastInsertRowid, 'angelegt', null, gespeichert);
|
||||
return { ...(gespeichert as Buchung), hinweise: ust.hinweise };
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ fehler: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/buchungen/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const vorher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id) as Buchung | undefined;
|
||||
if (!vorher) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' });
|
||||
if (vorher.festgeschrieben) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
'Die Buchung ist festgeschrieben und darf nach GoBD nicht mehr geändert werden. ' +
|
||||
'Bitte stornieren und neu erfassen.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const e = { ...vorher, ...(req.body as BuchungEingabe) } as BuchungEingabe & Buchung;
|
||||
const { konto, leistungsdatum, ust } = normalisiere({
|
||||
...e,
|
||||
brutto: (req.body as BuchungEingabe).brutto ?? vorher.betrag_brutto,
|
||||
});
|
||||
db.prepare(
|
||||
`UPDATE buchungen SET art=?, belegdatum=?, leistungsdatum=?, zahlungsdatum=?,
|
||||
betrag_brutto=?, betrag_netto=?, betrag_ust=?, ust_satz_bp=?, ust_behandlung=?,
|
||||
vorsteuer_abziehbar=?, konto=?, gegenkonto=?, beleg_nr=?, buchungstext=?, partner=?,
|
||||
privatanteil_bp=?, geaendert_am=datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
e.art, e.belegdatum, leistungsdatum, e.zahlungsdatum || null,
|
||||
ust.brutto, ust.netto, ust.ust, ust.satzBp, ust.behandlung,
|
||||
ust.vorsteuerAbziehbar ? 1 : 0, konto.nummer, e.gegenkonto || vorbelegtesGegenkonto(),
|
||||
e.beleg_nr ?? '', e.buchungstext ?? '', e.partner ?? '', e.privatanteil_bp ?? 0, id,
|
||||
);
|
||||
const nachher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id);
|
||||
protokolliere('buchungen', id, 'geaendert', vorher, nachher);
|
||||
return { ...(nachher as Buchung), hinweise: ust.hinweise };
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ fehler: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/** Zahlung nachtragen – erst damit wird die Buchung für die EÜR wirksam. */
|
||||
app.post('/api/buchungen/:id/zahlung', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { zahlungsdatum } = req.body as { zahlungsdatum: string | null };
|
||||
const vorher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id);
|
||||
if (!vorher) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' });
|
||||
db.prepare("UPDATE buchungen SET zahlungsdatum = ?, geaendert_am = datetime('now') WHERE id = ?")
|
||||
.run(zahlungsdatum, id);
|
||||
const nachher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id);
|
||||
protokolliere('buchungen', id, 'zahlung', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
|
||||
/**
|
||||
* GoBD-konforme Korrektur: statt zu löschen wird eine Gegenbuchung erzeugt
|
||||
* und der ursprüngliche Satz als storniert markiert.
|
||||
*/
|
||||
app.post('/api/buchungen/:id/storno', 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.storniert) return reply.code(409).send({ fehler: 'Die Buchung ist bereits storniert.' });
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('UPDATE buchungen SET storniert = 1 WHERE id = ?').run(id);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO buchungen
|
||||
(art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto, betrag_netto,
|
||||
betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto,
|
||||
beleg_nr, buchungstext, partner, privatanteil_bp, anlagegut_id, storniert, storno_von)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?)`,
|
||||
)
|
||||
.run(
|
||||
b.art, b.belegdatum, b.leistungsdatum, b.zahlungsdatum,
|
||||
-b.betrag_brutto, -b.betrag_netto, -b.betrag_ust, b.ust_satz_bp, b.ust_behandlung,
|
||||
b.vorsteuer_abziehbar, b.konto, b.gegenkonto, b.beleg_nr,
|
||||
`Storno zu Buchung ${b.id}: ${b.buchungstext}`, b.partner, b.privatanteil_bp,
|
||||
b.anlagegut_id, b.id,
|
||||
);
|
||||
return info.lastInsertRowid;
|
||||
});
|
||||
const stornoId = tx();
|
||||
protokolliere('buchungen', id, 'storniert', b, { stornoId });
|
||||
return { ok: true, stornoId };
|
||||
});
|
||||
|
||||
app.post('/api/buchungen/festschreiben', async (req) => {
|
||||
const { bis } = req.body as { bis: string };
|
||||
const info = db
|
||||
.prepare(
|
||||
`UPDATE buchungen SET festgeschrieben = 1
|
||||
WHERE festgeschrieben = 0 AND COALESCE(zahlungsdatum, leistungsdatum) <= ?`,
|
||||
)
|
||||
.run(bis);
|
||||
protokolliere('buchungen', 'sammel', 'festgeschrieben', null, { bis, anzahl: info.changes });
|
||||
return { anzahl: info.changes, bis };
|
||||
});
|
||||
|
||||
// ---- Anlagevermögen -----------------------------------------------------
|
||||
app.get('/api/anlagen', async (req) => {
|
||||
const { jahr } = req.query as { jahr?: string };
|
||||
const gueter = db.prepare('SELECT * FROM anlagegueter ORDER BY anschaffungsdatum').all() as Anlagegut[];
|
||||
const j = Number(jahr ?? new Date().getFullYear());
|
||||
return gueter.map((a) => ({
|
||||
...a,
|
||||
afa: afaFuer(a, j, regimeAm(a.anschaffungsdatum) === 'kleinunternehmer'),
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/anlagen', async (req) => {
|
||||
const a = req.body as Anlagegut;
|
||||
const methode = a.afa_methode ?? methodeVorschlag(a.akosten_netto);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO anlagegueter
|
||||
(bezeichnung, anschaffungsdatum, akosten_netto, akosten_brutto, nutzungsdauer,
|
||||
afa_methode, konto, notiz)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
a.bezeichnung, a.anschaffungsdatum, a.akosten_netto, a.akosten_brutto,
|
||||
a.nutzungsdauer, methode, a.konto ?? null, a.notiz ?? '',
|
||||
);
|
||||
protokolliere('anlagegueter', info.lastInsertRowid, 'angelegt', null, a);
|
||||
return db.prepare('SELECT * FROM anlagegueter WHERE id = ?').get(info.lastInsertRowid);
|
||||
});
|
||||
|
||||
app.put('/api/anlagen/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const a = req.body as Partial<Anlagegut>;
|
||||
const vorher = db.prepare('SELECT * FROM anlagegueter WHERE id = ?').get(id);
|
||||
const felder = (['bezeichnung', 'anschaffungsdatum', 'akosten_netto', 'akosten_brutto',
|
||||
'nutzungsdauer', 'afa_methode', 'konto', 'abgang_datum', 'abgang_erloes', 'notiz'] as const)
|
||||
.filter((f) => f in a);
|
||||
if (felder.length > 0) {
|
||||
db.prepare(`UPDATE anlagegueter SET ${felder.map((f) => `${f} = ?`).join(', ')} WHERE id = ?`)
|
||||
.run(...felder.map((f) => a[f] as string | number | null), id);
|
||||
}
|
||||
const nachher = db.prepare('SELECT * FROM anlagegueter WHERE id = ?').get(id);
|
||||
protokolliere('anlagegueter', id, 'aktualisiert', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { resolve } from 'node:path';
|
||||
import { datenVerzeichnis } from '../pfade.js';
|
||||
import { db, protokolliere } from '../db.js';
|
||||
import { berechneUstva } from '../domain/ustva.js';
|
||||
import { ustvaXml, type XmlOptionen } from '../elster/ustvaXml.js';
|
||||
import { ericStatus, uebermittle } from '../elster/eric.js';
|
||||
import { SteuernummerFehler } from '../elster/steuernummer.js';
|
||||
import type { Mandant } from '../domain/typen.js';
|
||||
|
||||
function mandant(): Mandant {
|
||||
return db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
}
|
||||
|
||||
export async function elsterRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/elster/status', async (req) => {
|
||||
const s = ericStatus();
|
||||
const testmandant = req.mandant?.testmandant ?? false;
|
||||
// Aus einem Testmandanten darf nur mit gesetztem Testmerker gesendet werden –
|
||||
// sonst ginge Spielmaterial als echte Anmeldung an das Finanzamt.
|
||||
const testmandantSperre = testmandant && !s.testmodus;
|
||||
return {
|
||||
...s,
|
||||
testmandant,
|
||||
testmandantSperre,
|
||||
mandant: req.mandant ? { slug: req.mandant.slug, name: req.mandant.name } : null,
|
||||
bereitZumSenden: s.verfuegbar && s.zertifikat && s.herstellerId && !testmandantSperre,
|
||||
anleitung: [
|
||||
'ERiC im ELSTER-Entwicklerportal (elster.de/elsterweb/entwickler) registrieren und herunterladen.',
|
||||
'ERIC_LIB_PATH auf libericapi.so und ERIC_PLUGIN_PATH auf das Verzeichnis plugins2 setzen.',
|
||||
'ELSTER-Zertifikat (.pfx) unter ELSTER_ZERTIFIKAT ablegen und ELSTER_PIN setzen.',
|
||||
'Registrierte Hersteller-ID unter ELSTER_HERSTELLER_ID hinterlegen.',
|
||||
'Für Probeübermittlungen ohne steuerliche Wirkung ELSTER_TESTMERKER=700000004 setzen.',
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/** XML-Vorschau, ohne ERiC – zum Nachvollziehen dessen, was übermittelt würde. */
|
||||
app.get('/api/elster/ustva/:jahr/:zeitraum/xml', async (req, reply) => {
|
||||
const { jahr, zeitraum } = req.params as { jahr: string; zeitraum: string };
|
||||
const opt = req.query as XmlOptionen;
|
||||
try {
|
||||
const ergebnis = berechneUstva(Number(jahr), zeitraum);
|
||||
const x = ustvaXml(ergebnis, mandant(), opt);
|
||||
return {
|
||||
xml: x.xml,
|
||||
datenartVersion: x.datenartVersion,
|
||||
bundesSteuernummer: x.bundesSteuernummer,
|
||||
warnungen: [...x.warnungen, ...ergebnis.hinweise],
|
||||
zahllast: ergebnis.zahllast,
|
||||
abgabepflichtig: ergebnis.abgabepflichtig,
|
||||
};
|
||||
} catch (e) {
|
||||
const code = e instanceof SteuernummerFehler ? 400 : 500;
|
||||
return reply.code(code).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/elster/ustva/:jahr/:zeitraum/validieren', async (req, reply) => {
|
||||
const { jahr, zeitraum } = req.params as { jahr: string; zeitraum: string };
|
||||
const opt = (req.body ?? {}) as XmlOptionen;
|
||||
try {
|
||||
const ergebnis = berechneUstva(Number(jahr), zeitraum);
|
||||
const x = ustvaXml(ergebnis, mandant(), opt);
|
||||
const antwort = await uebermittle(x.xml, x.datenartVersion, { nurValidieren: true });
|
||||
speichere(Number(jahr), zeitraum, ergebnis, x.xml, opt, antwort.erfolg ? 'validiert' : 'fehler', antwort);
|
||||
return { ...antwort, warnungen: x.warnungen, zahllast: ergebnis.zahllast };
|
||||
} catch (e) {
|
||||
return reply.code(400).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Echte Übermittlung. Sie ist gegenüber dem Finanzamt verbindlich und nicht
|
||||
* zurückzunehmen – deshalb muss der Aufruf ausdrücklich bestätigt werden.
|
||||
*/
|
||||
app.post('/api/elster/ustva/:jahr/:zeitraum/senden', async (req, reply) => {
|
||||
const { jahr, zeitraum } = req.params as { jahr: string; zeitraum: string };
|
||||
const body = (req.body ?? {}) as XmlOptionen & { bestaetigt?: boolean };
|
||||
|
||||
if (!body.bestaetigt) {
|
||||
return reply.code(428).send({
|
||||
fehler:
|
||||
'Die Übermittlung an das Finanzamt ist verbindlich und kann nicht zurückgenommen werden. ' +
|
||||
'Bitte mit "bestaetigt": true erneut aufrufen.',
|
||||
});
|
||||
}
|
||||
|
||||
if (req.mandant?.testmandant && !process.env['ELSTER_TESTMERKER']) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
`"${req.mandant.name}" ist als Testmandant angelegt – aus ihm wird nichts an das Finanzamt ` +
|
||||
'übermittelt. Für Probeübermittlungen ohne steuerliche Wirkung ELSTER_TESTMERKER=700000004 ' +
|
||||
'setzen, für den Echtbetrieb den realen Mandanten wählen.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ergebnis = berechneUstva(Number(jahr), zeitraum);
|
||||
if (!ergebnis.abgabepflichtig && !body.berichtigt) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
'Für diesen Zeitraum besteht keine Abgabepflicht (durchgehend Kleinunternehmer ohne ' +
|
||||
'Steuerschuld als Leistungsempfänger). Falls das Finanzamt dennoch eine Voranmeldung ' +
|
||||
'angefordert hat, bitte die Abgabepflicht in den Stammdaten klären.',
|
||||
});
|
||||
}
|
||||
|
||||
const x = ustvaXml(ergebnis, mandant(), body);
|
||||
const pdfPfad = resolve(datenVerzeichnis, `elster-ausgang/UStVA-${jahr}-${zeitraum}.pdf`);
|
||||
const antwort = await uebermittle(x.xml, x.datenartVersion, {
|
||||
nurValidieren: false,
|
||||
pdfPfad,
|
||||
});
|
||||
|
||||
const status = antwort.simulation ? 'entwurf' : antwort.erfolg ? 'uebermittelt' : 'fehler';
|
||||
const id = speichere(Number(jahr), zeitraum, ergebnis, x.xml, body, status, antwort);
|
||||
protokolliere('ustva_meldungen', id, status, null, {
|
||||
transferticket: antwort.transferticket,
|
||||
simulation: antwort.simulation,
|
||||
});
|
||||
|
||||
return { ...antwort, meldungId: id, warnungen: x.warnungen, zahllast: ergebnis.zahllast };
|
||||
} catch (e) {
|
||||
return reply.code(400).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/elster/meldungen', async () =>
|
||||
db.prepare('SELECT id, jahr, zeitraum, zahllast, status, berichtigung, transferticket, erstellt_am, uebermittelt_am FROM ustva_meldungen ORDER BY jahr DESC, zeitraum DESC').all(),
|
||||
);
|
||||
|
||||
app.get('/api/elster/meldungen/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
return db.prepare('SELECT * FROM ustva_meldungen WHERE id = ?').get(id);
|
||||
});
|
||||
}
|
||||
|
||||
function speichere(
|
||||
jahr: number,
|
||||
zeitraum: string,
|
||||
ergebnis: ReturnType<typeof berechneUstva>,
|
||||
xml: string,
|
||||
opt: XmlOptionen,
|
||||
status: string,
|
||||
antwort: Awaited<ReturnType<typeof uebermittle>>,
|
||||
): number {
|
||||
const berichtigung = opt.berichtigt ? 1 : 0;
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO ustva_meldungen
|
||||
(jahr, zeitraum, kennzahlen, zahllast, status, berichtigung, xml, transferticket,
|
||||
eric_protokoll, uebermittelt_am)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(jahr, zeitraum, berichtigung) DO UPDATE SET
|
||||
kennzahlen = excluded.kennzahlen, zahllast = excluded.zahllast,
|
||||
status = excluded.status, xml = excluded.xml,
|
||||
transferticket = COALESCE(excluded.transferticket, ustva_meldungen.transferticket),
|
||||
eric_protokoll = excluded.eric_protokoll,
|
||||
uebermittelt_am = COALESCE(excluded.uebermittelt_am, ustva_meldungen.uebermittelt_am)`,
|
||||
)
|
||||
.run(
|
||||
jahr, zeitraum, JSON.stringify(ergebnis.kennzahlen), ergebnis.zahllast, status,
|
||||
berichtigung, xml, antwort.transferticket,
|
||||
[antwort.protokoll, ...antwort.fehler, ...antwort.hinweise].filter(Boolean).join('\n'),
|
||||
status === 'uebermittelt' ? new Date().toISOString() : null,
|
||||
);
|
||||
if (info.lastInsertRowid) return Number(info.lastInsertRowid);
|
||||
const row = db
|
||||
.prepare('SELECT id FROM ustva_meldungen WHERE jahr = ? AND zeitraum = ? AND berichtigung = ?')
|
||||
.get(jahr, zeitraum, berichtigung) as { id: number };
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buchungsstapel, kontenbeschriftungen, alsAnsi } from '../export/datev.js';
|
||||
import { gdpduExport } from '../export/gdpdu.js';
|
||||
import { zip } from '../export/zip.js';
|
||||
import { berechneEuer } from '../domain/euer.js';
|
||||
import { berechneBwa } from '../domain/bwa.js';
|
||||
import { dezimal } from '../domain/geld.js';
|
||||
|
||||
export async function exportRouten(app: FastifyInstance): Promise<void> {
|
||||
/** DATEV-Buchungsstapel + Kontenbeschriftungen als ZIP für den Steuerberater. */
|
||||
app.get('/api/export/datev/:jahr', async (req, reply) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const q = req.query as { von?: string; bis?: string; festschreibung?: string; buSchluessel?: string };
|
||||
const opt = {
|
||||
jahr: Number(jahr),
|
||||
von: q.von,
|
||||
bis: q.bis,
|
||||
festschreibung: q.festschreibung === '1',
|
||||
mitBuSchluessel: q.buSchluessel === '1',
|
||||
};
|
||||
const stapel = buchungsstapel(opt);
|
||||
const konten = kontenbeschriftungen(opt);
|
||||
const paket = zip([
|
||||
{ name: stapel.dateiname, inhalt: stapel.inhalt },
|
||||
{ name: konten.dateiname, inhalt: konten.inhalt },
|
||||
{
|
||||
name: 'LIESMICH.txt',
|
||||
inhalt: alsAnsi(
|
||||
`DATEV-Export ${jahr}\r\n` +
|
||||
`Format: EXTF, Version 700, Buchungsstapel Formatversion 13\r\n` +
|
||||
`Zeichensatz: ANSI (Windows-1252), Trennzeichen ";"\r\n\r\n` +
|
||||
`Die Buchungen sind nach dem Zahlungszeitpunkt sortiert (Einnahmen-Überschuss-Rechnung).\r\n` +
|
||||
`Das Leistungsdatum steht in Feld 115, die Zuordnung zur Steuerperiode in Feld 116.\r\n` +
|
||||
(opt.mitBuSchluessel
|
||||
? 'BU-Schlüssel sind gesetzt – Automatikkonten im Ziel-Kontenrahmen beachten.\r\n'
|
||||
: 'Ohne BU-Schlüssel: die Steuer wird über die Automatikkonten des SKR abgeleitet.\r\n'),
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'application/zip')
|
||||
.header('Content-Disposition', `attachment; filename="DATEV-Export-${jahr}.zip"`)
|
||||
.send(paket);
|
||||
});
|
||||
|
||||
/** Einzelne Buchungsstapel-Datei (falls der Berater nur die CSV möchte). */
|
||||
app.get('/api/export/datev/:jahr/buchungsstapel.csv', async (req, reply) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const datei = buchungsstapel({ jahr: Number(jahr) });
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv; charset=windows-1252')
|
||||
.header('Content-Disposition', `attachment; filename="${datei.dateiname}"`)
|
||||
.send(datei.inhalt);
|
||||
});
|
||||
|
||||
/** GoBD-Datenträgerüberlassung (GDPdU, Format Z3) für die Betriebsprüfung. */
|
||||
app.get('/api/export/gdpdu/:jahr', async (req, reply) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const datei = gdpduExport(Number(jahr));
|
||||
return reply
|
||||
.header('Content-Type', 'application/zip')
|
||||
.header('Content-Disposition', `attachment; filename="${datei.dateiname}"`)
|
||||
.send(datei.inhalt);
|
||||
});
|
||||
|
||||
app.get('/api/export/euer/:jahr.csv', async (req, reply) => {
|
||||
const jahr = Number((req.params as Record<string, string>)['jahr']);
|
||||
const e = berechneEuer(jahr);
|
||||
const zeilen = [
|
||||
'Bereich;Zeile;Posten;Betrag',
|
||||
...e.einnahmen.map((z) => `Betriebseinnahmen;${z.zeile ?? ''};"${z.bezeichnung}";${dezimal(z.betrag)}`),
|
||||
`Summe;;"Summe der Betriebseinnahmen";${dezimal(e.summeEinnahmen)}`,
|
||||
...e.ausgaben.map((z) => `Betriebsausgaben;${z.zeile ?? ''};"${z.bezeichnung}";${dezimal(z.betrag)}`),
|
||||
`Summe;;"Summe der Betriebsausgaben";${dezimal(e.summeAusgaben)}`,
|
||||
`Ergebnis;;"Gewinn / Verlust";${dezimal(e.gewinn)}`,
|
||||
].join('\r\n');
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv; charset=windows-1252')
|
||||
.header('Content-Disposition', `attachment; filename="EUER-${jahr}.csv"`)
|
||||
.send(alsAnsi(zeilen + '\r\n'));
|
||||
});
|
||||
|
||||
app.get('/api/export/bwa/:jahr/:monat.csv', async (req, reply) => {
|
||||
const p = req.params as Record<string, string>;
|
||||
const jahr = Number(p['jahr']);
|
||||
const monat = Number(p['monat']);
|
||||
const b = berechneBwa(jahr, monat, (req.query as { basis?: 'zahlung' | 'leistung' }).basis ?? 'zahlung');
|
||||
const zeilen = [
|
||||
'Zeile;Monat;Kumuliert;Vorjahr kumuliert;Quote %',
|
||||
...b.zeilen.map(
|
||||
(z) =>
|
||||
`"${z.bezeichnung}";${dezimal(z.monat)};${dezimal(z.kumuliert)};` +
|
||||
`${dezimal(z.vorjahrKumuliert)};${z.quoteKumuliert.toFixed(1).replace('.', ',')}`,
|
||||
),
|
||||
].join('\r\n');
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv; charset=windows-1252')
|
||||
.header('Content-Disposition', `attachment; filename="BWA-${jahr}-${monat}.csv"`)
|
||||
.send(alsAnsi(zeilen + '\r\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { imMandantKontext, type MandantKontext } from '../db.js';
|
||||
import { istKontenrahmen, KONTENRAHMEN, type KontenrahmenId } from '../kontenrahmen/index.js';
|
||||
import {
|
||||
alleMandanten,
|
||||
benenneMandantUm,
|
||||
entferneMandant,
|
||||
findeMandant,
|
||||
kontextFuer,
|
||||
legeMandantAn,
|
||||
MandantFehler,
|
||||
standardMandant,
|
||||
} from '../mandanten.js';
|
||||
|
||||
/** Diese Pfade arbeiten mandantenübergreifend und brauchen keinen Kontext. */
|
||||
const OHNE_MANDANT = ['/api/mandanten', '/api/kontenrahmen', '/api/gesundheit'];
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
mandant?: MandantKontext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bindet jeden Request an genau einen Mandanten.
|
||||
*
|
||||
* Der Mandant kommt aus dem Header `X-Mandant` oder – für Download-Links, die
|
||||
* keine Header setzen können – aus dem Query-Parameter `mandant`. Der Handler
|
||||
* läuft anschließend innerhalb von imMandantKontext, sodass jeder db-Zugriff
|
||||
* automatisch auf der richtigen Datei landet.
|
||||
*/
|
||||
export function mandantenScoping(app: FastifyInstance): void {
|
||||
app.addHook('onRequest', (req: FastifyRequest, reply: FastifyReply, weiter: () => void) => {
|
||||
if (OHNE_MANDANT.some((p) => req.url.split('?')[0]?.startsWith(p))) return weiter();
|
||||
|
||||
const ausHeader = req.headers['x-mandant'];
|
||||
const ausQuery = (req.query as { mandant?: string } | undefined)?.mandant;
|
||||
const gewuenscht = (Array.isArray(ausHeader) ? ausHeader[0] : ausHeader) || ausQuery;
|
||||
|
||||
try {
|
||||
const eintrag = gewuenscht ? findeMandant(gewuenscht) : standardMandant();
|
||||
if (!eintrag) {
|
||||
return reply.code(404).send({
|
||||
fehler: `Der Mandant "${gewuenscht}" ist nicht bekannt. Verfügbar: ${alleMandanten()
|
||||
.map((m) => m.slug)
|
||||
.join(', ')}`,
|
||||
});
|
||||
}
|
||||
const kontext = kontextFuer(eintrag);
|
||||
req.mandant = kontext;
|
||||
reply.header('X-Mandant', kontext.slug);
|
||||
// weiter() innerhalb von run() aufrufen: die restliche Verarbeitung des
|
||||
// Requests läuft dadurch im Kontext dieses Mandanten.
|
||||
imMandantKontext(kontext, weiter);
|
||||
} catch (e) {
|
||||
reply.code(e instanceof MandantFehler ? 400 : 500).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function mandantenRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/kontenrahmen', async () =>
|
||||
Object.entries(KONTENRAHMEN).map(([id, r]) => ({ id, bezeichnung: r.bezeichnung, konten: r.konten.length })),
|
||||
);
|
||||
|
||||
app.get('/api/mandanten', async () => {
|
||||
const standard = (() => {
|
||||
try {
|
||||
return standardMandant().slug;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return {
|
||||
standard,
|
||||
mandanten: alleMandanten().map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
testmandant: m.testmandant === 1,
|
||||
angelegt_am: m.angelegt_am,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/mandanten', async (req, reply) => {
|
||||
const b = req.body as {
|
||||
name?: string; slug?: string; testmandant?: boolean; kontenrahmen?: string;
|
||||
};
|
||||
if (!b.name?.trim()) {
|
||||
return reply.code(400).send({ fehler: 'Ein Name ist erforderlich.' });
|
||||
}
|
||||
try {
|
||||
if (b.kontenrahmen && !istKontenrahmen(b.kontenrahmen)) {
|
||||
return reply.code(400).send({ fehler: `Unbekannter Kontenrahmen "${b.kontenrahmen}".` });
|
||||
}
|
||||
const eintrag = legeMandantAn({
|
||||
name: b.name.trim(),
|
||||
slug: b.slug,
|
||||
testmandant: b.testmandant,
|
||||
kontenrahmen: b.kontenrahmen as KontenrahmenId | undefined,
|
||||
});
|
||||
return {
|
||||
slug: eintrag.slug,
|
||||
name: eintrag.name,
|
||||
testmandant: eintrag.testmandant === 1,
|
||||
hinweis: b.testmandant
|
||||
? 'Testmandant angelegt. Aus ihm ist keine echte ELSTER-Übermittlung möglich.'
|
||||
: `Mandant angelegt, Kontenrahmen ${b.kontenrahmen ?? 'SKR04'} eingerichtet.`,
|
||||
};
|
||||
} catch (e) {
|
||||
return reply.code(e instanceof MandantFehler ? 409 : 500).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mandanten/:slug', async (req, reply) => {
|
||||
const { slug } = req.params as { slug: string };
|
||||
const b = req.body as { name?: string };
|
||||
if (!b.name?.trim()) return reply.code(400).send({ fehler: 'Ein Name ist erforderlich.' });
|
||||
try {
|
||||
const eintrag = benenneMandantUm(slug, b.name.trim());
|
||||
return { slug: eintrag.slug, name: eintrag.name };
|
||||
} catch (e) {
|
||||
return reply.code(404).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Entfernen ist eine schwer umkehrbare Sache – deshalb muss der Name genau
|
||||
* getippt werden, und die Datei wird nur beiseitegelegt, nicht gelöscht.
|
||||
*/
|
||||
app.delete('/api/mandanten/:slug', async (req, reply) => {
|
||||
const { slug } = req.params as { slug: string };
|
||||
const b = (req.body ?? {}) as { bestaetigterName?: string };
|
||||
const eintrag = findeMandant(slug);
|
||||
if (!eintrag) return reply.code(404).send({ fehler: `Mandant "${slug}" ist nicht bekannt.` });
|
||||
|
||||
if (b.bestaetigterName !== eintrag.name) {
|
||||
return reply.code(428).send({
|
||||
fehler:
|
||||
`Zum Entfernen bitte den Namen des Mandanten exakt bestätigen ("${eintrag.name}"). ` +
|
||||
'Die Datei wird dabei nicht gelöscht, sondern mit Zeitstempel beiseitegelegt.',
|
||||
});
|
||||
}
|
||||
if (alleMandanten().length === 1) {
|
||||
return reply.code(409).send({ fehler: 'Der letzte verbleibende Mandant kann nicht entfernt werden.' });
|
||||
}
|
||||
|
||||
const zeitstempel = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const { abgelegtAls } = entferneMandant(slug, zeitstempel);
|
||||
return { ok: true, abgelegtAls };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db, protokolliere } from '../db.js';
|
||||
import { alleRegime, grenzenStatus, regimeAm } from '../domain/regime.js';
|
||||
import { BUNDESLAENDER, bundeseinheitlich, SteuernummerFehler, type Bundesland } from '../elster/steuernummer.js';
|
||||
import { standardGegenkonto } from '../kontenrahmen/index.js';
|
||||
import type { Konto, Mandant } from '../domain/typen.js';
|
||||
|
||||
/*
|
||||
* `kontenrahmen` fehlt hier bewusst: das Feld wird beim Anlegen des Mandanten
|
||||
* aus dem tatsächlich eingespielten Kontenrahmen gesetzt. Wäre es frei
|
||||
* änderbar, könnte im Kopf des DATEV-Exports "SKR03" stehen, während die
|
||||
* Buchungszeilen SKR04-Nummern enthalten – der Import beim Steuerberater liefe
|
||||
* durch und verbuchte z.B. Erlöse als Aufwand, weil dieselbe Nummer in beiden
|
||||
* Rahmen etwas anderes bedeutet.
|
||||
*/
|
||||
const MANDANT_FELDER = [
|
||||
'name', 'rechtsform', 'strasse', 'plz', 'ort', 'bundesland', 'steuernummer',
|
||||
'ust_idnr', 'finanzamt_nr', 'taetigkeit', 'betriebsbeginn', 'besteuerungsart',
|
||||
'voranmeldungszeitraum', 'dauerfristverlaengerung',
|
||||
'datev_berater_nr', 'datev_mandanten_nr', 'telefon', 'email',
|
||||
] as const;
|
||||
|
||||
export async function stammdatenRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/mandant', async () => {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
let bundesSteuernummer: string | null = null;
|
||||
let steuernummerFehler: string | null = null;
|
||||
try {
|
||||
bundesSteuernummer = bundeseinheitlich(m.steuernummer, m.bundesland as Bundesland);
|
||||
} catch (e) {
|
||||
steuernummerFehler = e instanceof SteuernummerFehler ? e.message : String(e);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
bundesSteuernummer,
|
||||
steuernummerFehler,
|
||||
bundeslaender: BUNDESLAENDER,
|
||||
standardGegenkonto: standardGegenkonto(m.kontenrahmen),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/mandant', async (req, reply) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const vorher = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
|
||||
if ('kontenrahmen' in body && body['kontenrahmen'] !== vorher.kontenrahmen) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
`Der Kontenrahmen (${vorher.kontenrahmen}) lässt sich nachträglich nicht wechseln – ` +
|
||||
'alle vorhandenen Buchungen tragen Kontonummern dieses Rahmens, und dieselbe Nummer ' +
|
||||
'bedeutet in SKR03 und SKR04 etwas anderes. Für den anderen Rahmen bitte einen neuen ' +
|
||||
'Mandanten anlegen.',
|
||||
});
|
||||
}
|
||||
const felder = MANDANT_FELDER.filter((f) => f in body);
|
||||
if (felder.length > 0) {
|
||||
db.prepare(
|
||||
`UPDATE mandant SET ${felder.map((f) => `${f} = ?`).join(', ')} WHERE id = 1`,
|
||||
).run(...felder.map((f) => body[f] as string | number));
|
||||
}
|
||||
const nachher = db.prepare('SELECT * FROM mandant WHERE id = 1').get();
|
||||
protokolliere('mandant', 1, 'aktualisiert', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
|
||||
// ---- Besteuerungs-Regime ------------------------------------------------
|
||||
app.get('/api/regime', async () => {
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
return { eintraege: alleRegime(), aktuell: regimeAm(heute), stichtag: heute };
|
||||
});
|
||||
|
||||
app.post('/api/regime', async (req, reply) => {
|
||||
const b = req.body as { gueltig_ab: string; modus: string; grund?: string; notiz?: string };
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(b.gueltig_ab ?? '')) {
|
||||
return reply.code(400).send({ fehler: 'gueltig_ab muss im Format JJJJ-MM-TT vorliegen.' });
|
||||
}
|
||||
if (b.modus !== 'kleinunternehmer' && b.modus !== 'regelbesteuerung') {
|
||||
return reply.code(400).send({ fehler: 'Unbekannter Modus.' });
|
||||
}
|
||||
const info = db
|
||||
.prepare('INSERT INTO steuer_regime (gueltig_ab, modus, grund, notiz) VALUES (?,?,?,?)')
|
||||
.run(b.gueltig_ab, b.modus, b.grund ?? '', b.notiz ?? '');
|
||||
protokolliere('steuer_regime', info.lastInsertRowid, 'angelegt', null, b);
|
||||
|
||||
const hinweise: string[] = [];
|
||||
if (!b.gueltig_ab.endsWith('-01-01')) {
|
||||
hinweise.push(
|
||||
'Der Wechsel liegt nicht auf dem 1. Januar. Das ist nur in bestimmten Fällen richtig – ' +
|
||||
'etwa beim Überschreiten der 100.000-EUR-Grenze im laufenden Jahr (§19 Abs. 1 Satz 3 UStG) ' +
|
||||
'oder bei Betriebseröffnung.',
|
||||
);
|
||||
}
|
||||
hinweise.push(
|
||||
'Bestehende Buchungen bleiben unverändert; ihre umsatzsteuerliche Behandlung richtet sich ' +
|
||||
'weiterhin nach dem Regime zu ihrem Leistungsdatum. Buchungen nach dem Wechseldatum sind ' +
|
||||
'gegebenenfalls neu zu erfassen.',
|
||||
);
|
||||
return { id: info.lastInsertRowid, hinweise };
|
||||
});
|
||||
|
||||
app.delete('/api/regime/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const vorher = db.prepare('SELECT * FROM steuer_regime WHERE id = ?').get(id);
|
||||
db.prepare('DELETE FROM steuer_regime WHERE id = ?').run(id);
|
||||
protokolliere('steuer_regime', id, 'geloescht', vorher, null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/regime/grenzen/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
return grenzenStatus(Number(jahr));
|
||||
});
|
||||
|
||||
// ---- Konten -------------------------------------------------------------
|
||||
app.get('/api/konten', async (req) => {
|
||||
const { alle } = req.query as { alle?: string };
|
||||
const sql = alle
|
||||
? 'SELECT * FROM konten ORDER BY nummer'
|
||||
: 'SELECT * FROM konten WHERE aktiv = 1 ORDER BY nummer';
|
||||
return db.prepare(sql).all() as Konto[];
|
||||
});
|
||||
|
||||
app.put('/api/konten/:nummer', async (req) => {
|
||||
const { nummer } = req.params as { nummer: string };
|
||||
const b = req.body as Partial<Konto>;
|
||||
const vorher = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(nummer);
|
||||
const felder = (['bezeichnung', 'typ', 'euer_zeile', 'bwa_gruppe', 'ustva_kz',
|
||||
'ust_satz_bp', 'ust_behandlung', 'aktiv'] as const).filter((f) => f in b);
|
||||
if (felder.length > 0) {
|
||||
db.prepare(`UPDATE konten SET ${felder.map((f) => `${f} = ?`).join(', ')} WHERE nummer = ?`)
|
||||
.run(...felder.map((f) => b[f] as string | number | null), nummer);
|
||||
}
|
||||
const nachher = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(nummer);
|
||||
protokolliere('konten', nummer, 'aktualisiert', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
|
||||
app.post('/api/konten', async (req, reply) => {
|
||||
const b = req.body as Konto;
|
||||
if (!b.nummer || !b.bezeichnung) {
|
||||
return reply.code(400).send({ fehler: 'Nummer und Bezeichnung sind erforderlich.' });
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO konten (nummer, bezeichnung, typ, euer_zeile, bwa_gruppe, ustva_kz,
|
||||
ust_satz_bp, ust_behandlung, aktiv) VALUES (?,?,?,?,?,?,?,?,1)`,
|
||||
).run(
|
||||
b.nummer, b.bezeichnung, b.typ ?? 'aufwand', b.euer_zeile ?? null,
|
||||
b.bwa_gruppe ?? null, b.ustva_kz ?? null, b.ust_satz_bp ?? 1900,
|
||||
b.ust_behandlung ?? 'regel',
|
||||
);
|
||||
protokolliere('konten', b.nummer, 'angelegt', null, b);
|
||||
return db.prepare('SELECT * FROM konten WHERE nummer = ?').get(b.nummer);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import './env.js'; // muss vor db.js stehen (EUER_DB)
|
||||
import { db } from './db.js';
|
||||
import {
|
||||
findeMandant,
|
||||
kontenrahmenAnlegen,
|
||||
legeMandantAn,
|
||||
waehleMandantFuerSkript,
|
||||
} from './mandanten.js';
|
||||
import { berechneUst } from './domain/regime.js';
|
||||
|
||||
/**
|
||||
* Beispieldaten: ein Einzelunternehmen, das 2025 Kleinunternehmer ist und zum
|
||||
* 01.01.2026 in die Regelbesteuerung wechselt. Damit lässt sich der Wechsel
|
||||
* in EÜR, UStVA und BWA unmittelbar nachvollziehen.
|
||||
*
|
||||
* npm run seed -- legt die Beispieldaten an
|
||||
* npm run seed -- reset -- löscht vorher alle Bewegungsdaten
|
||||
*/
|
||||
|
||||
/*
|
||||
* Zielmandant über --mandant=<bezeichner>. Existiert er nicht, wird er als
|
||||
* Testmandant angelegt – Beispieldaten gehören nicht in eine echte Buchhaltung.
|
||||
*/
|
||||
const gewuenscht = process.argv.find((a) => a.startsWith('--mandant='))?.split('=')[1];
|
||||
if (gewuenscht && !findeMandant(gewuenscht)) {
|
||||
legeMandantAn({ name: `Testmandant (${gewuenscht})`, slug: gewuenscht, testmandant: true });
|
||||
console.log(`Testmandant "${gewuenscht}" angelegt.`);
|
||||
}
|
||||
const kontext = waehleMandantFuerSkript(gewuenscht);
|
||||
kontenrahmenAnlegen(kontext.verbindung);
|
||||
console.log(`Zielmandant: ${kontext.name} (${kontext.slug})${kontext.testmandant ? ' [Test]' : ''}`);
|
||||
|
||||
if (!kontext.testmandant && !process.argv.includes('--auch-echt')) {
|
||||
console.error(
|
||||
`\nAbbruch: "${kontext.name}" ist kein Testmandant.\n` +
|
||||
'Beispieldaten in einer echten Buchhaltung verfälschen EÜR und Voranmeldung.\n\n' +
|
||||
' npm run seed -- --mandant=test legt einen Testmandanten an und befüllt ihn\n' +
|
||||
' npm run seed -- --auch-echt erzwingt das Befüllen des aktuellen Mandanten\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const reset = process.argv.includes('reset');
|
||||
if (reset) {
|
||||
db.exec('DELETE FROM buchungen; DELETE FROM anlagegueter; DELETE FROM steuer_regime; DELETE FROM ustva_meldungen;');
|
||||
}
|
||||
|
||||
/*
|
||||
* Ohne diese Sperre fügt ein zweiter Aufruf die Beispieldaten erneut ein und
|
||||
* verdoppelt sämtliche Auswertungen. Vorhandene Daten werden nie stillschweigend
|
||||
* überschrieben – dafür gibt es `reset`.
|
||||
*/
|
||||
const vorhandene = (db.prepare('SELECT COUNT(*) AS n FROM buchungen').get() as { n: number }).n;
|
||||
if (vorhandene > 0) {
|
||||
console.error(
|
||||
`Abbruch: Die Datenbank enthält bereits ${vorhandene} Buchungen.\n` +
|
||||
'Ein erneutes Einspielen der Beispieldaten würde alle Auswertungen verfälschen.\n\n' +
|
||||
' npm run seed -- reset löscht alle Bewegungsdaten und legt die Beispieldaten neu an\n\n' +
|
||||
'Achtung: `reset` verwirft auch selbst erfasste Buchungen.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE mandant SET name=?, strasse=?, plz=?, ort=?, bundesland=?, steuernummer=?,
|
||||
taetigkeit=?, betriebsbeginn=?, besteuerungsart=?, voranmeldungszeitraum=?,
|
||||
telefon=?, email=?, datev_berater_nr=?, datev_mandanten_nr=? WHERE id=1`,
|
||||
).run(
|
||||
'Beispiel Einzelunternehmen', 'Musterweg 1', '80331', 'München', 'BY',
|
||||
'123/456/78901', 'IT-Dienstleistungen', '2024-01-15', 'ist', 'vierteljaehrlich',
|
||||
'089 1234567', 'kontakt@beispiel.de', '1234567', '54321',
|
||||
);
|
||||
|
||||
if ((db.prepare('SELECT COUNT(*) AS n FROM steuer_regime').get() as { n: number }).n === 0) {
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus, grund, notiz) VALUES (?,?,?,?)').run(
|
||||
'2024-01-15', 'kleinunternehmer', 'Betriebseröffnung', 'Anwendung des §19 UStG ab Gründung',
|
||||
);
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus, grund, notiz) VALUES (?,?,?,?)').run(
|
||||
'2026-01-01', 'regelbesteuerung', 'Vorjahresumsatz über 25.000 EUR',
|
||||
'Wechsel zum Jahresbeginn, da die Vorjahresgrenze 2025 überschritten wurde',
|
||||
);
|
||||
}
|
||||
|
||||
interface Zeile {
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
datum: string;
|
||||
brutto: number;
|
||||
konto: string;
|
||||
text: string;
|
||||
partner?: string;
|
||||
satz?: number;
|
||||
anlage?: { bezeichnung: string; nutzungsdauer: number };
|
||||
}
|
||||
|
||||
const zeilen: Zeile[] = [
|
||||
// 2025 – Kleinunternehmer
|
||||
...monatlich(2025, '4200', 'Beratungsleistung', 'Kundschaft GmbH', 2_400_00),
|
||||
{ art: 'ausgabe', datum: '2025-02-10', brutto: 59_50, konto: '6805', text: 'Internetanschluss Februar' },
|
||||
{ art: 'ausgabe', datum: '2025-03-05', brutto: 149_00, konto: '6815', text: 'Bürobedarf' },
|
||||
{ art: 'ausgabe', datum: '2025-06-20', brutto: 890_00, konto: '6825', text: 'Rechtsberatung Vertragsprüfung' },
|
||||
{ art: 'ausgabe', datum: '2025-09-01', brutto: 1_190_00, konto: '6600', text: 'Website-Relaunch' },
|
||||
{
|
||||
art: 'ausgabe', datum: '2025-04-12', brutto: 2_380_00, konto: '0670',
|
||||
text: 'Notebook', anlage: { bezeichnung: 'Notebook 16"', nutzungsdauer: 3 },
|
||||
},
|
||||
|
||||
// 2026 – Regelbesteuerung
|
||||
...monatlich(2026, '4400', 'Beratungsleistung', 'Kundschaft GmbH', 3_570_00),
|
||||
{ art: 'einnahme', datum: '2026-03-18', brutto: 1_200_00, konto: '4180', text: 'Beratung Kunde in Österreich', partner: 'AT-Kunde GmbH' },
|
||||
{ art: 'ausgabe', datum: '2026-01-15', brutto: 59_50, konto: '6805', text: 'Internetanschluss Januar' },
|
||||
{ art: 'ausgabe', datum: '2026-02-15', brutto: 59_50, konto: '6805', text: 'Internetanschluss Februar' },
|
||||
{ art: 'ausgabe', datum: '2026-02-28', brutto: 1_785_00, konto: '5900', text: 'Fremdleistung Grafik' },
|
||||
{ art: 'ausgabe', datum: '2026-03-31', brutto: 476_00, konto: '6827', text: 'Buchführung 1. Quartal' },
|
||||
{ art: 'ausgabe', datum: '2026-04-10', brutto: 238_00, konto: '6640', text: 'Geschäftsessen mit Kundschaft' },
|
||||
{ art: 'ausgabe', datum: '2026-05-05', brutto: 1_000_00, konto: '5906', text: 'Werbeleistung EU-Anbieter (§13b)' },
|
||||
];
|
||||
|
||||
const einfuegen = db.prepare(
|
||||
`INSERT INTO buchungen
|
||||
(art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto, betrag_netto, betrag_ust,
|
||||
ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto, beleg_nr,
|
||||
buchungstext, partner, anlagegut_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
|
||||
let nr = 1;
|
||||
db.transaction(() => {
|
||||
for (const z of zeilen) {
|
||||
const konto = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(z.konto) as
|
||||
| { ust_satz_bp: number; ust_behandlung: string }
|
||||
| undefined;
|
||||
if (!konto) continue;
|
||||
|
||||
const ust = berechneUst({
|
||||
art: z.art,
|
||||
leistungsdatum: z.datum,
|
||||
brutto: z.brutto,
|
||||
satzBp: z.satz ?? konto.ust_satz_bp,
|
||||
behandlung: konto.ust_behandlung as never,
|
||||
});
|
||||
|
||||
let anlageId: number | null = null;
|
||||
if (z.anlage) {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO anlagegueter (bezeichnung, anschaffungsdatum, akosten_netto,
|
||||
akosten_brutto, nutzungsdauer, afa_methode, konto)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(z.anlage.bezeichnung, z.datum, ust.netto, ust.brutto, z.anlage.nutzungsdauer, 'linear', z.konto);
|
||||
anlageId = Number(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
einfuegen.run(
|
||||
z.art, z.datum, z.datum, z.datum, ust.brutto, ust.netto, ust.ust, ust.satzBp,
|
||||
ust.behandlung, ust.vorsteuerAbziehbar ? 1 : 0, z.konto, '1800',
|
||||
`${z.art === 'einnahme' ? 'RE' : 'ER'}-${String(nr++).padStart(4, '0')}`,
|
||||
z.text, z.partner ?? '', anlageId,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
function monatlich(jahr: number, konto: string, text: string, partner: string, brutto: number): Zeile[] {
|
||||
return Array.from({ length: 12 }, (_, i) => ({
|
||||
art: 'einnahme' as const,
|
||||
datum: `${jahr}-${String(i + 1).padStart(2, '0')}-28`,
|
||||
brutto,
|
||||
konto,
|
||||
text: `${text} ${String(i + 1).padStart(2, '0')}/${jahr}`,
|
||||
partner,
|
||||
}));
|
||||
}
|
||||
|
||||
const anzahl = (db.prepare('SELECT COUNT(*) AS n FROM buchungen').get() as { n: number }).n;
|
||||
console.log(`Beispieldaten angelegt. Buchungen in der Datenbank: ${anzahl}`);
|
||||
console.log('Regimewechsel: Kleinunternehmer ab 15.01.2024, Regelbesteuerung ab 01.01.2026.');
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Einnahmen-Überschuss-Rechnung</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:polling": "EUER_POLLING=1 vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import MandantenWahl from './components/MandantenWahl';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Buchungen from './pages/Buchungen';
|
||||
import Anlagen from './pages/Anlagen';
|
||||
import Euer from './pages/Euer';
|
||||
import Bwa from './pages/Bwa';
|
||||
import Ustva from './pages/Ustva';
|
||||
import Stammdaten from './pages/Stammdaten';
|
||||
import Export from './pages/Export';
|
||||
|
||||
const NAVIGATION = [
|
||||
{
|
||||
gruppe: 'Übersicht',
|
||||
eintraege: [{ pfad: '/', titel: 'Startseite', exakt: true }],
|
||||
},
|
||||
{
|
||||
gruppe: 'Erfassung',
|
||||
eintraege: [
|
||||
{ pfad: '/buchungen', titel: 'Buchungen' },
|
||||
{ pfad: '/anlagen', titel: 'Anlagevermögen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
gruppe: 'Auswertungen',
|
||||
eintraege: [
|
||||
{ pfad: '/euer', titel: 'EÜR' },
|
||||
{ pfad: '/bwa', titel: 'BWA' },
|
||||
{ pfad: '/ustva', titel: 'USt-Voranmeldung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
gruppe: 'Verwaltung',
|
||||
eintraege: [
|
||||
{ pfad: '/stammdaten', titel: 'Stammdaten & Regime' },
|
||||
{ pfad: '/export', titel: 'Export' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="rahmen">
|
||||
<nav className="seitenleiste">
|
||||
<div className="marke">
|
||||
Einnahmen-Überschuss-Rechnung
|
||||
<small>open-eur-webapp</small>
|
||||
</div>
|
||||
<MandantenWahl />
|
||||
{NAVIGATION.map((g) => (
|
||||
<div key={g.gruppe}>
|
||||
<div className="nav-gruppe">{g.gruppe}</div>
|
||||
{g.eintraege.map((e) => (
|
||||
<NavLink
|
||||
key={e.pfad}
|
||||
to={e.pfad}
|
||||
end={'exakt' in e ? e.exakt : false}
|
||||
className={({ isActive }) => (isActive ? 'aktiv' : '')}
|
||||
>
|
||||
{e.titel}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<main className="inhalt">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/buchungen" element={<Buchungen />} />
|
||||
<Route path="/anlagen" element={<Anlagen />} />
|
||||
<Route path="/euer" element={<Euer />} />
|
||||
<Route path="/bwa" element={<Bwa />} />
|
||||
<Route path="/ustva" element={<Ustva />} />
|
||||
<Route path="/stammdaten" element={<Stammdaten />} />
|
||||
<Route path="/export" element={<Export />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { api, useDaten } from '../lib/api';
|
||||
import { aktiverMandant, wechsleMandant, type Mandant } from '../lib/mandant';
|
||||
|
||||
interface Antwort {
|
||||
standard: string | null;
|
||||
mandanten: Mandant[];
|
||||
}
|
||||
|
||||
export default function MandantenWahl() {
|
||||
const { daten, neuLaden } = useDaten<Antwort>('/api/mandanten');
|
||||
const { daten: rahmen } = useDaten<{ id: string; bezeichnung: string; konten: number }[]>(
|
||||
'/api/kontenrahmen',
|
||||
);
|
||||
const [anlegen, setAnlegen] = useState(false);
|
||||
const [neu, setNeu] = useState({ name: '', testmandant: true, kontenrahmen: 'SKR04' });
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
if (!daten) return null;
|
||||
|
||||
const aktiv = aktiverMandant() ?? daten.standard;
|
||||
const gewaehlt = daten.mandanten.find((m) => m.slug === aktiv);
|
||||
|
||||
async function speichern(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFehler(null);
|
||||
try {
|
||||
const r = await api.post<{ slug: string }>('/api/mandanten', neu);
|
||||
neuLaden();
|
||||
wechsleMandant(r.slug);
|
||||
} catch (err) {
|
||||
setFehler((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mandantenwahl">
|
||||
<label htmlFor="mandant">Mandant</label>
|
||||
<select
|
||||
id="mandant"
|
||||
value={aktiv ?? ''}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__neu__') setAnlegen(true);
|
||||
else wechsleMandant(e.target.value);
|
||||
}}
|
||||
>
|
||||
{daten.mandanten.map((m) => (
|
||||
<option key={m.slug} value={m.slug}>
|
||||
{m.name}
|
||||
{m.testmandant ? ' (Test)' : ''}
|
||||
</option>
|
||||
))}
|
||||
<option value="__neu__">+ Neuer Mandant …</option>
|
||||
</select>
|
||||
|
||||
{gewaehlt?.testmandant && (
|
||||
<div className="testkennung" role="status">
|
||||
Testmandant – keine Übermittlung ans Finanzamt
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anlegen && (
|
||||
<form onSubmit={speichern} className="mandant-neu">
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
placeholder="Name des Unternehmens"
|
||||
value={neu.name}
|
||||
onChange={(e) => setNeu({ ...neu, name: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
value={neu.kontenrahmen}
|
||||
onChange={(e) => setNeu({ ...neu, kontenrahmen: e.target.value })}
|
||||
>
|
||||
{(rahmen ?? []).map((r) => (
|
||||
<option key={r.id} value={r.id} title={r.bezeichnung}>
|
||||
{r.id} ({r.konten} Konten)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="hilfe" style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Nach dem Anlegen nicht mehr wechselbar – dieselbe Kontonummer bedeutet in SKR03 und
|
||||
SKR04 etwas anderes.
|
||||
</span>
|
||||
<label className="ankreuz">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={neu.testmandant}
|
||||
onChange={(e) => setNeu({ ...neu, testmandant: e.target.checked })}
|
||||
/>
|
||||
Testmandant
|
||||
</label>
|
||||
{fehler && <div className="hinweis kritisch">{fehler}</div>}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="submit" className="primaer" style={{ flex: 1, padding: '5px 8px', fontSize: 13 }}>
|
||||
Anlegen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ padding: '5px 8px', fontSize: 13 }}
|
||||
onClick={() => {
|
||||
setAnlegen(false);
|
||||
setFehler(null);
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { eur, eurKurz } from '../lib/format';
|
||||
|
||||
export interface MonatsWert {
|
||||
monat: string; // YYYY-MM
|
||||
einnahmen: number;
|
||||
ausgaben: number;
|
||||
}
|
||||
|
||||
const KURZ = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
|
||||
const BREITE = 760;
|
||||
const HOEHE = 220;
|
||||
const RAND = { oben: 12, rechts: 8, unten: 26, links: 52 };
|
||||
const ECKE = 4;
|
||||
const LUECKE = 2; // Trennung zwischen benachbarten Balken
|
||||
|
||||
/** Rechteck mit abgerundeten oberen Ecken, an der Grundlinie verankert. */
|
||||
function balkenPfad(x: number, y: number, b: number, h: number): string {
|
||||
if (h <= 0) return '';
|
||||
const r = Math.min(ECKE, h, b / 2);
|
||||
return `M${x},${y + h}V${y + r}a${r},${r} 0 0 1 ${r},${-r}h${b - 2 * r}a${r},${r} 0 0 1 ${r},${r}V${y + h}Z`;
|
||||
}
|
||||
|
||||
export default function MonatsDiagramm({ werte }: { werte: MonatsWert[] }) {
|
||||
const [aktiv, setAktiv] = useState<number | null>(null);
|
||||
|
||||
const max = Math.max(1, ...werte.flatMap((w) => [w.einnahmen, w.ausgaben]));
|
||||
// Achsenschritt aus einer Leiter runder Zahlen wählen, damit die
|
||||
// Beschriftungen nicht auf krummen Werten wie "1,3 T€" landen.
|
||||
const roh = max / 4;
|
||||
const zehner = Math.pow(10, Math.floor(Math.log10(roh)));
|
||||
const schritt = ([1, 2, 2.5, 5, 10].find((f) => f * zehner >= roh) ?? 10) * zehner;
|
||||
const obergrenze = Math.ceil(max / schritt) * schritt;
|
||||
|
||||
const plotBreite = BREITE - RAND.links - RAND.rechts;
|
||||
const plotHoehe = HOEHE - RAND.oben - RAND.unten;
|
||||
const gruppenBreite = plotBreite / werte.length;
|
||||
const balkenBreite = Math.max(3, (gruppenBreite - 10 - LUECKE) / 2);
|
||||
const y = (v: number) => RAND.oben + plotHoehe - (v / obergrenze) * plotHoehe;
|
||||
|
||||
const gitter = Array.from({ length: Math.round(obergrenze / schritt) + 1 }, (_, i) => i * schritt);
|
||||
const gezeigt = aktiv !== null ? werte[aktiv] : null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="legende">
|
||||
<span>
|
||||
<i style={{ background: 'var(--series-1)' }} /> Betriebseinnahmen
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: 'var(--series-2)' }} /> Betriebsausgaben
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="diagramm"
|
||||
viewBox={`0 0 ${BREITE} ${HOEHE}`}
|
||||
role="img"
|
||||
aria-label="Betriebseinnahmen und Betriebsausgaben je Monat"
|
||||
onMouseLeave={() => setAktiv(null)}
|
||||
>
|
||||
{gitter.map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={RAND.links}
|
||||
x2={BREITE - RAND.rechts}
|
||||
y1={y(v)}
|
||||
y2={y(v)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={RAND.links - 8}
|
||||
y={y(v) + 4}
|
||||
textAnchor="end"
|
||||
fontSize={11}
|
||||
fill="var(--text-muted)"
|
||||
>
|
||||
{eurKurz(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{werte.map((w, i) => {
|
||||
const x0 = RAND.links + i * gruppenBreite + 5;
|
||||
return (
|
||||
<g
|
||||
key={w.monat}
|
||||
onMouseEnter={() => setAktiv(i)}
|
||||
onFocus={() => setAktiv(i)}
|
||||
tabIndex={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* großzügige Trefferfläche über die gesamte Monatsspalte */}
|
||||
<rect
|
||||
x={RAND.links + i * gruppenBreite}
|
||||
y={RAND.oben}
|
||||
width={gruppenBreite}
|
||||
height={plotHoehe}
|
||||
fill={aktiv === i ? 'var(--surface-2)' : 'transparent'}
|
||||
/>
|
||||
<path
|
||||
d={balkenPfad(x0, y(w.einnahmen), balkenBreite, plotHoehe - (y(w.einnahmen) - RAND.oben))}
|
||||
fill="var(--series-1)"
|
||||
/>
|
||||
<path
|
||||
d={balkenPfad(
|
||||
x0 + balkenBreite + LUECKE,
|
||||
y(w.ausgaben),
|
||||
balkenBreite,
|
||||
plotHoehe - (y(w.ausgaben) - RAND.oben),
|
||||
)}
|
||||
fill="var(--series-2)"
|
||||
/>
|
||||
<text
|
||||
x={RAND.links + i * gruppenBreite + gruppenBreite / 2}
|
||||
y={HOEHE - 8}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="var(--text-muted)"
|
||||
>
|
||||
{KURZ[i]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<line
|
||||
x1={RAND.links}
|
||||
x2={BREITE - RAND.rechts}
|
||||
y1={y(0)}
|
||||
y2={y(0)}
|
||||
stroke="var(--border-stark)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{gezeigt && aktiv !== null && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 26,
|
||||
left: `${((RAND.links + aktiv * gruppenBreite + gruppenBreite / 2) / BREITE) * 100}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'var(--surface-1)',
|
||||
border: '1px solid var(--border-stark)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{KURZ[aktiv]} {gezeigt.monat.slice(0, 4)}
|
||||
</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<i
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: 'var(--series-1)',
|
||||
marginRight: 6,
|
||||
}}
|
||||
/>
|
||||
Einnahmen <span className="zahl">{eur(gezeigt.einnahmen)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<i
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: 'var(--series-2)',
|
||||
marginRight: 6,
|
||||
}}
|
||||
/>
|
||||
Ausgaben <span className="zahl">{eur(gezeigt.ausgaben)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { aktiverMandant } from './mandant';
|
||||
|
||||
export class ApiFehler extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function anfrage<T>(pfad: string, init?: RequestInit): Promise<T> {
|
||||
// Jeder Aufruf trägt den aktiven Mandanten; ohne Angabe wählt der Server.
|
||||
const slug = aktiverMandant();
|
||||
const antwort = await fetch(pfad, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(slug ? { 'X-Mandant': slug } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const text = await antwort.text();
|
||||
const daten = text ? JSON.parse(text) : null;
|
||||
if (!antwort.ok) {
|
||||
throw new ApiFehler(daten?.fehler ?? `Fehler ${antwort.status}`, antwort.status);
|
||||
}
|
||||
return daten as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T,>(pfad: string) => anfrage<T>(pfad),
|
||||
post: <T,>(pfad: string, body?: unknown) =>
|
||||
anfrage<T>(pfad, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
||||
put: <T,>(pfad: string, body: unknown) =>
|
||||
anfrage<T>(pfad, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
del: <T,>(pfad: string) => anfrage<T>(pfad, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export function useDaten<T>(pfad: string | null): {
|
||||
daten: T | null;
|
||||
fehler: string | null;
|
||||
laedt: boolean;
|
||||
neuLaden: () => void;
|
||||
} {
|
||||
const [daten, setDaten] = useState<T | null>(null);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [laedt, setLaedt] = useState(true);
|
||||
const [zaehler, setZaehler] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pfad) return;
|
||||
let abgebrochen = false;
|
||||
setLaedt(true);
|
||||
api
|
||||
.get<T>(pfad)
|
||||
.then((d) => {
|
||||
if (!abgebrochen) {
|
||||
setDaten(d);
|
||||
setFehler(null);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => !abgebrochen && setFehler(e.message))
|
||||
.finally(() => !abgebrochen && setLaedt(false));
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
};
|
||||
}, [pfad, zaehler]);
|
||||
|
||||
const neuLaden = useCallback(() => setZaehler((z) => z + 1), []);
|
||||
return { daten, fehler, laedt, neuLaden };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Alle Beträge kommen als ganzzahlige Cent aus dem Backend. */
|
||||
|
||||
export function eur(cent: number, mitZeichen = false): string {
|
||||
const s = (cent / 100).toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return mitZeichen && cent > 0 ? `+${s} €` : `${s} €`;
|
||||
}
|
||||
|
||||
export function eurKurz(cent: number): string {
|
||||
const v = cent / 100;
|
||||
if (Math.abs(v) >= 1000) return `${(v / 1000).toLocaleString('de-DE', { maximumFractionDigits: 1 })} T€`;
|
||||
return `${v.toLocaleString('de-DE', { maximumFractionDigits: 0 })} €`;
|
||||
}
|
||||
|
||||
/** Eingabefeld "1.234,56" -> 123456 Cent */
|
||||
export function centAus(eingabe: string): number {
|
||||
const bereinigt = eingabe.replace(/\s|€/g, '').replace(/\./g, '').replace(',', '.');
|
||||
const zahl = Number.parseFloat(bereinigt);
|
||||
return Number.isFinite(zahl) ? Math.round(zahl * 100) : 0;
|
||||
}
|
||||
|
||||
export function centZuFeld(cent: number): string {
|
||||
return (cent / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
export function datum(iso: string | null): string {
|
||||
if (!iso) return '–';
|
||||
const [j, m, t] = iso.slice(0, 10).split('-');
|
||||
return `${t}.${m}.${j}`;
|
||||
}
|
||||
|
||||
export function prozent(bp: number): string {
|
||||
return `${(bp / 100).toLocaleString('de-DE', { maximumFractionDigits: 2 })} %`;
|
||||
}
|
||||
|
||||
export const MONATSNAMEN = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
export const ZEITRAUM_LABEL: Record<string, string> = {
|
||||
monatlich: 'monatlich',
|
||||
vierteljaehrlich: 'vierteljährlich',
|
||||
jaehrlich: 'jährlich',
|
||||
};
|
||||
|
||||
/** Prozentwert mit deutschem Dezimalkomma. */
|
||||
export function proz(wert: number, stellen = 1, mitVorzeichen = false): string {
|
||||
const s = wert.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: stellen,
|
||||
maximumFractionDigits: stellen,
|
||||
});
|
||||
return `${mitVorzeichen && wert > 0 ? '+' : ''}${s} %`;
|
||||
}
|
||||
|
||||
export const REGIME_LABEL: Record<string, string> = {
|
||||
kleinunternehmer: 'Kleinunternehmer (§19 UStG)',
|
||||
regelbesteuerung: 'Regelbesteuerung',
|
||||
};
|
||||
|
||||
export const BEHANDLUNG_LABEL: Record<string, string> = {
|
||||
regel: 'Steuerpflichtig',
|
||||
kleinunternehmer: 'Kleinunternehmer §19',
|
||||
steuerfrei_mit_vst: 'Steuerfrei mit Vorsteuerabzug',
|
||||
steuerfrei_ohne_vst: 'Steuerfrei ohne Vorsteuerabzug',
|
||||
ig_lieferung: 'Innergem. Lieferung',
|
||||
ig_erwerb: 'Innergem. Erwerb',
|
||||
reverse_charge_13b: 'Reverse Charge §13b',
|
||||
nicht_steuerbar: 'Nicht steuerbar',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const SCHLUESSEL = 'euer.jahr';
|
||||
|
||||
/** Das gewählte Wirtschaftsjahr wird seitenübergreifend gemerkt. */
|
||||
export function useJahr(): [number, (j: number) => void, number[]] {
|
||||
const [jahr, setJahrIntern] = useState(() => {
|
||||
const gespeichert = Number(localStorage.getItem(SCHLUESSEL));
|
||||
return gespeichert || new Date().getFullYear();
|
||||
});
|
||||
|
||||
const setJahr = useCallback((j: number) => {
|
||||
localStorage.setItem(SCHLUESSEL, String(j));
|
||||
setJahrIntern(j);
|
||||
}, []);
|
||||
|
||||
const aktuell = new Date().getFullYear();
|
||||
const auswahl = Array.from({ length: 7 }, (_, i) => aktuell + 1 - i);
|
||||
return [jahr, setJahr, auswahl];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const SCHLUESSEL = 'euer.mandant';
|
||||
|
||||
export interface Mandant {
|
||||
slug: string;
|
||||
name: string;
|
||||
testmandant: boolean;
|
||||
angelegt_am?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der aktive Mandant steht im localStorage und wird bei jedem API-Aufruf als
|
||||
* Header mitgeschickt. Ist nichts gesetzt, entscheidet der Server (erster
|
||||
* echter Mandant) – so funktioniert die Anwendung auch beim ersten Start.
|
||||
*/
|
||||
export function aktiverMandant(): string | null {
|
||||
return localStorage.getItem(SCHLUESSEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt den Mandanten und lädt die Anwendung neu. Der harte Neuaufbau ist
|
||||
* hier das Richtige: sonst blieben Zwischenstände des vorherigen Mandanten in
|
||||
* den Formularen stehen und man bucht am Ende im falschen Unternehmen.
|
||||
*/
|
||||
export function wechsleMandant(slug: string): void {
|
||||
localStorage.setItem(SCHLUESSEL, slug);
|
||||
window.location.assign('/');
|
||||
}
|
||||
|
||||
export function vergissMandant(): void {
|
||||
localStorage.removeItem(SCHLUESSEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download-Links sind einfache <a href>-Elemente und können keine Header
|
||||
* setzen – deshalb wird der Mandant dort als Query-Parameter angehängt.
|
||||
*/
|
||||
export function mitMandant(url: string): string {
|
||||
const slug = aktiverMandant();
|
||||
if (!slug) return url;
|
||||
return url + (url.includes('?') ? '&' : '?') + `mandant=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { api, useDaten } from '../lib/api';
|
||||
import { useJahr } from '../lib/jahr';
|
||||
import { centAus, datum, eur } from '../lib/format';
|
||||
|
||||
interface AnlageMitAfa {
|
||||
id: number;
|
||||
bezeichnung: string;
|
||||
anschaffungsdatum: string;
|
||||
akosten_netto: number;
|
||||
akosten_brutto: number;
|
||||
nutzungsdauer: number;
|
||||
afa_methode: string;
|
||||
abgang_datum: string | null;
|
||||
notiz: string;
|
||||
afa: {
|
||||
bemessung: number;
|
||||
buchwertAnfang: number;
|
||||
afa: number;
|
||||
buchwertEnde: number;
|
||||
restbuchwertAbgang: number;
|
||||
hinweis: string;
|
||||
};
|
||||
}
|
||||
|
||||
const METHODEN: Record<string, string> = {
|
||||
linear: 'linear (§7 Abs. 1 EStG)',
|
||||
gwg_sofort: 'GWG-Sofortabschreibung',
|
||||
sammelposten: 'Sammelposten über 5 Jahre',
|
||||
keine: 'nicht abnutzbar',
|
||||
};
|
||||
|
||||
export default function Anlagen() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const { daten, neuLaden } = useDaten<AnlageMitAfa[]>(`/api/anlagen?jahr=${jahr}`);
|
||||
const [form, setForm] = useState({
|
||||
bezeichnung: '',
|
||||
anschaffungsdatum: new Date().toISOString().slice(0, 10),
|
||||
nettoFeld: '',
|
||||
bruttoFeld: '',
|
||||
nutzungsdauer: '3',
|
||||
afa_methode: '',
|
||||
});
|
||||
const [meldung, setMeldung] = useState<string | null>(null);
|
||||
|
||||
async function anlegen(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const netto = centAus(form.nettoFeld);
|
||||
await api.post('/api/anlagen', {
|
||||
bezeichnung: form.bezeichnung,
|
||||
anschaffungsdatum: form.anschaffungsdatum,
|
||||
akosten_netto: netto,
|
||||
akosten_brutto: form.bruttoFeld ? centAus(form.bruttoFeld) : netto,
|
||||
nutzungsdauer: Number(form.nutzungsdauer) || 3,
|
||||
afa_methode: form.afa_methode || undefined,
|
||||
});
|
||||
setForm({ ...form, bezeichnung: '', nettoFeld: '', bruttoFeld: '' });
|
||||
setMeldung('Anlagegut angelegt.');
|
||||
neuLaden();
|
||||
} catch (err) {
|
||||
setMeldung((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function abgangEintragen(a: AnlageMitAfa) {
|
||||
const d = prompt(`Abgangsdatum für "${a.bezeichnung}" (JJJJ-MM-TT):`, `${jahr}-12-31`);
|
||||
if (!d) return;
|
||||
await api.put(`/api/anlagen/${a.id}`, { abgang_datum: d });
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
const summeAfa = (daten ?? []).reduce((s, a) => s + a.afa.afa, 0);
|
||||
const summeBuchwert = (daten ?? []).reduce((s, a) => s + a.afa.buchwertEnde, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Anlagevermögen</h1>
|
||||
<p className="untertitel">
|
||||
Wirtschaftsgüter mit mehrjähriger Nutzung sind nicht sofort abziehbar, sondern über die
|
||||
Nutzungsdauer abzuschreiben. Die AfA fließt automatisch in EÜR und BWA ein.
|
||||
</p>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label>Jahr</label>
|
||||
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kachelreihe">
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Abschreibung {jahr}</div>
|
||||
<div className="wert zahl">{eur(summeAfa)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Buchwert 31.12.{jahr}</div>
|
||||
<div className="wert zahl">{eur(summeBuchwert)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<h2>Anlagegut erfassen</h2>
|
||||
<form onSubmit={anlegen}>
|
||||
<div className="formularraster">
|
||||
<div className="feld" style={{ gridColumn: 'span 2' }}>
|
||||
<label>Bezeichnung</label>
|
||||
<input required value={form.bezeichnung} onChange={(e) => setForm({ ...form, bezeichnung: e.target.value })} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Anschaffungsdatum</label>
|
||||
<input type="date" required value={form.anschaffungsdatum} onChange={(e) => setForm({ ...form, anschaffungsdatum: e.target.value })} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Anschaffungskosten netto</label>
|
||||
<input required inputMode="decimal" placeholder="0,00" value={form.nettoFeld} onChange={(e) => setForm({ ...form, nettoFeld: e.target.value })} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>… brutto</label>
|
||||
<input inputMode="decimal" placeholder="leer = wie netto" value={form.bruttoFeld} onChange={(e) => setForm({ ...form, bruttoFeld: e.target.value })} />
|
||||
<span className="hilfe">für Kleinunternehmer maßgeblich</span>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Nutzungsdauer (Jahre)</label>
|
||||
<input inputMode="numeric" value={form.nutzungsdauer} onChange={(e) => setForm({ ...form, nutzungsdauer: e.target.value })} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>AfA-Methode</label>
|
||||
<select value={form.afa_methode} onChange={(e) => setForm({ ...form, afa_methode: e.target.value })}>
|
||||
<option value="">automatisch nach Betrag</option>
|
||||
{Object.entries(METHODEN).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knopfreihe">
|
||||
<button type="submit" className="primaer">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{meldung && <div className="hinweis" style={{ marginTop: 12 }}>{meldung}</div>}
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Wirtschaftsgut</th><th>Anschaffung</th><th>Methode</th>
|
||||
<th className="rechts">Bemessung</th><th className="rechts">Buchwert 1.1.</th>
|
||||
<th className="rechts">AfA {jahr}</th><th className="rechts">Buchwert 31.12.</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(daten ?? []).map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>
|
||||
{a.bezeichnung}
|
||||
{a.abgang_datum && <div className="abzeichen">Abgang {datum(a.abgang_datum)}</div>}
|
||||
</td>
|
||||
<td>{datum(a.anschaffungsdatum)}</td>
|
||||
<td className="gedimmt" style={{ fontSize: 12 }}>{METHODEN[a.afa_methode] ?? a.afa_methode}</td>
|
||||
<td className="rechts zahl">{eur(a.afa.bemessung)}</td>
|
||||
<td className="rechts zahl">{eur(a.afa.buchwertAnfang)}</td>
|
||||
<td className="rechts zahl">{eur(a.afa.afa)}</td>
|
||||
<td className="rechts zahl">{eur(a.afa.buchwertEnde)}</td>
|
||||
<td className="rechts">
|
||||
{!a.abgang_datum && (
|
||||
<button type="button" style={{ padding: '3px 8px', fontSize: 12 }} onClick={() => abgangEintragen(a)}>
|
||||
Abgang
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{daten?.length === 0 && (
|
||||
<tr><td colSpan={8} className="gedimmt">Noch kein Anlagevermögen erfasst.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
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';
|
||||
|
||||
interface Konto {
|
||||
nummer: string;
|
||||
bezeichnung: string;
|
||||
typ: string;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: string;
|
||||
}
|
||||
|
||||
interface Buchung {
|
||||
id: number;
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
belegdatum: string;
|
||||
leistungsdatum: string;
|
||||
zahlungsdatum: string | null;
|
||||
betrag_brutto: number;
|
||||
betrag_netto: number;
|
||||
betrag_ust: number;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: string;
|
||||
vorsteuer_abziehbar: number;
|
||||
konto: string;
|
||||
beleg_nr: string;
|
||||
buchungstext: string;
|
||||
partner: string;
|
||||
storniert: number;
|
||||
festgeschrieben: number;
|
||||
anlagegut_id: number | null;
|
||||
}
|
||||
|
||||
interface Aktivierung {
|
||||
/** true = das Konto erzwingt die Aktivierung, das Häkchen ist nicht abwählbar */
|
||||
erforderlich: boolean;
|
||||
methode: 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine';
|
||||
bemessung: number;
|
||||
nutzungsdauerNoetig: boolean;
|
||||
}
|
||||
|
||||
const METHODE_TEXT: Record<string, string> = {
|
||||
gwg_sofort: 'geringwertiges Wirtschaftsgut – im Anschaffungsjahr voll abgesetzt (§6 Abs. 2 EStG)',
|
||||
sammelposten: 'Sammelposten – über fünf Jahre aufgelöst (§6 Abs. 2a EStG)',
|
||||
linear: 'linear über die Nutzungsdauer abzuschreiben (§7 Abs. 1 EStG)',
|
||||
keine: 'nicht abnutzbar – keine Abschreibung',
|
||||
};
|
||||
|
||||
interface Vorschau {
|
||||
regime: string;
|
||||
aktivierung: Aktivierung | null;
|
||||
brutto: number;
|
||||
netto: number;
|
||||
ust: number;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: string;
|
||||
vorsteuer_abziehbar: boolean;
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
const heute = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const LEER = {
|
||||
art: 'ausgabe' as 'einnahme' | 'ausgabe',
|
||||
belegdatum: heute(),
|
||||
leistungsdatum: '',
|
||||
zahlungsdatum: heute(),
|
||||
betragFeld: '',
|
||||
konto: '',
|
||||
gegenkonto: '',
|
||||
beleg_nr: '',
|
||||
buchungstext: '',
|
||||
partner: '',
|
||||
privatanteilFeld: '0',
|
||||
alsAnlage: false,
|
||||
anlageBezeichnung: '',
|
||||
anlageNutzungsdauer: '3',
|
||||
};
|
||||
|
||||
export default function Buchungen() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const [filter, setFilter] = useState({ art: '', suche: '', nurOffen: false });
|
||||
const [form, setForm] = useState(LEER);
|
||||
const [vorschau, setVorschau] = useState<Vorschau | null>(null);
|
||||
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
|
||||
|
||||
const { daten: konten } = useDaten<Konto[]>('/api/konten');
|
||||
// 1800 ist NUR im SKR04 die Bank – im SKR03 sind das Privatentnahmen.
|
||||
const { daten: mandant } = useDaten<{ standardGegenkonto: string }>('/api/mandant');
|
||||
const bankkonto = mandant?.standardGegenkonto ?? '';
|
||||
const abfrage = useMemo(() => {
|
||||
const p = new URLSearchParams({ jahr: String(jahr) });
|
||||
if (filter.art) p.set('art', filter.art);
|
||||
if (filter.suche) p.set('suche', filter.suche);
|
||||
if (filter.nurOffen) p.set('nurOffen', '1');
|
||||
return `/api/buchungen?${p}`;
|
||||
}, [jahr, filter]);
|
||||
const { daten: buchungen, neuLaden } = useDaten<Buchung[]>(abfrage);
|
||||
|
||||
const passendeKonten = (konten ?? []).filter((k) =>
|
||||
form.art === 'einnahme' ? k.typ === 'erloes' : k.typ === 'aufwand' || k.typ === 'anlage',
|
||||
);
|
||||
|
||||
/*
|
||||
* Bei einem Anlagekonto ist die Aktivierung nicht verhandelbar – der Server
|
||||
* lehnt die Buchung sonst ab. Das Häkchen wird deshalb gesetzt und gesperrt,
|
||||
* statt den Anwender in eine Fehlermeldung laufen zu lassen.
|
||||
*/
|
||||
const erzwungeneAnlage =
|
||||
form.art === 'ausgabe' &&
|
||||
(konten ?? []).find((k) => k.nummer === form.konto)?.typ === 'anlage';
|
||||
|
||||
// Live-Vorschau: das Backend entscheidet über die USt-Behandlung, nicht das Formular.
|
||||
useEffect(() => {
|
||||
const brutto = centAus(form.betragFeld);
|
||||
if (!form.konto || brutto === 0) {
|
||||
setVorschau(null);
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
api
|
||||
.post<Vorschau>('/api/buchungen/vorschau', {
|
||||
art: form.art,
|
||||
belegdatum: form.belegdatum,
|
||||
leistungsdatum: form.leistungsdatum || form.belegdatum,
|
||||
brutto,
|
||||
konto: form.konto,
|
||||
})
|
||||
.then(setVorschau)
|
||||
.catch(() => setVorschau(null));
|
||||
}, 200);
|
||||
return () => clearTimeout(id);
|
||||
}, [form.art, form.konto, form.betragFeld, form.belegdatum, form.leistungsdatum]);
|
||||
|
||||
async function speichern(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMeldung(null);
|
||||
try {
|
||||
const antwort = await api.post<Buchung & { hinweise: string[] }>('/api/buchungen', {
|
||||
art: form.art,
|
||||
belegdatum: form.belegdatum,
|
||||
leistungsdatum: form.leistungsdatum || form.belegdatum,
|
||||
zahlungsdatum: form.zahlungsdatum || null,
|
||||
brutto: centAus(form.betragFeld),
|
||||
konto: form.konto,
|
||||
gegenkonto: form.gegenkonto || bankkonto,
|
||||
beleg_nr: form.beleg_nr,
|
||||
buchungstext: form.buchungstext,
|
||||
partner: form.partner,
|
||||
privatanteil_bp: Math.round(Number(form.privatanteilFeld.replace(',', '.')) * 100) || 0,
|
||||
anlage: form.alsAnlage || erzwungeneAnlage
|
||||
? {
|
||||
bezeichnung: form.anlageBezeichnung || form.buchungstext,
|
||||
nutzungsdauer: Number(form.anlageNutzungsdauer) || 3,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
setMeldung({
|
||||
art: 'gut',
|
||||
text: `Buchung gespeichert (${eur(antwort.betrag_brutto)}).${antwort.hinweise.length ? ' ' + antwort.hinweise.join(' ') : ''}`,
|
||||
});
|
||||
setForm({ ...LEER, art: form.art, konto: form.konto, gegenkonto: form.gegenkonto });
|
||||
neuLaden();
|
||||
} catch (err) {
|
||||
setMeldung({ art: 'kritisch', text: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function stornieren(id: number) {
|
||||
if (!confirm('Die Buchung wird per Gegenbuchung storniert. Fortfahren?')) return;
|
||||
try {
|
||||
await api.post(`/api/buchungen/${id}/storno`);
|
||||
neuLaden();
|
||||
} catch (err) {
|
||||
setMeldung({ art: 'kritisch', text: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function zahlungNachtragen(b: Buchung) {
|
||||
const eingabe = prompt('Zahlungsdatum (JJJJ-MM-TT):', heute());
|
||||
if (!eingabe) return;
|
||||
await api.post(`/api/buchungen/${b.id}/zahlung`, { zahlungsdatum: eingabe });
|
||||
neuLaden();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Buchungen</h1>
|
||||
<p className="untertitel">
|
||||
Maßgeblich für die EÜR ist das Zahlungsdatum. Ohne Zahlungsdatum gilt die Buchung als
|
||||
offener Posten und fließt noch nicht in den Gewinn ein.
|
||||
</p>
|
||||
|
||||
<div className="karte">
|
||||
<h2>Neue Buchung</h2>
|
||||
<form onSubmit={speichern}>
|
||||
<div className="formularraster">
|
||||
<div className="feld">
|
||||
<label>Art</label>
|
||||
<select
|
||||
value={form.art}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, art: e.target.value as 'einnahme' | 'ausgabe', konto: '' })
|
||||
}
|
||||
>
|
||||
<option value="ausgabe">Betriebsausgabe</option>
|
||||
<option value="einnahme">Betriebseinnahme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Belegdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={form.belegdatum}
|
||||
onChange={(e) => setForm({ ...form, belegdatum: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Leistungsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.leistungsdatum}
|
||||
onChange={(e) => setForm({ ...form, leistungsdatum: e.target.value })}
|
||||
/>
|
||||
<span className="hilfe">leer = Belegdatum; bestimmt das Steuerregime</span>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Zahlungsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.zahlungsdatum}
|
||||
onChange={(e) => setForm({ ...form, zahlungsdatum: e.target.value })}
|
||||
/>
|
||||
<span className="hilfe">leer = noch offen</span>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Bruttobetrag</label>
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={form.betragFeld}
|
||||
onChange={(e) => setForm({ ...form, betragFeld: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld" style={{ gridColumn: 'span 2' }}>
|
||||
<label>Konto</label>
|
||||
<select
|
||||
required
|
||||
value={form.konto}
|
||||
onChange={(e) => setForm({ ...form, konto: e.target.value })}
|
||||
>
|
||||
<option value="">– bitte wählen –</option>
|
||||
{passendeKonten.map((k) => (
|
||||
<option key={k.nummer} value={k.nummer}>
|
||||
{k.nummer} · {k.bezeichnung}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Gegenkonto</label>
|
||||
<select
|
||||
value={form.gegenkonto || bankkonto}
|
||||
onChange={(e) => setForm({ ...form, gegenkonto: e.target.value })}
|
||||
>
|
||||
{(konten ?? [])
|
||||
.filter((k) => k.typ === 'bestand' || k.typ === 'privat')
|
||||
.map((k) => (
|
||||
<option key={k.nummer} value={k.nummer}>
|
||||
{k.nummer} · {k.bezeichnung}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Belegnummer</label>
|
||||
<input
|
||||
value={form.beleg_nr}
|
||||
onChange={(e) => setForm({ ...form, beleg_nr: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Geschäftspartner</label>
|
||||
<input
|
||||
value={form.partner}
|
||||
onChange={(e) => setForm({ ...form, partner: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld" style={{ gridColumn: 'span 2' }}>
|
||||
<label>Buchungstext</label>
|
||||
<input
|
||||
value={form.buchungstext}
|
||||
onChange={(e) => setForm({ ...form, buchungstext: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Privatanteil in %</label>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={form.privatanteilFeld}
|
||||
onChange={(e) => setForm({ ...form, privatanteilFeld: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.art === 'ausgabe' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label style={{ fontSize: 13, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto' }}
|
||||
checked={form.alsAnlage || erzwungeneAnlage}
|
||||
disabled={erzwungeneAnlage}
|
||||
onChange={(e) => setForm({ ...form, alsAnlage: e.target.checked })}
|
||||
/>
|
||||
Als Anlagegut erfassen (Abschreibung statt Sofortabzug)
|
||||
{erzwungeneAnlage && (
|
||||
<span className="abzeichen aktiv">durch das gewählte Anlagekonto vorgegeben</span>
|
||||
)}
|
||||
</label>
|
||||
{vorschau?.aktivierung && (
|
||||
<div className="hinweis" style={{ marginTop: 10 }}>
|
||||
Wird als Anlagegut geführt:{' '}
|
||||
{METHODE_TEXT[vorschau.aktivierung.methode] ?? vorschau.aktivierung.methode}.
|
||||
{vorschau.aktivierung.nutzungsdauerNoetig &&
|
||||
' Die Nutzungsdauer ist deshalb Pflicht.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(form.alsAnlage || erzwungeneAnlage) && (
|
||||
<div className="formularraster" style={{ marginTop: 10 }}>
|
||||
<div className="feld">
|
||||
<label>Bezeichnung</label>
|
||||
<input
|
||||
value={form.anlageBezeichnung}
|
||||
onChange={(e) => setForm({ ...form, anlageBezeichnung: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Nutzungsdauer in Jahren</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={form.anlageNutzungsdauer}
|
||||
onChange={(e) => setForm({ ...form, anlageNutzungsdauer: e.target.value })}
|
||||
/>
|
||||
<span className="hilfe">
|
||||
bis 800 € netto automatisch GWG, bis 1.000 € Sammelposten
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vorschau && (
|
||||
<div className="hinweis" style={{ marginTop: 14 }}>
|
||||
<strong>Vorschau · {REGIME_LABEL[vorschau.regime]}</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
Netto <span className="zahl">{eur(vorschau.netto)}</span> · Umsatzsteuer{' '}
|
||||
<span className="zahl">{eur(vorschau.ust)}</span> · Brutto{' '}
|
||||
<span className="zahl">{eur(vorschau.brutto)}</span> ·{' '}
|
||||
{BEHANDLUNG_LABEL[vorschau.ust_behandlung]}
|
||||
{form.art === 'ausgabe' &&
|
||||
(vorschau.vorsteuer_abziehbar ? ' · Vorsteuer abziehbar' : ' · kein Vorsteuerabzug')}
|
||||
</div>
|
||||
{vorschau.hinweise.map((h) => (
|
||||
<div key={h} style={{ marginTop: 6 }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="knopfreihe">
|
||||
<button type="submit" className="primaer">Buchung speichern</button>
|
||||
<button type="button" onClick={() => setForm(LEER)}>Zurücksetzen</button>
|
||||
</div>
|
||||
</form>
|
||||
{meldung && <div className={`hinweis ${meldung.art}`} style={{ marginTop: 12 }}>{meldung.text}</div>}
|
||||
</div>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label>Jahr</label>
|
||||
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Art</label>
|
||||
<select value={filter.art} onChange={(e) => setFilter({ ...filter, art: e.target.value })}>
|
||||
<option value="">alle</option>
|
||||
<option value="einnahme">Einnahmen</option>
|
||||
<option value="ausgabe">Ausgaben</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld" style={{ minWidth: 220 }}>
|
||||
<label>Suche</label>
|
||||
<input
|
||||
value={filter.suche}
|
||||
placeholder="Text, Partner oder Belegnummer"
|
||||
onChange={(e) => setFilter({ ...filter, suche: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld" style={{ minWidth: 'auto' }}>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto' }}
|
||||
checked={filter.nurOffen}
|
||||
onChange={(e) => setFilter({ ...filter, nurOffen: e.target.checked })}
|
||||
/>
|
||||
nur offene Posten
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Beleg</th>
|
||||
<th>Text</th>
|
||||
<th>Konto</th>
|
||||
<th>Behandlung</th>
|
||||
<th className="rechts">Netto</th>
|
||||
<th className="rechts">USt</th>
|
||||
<th className="rechts">Brutto</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(buchungen ?? []).map((b) => (
|
||||
<tr key={b.id} style={b.storniert ? { opacity: 0.5, textDecoration: 'line-through' } : undefined}>
|
||||
<td>
|
||||
{datum(b.zahlungsdatum ?? b.leistungsdatum)}
|
||||
{!b.zahlungsdatum && <div className="abzeichen" style={{ marginTop: 3 }}>offen</div>}
|
||||
</td>
|
||||
<td className="gedimmt">{b.beleg_nr}</td>
|
||||
<td>
|
||||
{b.buchungstext}
|
||||
{b.partner && <div className="gedimmt" style={{ fontSize: 12 }}>{b.partner}</div>}
|
||||
{b.anlagegut_id && <div className="abzeichen">Anlagegut</div>}
|
||||
</td>
|
||||
<td className="zahl">{b.konto}</td>
|
||||
<td style={{ fontSize: 12 }}>{BEHANDLUNG_LABEL[b.ust_behandlung] ?? b.ust_behandlung}</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.art === 'einnahme' ? 'gut' : ''}`}>
|
||||
{b.art === 'einnahme' ? '+' : '−'}
|
||||
{eur(b.betrag_brutto)}
|
||||
</td>
|
||||
<td className="rechts">
|
||||
{!b.storniert && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
{!b.zahlungsdatum && (
|
||||
<button type="button" onClick={() => zahlungNachtragen(b)} style={{ padding: '3px 8px', fontSize: 12 }}>
|
||||
bezahlt
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="gefahr" onClick={() => stornieren(b.id)} style={{ padding: '3px 8px', fontSize: 12 }}>
|
||||
Storno
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{buchungen?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="gedimmt">Keine Buchungen für diese Auswahl.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useDaten } from '../lib/api';
|
||||
import { useJahr } from '../lib/jahr';
|
||||
import { mitMandant } from '../lib/mandant';
|
||||
import { eur, MONATSNAMEN, proz } from '../lib/format';
|
||||
|
||||
interface BwaZeile {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
art: string;
|
||||
zwischensumme: boolean;
|
||||
monat: number;
|
||||
kumuliert: number;
|
||||
vorjahrKumuliert: number;
|
||||
quoteKumuliert: number;
|
||||
}
|
||||
|
||||
interface BwaDaten {
|
||||
jahr: number;
|
||||
monat: number;
|
||||
basis: string;
|
||||
zeilen: BwaZeile[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
export default function Bwa() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const [monat, setMonat] = useState(() => new Date().getMonth() + 1);
|
||||
const [basis, setBasis] = useState<'zahlung' | 'leistung'>('zahlung');
|
||||
|
||||
const { daten, fehler, laedt } = useDaten<BwaDaten>(`/api/bwa/${jahr}/${monat}?basis=${basis}`);
|
||||
|
||||
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
|
||||
if (laedt || !daten) return <p className="gedimmt">Wird geladen …</p>;
|
||||
|
||||
const ergebnis = daten.zeilen.find((z) => z.id === 'vorlaeufiges_ergebnis');
|
||||
const betriebsergebnis = daten.zeilen.find((z) => z.id === 'betriebsergebnis');
|
||||
const gesamtleistung = daten.zeilen.find((z) => z.id === 'gesamtleistung');
|
||||
|
||||
const veraenderung = (z: BwaZeile) =>
|
||||
z.vorjahrKumuliert === 0 ? null : ((z.kumuliert - z.vorjahrKumuliert) / Math.abs(z.vorjahrKumuliert)) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Betriebswirtschaftliche Auswertung</h1>
|
||||
<p className="untertitel">
|
||||
Kurzfristige Erfolgsrechnung im Aufbau der BWA Form 01 – für den Monat und kumuliert seit
|
||||
Jahresbeginn, mit Vergleich zum Vorjahreszeitraum.
|
||||
</p>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label>Jahr</label>
|
||||
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Monat</label>
|
||||
<select value={monat} onChange={(e) => setMonat(Number(e.target.value))}>
|
||||
{MONATSNAMEN.map((m, i) => <option key={m} value={i + 1}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld" style={{ minWidth: 200 }}>
|
||||
<label>Zeitliche Zuordnung</label>
|
||||
<select value={basis} onChange={(e) => setBasis(e.target.value as 'zahlung' | 'leistung')}>
|
||||
<option value="zahlung">Zahlungszeitpunkt (wie EÜR)</option>
|
||||
<option value="leistung">Leistungszeitpunkt (periodengerecht)</option>
|
||||
</select>
|
||||
</div>
|
||||
<a className="knopf" href={mitMandant(`/api/export/bwa/${jahr}/${monat}.csv?basis=${basis}`)}>
|
||||
Als CSV herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="kachelreihe">
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Gesamtleistung kumuliert</div>
|
||||
<div className="wert zahl">{eur(gesamtleistung?.kumuliert ?? 0)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Betriebsergebnis kumuliert</div>
|
||||
<div className={`wert zahl ${(betriebsergebnis?.kumuliert ?? 0) < 0 ? 'kritisch' : 'gut'}`}>
|
||||
{eur(betriebsergebnis?.kumuliert ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Vorläufiges Ergebnis</div>
|
||||
<div className={`wert zahl ${(ergebnis?.kumuliert ?? 0) < 0 ? 'kritisch' : 'gut'}`}>
|
||||
{eur(ergebnis?.kumuliert ?? 0)}
|
||||
</div>
|
||||
<div className="zusatz">
|
||||
Vorjahr: {eur(ergebnis?.vorjahrKumuliert ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeile</th>
|
||||
<th className="rechts">{MONATSNAMEN[monat - 1]}</th>
|
||||
<th className="rechts">Kumuliert</th>
|
||||
<th className="rechts">% der Gesamtleistung</th>
|
||||
<th className="rechts">Vorjahr kumuliert</th>
|
||||
<th className="rechts">Veränderung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.zeilen.map((z) => {
|
||||
const v = veraenderung(z);
|
||||
const leer = z.monat === 0 && z.kumuliert === 0 && z.vorjahrKumuliert === 0;
|
||||
if (leer && !z.zwischensumme) return null;
|
||||
return (
|
||||
<tr key={z.id} className={z.id === 'vorlaeufiges_ergebnis' ? 'ergebnis' : z.zwischensumme ? 'summe' : ''}>
|
||||
<td>{z.bezeichnung}</td>
|
||||
<td className="rechts zahl">{eur(z.monat)}</td>
|
||||
<td className="rechts zahl">{eur(z.kumuliert)}</td>
|
||||
<td className="rechts zahl gedimmt">
|
||||
{gesamtleistung?.kumuliert ? proz(z.quoteKumuliert) : '–'}
|
||||
</td>
|
||||
<td className="rechts zahl gedimmt">{eur(z.vorjahrKumuliert)}</td>
|
||||
<td className={`rechts zahl ${v === null ? 'gedimmt' : v >= 0 ? 'gut' : 'kritisch'}`}>
|
||||
{v === null ? '–' : proz(v, 1, true)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daten.hinweise.map((h) => <div key={h} className="hinweis">{h}</div>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDaten } from '../lib/api';
|
||||
import { useJahr } from '../lib/jahr';
|
||||
import { eur, proz, REGIME_LABEL, ZEITRAUM_LABEL } from '../lib/format';
|
||||
import MonatsDiagramm, { type MonatsWert } from '../components/MonatsDiagramm';
|
||||
|
||||
interface DashboardDaten {
|
||||
jahr: number;
|
||||
regime: string;
|
||||
besteuerungsart: string;
|
||||
voranmeldungszeitraum: string;
|
||||
einnahmen: number;
|
||||
ausgaben: number;
|
||||
gewinn: number;
|
||||
verlauf: MonatsWert[];
|
||||
offen: { art: string; anzahl: number; summe: number }[];
|
||||
grenzen: {
|
||||
laufenderUmsatz: number;
|
||||
grenzeVorjahr: number;
|
||||
grenzeLaufend: number;
|
||||
warnungen: string[];
|
||||
};
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const { daten, fehler, laedt } = useDaten<DashboardDaten>(`/api/dashboard/${jahr}`);
|
||||
|
||||
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
|
||||
if (laedt || !daten) return <p className="gedimmt">Wird geladen …</p>;
|
||||
|
||||
const offenEin = daten.offen.find((o) => o.art === 'einnahme');
|
||||
const offenAus = daten.offen.find((o) => o.art === 'ausgabe');
|
||||
const auslastung = Math.min(100, (daten.grenzen.laufenderUmsatz / daten.grenzen.grenzeLaufend) * 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Übersicht {jahr}</h1>
|
||||
<p className="untertitel">
|
||||
{REGIME_LABEL[daten.regime]} · {daten.besteuerungsart === 'ist' ? 'Ist-Versteuerung' : 'Soll-Versteuerung'} ·
|
||||
Voranmeldung {ZEITRAUM_LABEL[daten.voranmeldungszeitraum] ?? daten.voranmeldungszeitraum}
|
||||
</p>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label htmlFor="jahr">Wirtschaftsjahr</label>
|
||||
<select id="jahr" value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daten.grenzen.warnungen.map((w) => (
|
||||
<div key={w} className="hinweis warnung">{w}</div>
|
||||
))}
|
||||
|
||||
<div className="kachelreihe">
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Betriebseinnahmen</div>
|
||||
<div className="wert zahl">{eur(daten.einnahmen)}</div>
|
||||
<div className="zusatz">nach Zuflussprinzip (§11 EStG)</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Betriebsausgaben</div>
|
||||
<div className="wert zahl">{eur(daten.ausgaben)}</div>
|
||||
<div className="zusatz">einschließlich Abschreibungen</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Gewinn</div>
|
||||
<div className={`wert zahl ${daten.gewinn < 0 ? 'kritisch' : 'gut'}`}>{eur(daten.gewinn)}</div>
|
||||
<div className="zusatz">vorläufig, vor Steuern</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Offene Posten</div>
|
||||
<div className="wert zahl">{eur((offenEin?.summe ?? 0) - (offenAus?.summe ?? 0))}</div>
|
||||
<div className="zusatz">
|
||||
{offenEin?.anzahl ?? 0} Forderungen · {offenAus?.anzahl ?? 0} Verbindlichkeiten
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<h2>Verlauf über das Jahr</h2>
|
||||
<MonatsDiagramm werte={daten.verlauf} />
|
||||
<details style={{ marginTop: 10 }}>
|
||||
<summary className="gedimmt" style={{ cursor: 'pointer', fontSize: 13 }}>
|
||||
Werte als Tabelle
|
||||
</summary>
|
||||
<div className="tabellenrahmen" style={{ marginTop: 8 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
<th className="rechts">Einnahmen</th>
|
||||
<th className="rechts">Ausgaben</th>
|
||||
<th className="rechts">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.verlauf.map((m) => (
|
||||
<tr key={m.monat}>
|
||||
<td>{m.monat}</td>
|
||||
<td className="rechts zahl">{eur(m.einnahmen)}</td>
|
||||
<td className="rechts zahl">{eur(m.ausgaben)}</td>
|
||||
<td className="rechts zahl">{eur(m.einnahmen - m.ausgaben)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{daten.regime === 'kleinunternehmer' && (
|
||||
<div className="karte">
|
||||
<h2>Umsatzgrenzen nach §19 UStG</h2>
|
||||
<p className="untertitel" style={{ marginBottom: 10 }}>
|
||||
Umsatz {jahr}: <span className="zahl">{eur(daten.grenzen.laufenderUmsatz)}</span> von{' '}
|
||||
<span className="zahl">{eur(daten.grenzen.grenzeLaufend)}</span> ({proz(auslastung, 0)})
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: 'var(--surface-2)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${auslastung}%`,
|
||||
height: '100%',
|
||||
background: auslastung > 80 ? 'var(--warnung)' : 'var(--series-1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="gedimmt" style={{ fontSize: 13, marginTop: 10 }}>
|
||||
Wird die Grenze überschritten, endet die Kleinunternehmerregelung sofort mit dem
|
||||
überschreitenden Umsatz. Den Wechsel unter{' '}
|
||||
<Link to="/stammdaten">Stammdaten & Regime</Link> eintragen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{daten.hinweise.map((h) => (
|
||||
<div key={h} className="hinweis">{h}</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useDaten } from '../lib/api';
|
||||
import { useJahr } from '../lib/jahr';
|
||||
import { mitMandant } from '../lib/mandant';
|
||||
import { datum, eur, REGIME_LABEL } from '../lib/format';
|
||||
|
||||
interface Zeile {
|
||||
key: string;
|
||||
zeile: number | null;
|
||||
bezeichnung: string;
|
||||
betrag: number;
|
||||
anzahl: number;
|
||||
hinweis?: string;
|
||||
}
|
||||
|
||||
interface EuerDaten {
|
||||
jahr: number;
|
||||
regimeStart: string;
|
||||
regimeEnde: string;
|
||||
wechselImJahr: { gueltig_ab: string; modus: string; grund: string }[];
|
||||
einnahmen: Zeile[];
|
||||
ausgaben: Zeile[];
|
||||
summeEinnahmen: number;
|
||||
summeAusgaben: number;
|
||||
gewinn: number;
|
||||
offenePosten: { einnahmen: number; ausgaben: number };
|
||||
fehlbuchungen: { id: number; datum: string; konto: string; text: string; betrag: number }[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
interface AfaZeile {
|
||||
anlagegut: { id: number; bezeichnung: string; anschaffungsdatum: string; nutzungsdauer: number };
|
||||
bemessung: number;
|
||||
buchwertAnfang: number;
|
||||
afa: number;
|
||||
buchwertEnde: number;
|
||||
restbuchwertAbgang: number;
|
||||
hinweis: string;
|
||||
}
|
||||
|
||||
export default function Euer() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const { daten, fehler, laedt } = useDaten<EuerDaten>(`/api/euer/${jahr}`);
|
||||
const { daten: afa } = useDaten<AfaZeile[]>(`/api/euer/${jahr}/anlagen`);
|
||||
|
||||
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
|
||||
if (laedt || !daten) return <p className="gedimmt">Wird geladen …</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Einnahmen-Überschuss-Rechnung {jahr}</h1>
|
||||
<p className="untertitel">
|
||||
Gewinnermittlung nach §4 Abs. 3 EStG. Die Zeilennummern beziehen sich auf die Anlage EÜR
|
||||
und sind vor der Abgabe gegen das amtliche Formular des Jahres zu prüfen.
|
||||
</p>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label>Wirtschaftsjahr</label>
|
||||
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<a className="knopf" href={mitMandant(`/api/export/euer/${jahr}.csv`)}>Als CSV herunterladen</a>
|
||||
</div>
|
||||
|
||||
<div className="kachelreihe">
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Summe Betriebseinnahmen</div>
|
||||
<div className="wert zahl">{eur(daten.summeEinnahmen)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Summe Betriebsausgaben</div>
|
||||
<div className="wert zahl">{eur(daten.summeAusgaben)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">{daten.gewinn < 0 ? 'Verlust' : 'Gewinn'}</div>
|
||||
<div className={`wert zahl ${daten.gewinn < 0 ? 'kritisch' : 'gut'}`}>{eur(daten.gewinn)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Besteuerungsform</div>
|
||||
<div className="wert" style={{ fontSize: 15 }}>
|
||||
{daten.regimeStart === daten.regimeEnde
|
||||
? REGIME_LABEL[daten.regimeStart]
|
||||
: `${REGIME_LABEL[daten.regimeStart]} → ${REGIME_LABEL[daten.regimeEnde]}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daten.wechselImJahr.map((w) => (
|
||||
<div key={w.gueltig_ab} className="hinweis warnung">
|
||||
Wechsel zum {datum(w.gueltig_ab)}: {REGIME_LABEL[w.modus]}
|
||||
{w.grund && ` (${w.grund})`}. Umsätze vor und nach diesem Datum werden getrennt ausgewiesen.
|
||||
</div>
|
||||
))}
|
||||
{daten.hinweise.map((h) => <div key={h} className="hinweis">{h}</div>)}
|
||||
|
||||
{daten.fehlbuchungen?.length > 0 && (
|
||||
<div className="karte" style={{ borderColor: 'var(--warnung)' }}>
|
||||
<h2 style={{ color: 'var(--warnung)' }}>Zu prüfen: Anschaffungen ohne Abschreibung</h2>
|
||||
<p className="untertitel" style={{ marginBottom: 10 }}>
|
||||
Diese Buchungen liegen auf einem Anlagekonto, ohne dass ein Anlagegut hinterlegt ist.
|
||||
Sie sind oben in voller Höhe als Betriebsausgabe enthalten, statt über die Nutzungsdauer
|
||||
verteilt zu werden. Bitte stornieren und mit Nutzungsdauer neu erfassen.
|
||||
</p>
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Konto</th><th>Buchungstext</th><th className="rechts">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.fehlbuchungen.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{datum(f.datum)}</td>
|
||||
<td className="zahl">{f.konto}</td>
|
||||
<td>{f.text || <span className="gedimmt">ohne Text</span>}</td>
|
||||
<td className="rechts zahl">{eur(f.betrag)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(daten.offenePosten.einnahmen > 0 || daten.offenePosten.ausgaben > 0) && (
|
||||
<div className="hinweis">
|
||||
Nicht enthalten, weil noch nicht bezahlt: Forderungen{' '}
|
||||
<span className="zahl">{eur(daten.offenePosten.einnahmen)}</span>, Verbindlichkeiten{' '}
|
||||
<span className="zahl">{eur(daten.offenePosten.ausgaben)}</span>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="karte">
|
||||
<h2>Betriebseinnahmen</h2>
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>Zeile</th>
|
||||
<th>Posten</th>
|
||||
<th className="rechts" style={{ width: 90 }}>Belege</th>
|
||||
<th className="rechts" style={{ width: 140 }}>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.einnahmen.map((z) => (
|
||||
<tr key={z.key}>
|
||||
<td className="zahl gedimmt">{z.zeile ?? '–'}</td>
|
||||
<td>{z.bezeichnung}</td>
|
||||
<td className="rechts gedimmt">{z.anzahl || '–'}</td>
|
||||
<td className="rechts zahl">{eur(z.betrag)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="summe">
|
||||
<td></td>
|
||||
<td>Summe der Betriebseinnahmen</td>
|
||||
<td></td>
|
||||
<td className="rechts zahl">{eur(daten.summeEinnahmen)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<h2>Betriebsausgaben</h2>
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>Zeile</th>
|
||||
<th>Posten</th>
|
||||
<th className="rechts" style={{ width: 90 }}>Belege</th>
|
||||
<th className="rechts" style={{ width: 140 }}>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.ausgaben.map((z) => (
|
||||
<tr key={z.key}>
|
||||
<td className="zahl gedimmt">{z.zeile ?? '–'}</td>
|
||||
<td>
|
||||
{z.bezeichnung}
|
||||
{z.hinweis && <div className="gedimmt" style={{ fontSize: 12 }}>{z.hinweis}</div>}
|
||||
</td>
|
||||
<td className="rechts gedimmt">{z.anzahl || '–'}</td>
|
||||
<td className="rechts zahl">{eur(z.betrag)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="summe">
|
||||
<td></td>
|
||||
<td>Summe der Betriebsausgaben</td>
|
||||
<td></td>
|
||||
<td className="rechts zahl">{eur(daten.summeAusgaben)}</td>
|
||||
</tr>
|
||||
<tr className="ergebnis">
|
||||
<td></td>
|
||||
<td>{daten.gewinn < 0 ? 'Verlust' : 'Gewinn'} {jahr}</td>
|
||||
<td></td>
|
||||
<td className="rechts zahl">{eur(daten.gewinn)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{afa && afa.length > 0 && (
|
||||
<div className="karte">
|
||||
<h2>Anlagenverzeichnis {jahr}</h2>
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Wirtschaftsgut</th>
|
||||
<th>Anschaffung</th>
|
||||
<th className="rechts">Bemessung</th>
|
||||
<th className="rechts">Buchwert 1.1.</th>
|
||||
<th className="rechts">AfA</th>
|
||||
<th className="rechts">Buchwert 31.12.</th>
|
||||
<th>Methode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{afa.map((z) => (
|
||||
<tr key={z.anlagegut.id}>
|
||||
<td>{z.anlagegut.bezeichnung}</td>
|
||||
<td>{datum(z.anlagegut.anschaffungsdatum)}</td>
|
||||
<td className="rechts zahl">{eur(z.bemessung)}</td>
|
||||
<td className="rechts zahl">{eur(z.buchwertAnfang)}</td>
|
||||
<td className="rechts zahl">{eur(z.afa)}</td>
|
||||
<td className="rechts zahl">{eur(z.buchwertEnde)}</td>
|
||||
<td className="gedimmt" style={{ fontSize: 12 }}>{z.hinweis}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { useJahr } from '../lib/jahr';
|
||||
import { mitMandant } from '../lib/mandant';
|
||||
|
||||
export default function Export() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const [festschreibung, setFestschreibung] = useState(true);
|
||||
const [buSchluessel, setBuSchluessel] = useState(false);
|
||||
const [meldung, setMeldung] = useState<string | null>(null);
|
||||
|
||||
async function festschreiben() {
|
||||
const bis = `${jahr}-12-31`;
|
||||
if (
|
||||
!confirm(
|
||||
`Alle Buchungen bis zum 31.12.${jahr} werden festgeschrieben und sind danach nicht mehr ` +
|
||||
'änderbar – Korrekturen sind nur noch per Storno möglich (GoBD). Fortfahren?',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const r = await api.post<{ anzahl: number }>('/api/buchungen/festschreiben', { bis });
|
||||
setMeldung(`${r.anzahl} Buchungen festgeschrieben.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Export</h1>
|
||||
<p className="untertitel">
|
||||
Datenweitergabe an die Steuerkanzlei und an die Betriebsprüfung.
|
||||
</p>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label>Wirtschaftsjahr</label>
|
||||
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meldung && <div className="hinweis gut">{meldung}</div>}
|
||||
|
||||
<div className="karte">
|
||||
<h2>DATEV (für die Steuerkanzlei)</h2>
|
||||
<p style={{ marginTop: 0, color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Buchungsstapel im DATEV-Format (EXTF, Version 700, Formatversion 13) samt
|
||||
Kontenbeschriftungen. Das ist das Format, das eine Kanzlei in DATEV Rechnungswesen
|
||||
einliest. Die Buchungen werden nach dem Zahlungszeitpunkt sortiert; das Leistungsdatum
|
||||
reist im Feld „Leistungsdatum“ mit.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 13 }}>
|
||||
<input type="checkbox" style={{ width: 'auto' }} checked={festschreibung} onChange={(e) => setFestschreibung(e.target.checked)} />
|
||||
als festgeschrieben kennzeichnen
|
||||
</label>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 13 }}>
|
||||
<input type="checkbox" style={{ width: 'auto' }} checked={buSchluessel} onChange={(e) => setBuSchluessel(e.target.checked)} />
|
||||
BU-Schlüssel mitgeben (nur ohne Automatikkonten)
|
||||
</label>
|
||||
</div>
|
||||
<div className="knopfreihe" style={{ marginTop: 0 }}>
|
||||
<a
|
||||
className="knopf primaer"
|
||||
href={mitMandant(`/api/export/datev/${jahr}?festschreibung=${festschreibung ? 1 : 0}&buSchluessel=${buSchluessel ? 1 : 0}`)}
|
||||
>
|
||||
DATEV-Paket herunterladen (ZIP)
|
||||
</a>
|
||||
<a className="knopf" href={mitMandant(`/api/export/datev/${jahr}/buchungsstapel.csv`)}>
|
||||
Nur Buchungsstapel (CSV)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<h2>GoBD-Datenträgerüberlassung (GDPdU, Format Z3)</h2>
|
||||
<p style={{ marginTop: 0, color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
ZIP mit <code>index.xml</code> nach dem Beschreibungsstandard für die
|
||||
Datenträgerüberlassung sowie Buchungen, Kontenplan und Anlagenverzeichnis als CSV.
|
||||
Dieses Paket verlangt die Betriebsprüfung; es wird in Prüfsoftware wie IDEA eingelesen.
|
||||
</p>
|
||||
<div className="knopfreihe" style={{ marginTop: 0 }}>
|
||||
<a className="knopf primaer" href={mitMandant(`/api/export/gdpdu/${jahr}`)}>GoBD-Paket herunterladen (ZIP)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<h2>Auswertungen als CSV</h2>
|
||||
<div className="knopfreihe" style={{ marginTop: 0 }}>
|
||||
<a className="knopf" href={mitMandant(`/api/export/euer/${jahr}.csv`)}>Einnahmen-Überschuss-Rechnung</a>
|
||||
<a className="knopf" href={mitMandant(`/api/export/bwa/${jahr}/12.csv`)}>BWA (Dezember, kumuliert)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="karte">
|
||||
<h2>Festschreibung</h2>
|
||||
<p style={{ marginTop: 0, color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Nach der GoBD müssen Buchungen zeitnah unveränderbar gemacht werden. Festgeschriebene
|
||||
Buchungen lassen sich nicht mehr bearbeiten; Korrekturen erfolgen ausschließlich über
|
||||
eine Stornobuchung. Das ist bewusst nicht rückgängig zu machen.
|
||||
</p>
|
||||
<div className="knopfreihe" style={{ marginTop: 0 }}>
|
||||
<button type="button" onClick={festschreiben}>Buchungen bis 31.12.{jahr} festschreiben</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, useDaten } from '../lib/api';
|
||||
import { datum, eur, REGIME_LABEL } from '../lib/format';
|
||||
|
||||
interface Mandant {
|
||||
name: string;
|
||||
strasse: string;
|
||||
plz: string;
|
||||
ort: string;
|
||||
bundesland: string;
|
||||
steuernummer: string;
|
||||
ust_idnr: string;
|
||||
taetigkeit: string;
|
||||
betriebsbeginn: string | null;
|
||||
besteuerungsart: 'ist' | 'soll';
|
||||
voranmeldungszeitraum: string;
|
||||
dauerfristverlaengerung: number;
|
||||
datev_berater_nr: string;
|
||||
datev_mandanten_nr: string;
|
||||
telefon: string;
|
||||
email: string;
|
||||
bundesSteuernummer: string | null;
|
||||
steuernummerFehler: string | null;
|
||||
bundeslaender: { code: string; name: string }[];
|
||||
}
|
||||
|
||||
interface RegimeAntwort {
|
||||
eintraege: { id: number; gueltig_ab: string; modus: string; grund: string; notiz: string }[];
|
||||
aktuell: string;
|
||||
stichtag: string;
|
||||
}
|
||||
|
||||
interface Grenzen {
|
||||
jahr: number;
|
||||
vorjahresumsatz: number;
|
||||
laufenderUmsatz: number;
|
||||
grenzeVorjahr: number;
|
||||
grenzeLaufend: number;
|
||||
warnungen: string[];
|
||||
}
|
||||
|
||||
export default function Stammdaten() {
|
||||
const { daten: geladen, neuLaden: mandantNeu } = useDaten<Mandant>('/api/mandant');
|
||||
const { daten: regime, neuLaden: regimeNeu } = useDaten<RegimeAntwort>('/api/regime');
|
||||
const jahr = new Date().getFullYear();
|
||||
const { daten: grenzen } = useDaten<Grenzen>(`/api/regime/grenzen/${jahr}`);
|
||||
|
||||
const [form, setForm] = useState<Mandant | null>(null);
|
||||
const [meldung, setMeldung] = useState<{ art: string; text: string } | null>(null);
|
||||
const [neu, setNeu] = useState({ gueltig_ab: `${jahr + 1}-01-01`, modus: 'regelbesteuerung', grund: '', notiz: '' });
|
||||
|
||||
useEffect(() => { if (geladen) setForm(geladen); }, [geladen]);
|
||||
|
||||
if (!form || !regime) return <p className="gedimmt">Wird geladen …</p>;
|
||||
|
||||
async function speichern(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put('/api/mandant', form);
|
||||
setMeldung({ art: 'gut', text: 'Stammdaten gespeichert.' });
|
||||
mandantNeu();
|
||||
} catch (err) {
|
||||
setMeldung({ art: 'kritisch', text: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function regimeAnlegen(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const r = await api.post<{ hinweise: string[] }>('/api/regime', neu);
|
||||
setMeldung({ art: 'gut', text: `Wechsel eingetragen. ${r.hinweise.join(' ')}` });
|
||||
regimeNeu();
|
||||
} catch (err) {
|
||||
setMeldung({ art: 'kritisch', text: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function regimeLoeschen(id: number) {
|
||||
if (!confirm('Diesen Eintrag der Regime-Zeitachse entfernen? Die steuerliche Behandlung aller Buchungen danach ändert sich.')) return;
|
||||
await api.del(`/api/regime/${id}`);
|
||||
regimeNeu();
|
||||
}
|
||||
|
||||
const setzen = (feld: keyof Mandant, wert: string | number) => setForm({ ...form!, [feld]: wert });
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Stammdaten & Besteuerungsform</h1>
|
||||
<p className="untertitel">
|
||||
Die Zeitachse der Besteuerungsform steuert, wie jede Buchung umsatzsteuerlich behandelt
|
||||
wird – maßgeblich ist das Regime zum Leistungsdatum der Buchung.
|
||||
</p>
|
||||
|
||||
{meldung && <div className={`hinweis ${meldung.art}`}>{meldung.text}</div>}
|
||||
|
||||
<div className="karte">
|
||||
<h2>Zeitachse der Besteuerungsform</h2>
|
||||
<p className="gedimmt" style={{ fontSize: 13, marginTop: 0 }}>
|
||||
Aktuell gültig ({datum(regime.stichtag)}): <strong>{REGIME_LABEL[regime.aktuell]}</strong>
|
||||
</p>
|
||||
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Gültig ab</th><th>Besteuerungsform</th><th>Grund</th><th>Notiz</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{regime.eintraege.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{datum(r.gueltig_ab)}</td>
|
||||
<td><span className="abzeichen aktiv">{REGIME_LABEL[r.modus]}</span></td>
|
||||
<td>{r.grund || '–'}</td>
|
||||
<td className="gedimmt">{r.notiz || '–'}</td>
|
||||
<td className="rechts">
|
||||
<button type="button" className="gefahr" style={{ padding: '3px 8px', fontSize: 12 }} onClick={() => regimeLoeschen(r.id)}>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{regime.eintraege.length === 0 && (
|
||||
<tr><td colSpan={5} className="gedimmt">
|
||||
Noch kein Eintrag. Ohne Eintrag wird die Kleinunternehmerregelung angenommen.
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Wechsel eintragen</h3>
|
||||
<form onSubmit={regimeAnlegen}>
|
||||
<div className="formularraster">
|
||||
<div className="feld">
|
||||
<label>Gültig ab</label>
|
||||
<input type="date" required value={neu.gueltig_ab} onChange={(e) => setNeu({ ...neu, gueltig_ab: e.target.value })} />
|
||||
<span className="hilfe">in der Regel der 1. Januar</span>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Besteuerungsform</label>
|
||||
<select value={neu.modus} onChange={(e) => setNeu({ ...neu, modus: e.target.value })}>
|
||||
<option value="regelbesteuerung">Regelbesteuerung</option>
|
||||
<option value="kleinunternehmer">Kleinunternehmer (§19 UStG)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Grund</label>
|
||||
<input
|
||||
value={neu.grund}
|
||||
placeholder="z.B. Vorjahresumsatz über 25.000 €"
|
||||
onChange={(e) => setNeu({ ...neu, grund: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Notiz</label>
|
||||
<input value={neu.notiz} onChange={(e) => setNeu({ ...neu, notiz: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="knopfreihe">
|
||||
<button type="submit" className="primaer">Wechsel eintragen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{grenzen && (
|
||||
<>
|
||||
<h3>Umsatzgrenzen §19 UStG ({jahr})</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Umsatz Vorjahr ({jahr - 1})</td>
|
||||
<td className="rechts zahl">{eur(grenzen.vorjahresumsatz)}</td>
|
||||
<td className="rechts gedimmt">Grenze {eur(grenzen.grenzeVorjahr)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Umsatz laufendes Jahr</td>
|
||||
<td className="rechts zahl">{eur(grenzen.laufenderUmsatz)}</td>
|
||||
<td className="rechts gedimmt">Grenze {eur(grenzen.grenzeLaufend)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{grenzen.warnungen.map((w) => <div key={w} className="hinweis warnung" style={{ marginTop: 10 }}>{w}</div>)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="karte" onSubmit={speichern}>
|
||||
<h2>Unternehmen</h2>
|
||||
<div className="formularraster">
|
||||
<div className="feld" style={{ gridColumn: 'span 2' }}>
|
||||
<label>Name / Firma</label>
|
||||
<input value={form.name} onChange={(e) => setzen('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Straße</label>
|
||||
<input value={form.strasse} onChange={(e) => setzen('strasse', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>PLZ</label>
|
||||
<input value={form.plz} onChange={(e) => setzen('plz', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Ort</label>
|
||||
<input value={form.ort} onChange={(e) => setzen('ort', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Tätigkeit</label>
|
||||
<input value={form.taetigkeit} onChange={(e) => setzen('taetigkeit', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Betriebsbeginn</label>
|
||||
<input type="date" value={form.betriebsbeginn ?? ''} onChange={(e) => setzen('betriebsbeginn', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Telefon</label>
|
||||
<input value={form.telefon} onChange={(e) => setzen('telefon', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>E-Mail</label>
|
||||
<input value={form.email} onChange={(e) => setzen('email', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Finanzamt</h3>
|
||||
<div className="formularraster">
|
||||
<div className="feld">
|
||||
<label>Bundesland</label>
|
||||
<select value={form.bundesland} onChange={(e) => setzen('bundesland', e.target.value)}>
|
||||
{form.bundeslaender.map((b) => <option key={b.code} value={b.code}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Steuernummer</label>
|
||||
<input
|
||||
value={form.steuernummer}
|
||||
placeholder="123/456/78901"
|
||||
onChange={(e) => setzen('steuernummer', e.target.value)}
|
||||
/>
|
||||
<span className="hilfe">
|
||||
{geladen?.bundesSteuernummer
|
||||
? `ELSTER-Format: ${geladen.bundesSteuernummer}`
|
||||
: geladen?.steuernummerFehler}
|
||||
</span>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>USt-IdNr.</label>
|
||||
<input value={form.ust_idnr} onChange={(e) => setzen('ust_idnr', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Besteuerungsart</label>
|
||||
<select value={form.besteuerungsart} onChange={(e) => setzen('besteuerungsart', e.target.value)}>
|
||||
<option value="ist">Ist-Versteuerung (§20 UStG)</option>
|
||||
<option value="soll">Soll-Versteuerung</option>
|
||||
</select>
|
||||
<span className="hilfe">bestimmt, ob Zahlungs- oder Leistungsdatum zählt</span>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Voranmeldungszeitraum</label>
|
||||
<select value={form.voranmeldungszeitraum} onChange={(e) => setzen('voranmeldungszeitraum', e.target.value)}>
|
||||
<option value="monatlich">monatlich</option>
|
||||
<option value="vierteljaehrlich">vierteljährlich</option>
|
||||
<option value="jaehrlich">jährlich (keine Voranmeldung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Dauerfristverlängerung</label>
|
||||
<select
|
||||
value={form.dauerfristverlaengerung}
|
||||
onChange={(e) => setzen('dauerfristverlaengerung', Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>nein</option>
|
||||
<option value={1}>ja</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>DATEV</h3>
|
||||
<div className="formularraster">
|
||||
<div className="feld">
|
||||
<label>Beraternummer</label>
|
||||
<input value={form.datev_berater_nr} onChange={(e) => setzen('datev_berater_nr', e.target.value)} />
|
||||
</div>
|
||||
<div className="feld">
|
||||
<label>Mandantennummer</label>
|
||||
<input value={form.datev_mandanten_nr} onChange={(e) => setzen('datev_mandanten_nr', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="knopfreihe">
|
||||
<button type="submit" className="primaer">Stammdaten speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, useDaten } from '../lib/api';
|
||||
import { useJahr } from '../lib/jahr';
|
||||
import { datum, eur, REGIME_LABEL } from '../lib/format';
|
||||
|
||||
const KENNZAHLEN: Record<string, { text: string; art: 'bemessung' | 'steuer' }> = {
|
||||
'21': { text: 'Nicht steuerbare sonstige Leistungen (§18b Satz 1 Nr. 2 UStG)', art: 'bemessung' },
|
||||
'35': { text: 'Umsätze zu anderen Steuersätzen', art: 'bemessung' },
|
||||
'36': { text: 'Steuer auf Umsätze zu anderen Steuersätzen', art: 'steuer' },
|
||||
'41': { text: 'Innergemeinschaftliche Lieferungen', art: 'bemessung' },
|
||||
'43': { text: 'Weitere steuerfreie Umsätze mit Vorsteuerabzug', art: 'bemessung' },
|
||||
'45': { text: 'Nicht steuerbare Umsätze (Leistungsort nicht im Inland)', art: 'bemessung' },
|
||||
'46': { text: 'Leistungen eines im Ausland ansässigen Unternehmers (§13b)', art: 'bemessung' },
|
||||
'47': { text: 'Steuer darauf (§13b UStG)', art: 'steuer' },
|
||||
'48': { text: 'Steuerfreie Umsätze ohne Vorsteuerabzug', art: 'bemessung' },
|
||||
'61': { text: 'Vorsteuer aus innergemeinschaftlichen Erwerben', art: 'steuer' },
|
||||
'62': { text: 'Entrichtete Einfuhrumsatzsteuer', art: 'steuer' },
|
||||
'66': { text: 'Vorsteuerbeträge aus Rechnungen anderer Unternehmer', art: 'steuer' },
|
||||
'67': { text: 'Vorsteuer aus Leistungen nach §13b UStG', art: 'steuer' },
|
||||
'81': { text: 'Steuerpflichtige Umsätze zum Steuersatz 19 %', art: 'bemessung' },
|
||||
'83': { text: 'Verbleibende Umsatzsteuer-Vorauszahlung', art: 'steuer' },
|
||||
'86': { text: 'Steuerpflichtige Umsätze zum Steuersatz 7 %', art: 'bemessung' },
|
||||
'89': { text: 'Innergemeinschaftliche Erwerbe zum Steuersatz 19 %', art: 'bemessung' },
|
||||
'93': { text: 'Innergemeinschaftliche Erwerbe zum Steuersatz 7 %', art: 'bemessung' },
|
||||
'95': { text: 'Innergemeinschaftliche Erwerbe zu anderen Steuersätzen', art: 'bemessung' },
|
||||
'98': { text: 'Steuer auf innergemeinschaftliche Erwerbe zu anderen Sätzen', art: 'steuer' },
|
||||
};
|
||||
|
||||
interface UstvaDaten {
|
||||
zeitraum: { jahr: number; code: string; von: string; bis: string; bezeichnung: string };
|
||||
besteuerungsart: string;
|
||||
regimeStart: string;
|
||||
regimeEnde: string;
|
||||
abgabepflichtig: boolean;
|
||||
kennzahlen: Record<string, number>;
|
||||
umsatzsteuer: number;
|
||||
vorsteuer: number;
|
||||
zahllast: number;
|
||||
hinweise: string[];
|
||||
positionen: { buchungId: number; datum: string; text: string; kz: string; bemessung: number; steuer: number }[];
|
||||
meldungen: { id: number; status: string; transferticket: string | null; uebermittelt_am: string | null }[];
|
||||
}
|
||||
|
||||
interface ElsterStatus {
|
||||
verfuegbar: boolean;
|
||||
meldung: string;
|
||||
zertifikat: boolean;
|
||||
herstellerId: boolean;
|
||||
testmodus: boolean;
|
||||
bereitZumSenden: boolean;
|
||||
anleitung: string[];
|
||||
}
|
||||
|
||||
interface EricAntwort {
|
||||
erfolg: boolean;
|
||||
simulation: boolean;
|
||||
transferticket: string | null;
|
||||
protokoll: string;
|
||||
fehler: string[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
export default function Ustva() {
|
||||
const [jahr, setJahr, jahre] = useJahr();
|
||||
const [zeitraeume, setZeitraeume] = useState<{ code: string; bezeichnung: string; zahllast: number; abgabepflichtig: boolean }[]>([]);
|
||||
const [code, setCode] = useState('41');
|
||||
const [xml, setXml] = useState<string | null>(null);
|
||||
const [antwort, setAntwort] = useState<EricAntwort | null>(null);
|
||||
const [berichtigt, setBerichtigt] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
const { daten: status } = useDaten<ElsterStatus>('/api/elster/status');
|
||||
const { daten, fehler, neuLaden } = useDaten<UstvaDaten>(`/api/ustva/${jahr}/${code}`);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<typeof zeitraeume>(`/api/ustva/${jahr}`)
|
||||
.then((z) => {
|
||||
setZeitraeume(z);
|
||||
if (!z.some((x) => x.code === code) && z[0]) setCode(z[0].code);
|
||||
})
|
||||
.catch(() => setZeitraeume([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jahr]);
|
||||
|
||||
async function xmlAnzeigen() {
|
||||
setAntwort(null);
|
||||
const r = await api.get<{ xml: string }>(
|
||||
`/api/elster/ustva/${jahr}/${code}/xml?berichtigt=${berichtigt ? 1 : ''}`,
|
||||
);
|
||||
setXml(r.xml);
|
||||
}
|
||||
|
||||
async function pruefen() {
|
||||
setLaeuft(true);
|
||||
setXml(null);
|
||||
try {
|
||||
setAntwort(await api.post<EricAntwort>(`/api/elster/ustva/${jahr}/${code}/validieren`, { berichtigt }));
|
||||
} catch (e) {
|
||||
setAntwort({ erfolg: false, simulation: false, transferticket: null, protokoll: '', fehler: [(e as Error).message], hinweise: [] });
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
neuLaden();
|
||||
}
|
||||
}
|
||||
|
||||
async function senden() {
|
||||
const bestaetigung =
|
||||
`Die Umsatzsteuer-Voranmeldung für ${daten?.zeitraum.bezeichnung} wird verbindlich an das ` +
|
||||
`Finanzamt übermittelt.\n\nZahllast: ${eur(daten?.zahllast ?? 0)}\n\n` +
|
||||
'Eine Übermittlung lässt sich nicht zurücknehmen; eine Korrektur ist nur als berichtigte ' +
|
||||
'Anmeldung möglich.\n\nJetzt übermitteln?';
|
||||
if (!confirm(bestaetigung)) return;
|
||||
|
||||
setLaeuft(true);
|
||||
setXml(null);
|
||||
try {
|
||||
setAntwort(
|
||||
await api.post<EricAntwort>(`/api/elster/ustva/${jahr}/${code}/senden`, {
|
||||
berichtigt,
|
||||
bestaetigt: true,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setAntwort({ erfolg: false, simulation: false, transferticket: null, protokoll: '', fehler: [(e as Error).message], hinweise: [] });
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
neuLaden();
|
||||
}
|
||||
}
|
||||
|
||||
if (fehler) return <div className="hinweis kritisch">{fehler}</div>;
|
||||
|
||||
const kzListe = Object.entries(daten?.kennzahlen ?? {})
|
||||
.filter(([k]) => k !== '83')
|
||||
.sort(([a], [b]) => Number(a) - Number(b));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Umsatzsteuer-Voranmeldung</h1>
|
||||
<p className="untertitel">
|
||||
Ermittlung nach §18 UStG und Übermittlung über ELSTER. Die zeitliche Zuordnung folgt der
|
||||
eingestellten Besteuerungsart.
|
||||
</p>
|
||||
|
||||
<div className="filterreihe">
|
||||
<div className="feld">
|
||||
<label>Jahr</label>
|
||||
<select value={jahr} onChange={(e) => setJahr(Number(e.target.value))}>
|
||||
{jahre.map((j) => <option key={j} value={j}>{j}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld" style={{ minWidth: 190 }}>
|
||||
<label>Zeitraum</label>
|
||||
<select value={code} onChange={(e) => { setCode(e.target.value); setXml(null); setAntwort(null); }}>
|
||||
{zeitraeume.map((z) => (
|
||||
<option key={z.code} value={z.code}>
|
||||
{z.bezeichnung}
|
||||
{z.abgabepflichtig ? ` · ${eur(z.zahllast)}` : ' · keine Abgabepflicht'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="feld" style={{ minWidth: 'auto' }}>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto' }}
|
||||
checked={berichtigt}
|
||||
onChange={(e) => setBerichtigt(e.target.checked)}
|
||||
/>
|
||||
berichtigte Anmeldung (Kz 10)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daten && (
|
||||
<>
|
||||
<div className="kachelreihe">
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Umsatzsteuer</div>
|
||||
<div className="wert zahl">{eur(daten.umsatzsteuer)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">Abziehbare Vorsteuer</div>
|
||||
<div className="wert zahl">{eur(daten.vorsteuer)}</div>
|
||||
</div>
|
||||
<div className="kachel">
|
||||
<div className="beschriftung">{daten.zahllast < 0 ? 'Erstattungsanspruch' : 'Zahllast (Kz 83)'}</div>
|
||||
<div className={`wert zahl ${daten.zahllast < 0 ? 'gut' : ''}`}>{eur(daten.zahllast)}</div>
|
||||
<div className="zusatz">
|
||||
{daten.besteuerungsart === 'ist' ? 'Ist-Versteuerung' : 'Soll-Versteuerung'} ·{' '}
|
||||
{REGIME_LABEL[daten.regimeEnde]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daten.hinweise.map((h) => <div key={h} className="hinweis">{h}</div>)}
|
||||
|
||||
{!daten.abgabepflichtig && (
|
||||
<div className="hinweis warnung">
|
||||
Für diesen Zeitraum ist nach derzeitiger Datenlage keine Voranmeldung abzugeben.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="karte">
|
||||
<h2>Kennzahlen</h2>
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>Kz</th>
|
||||
<th>Sachverhalt</th>
|
||||
<th className="rechts" style={{ width: 150 }}>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kzListe.length === 0 && (
|
||||
<tr><td colSpan={3} className="gedimmt">Keine meldepflichtigen Umsätze in diesem Zeitraum.</td></tr>
|
||||
)}
|
||||
{kzListe.map(([k, wert]) => (
|
||||
<tr key={k}>
|
||||
<td className="zahl">{k}</td>
|
||||
<td>
|
||||
{KENNZAHLEN[k]?.text ?? 'Kennzahl'}
|
||||
{KENNZAHLEN[k]?.art === 'bemessung' && (
|
||||
<span className="gedimmt" style={{ fontSize: 12 }}> · Bemessungsgrundlage</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="rechts zahl">{eur(wert)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="ergebnis">
|
||||
<td className="zahl">83</td>
|
||||
<td>Verbleibende Umsatzsteuer-Vorauszahlung</td>
|
||||
<td className="rechts zahl">{eur(daten.zahllast)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daten.meldungen.length > 0 && (
|
||||
<div className="karte">
|
||||
<h3>Bisherige Meldungen zu diesem Zeitraum</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Status</th><th>Transferticket</th><th>Übermittelt</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.meldungen.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td><span className={`abzeichen ${m.status === 'uebermittelt' ? 'aktiv' : ''}`}>{m.status}</span></td>
|
||||
<td className="zahl">{m.transferticket ?? '–'}</td>
|
||||
<td>{m.uebermittelt_am ? datum(m.uebermittelt_am) : '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="karte">
|
||||
<h2>Übermittlung über ELSTER</h2>
|
||||
{status && (
|
||||
<>
|
||||
<div className={`hinweis ${status.bereitZumSenden ? 'gut' : 'warnung'}`}>
|
||||
{status.bereitZumSenden ? (
|
||||
<>ERiC ist eingebunden und einsatzbereit. {status.testmodus && 'Testmerker gesetzt – Übermittlungen bleiben ohne steuerliche Wirkung.'}</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Kein Versand möglich – ERiC ist nicht vollständig eingerichtet.</strong>
|
||||
<div style={{ marginTop: 4 }}>{status.meldung}</div>
|
||||
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
|
||||
{status.anleitung.map((a) => <li key={a}>{a}</li>)}
|
||||
</ul>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
Ohne ERiC lässt sich die Anmeldung erzeugen und prüfen, aber nicht absenden.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 13, marginBottom: 12 }}>
|
||||
<span className={`abzeichen ${status.verfuegbar ? 'aktiv' : ''}`}>ERiC-Bibliothek</span>
|
||||
<span className={`abzeichen ${status.zertifikat ? 'aktiv' : ''}`}>Zertifikat</span>
|
||||
<span className={`abzeichen ${status.herstellerId ? 'aktiv' : ''}`}>Hersteller-ID</span>
|
||||
<span className={`abzeichen ${status.testmodus ? 'aktiv' : ''}`}>Testmodus</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="knopfreihe" style={{ marginTop: 0 }}>
|
||||
<button type="button" onClick={xmlAnzeigen}>XML ansehen</button>
|
||||
<button type="button" onClick={pruefen} disabled={laeuft}>
|
||||
{laeuft ? 'Wird geprüft …' : 'Prüfen (ohne Versand)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primaer"
|
||||
onClick={senden}
|
||||
disabled={laeuft || !status?.bereitZumSenden}
|
||||
title={status?.bereitZumSenden ? '' : 'ERiC ist nicht eingerichtet'}
|
||||
>
|
||||
Verbindlich übermitteln
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{antwort && (
|
||||
<div className={`hinweis ${antwort.simulation ? 'warnung' : antwort.erfolg ? 'gut' : 'kritisch'}`} style={{ marginTop: 14 }}>
|
||||
<strong>
|
||||
{antwort.simulation
|
||||
? 'Simulationsmodus – nicht übermittelt'
|
||||
: antwort.erfolg
|
||||
? 'Erfolgreich'
|
||||
: 'Fehlgeschlagen'}
|
||||
</strong>
|
||||
{antwort.transferticket && (
|
||||
<div style={{ marginTop: 4 }}>Transferticket: <span className="zahl">{antwort.transferticket}</span></div>
|
||||
)}
|
||||
{antwort.fehler.map((f) => <div key={f} style={{ marginTop: 4 }}>{f}</div>)}
|
||||
{antwort.hinweise.map((h) => <div key={h} style={{ marginTop: 4 }}>{h}</div>)}
|
||||
{antwort.protokoll && (
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary style={{ cursor: 'pointer' }}>Protokoll</summary>
|
||||
<pre className="xml">{antwort.protokoll}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{xml && (
|
||||
<>
|
||||
<h3>Erzeugter ELSTER-Datensatz</h3>
|
||||
<pre className="xml">{xml}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{daten && daten.positionen.length > 0 && (
|
||||
<div className="karte">
|
||||
<h2>Zugrunde liegende Buchungen</h2>
|
||||
<div className="tabellenrahmen">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Vorgang</th><th>Kz</th>
|
||||
<th className="rechts">Bemessung</th><th className="rechts">Steuer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{daten.positionen.map((p) => (
|
||||
<tr key={`${p.buchungId}-${p.kz}`}>
|
||||
<td>{datum(p.datum)}</td>
|
||||
<td>{p.text}</td>
|
||||
<td className="zahl">{p.kz}</td>
|
||||
<td className="rechts zahl">{eur(p.bemessung)}</td>
|
||||
<td className="rechts zahl">{p.steuer ? eur(p.steuer) : '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-0: #f4f4f1;
|
||||
--surface-1: #fcfcfb;
|
||||
--surface-2: #eeeeea;
|
||||
--border: #dcdcd4;
|
||||
--border-stark: #c4c4b9;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #86857e;
|
||||
--akzent: #2a78d6;
|
||||
--akzent-weich: #e6f0fc;
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--gut: #0f7a4d;
|
||||
--warnung: #8a5a00;
|
||||
--warnung-flaeche: #fdf3dd;
|
||||
--kritisch: #b4231f;
|
||||
--kritisch-flaeche: #fdeceb;
|
||||
--radius: 10px;
|
||||
--schrift: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--schrift-zahl: 'SF Mono', ui-monospace, 'DejaVu Sans Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme='light'])) {
|
||||
color-scheme: dark;
|
||||
--surface-0: #131312;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--border: #34342f;
|
||||
--border-stark: #4a4a44;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #8f8e85;
|
||||
--akzent: #3987e5;
|
||||
--akzent-weich: #17273a;
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--gut: #3fae7d;
|
||||
--warnung: #d8a53c;
|
||||
--warnung-flaeche: #2e2716;
|
||||
--kritisch: #e66767;
|
||||
--kritisch-flaeche: #331b1b;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--schrift);
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Grundgerüst ------------------------------------------------------- */
|
||||
/* Der Verlauf färbt die Navigationsspalte über die volle Seitenhöhe,
|
||||
auch wenn die Navigation selbst kürzer ist als der Inhalt. */
|
||||
.rahmen {
|
||||
display: grid;
|
||||
grid-template-columns: 232px 1fr;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(to right, var(--surface-1) 232px, var(--surface-0) 232px);
|
||||
}
|
||||
|
||||
.seitenleiste {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.marke { font-weight: 650; font-size: 15px; padding: 0 10px 6px; letter-spacing: -0.01em; }
|
||||
.marke small { display: block; font-weight: 400; color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.nav-gruppe {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
padding: 14px 10px 4px;
|
||||
}
|
||||
|
||||
.seitenleiste a {
|
||||
display: block;
|
||||
padding: 7px 10px;
|
||||
border-radius: 7px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.seitenleiste a:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
.seitenleiste a.aktiv { background: var(--akzent-weich); color: var(--akzent); font-weight: 550; }
|
||||
|
||||
.inhalt { padding: 26px 30px 60px; max-width: 1180px; }
|
||||
|
||||
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 16px; margin: 26px 0 10px; letter-spacing: -0.01em; }
|
||||
h3 { font-size: 14px; margin: 18px 0 8px; color: var(--text-secondary); }
|
||||
.untertitel { color: var(--text-secondary); margin: 0 0 20px; font-size: 14px; }
|
||||
|
||||
/* ---- Bausteine --------------------------------------------------------- */
|
||||
.karte {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.karte > h2:first-child, .karte > h3:first-child { margin-top: 0; }
|
||||
|
||||
.kachelreihe { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
|
||||
.kachel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.kachel .beschriftung { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.kachel .wert { font-size: 24px; font-weight: 620; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||||
.kachel .zusatz { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.zahl { font-variant-numeric: tabular-nums; font-family: var(--schrift-zahl); }
|
||||
.rechts { text-align: right; }
|
||||
.gut { color: var(--gut); }
|
||||
.kritisch { color: var(--kritisch); }
|
||||
.gedimmt { color: var(--text-muted); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 550;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-stark);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td { padding: 7px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
tr.summe td { font-weight: 620; border-top: 1px solid var(--border-stark); background: var(--surface-2); }
|
||||
tr.ergebnis td { font-weight: 680; font-size: 15px; background: var(--akzent-weich); }
|
||||
.tabellenrahmen { overflow-x: auto; }
|
||||
|
||||
button, .knopf {
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-stark);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button:hover, .knopf:hover { background: var(--surface-2); }
|
||||
button.primaer, .knopf.primaer { background: var(--akzent); border-color: var(--akzent); color: #fff; }
|
||||
button.primaer:hover { filter: brightness(1.08); }
|
||||
button.gefahr { border-color: var(--kritisch); color: var(--kritisch); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-stark);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--akzent); outline-offset: -1px; }
|
||||
|
||||
.feld { display: flex; flex-direction: column; gap: 4px; }
|
||||
.feld > label { font-size: 12px; color: var(--text-secondary); }
|
||||
.feld .hilfe { font-size: 11px; color: var(--text-muted); }
|
||||
.formularraster { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
||||
.knopfreihe { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 14px; }
|
||||
.filterreihe { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
|
||||
.filterreihe .feld { min-width: 130px; flex: 0 1 auto; }
|
||||
|
||||
.hinweis {
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.hinweis.warnung { background: var(--warnung-flaeche); border-color: color-mix(in srgb, var(--warnung) 40%, transparent); color: var(--warnung); }
|
||||
.hinweis.kritisch { background: var(--kritisch-flaeche); border-color: color-mix(in srgb, var(--kritisch) 40%, transparent); color: var(--kritisch); }
|
||||
.hinweis.gut { background: color-mix(in srgb, var(--gut) 12%, transparent); border-color: color-mix(in srgb, var(--gut) 40%, transparent); color: var(--gut); }
|
||||
|
||||
.abzeichen {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.abzeichen.aktiv { background: var(--akzent-weich); border-color: var(--akzent); color: var(--akzent); }
|
||||
|
||||
pre.xml {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
font-family: var(--schrift-zahl);
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
/* ---- Diagramm ---------------------------------------------------------- */
|
||||
.diagramm { width: 100%; }
|
||||
.diagramm text { font-family: var(--schrift); }
|
||||
.legende { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.legende span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legende i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.rahmen { grid-template-columns: 1fr; background: var(--surface-0); }
|
||||
.seitenleiste { position: static; height: auto; flex-direction: row; flex-wrap: wrap; background: var(--surface-1); }
|
||||
.nav-gruppe { display: none; }
|
||||
.inhalt { padding: 18px 14px 50px; }
|
||||
}
|
||||
|
||||
/* ---- Mandantenwahl ------------------------------------------------------ */
|
||||
.mandantenwahl {
|
||||
padding: 8px 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mandantenwahl > label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mandantenwahl select { font-size: 13px; padding: 5px 8px; }
|
||||
|
||||
/* Testmandanten müssen auf jeder Seite sofort erkennbar sein – sonst bucht
|
||||
man versehentlich Spieldaten in die echte Buchhaltung. */
|
||||
.testkennung {
|
||||
margin-top: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
font-weight: 550;
|
||||
color: var(--warnung);
|
||||
background: var(--warnung-flaeche);
|
||||
border: 1px solid color-mix(in srgb, var(--warnung) 45%, transparent);
|
||||
}
|
||||
|
||||
.mandant-neu { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
|
||||
.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; }
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
/**
|
||||
* EUER_POLLING=1 stellt den Datei-Watcher auf Abfrage statt inotify um.
|
||||
* Nötig auf Linux-Systemen, deren fs.inotify.max_user_instances aufgebraucht
|
||||
* ist (Symptom: "EMFILE: too many open files, watch ..."). Kostet etwas CPU,
|
||||
* kommt aber ohne Änderung an Systemeinstellungen aus.
|
||||
*/
|
||||
const polling = process.env['EUER_POLLING'] === '1';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:3311',
|
||||
},
|
||||
watch: polling ? { usePolling: true, interval: 400 } : undefined,
|
||||
},
|
||||
build: { outDir: 'dist' },
|
||||
});
|
||||
Reference in New Issue
Block a user