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:
duffyduck
2026-08-03 21:45:52 +02:00
co-authored by Claude Opus 5
commit bf371c8118
59 changed files with 12913 additions and 0 deletions
+27
View File
@@ -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
View File
@@ -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
+277
View File
@@ -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.
View File
+4289
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"
}
}
+31
View File
@@ -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"
}
}
+353
View File
@@ -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);
});
});
+248
View File
@@ -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),
);
}
+116
View File
@@ -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';
}
+297
View File
@@ -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;
}
+226
View File
@@ -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,
};
}
+220
View File
@@ -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;
}
+39
View File
@@ -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);
}
+237
View File
@@ -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,
};
}
+104
View File
@@ -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
}
+299
View File
@@ -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;
}
+377
View File
@@ -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,
};
}
+115
View File
@@ -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);
}
+145
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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');
}
+65
View File
@@ -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;
+225
View File
@@ -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');
}
+221
View File
@@ -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>&#13;&#10;</RecordDelimiter>
<TextEncapsulator>"</TextEncapsulator>
${spaltenXml}
</VariableLength>
</Table>`;
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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) };
}
+86
View File
@@ -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]);
}
+78
View File
@@ -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}`,
);
+44
View File
@@ -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 };
+135
View File
@@ -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,
};
}
+127
View File
@@ -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,
};
}
+262
View File
@@ -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;
}
+17
View File
@@ -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');
+106
View File
@@ -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,
};
});
}
+388
View File
@@ -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;
});
}
+173
View File
@@ -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;
}
+102
View File
@@ -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'));
});
}
+152
View File
@@ -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 };
});
}
+154
View File
@@ -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);
});
}
+176
View File
@@ -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.');
+18
View File
@@ -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"]
}
+12
View File
@@ -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>
+25
View File
@@ -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"
}
}
+81
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+189
View File
@@ -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>
);
}
+73
View File
@@ -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 };
}
+72
View File
@@ -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',
};
+20
View File
@@ -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];
}
+41
View File
@@ -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)}`;
}
+13
View File
@@ -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>,
);
+185
View File
@@ -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>
</>
);
}
+483
View File
@@ -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>
</>
);
}
+139
View File
@@ -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>)}
</>
);
}
+153
View File
@@ -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 &amp; Regime</Link> eintragen.
</p>
</div>
)}
{daten.hinweise.map((h) => (
<div key={h} className="hinweis">{h}</div>
))}
</>
);
}
+243
View File
@@ -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>
)}
</>
);
}
+108
View File
@@ -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>
</>
);
}
+293
View File
@@ -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 &amp; 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>
</>
);
}
+369
View File
@@ -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>
)}
</>
);
}
+275
View File
@@ -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; }
+16
View File
@@ -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"]
}
+22
View File
@@ -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' },
});