Einnahmen-Überschuss-Rechnung mit ELSTER, DATEV und Mandantenfähigkeit
Gewinnermittlung nach §4 Abs. 3 EStG für Einzelunternehmen und Freiberufler. Node/Fastify mit SQLite im Backend, React/Vite im Frontend; die Anwendung läuft lokal ohne Cloud-Anbindung. Fachlicher Kern ist eine Zeitachse der Besteuerungsform: das umsatzsteuerliche Regime wird je Buchung aus dem Leistungsdatum aufgelöst, statt aus einer globalen Ja/Nein-Flagge. Dadurch ist auch ein unterjähriger Wechsel von der Kleinunternehmerregelung zur Regelbesteuerung abbildbar, wie ihn §19 Abs. 1 Satz 3 UStG seit 2025 beim Überschreiten der 100.000-EUR-Grenze vorsieht. Enthalten: - Buchungserfassung nach dem Zu-/Abflussprinzip mit Storno statt Löschung - Anlagevermögen mit AfA (linear, GWG, Sammelposten); die Aktivierung entscheidet das Konto, die Methode der Betrag - Anlage EÜR, BWA im Aufbau der Form 01, Umsatzsteuer-Voranmeldung - ELSTER-Anbindung über ERiC mit validierendem Simulationsmodus - DATEV-Buchungsstapel (EXTF 700) und GoBD-Datenträgerüberlassung (Z3) - Kontenrahmen SKR03 und SKR04, je Mandant beim Anlegen wählbar - Mandantenfähigkeit mit einer SQLite-Datei je Mandant; Testmandanten sind von der Übermittlung ans Finanzamt ausgeschlossen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
import { test, describe, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Jeder Testlauf bekommt eine eigene Datenbank – vor dem ersten Import von db.ts setzen.
|
||||
process.env['EUER_DB'] = join(mkdtempSync(join(tmpdir(), 'euer-test-')), 'test.sqlite');
|
||||
|
||||
const { ustAusBrutto, ustAusNetto, volleEuroAbgerundet } = await import('./domain/geld.js');
|
||||
const { bundeseinheitlich, SteuernummerFehler } = await import('./elster/steuernummer.js');
|
||||
const { db, initDb } = await import('./db.js');
|
||||
const { SKR03, SKR04, standardGegenkonto } = await import('./kontenrahmen/index.js');
|
||||
const { berechneUst, regimeAm } = await import('./domain/regime.js');
|
||||
const { berechneEuer } = await import('./domain/euer.js');
|
||||
const { berechneUstva } = await import('./domain/ustva.js');
|
||||
const { afaFuer } = await import('./domain/afa.js');
|
||||
const { istAnlagekonto } = await import('./routes/buchungen.js');
|
||||
const { methodeVorschlag } = await import('./domain/afa.js');
|
||||
const { oeffneDatenbank, imMandantKontext } = await import('./db.js');
|
||||
const { kontenrahmenAnlegen } = await import('./mandanten.js');
|
||||
|
||||
describe('Geldrechnung', () => {
|
||||
test('rechnet die Umsatzsteuer aus dem Bruttobetrag heraus', () => {
|
||||
assert.equal(ustAusBrutto(11900, 1900), 1900);
|
||||
assert.equal(ustAusBrutto(10700, 700), 700);
|
||||
assert.equal(ustAusBrutto(10000, 0), 0);
|
||||
});
|
||||
|
||||
test('rundet kaufmännisch auf ganze Cent', () => {
|
||||
// 33,33 EUR brutto bei 19 % -> 5,32 EUR Steuer
|
||||
assert.equal(ustAusBrutto(3333, 1900), 532);
|
||||
assert.equal(ustAusNetto(3333, 1900), 633);
|
||||
});
|
||||
|
||||
test('rundet Bemessungsgrundlagen für ELSTER auf volle Euro ab', () => {
|
||||
assert.equal(volleEuroAbgerundet(199999), 1999);
|
||||
assert.equal(volleEuroAbgerundet(-199999), -1999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Steuernummer nach Bundesschema', () => {
|
||||
test('Bayern: 11 Stellen mit vorangestellter 9', () => {
|
||||
assert.equal(bundeseinheitlich('123/456/78901', 'BY'), '9123045678901');
|
||||
});
|
||||
|
||||
test('Nordrhein-Westfalen hat einen vierstelligen Bezirk', () => {
|
||||
assert.equal(bundeseinheitlich('133/8150/8159', 'NW'), '5133081508159');
|
||||
});
|
||||
|
||||
test('Baden-Württemberg: zweistelliges Landespräfix', () => {
|
||||
assert.equal(bundeseinheitlich('93815/08152', 'BW'), '2893081508152');
|
||||
});
|
||||
|
||||
test('Hessen: führende Füllziffer wird entfernt', () => {
|
||||
assert.equal(bundeseinheitlich('01381508152', 'HE'), '2613081508152');
|
||||
});
|
||||
|
||||
test('meldet eine falsche Stellenzahl statt still zu raten', () => {
|
||||
assert.throws(() => bundeseinheitlich('123', 'BY'), SteuernummerFehler);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regimewechsel Kleinunternehmer -> Regelbesteuerung', () => {
|
||||
before(() => {
|
||||
initDb();
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR IGNORE INTO konten (nummer, bezeichnung, typ, euer_zeile, bwa_gruppe, ustva_kz,
|
||||
ust_satz_bp, ust_behandlung, aktiv, sortierung)
|
||||
VALUES (@nummer, @bezeichnung, @typ, @euer_zeile, @bwa_gruppe, @ustva_kz,
|
||||
@ust_satz_bp, @ust_behandlung, @aktiv, @sortierung)`,
|
||||
);
|
||||
db.transaction(() => SKR04.forEach((k) => stmt.run(k)))();
|
||||
db.exec('DELETE FROM steuer_regime');
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus) VALUES (?,?)').run('2025-01-01', 'kleinunternehmer');
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus) VALUES (?,?)').run('2026-01-01', 'regelbesteuerung');
|
||||
});
|
||||
|
||||
test('löst das Regime nach dem Leistungsdatum auf', () => {
|
||||
assert.equal(regimeAm('2025-12-31'), 'kleinunternehmer');
|
||||
assert.equal(regimeAm('2026-01-01'), 'regelbesteuerung');
|
||||
// vor dem ersten Eintrag gilt der konservative Default
|
||||
assert.equal(regimeAm('2024-06-01'), 'kleinunternehmer');
|
||||
});
|
||||
|
||||
test('erzwingt USt-Freiheit für Umsätze vor dem Wechsel', () => {
|
||||
const r = berechneUst({
|
||||
art: 'einnahme', leistungsdatum: '2025-11-30', brutto: 119_00,
|
||||
satzBp: 1900, behandlung: 'regel',
|
||||
});
|
||||
assert.equal(r.ust, 0);
|
||||
assert.equal(r.netto, 119_00);
|
||||
assert.equal(r.behandlung, 'kleinunternehmer');
|
||||
assert.ok(r.hinweise.length > 0);
|
||||
});
|
||||
|
||||
test('weist nach dem Wechsel Umsatzsteuer aus', () => {
|
||||
const r = berechneUst({
|
||||
art: 'einnahme', leistungsdatum: '2026-01-02', brutto: 119_00,
|
||||
satzBp: 1900, behandlung: 'regel',
|
||||
});
|
||||
assert.equal(r.ust, 19_00);
|
||||
assert.equal(r.netto, 100_00);
|
||||
assert.equal(r.behandlung, 'regel');
|
||||
});
|
||||
|
||||
test('versagt dem Kleinunternehmer den Vorsteuerabzug', () => {
|
||||
const r = berechneUst({
|
||||
art: 'ausgabe', leistungsdatum: '2025-05-05', brutto: 119_00,
|
||||
satzBp: 1900, behandlung: 'regel',
|
||||
});
|
||||
assert.equal(r.vorsteuerAbziehbar, false);
|
||||
assert.equal(r.ust, 0);
|
||||
});
|
||||
|
||||
test('behält die Steuerschuld nach §13b auch für Kleinunternehmer', () => {
|
||||
const r = berechneUst({
|
||||
art: 'ausgabe', leistungsdatum: '2025-05-05', netto: 1_000_00,
|
||||
satzBp: 1900, behandlung: 'reverse_charge_13b',
|
||||
});
|
||||
assert.equal(r.behandlung, 'reverse_charge_13b');
|
||||
assert.equal(r.ust, 190_00);
|
||||
assert.equal(r.vorsteuerAbziehbar, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EÜR und Voranmeldung über den Wechsel hinweg', () => {
|
||||
before(() => {
|
||||
db.exec('DELETE FROM buchungen; DELETE FROM anlagegueter;');
|
||||
const ein = db.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
// 2025: Kleinunternehmer – 1.000 EUR Erlös, 119 EUR Ausgabe brutto
|
||||
ein.run('einnahme', '2025-03-01', '2025-03-01', '2025-03-05', 1_000_00, 1_000_00, 0, 0, 'kleinunternehmer', 0, '4200', '1800');
|
||||
ein.run('ausgabe', '2025-03-02', '2025-03-02', '2025-03-02', 119_00, 119_00, 0, 0, 'kleinunternehmer', 0, '6815', '1800');
|
||||
// 2026: Regelbesteuerung – 1.190 EUR brutto Erlös, 119 EUR brutto Ausgabe
|
||||
ein.run('einnahme', '2026-02-01', '2026-02-01', '2026-02-10', 1_190_00, 1_000_00, 190_00, 1900, 'regel', 1, '4400', '1800');
|
||||
ein.run('ausgabe', '2026-02-03', '2026-02-03', '2026-02-03', 119_00, 100_00, 19_00, 1900, 'regel', 1, '6815', '1800');
|
||||
});
|
||||
|
||||
test('bucht 2025 brutto, weil kein Vorsteuerabzug besteht', () => {
|
||||
const e = berechneEuer(2025);
|
||||
assert.equal(e.summeEinnahmen, 1_000_00);
|
||||
assert.equal(e.summeAusgaben, 119_00);
|
||||
assert.equal(e.gewinn, 881_00);
|
||||
assert.ok(e.einnahmen.some((z) => z.key === 'be_ku'));
|
||||
});
|
||||
|
||||
test('trennt 2026 Netto, Umsatzsteuer und Vorsteuer', () => {
|
||||
const e = berechneEuer(2026);
|
||||
const be = (k: string) => e.einnahmen.find((z) => z.key === k)?.betrag ?? 0;
|
||||
const ba = (k: string) => e.ausgaben.find((z) => z.key === k)?.betrag ?? 0;
|
||||
assert.equal(be('be_pflichtig'), 1_000_00);
|
||||
assert.equal(be('be_ust'), 190_00);
|
||||
assert.equal(ba('ba_buero'), 100_00);
|
||||
assert.equal(ba('ba_vorsteuer'), 19_00);
|
||||
assert.equal(e.gewinn, 1_190_00 - 119_00);
|
||||
});
|
||||
|
||||
test('meldet für den Kleinunternehmer-Zeitraum keine Abgabepflicht', () => {
|
||||
const u = berechneUstva(2025, '41');
|
||||
assert.equal(u.abgabepflichtig, false);
|
||||
assert.equal(u.zahllast, 0);
|
||||
});
|
||||
|
||||
test('ermittelt Zahllast und Kennzahlen nach dem Wechsel', () => {
|
||||
const u = berechneUstva(2026, '41');
|
||||
assert.equal(u.abgabepflichtig, true);
|
||||
assert.equal(u.kennzahlen['81'], 1_000_00);
|
||||
assert.equal(u.kennzahlen['66'], 19_00);
|
||||
assert.equal(u.zahllast, 190_00 - 19_00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abschreibung', () => {
|
||||
test('rechnet linear zeitanteilig ab dem Anschaffungsmonat', () => {
|
||||
const gut = {
|
||||
id: 1, bezeichnung: 'Notebook', anschaffungsdatum: '2026-04-15',
|
||||
akosten_netto: 3_600_00, akosten_brutto: 4_284_00, nutzungsdauer: 3,
|
||||
afa_methode: 'linear' as const, konto: '0670', abgang_datum: null,
|
||||
abgang_erloes: null, notiz: '',
|
||||
};
|
||||
// 3.600 / 3 Jahre = 1.200 pro Jahr, davon 9/12 im Anschaffungsjahr
|
||||
assert.equal(afaFuer(gut, 2026, false).afa, 900_00);
|
||||
assert.equal(afaFuer(gut, 2027, false).afa, 1_200_00);
|
||||
// Kleinunternehmer schreiben vom Bruttobetrag ab
|
||||
assert.equal(afaFuer(gut, 2027, true).afa, 1_428_00);
|
||||
});
|
||||
|
||||
test('setzt geringwertige Wirtschaftsgüter sofort ab', () => {
|
||||
const gwg = {
|
||||
id: 2, bezeichnung: 'Bürostuhl', anschaffungsdatum: '2026-11-20',
|
||||
akosten_netto: 600_00, akosten_brutto: 714_00, nutzungsdauer: 13,
|
||||
afa_methode: 'gwg_sofort' as const, konto: '0675', abgang_datum: null,
|
||||
abgang_erloes: null, notiz: '',
|
||||
};
|
||||
assert.equal(afaFuer(gwg, 2026, false).afa, 600_00);
|
||||
assert.equal(afaFuer(gwg, 2027, false).afa, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anlagevermögen statt Sofortabzug', () => {
|
||||
test('erkennt Anlagekonten am Kontotyp, nicht am Betrag', () => {
|
||||
const anlage = { nummer: '0670', typ: 'anlage' } as never;
|
||||
const aufwand = { nummer: '6600', typ: 'aufwand' } as never;
|
||||
assert.equal(istAnlagekonto(anlage), true);
|
||||
// Eine teure Werbekampagne bleibt sofort abziehbar
|
||||
assert.equal(istAnlagekonto(aufwand), false);
|
||||
});
|
||||
|
||||
test('leitet die Abschreibungsmethode aus den Anschaffungskosten ab', () => {
|
||||
assert.equal(methodeVorschlag(800_00), 'gwg_sofort');
|
||||
assert.equal(methodeVorschlag(800_01), 'sammelposten');
|
||||
assert.equal(methodeVorschlag(1_000_00), 'sammelposten');
|
||||
assert.equal(methodeVorschlag(1_000_01), 'linear');
|
||||
});
|
||||
|
||||
test('meldet Buchungen auf Anlagekonten ohne Anlagegut als prüfbedürftig', () => {
|
||||
db.exec('DELETE FROM buchungen; DELETE FROM anlagegueter;');
|
||||
db.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES ('ausgabe','2026-03-01','2026-03-01','2026-03-01',357000,300000,57000,1900,'regel',1,'0670','1800')`,
|
||||
).run();
|
||||
|
||||
const e = berechneEuer(2026);
|
||||
assert.equal(e.fehlbuchungen.length, 1);
|
||||
assert.equal(e.fehlbuchungen[0]?.konto, '0670');
|
||||
assert.ok(e.hinweise.some((h) => h.includes('Anlagekonto')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mandantentrennung', () => {
|
||||
test('führt Buchungen strikt getrennt', () => {
|
||||
const ordner = mkdtempSync(join(tmpdir(), 'euer-mandanten-'));
|
||||
const anlegen = (name: string) => {
|
||||
const verbindung = oeffneDatenbank(join(ordner, `${name}.sqlite`));
|
||||
kontenrahmenAnlegen(verbindung); // buchungen.konto verweist per Fremdschlüssel auf konten
|
||||
return { slug: name, name, testmandant: false, verbindung };
|
||||
};
|
||||
const a = anlegen('alpha');
|
||||
const b = anlegen('beta');
|
||||
|
||||
const schreibe = (betrag: number) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES ('ausgabe','2026-01-01','2026-01-01','2026-01-01',?,?,0,0,'regel',1,'6815','1800')`,
|
||||
)
|
||||
.run(betrag, betrag);
|
||||
const zaehle = () =>
|
||||
(db.prepare('SELECT COUNT(*) AS n FROM buchungen').get() as { n: number }).n;
|
||||
|
||||
imMandantKontext(a, () => { schreibe(100); schreibe(200); });
|
||||
imMandantKontext(b, () => schreibe(999));
|
||||
|
||||
assert.equal(imMandantKontext(a, zaehle), 2);
|
||||
assert.equal(imMandantKontext(b, zaehle), 1);
|
||||
// Verschachtelung: der innere Kontext darf den äußeren nicht überschreiben
|
||||
imMandantKontext(a, () => {
|
||||
assert.equal(imMandantKontext(b, zaehle), 1);
|
||||
assert.equal(zaehle(), 2);
|
||||
});
|
||||
|
||||
a.verbindung.close();
|
||||
b.verbindung.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kontenrahmen SKR03', () => {
|
||||
test('deckt dieselben fachlichen Posten ab wie der SKR04', () => {
|
||||
const posten = (ks: typeof SKR03) => new Set(ks.map((k) => k.euer_zeile).filter(Boolean));
|
||||
const gruppen = (ks: typeof SKR03) => new Set(ks.map((k) => k.bwa_gruppe).filter(Boolean));
|
||||
for (const p of posten(SKR04)) assert.ok(posten(SKR03).has(p), `EÜR-Posten fehlt im SKR03: ${p}`);
|
||||
for (const g of gruppen(SKR04)) assert.ok(gruppen(SKR03).has(g), `BWA-Gruppe fehlt im SKR03: ${g}`);
|
||||
});
|
||||
|
||||
test('belegt gleiche Kontonummern unterschiedlich – deshalb ist ein Wechsel gesperrt', () => {
|
||||
const skr04 = new Map(SKR04.map((k) => [k.nummer, k]));
|
||||
// Vergleich über Kontotyp UND EÜR-Posten: 1800 hat in beiden Rahmen keinen
|
||||
// EÜR-Posten, ist aber einmal Bank und einmal Privatentnahme.
|
||||
const kennung = (k: (typeof SKR03)[number]) => `${k.typ}/${k.euer_zeile ?? '-'}`;
|
||||
const kollisionen = SKR03.filter((k) => skr04.has(k.nummer)).map((k) => ({
|
||||
nummer: k.nummer,
|
||||
skr03: kennung(k),
|
||||
skr04: kennung(skr04.get(k.nummer)!),
|
||||
}));
|
||||
|
||||
// Es MUSS Kollisionen geben: 4830 ist im SKR03 "Abschreibungen auf Sachanlagen",
|
||||
// im SKR04 dagegen "Sonstige betriebliche Erträge". Würde man den Kontenrahmen
|
||||
// eines Mandanten nachträglich umstellen, würde aus Aufwand ein Ertrag.
|
||||
assert.ok(kollisionen.length > 0, 'Erwartet: überlappende Nummern mit anderer Bedeutung');
|
||||
|
||||
// 1800 ist der gefährlichste Fall: im SKR04 die Bank, im SKR03 Privatentnahmen.
|
||||
// Ein fest verdrahtetes Gegenkonto würde im SKR03 jede Zahlung als Entnahme buchen.
|
||||
const skr03_1800 = SKR03.find((k) => k.nummer === '1800');
|
||||
assert.equal(skr03_1800?.typ, 'privat');
|
||||
assert.equal(skr04.get('1800')?.typ, 'bestand');
|
||||
assert.equal(standardGegenkonto('SKR03'), '1200');
|
||||
assert.equal(standardGegenkonto('SKR04'), '1800');
|
||||
|
||||
// Konten, die in beiden Rahmen dieselbe Nummer UND Bedeutung haben (z.B. 1360
|
||||
// Geldtransit), sind unkritisch; alle übrigen müssen sich unterscheiden.
|
||||
const gleicheBedeutung = kollisionen.filter((k) => k.skr03 === k.skr04);
|
||||
assert.deepEqual(gleicheBedeutung.map((k) => k.nummer), ['1360']);
|
||||
});
|
||||
|
||||
test('hat je Kontenrahmen eindeutige Nummern', () => {
|
||||
for (const [name, ks] of [['SKR03', SKR03], ['SKR04', SKR04]] as const) {
|
||||
assert.equal(new Set(ks.map((k) => k.nummer)).size, ks.length, `doppelte Nummer in ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('rechnet die EÜR mit SKR03-Konten identisch', () => {
|
||||
const ordner = mkdtempSync(join(tmpdir(), 'euer-skr03-'));
|
||||
const v = oeffneDatenbank(join(ordner, 'skr03.sqlite'));
|
||||
kontenrahmenAnlegen(v, 'SKR03');
|
||||
const kontext = { slug: 's3', name: 'SKR03', testmandant: false, verbindung: v };
|
||||
|
||||
imMandantKontext(kontext, () => {
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus) VALUES (?,?)').run('2026-01-01', 'regelbesteuerung');
|
||||
const ein = db.prepare(
|
||||
`INSERT INTO buchungen (art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto,
|
||||
betrag_netto, betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
// 8400 = Erlöse 19 % USt, 4930 = Bürobedarf, 1200 = Bank
|
||||
ein.run('einnahme', '2026-02-01', '2026-02-01', '2026-02-10', 1_190_00, 1_000_00, 190_00, 1900, 'regel', 1, '8400', '1200');
|
||||
ein.run('ausgabe', '2026-02-03', '2026-02-03', '2026-02-03', 119_00, 100_00, 19_00, 1900, 'regel', 1, '4930', '1200');
|
||||
|
||||
const e = berechneEuer(2026);
|
||||
assert.equal(e.einnahmen.find((z) => z.key === 'be_pflichtig')?.betrag, 1_000_00);
|
||||
assert.equal(e.ausgaben.find((z) => z.key === 'ba_buero')?.betrag, 100_00);
|
||||
assert.equal(e.gewinn, 1_190_00 - 119_00);
|
||||
assert.equal(e.fehlbuchungen.length, 0);
|
||||
|
||||
const u = berechneUstva(2026, '41');
|
||||
assert.equal(u.kennzahlen['81'], 1_000_00);
|
||||
assert.equal(u.kennzahlen['66'], 19_00);
|
||||
});
|
||||
v.close();
|
||||
});
|
||||
|
||||
test('führt Kleinunternehmer-Erlöse auf dem SKR03-Konto 8192', () => {
|
||||
const konto = SKR03.find((k) => k.nummer === '8192');
|
||||
assert.equal(konto?.ust_behandlung, 'kleinunternehmer');
|
||||
assert.equal(konto?.euer_zeile, 'be_ku');
|
||||
assert.equal(konto?.ust_satz_bp, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
import { projektWurzel } from './pfade.js';
|
||||
|
||||
/**
|
||||
* Mandantentrennung: jeder Mandant hat eine eigene SQLite-Datei.
|
||||
*
|
||||
* Damit ist es strukturell unmöglich, dass Testbuchungen in der echten EÜR oder
|
||||
* in einer ELSTER-Übermittlung auftauchen – anders als bei einer gemeinsamen
|
||||
* Datenbank mit mandant_id-Spalte, wo ein einziges vergessenes WHERE genügt.
|
||||
*
|
||||
* Welcher Mandant gilt, hängt am laufenden Request und wird über
|
||||
* AsyncLocalStorage geführt. Der Export `db` ist ein Proxy, der jeden Zugriff an
|
||||
* die Verbindung des aktiven Mandanten weiterreicht – die Fachmodule bleiben
|
||||
* dadurch unverändert und können gar nicht erst am falschen Mandanten arbeiten.
|
||||
*/
|
||||
export interface MandantKontext {
|
||||
slug: string;
|
||||
name: string;
|
||||
testmandant: boolean;
|
||||
verbindung: Database.Database;
|
||||
}
|
||||
|
||||
const speicher = new AsyncLocalStorage<MandantKontext>();
|
||||
|
||||
/** Fallback für Skripte und Tests, die außerhalb eines Requests laufen. */
|
||||
let standardKontext: MandantKontext | null = null;
|
||||
|
||||
export function aktuellerMandant(): MandantKontext {
|
||||
const kontext = speicher.getStore() ?? standardKontext;
|
||||
if (!kontext) {
|
||||
throw new Error(
|
||||
'Kein Mandant im Kontext. Innerhalb eines Requests setzt der Server ihn automatisch; ' +
|
||||
'in Skripten zuvor initDb() oder setzeStandardKontext() aufrufen.',
|
||||
);
|
||||
}
|
||||
return kontext;
|
||||
}
|
||||
|
||||
/** Führt fn im Kontext des Mandanten aus – alle db-Zugriffe darin gehen an dessen Datei. */
|
||||
export function imMandantKontext<T>(kontext: MandantKontext, fn: () => T): T {
|
||||
return speicher.run(kontext, fn);
|
||||
}
|
||||
|
||||
export function setzeStandardKontext(kontext: MandantKontext | null): void {
|
||||
standardKontext = kontext;
|
||||
}
|
||||
|
||||
export const db = new Proxy({} as Database.Database, {
|
||||
get(_ziel, eigenschaft) {
|
||||
const verbindung = aktuellerMandant().verbindung as unknown as Record<
|
||||
string | symbol,
|
||||
unknown
|
||||
>;
|
||||
const wert = verbindung[eigenschaft];
|
||||
return typeof wert === 'function' ? (wert as (...a: unknown[]) => unknown).bind(verbindung) : wert;
|
||||
},
|
||||
});
|
||||
|
||||
/** Relative Pfadangaben beziehen sich auf die Projektwurzel, nicht auf das Arbeitsverzeichnis. */
|
||||
export function ausProjektwurzel(pfad: string): string {
|
||||
return isAbsolute(pfad) ? pfad : resolve(projektWurzel, pfad);
|
||||
}
|
||||
|
||||
/** Öffnet eine Mandantendatei und legt das Schema an, falls es noch fehlt. */
|
||||
export function oeffneDatenbank(datei: string): Database.Database {
|
||||
const pfad = ausProjektwurzel(datei);
|
||||
mkdirSync(dirname(pfad), { recursive: true });
|
||||
const verbindung = new Database(pfad);
|
||||
verbindung.pragma('journal_mode = WAL');
|
||||
verbindung.pragma('foreign_keys = ON');
|
||||
verbindung.exec(SCHEMA);
|
||||
verbindung.prepare('INSERT OR IGNORE INTO mandant (id) VALUES (1)').run();
|
||||
return verbindung;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Geldbeträge werden als ganzzahlige Cent gespeichert, alle Steuersätze als
|
||||
* Basispunkte (1900 = 19,00 %). Damit gibt es keine Fließkomma-Rundungsfehler in
|
||||
* der Steuerberechnung.
|
||||
*/
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS mandant (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
rechtsform TEXT NOT NULL DEFAULT 'einzelunternehmen',
|
||||
strasse TEXT NOT NULL DEFAULT '',
|
||||
plz TEXT NOT NULL DEFAULT '',
|
||||
ort TEXT NOT NULL DEFAULT '',
|
||||
bundesland TEXT NOT NULL DEFAULT 'BY',
|
||||
steuernummer TEXT NOT NULL DEFAULT '',
|
||||
ust_idnr TEXT NOT NULL DEFAULT '',
|
||||
finanzamt_nr TEXT NOT NULL DEFAULT '',
|
||||
taetigkeit TEXT NOT NULL DEFAULT '',
|
||||
betriebsbeginn TEXT,
|
||||
-- 'ist' = Ist-Versteuerung (§20 UStG), 'soll' = Soll-Versteuerung (§16 UStG)
|
||||
besteuerungsart TEXT NOT NULL DEFAULT 'ist' CHECK (besteuerungsart IN ('ist','soll')),
|
||||
-- 'monatlich' | 'vierteljaehrlich' | 'jaehrlich'
|
||||
voranmeldungszeitraum TEXT NOT NULL DEFAULT 'vierteljaehrlich',
|
||||
dauerfristverlaengerung INTEGER NOT NULL DEFAULT 0,
|
||||
kontenrahmen TEXT NOT NULL DEFAULT 'SKR04',
|
||||
-- Stammdaten für DATEV
|
||||
datev_berater_nr TEXT NOT NULL DEFAULT '',
|
||||
datev_mandanten_nr TEXT NOT NULL DEFAULT '',
|
||||
-- Kontaktdaten für den ELSTER-Datenlieferant-Block
|
||||
telefon TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
/*
|
||||
* Zeitachse der umsatzsteuerlichen Regime. Statt einer globalen Ja/Nein-Flagge
|
||||
* wird das Regime je Datum aufgelöst -> unterjähriger Wechsel möglich
|
||||
* (z.B. Überschreiten der 100.000-EUR-Grenze nach §19 Abs. 1 Satz 3 UStG).
|
||||
*/
|
||||
CREATE TABLE IF NOT EXISTS steuer_regime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gueltig_ab TEXT NOT NULL UNIQUE, -- YYYY-MM-DD
|
||||
modus TEXT NOT NULL CHECK (modus IN ('kleinunternehmer','regelbesteuerung')),
|
||||
grund TEXT NOT NULL DEFAULT '',
|
||||
notiz TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS konten (
|
||||
nummer TEXT PRIMARY KEY,
|
||||
bezeichnung TEXT NOT NULL,
|
||||
-- erloes | aufwand | anlage | privat | neutral | bestand
|
||||
typ TEXT NOT NULL,
|
||||
euer_zeile TEXT, -- Schlüssel in domain/euerZeilen.ts
|
||||
bwa_gruppe TEXT, -- Schlüssel in domain/bwa.ts
|
||||
ustva_kz TEXT, -- optionale Kennzahl-Zuordnung für die UStVA
|
||||
ust_satz_bp INTEGER NOT NULL DEFAULT 1900,
|
||||
ust_behandlung TEXT NOT NULL DEFAULT 'regel',
|
||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||
sortierung INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS anlagegueter (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bezeichnung TEXT NOT NULL,
|
||||
anschaffungsdatum TEXT NOT NULL,
|
||||
akosten_netto INTEGER NOT NULL, -- Cent, ohne abziehbare Vorsteuer
|
||||
akosten_brutto INTEGER NOT NULL,
|
||||
nutzungsdauer INTEGER NOT NULL, -- Jahre
|
||||
-- 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine'
|
||||
afa_methode TEXT NOT NULL DEFAULT 'linear',
|
||||
konto TEXT REFERENCES konten(nummer),
|
||||
abgang_datum TEXT,
|
||||
abgang_erloes INTEGER,
|
||||
notiz TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS buchungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
art TEXT NOT NULL CHECK (art IN ('einnahme','ausgabe')),
|
||||
belegdatum TEXT NOT NULL, -- Datum der Rechnung / des Belegs
|
||||
leistungsdatum TEXT NOT NULL, -- maßgeblich für das USt-Regime + Soll-Versteuerung
|
||||
zahlungsdatum TEXT, -- maßgeblich für EÜR (§11 EStG) + Ist-Versteuerung
|
||||
betrag_brutto INTEGER NOT NULL, -- Cent, immer positiv
|
||||
betrag_netto INTEGER NOT NULL,
|
||||
betrag_ust INTEGER NOT NULL,
|
||||
ust_satz_bp INTEGER NOT NULL DEFAULT 0,
|
||||
/* regel | kleinunternehmer | steuerfrei_mit_vst | steuerfrei_ohne_vst
|
||||
| ig_lieferung | ig_erwerb | reverse_charge_13b | nicht_steuerbar */
|
||||
ust_behandlung TEXT NOT NULL DEFAULT 'regel',
|
||||
vorsteuer_abziehbar INTEGER NOT NULL DEFAULT 1,
|
||||
konto TEXT NOT NULL REFERENCES konten(nummer),
|
||||
gegenkonto TEXT NOT NULL DEFAULT '1800',
|
||||
beleg_nr TEXT NOT NULL DEFAULT '',
|
||||
buchungstext TEXT NOT NULL DEFAULT '',
|
||||
partner TEXT NOT NULL DEFAULT '',
|
||||
privatanteil_bp INTEGER NOT NULL DEFAULT 0,
|
||||
anlagegut_id INTEGER REFERENCES anlagegueter(id),
|
||||
beleg_datei TEXT,
|
||||
-- GoBD: keine Löschung, nur Storno; festgeschriebene Sätze sind unveränderbar
|
||||
storniert INTEGER NOT NULL DEFAULT 0,
|
||||
storno_von INTEGER REFERENCES buchungen(id),
|
||||
festgeschrieben INTEGER NOT NULL DEFAULT 0,
|
||||
erstellt_am TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
geaendert_am TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buchungen_zahlung ON buchungen(zahlungsdatum);
|
||||
CREATE INDEX IF NOT EXISTS idx_buchungen_leistung ON buchungen(leistungsdatum);
|
||||
CREATE INDEX IF NOT EXISTS idx_buchungen_konto ON buchungen(konto);
|
||||
|
||||
/* Änderungsprotokoll (GoBD-Verfahrensdokumentation) */
|
||||
CREATE TABLE IF NOT EXISTS aenderungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tabelle TEXT NOT NULL,
|
||||
datensatz_id TEXT NOT NULL,
|
||||
aktion TEXT NOT NULL,
|
||||
vorher TEXT,
|
||||
nachher TEXT,
|
||||
zeitpunkt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ustva_meldungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jahr INTEGER NOT NULL,
|
||||
zeitraum TEXT NOT NULL, -- '01'..'12' oder '41'..'44' (Quartale)
|
||||
kennzahlen TEXT NOT NULL, -- JSON
|
||||
zahllast INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'entwurf', -- entwurf | validiert | uebermittelt | fehler
|
||||
berichtigung INTEGER NOT NULL DEFAULT 0,
|
||||
xml TEXT,
|
||||
transferticket TEXT,
|
||||
eric_protokoll TEXT,
|
||||
erstellt_am TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
uebermittelt_am TEXT,
|
||||
UNIQUE (jahr, zeitraum, berichtigung)
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Für Skripte und Tests: öffnet die über EUER_DB angegebene Datei (Standard
|
||||
* data/euer.sqlite) und macht sie zum Standardmandanten. Der Server benutzt
|
||||
* stattdessen das Mandantenregister.
|
||||
*/
|
||||
export function initDb(): MandantKontext {
|
||||
const kontext: MandantKontext = {
|
||||
slug: 'standard',
|
||||
name: 'Standard',
|
||||
testmandant: false,
|
||||
verbindung: oeffneDatenbank(process.env.EUER_DB ?? 'data/euer.sqlite'),
|
||||
};
|
||||
setzeStandardKontext(kontext);
|
||||
return kontext;
|
||||
}
|
||||
|
||||
export function protokolliere(
|
||||
tabelle: string,
|
||||
datensatzId: string | number | bigint,
|
||||
aktion: string,
|
||||
vorher: unknown,
|
||||
nachher: unknown,
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT INTO aenderungen (tabelle, datensatz_id, aktion, vorher, nachher) VALUES (?,?,?,?,?)',
|
||||
).run(
|
||||
tabelle,
|
||||
String(datensatzId),
|
||||
aktion,
|
||||
vorher === undefined ? null : JSON.stringify(vorher),
|
||||
nachher === undefined ? null : JSON.stringify(nachher),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { db } from '../db.js';
|
||||
import type { Anlagegut } from './typen.js';
|
||||
import { runde } from './geld.js';
|
||||
|
||||
/** GWG-Grenze (§6 Abs. 2 EStG) und Sammelposten-Spanne (§6 Abs. 2a EStG), netto in Cent. */
|
||||
export const GWG_GRENZE = 800_00;
|
||||
export const SAMMELPOSTEN_VON = 250_00;
|
||||
export const SAMMELPOSTEN_BIS = 1_000_00;
|
||||
export const SAMMELPOSTEN_JAHRE = 5;
|
||||
|
||||
export interface AfaZeile {
|
||||
anlagegut: Anlagegut;
|
||||
jahr: number;
|
||||
/** Bemessungsgrundlage: netto bei Regelbesteuerung, brutto beim Kleinunternehmer */
|
||||
bemessung: number;
|
||||
buchwertAnfang: number;
|
||||
afa: number;
|
||||
buchwertEnde: number;
|
||||
/** Restbuchwert bei Abgang im Jahr – geht als Betriebsausgabe in die EÜR */
|
||||
restbuchwertAbgang: number;
|
||||
monate: number;
|
||||
hinweis: string;
|
||||
}
|
||||
|
||||
function monateImJahr(anschaffung: string, jahr: number, abgang: string | null): number {
|
||||
const aJahr = Number(anschaffung.slice(0, 4));
|
||||
const aMonat = Number(anschaffung.slice(5, 7));
|
||||
let von = 1;
|
||||
let bis = 12;
|
||||
if (aJahr > jahr) return 0;
|
||||
if (aJahr === jahr) von = aMonat;
|
||||
if (abgang) {
|
||||
const bJahr = Number(abgang.slice(0, 4));
|
||||
const bMonat = Number(abgang.slice(5, 7));
|
||||
if (bJahr < jahr) return 0;
|
||||
// Im Abgangsjahr wird bis einschließlich des Abgangsmonats abgeschrieben.
|
||||
if (bJahr === jahr) bis = bMonat;
|
||||
}
|
||||
return Math.max(0, bis - von + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineare AfA (§7 Abs. 1 EStG) zeitanteilig ab dem Monat der Anschaffung.
|
||||
* GWG werden im Anschaffungsjahr voll abgesetzt, Sammelposten über 5 Jahre
|
||||
* zu je 20 % – unabhängig vom Monat der Anschaffung.
|
||||
*/
|
||||
export function afaFuer(a: Anlagegut, jahr: number, kleinunternehmerBeiAnschaffung: boolean): AfaZeile {
|
||||
const bemessung = kleinunternehmerBeiAnschaffung ? a.akosten_brutto : a.akosten_netto;
|
||||
const aJahr = Number(a.anschaffungsdatum.slice(0, 4));
|
||||
const monate = monateImJahr(a.anschaffungsdatum, jahr, a.abgang_datum);
|
||||
|
||||
let afa = 0;
|
||||
let hinweis = '';
|
||||
let kumuliertBisVorjahr = 0;
|
||||
|
||||
if (a.afa_methode === 'gwg_sofort') {
|
||||
afa = jahr === aJahr ? bemessung : 0;
|
||||
kumuliertBisVorjahr = jahr > aJahr ? bemessung : 0;
|
||||
hinweis = 'Sofortabschreibung GWG (§6 Abs. 2 EStG)';
|
||||
} else if (a.afa_methode === 'sammelposten') {
|
||||
const jahresrate = runde(bemessung / SAMMELPOSTEN_JAHRE);
|
||||
const idx = jahr - aJahr;
|
||||
afa = idx >= 0 && idx < SAMMELPOSTEN_JAHRE ? jahresrate : 0;
|
||||
if (idx === SAMMELPOSTEN_JAHRE - 1) afa = bemessung - jahresrate * (SAMMELPOSTEN_JAHRE - 1);
|
||||
kumuliertBisVorjahr = Math.min(Math.max(idx, 0), SAMMELPOSTEN_JAHRE) * jahresrate;
|
||||
hinweis = `Sammelposten ${SAMMELPOSTEN_JAHRE} Jahre (§6 Abs. 2a EStG)`;
|
||||
} else if (a.afa_methode === 'keine') {
|
||||
hinweis = 'nicht abnutzbar – keine AfA';
|
||||
} else {
|
||||
const jahresAfa = a.nutzungsdauer > 0 ? bemessung / a.nutzungsdauer : 0;
|
||||
for (let j = aJahr; j < jahr; j++) {
|
||||
kumuliertBisVorjahr += runde((jahresAfa * monateImJahr(a.anschaffungsdatum, j, a.abgang_datum)) / 12);
|
||||
}
|
||||
kumuliertBisVorjahr = Math.min(kumuliertBisVorjahr, bemessung);
|
||||
afa = runde((jahresAfa * monate) / 12);
|
||||
// letzte Rate: exakt auf 0 bzw. Erinnerungswert auslaufen lassen
|
||||
if (kumuliertBisVorjahr + afa > bemessung) afa = bemessung - kumuliertBisVorjahr;
|
||||
hinweis = `linear über ${a.nutzungsdauer} Jahre, ${monate}/12 Monate`;
|
||||
}
|
||||
|
||||
const buchwertAnfang = Math.max(0, bemessung - kumuliertBisVorjahr);
|
||||
const buchwertEnde = Math.max(0, buchwertAnfang - afa);
|
||||
|
||||
let restbuchwertAbgang = 0;
|
||||
if (a.abgang_datum && Number(a.abgang_datum.slice(0, 4)) === jahr) {
|
||||
restbuchwertAbgang = buchwertEnde;
|
||||
}
|
||||
|
||||
return {
|
||||
anlagegut: a,
|
||||
jahr,
|
||||
bemessung,
|
||||
buchwertAnfang,
|
||||
afa,
|
||||
buchwertEnde: restbuchwertAbgang > 0 ? 0 : buchwertEnde,
|
||||
restbuchwertAbgang,
|
||||
monate,
|
||||
hinweis,
|
||||
};
|
||||
}
|
||||
|
||||
export function anlagenverzeichnis(jahr: number, regimeAm: (d: string) => string): AfaZeile[] {
|
||||
const gueter = db
|
||||
.prepare('SELECT * FROM anlagegueter ORDER BY anschaffungsdatum')
|
||||
.all() as Anlagegut[];
|
||||
return gueter
|
||||
.map((a) => afaFuer(a, jahr, regimeAm(a.anschaffungsdatum) === 'kleinunternehmer'))
|
||||
.filter((z) => z.afa !== 0 || z.buchwertAnfang > 0 || z.restbuchwertAbgang > 0);
|
||||
}
|
||||
|
||||
/** Schlägt anhand der Netto-Anschaffungskosten die passende AfA-Methode vor. */
|
||||
export function methodeVorschlag(nettoCent: number): Anlagegut['afa_methode'] {
|
||||
if (nettoCent <= GWG_GRENZE) return 'gwg_sofort';
|
||||
if (nettoCent <= SAMMELPOSTEN_BIS) return 'sammelposten';
|
||||
return 'linear';
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { db } from '../db.js';
|
||||
import { anteil } from './geld.js';
|
||||
import { anlagenverzeichnis } from './afa.js';
|
||||
import { regimeAm } from './regime.js';
|
||||
import { POSTEN_NACH_KEY } from './euerZeilen.js';
|
||||
import type { Buchung, Konto } from './typen.js';
|
||||
|
||||
/**
|
||||
* Betriebswirtschaftliche Auswertung im Aufbau der klassischen "BWA Form 01"
|
||||
* (kurzfristige Erfolgsrechnung). Zeilen mit `formel` werden aus den
|
||||
* Einzelgruppen berechnet, alle übrigen aus den Kontensalden.
|
||||
*/
|
||||
export type BwaGruppenId =
|
||||
| 'umsatzerloese' | 'bestandsveraenderung' | 'akt_eigenleistungen'
|
||||
| 'mat_wareneinkauf' | 'so_betr_erloese'
|
||||
| 'personalkosten' | 'raumkosten' | 'betriebliche_steuern'
|
||||
| 'versicherungen_beitraege' | 'besondere_kosten' | 'kfz_kosten'
|
||||
| 'werbe_reisekosten' | 'kosten_warenabgabe' | 'abschreibungen'
|
||||
| 'reparatur_instandhaltung' | 'sonstige_kosten'
|
||||
| 'zinsaufwand' | 'so_neutraler_aufwand'
|
||||
| 'zinsertraege' | 'so_neutrale_ertraege'
|
||||
| 'steuern_eink_ertrag';
|
||||
|
||||
interface BwaDefinition {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
art: 'erloes' | 'kosten' | 'summe' | 'neutral';
|
||||
/** Aufbau als Summe/Differenz anderer Zeilen */
|
||||
formel?: { id: string; faktor: 1 | -1 }[];
|
||||
/** true = fett/abgesetzt darstellen */
|
||||
zwischensumme?: boolean;
|
||||
}
|
||||
|
||||
export const BWA_AUFBAU: BwaDefinition[] = [
|
||||
{ id: 'umsatzerloese', bezeichnung: 'Umsatzerlöse', art: 'erloes' },
|
||||
{ id: 'bestandsveraenderung', bezeichnung: 'Bestandsveränderungen FE/UE', art: 'erloes' },
|
||||
{ id: 'akt_eigenleistungen', bezeichnung: 'Aktivierte Eigenleistungen', art: 'erloes' },
|
||||
{
|
||||
id: 'gesamtleistung',
|
||||
bezeichnung: 'Gesamtleistung',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'umsatzerloese', faktor: 1 },
|
||||
{ id: 'bestandsveraenderung', faktor: 1 },
|
||||
{ id: 'akt_eigenleistungen', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{ id: 'mat_wareneinkauf', bezeichnung: 'Material-/Wareneinkauf', art: 'kosten' },
|
||||
{
|
||||
id: 'rohertrag',
|
||||
bezeichnung: 'Rohertrag',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'gesamtleistung', faktor: 1 },
|
||||
{ id: 'mat_wareneinkauf', faktor: -1 },
|
||||
],
|
||||
},
|
||||
{ id: 'so_betr_erloese', bezeichnung: 'Sonstige betriebliche Erlöse', art: 'erloes' },
|
||||
{
|
||||
id: 'betrieblicher_rohertrag',
|
||||
bezeichnung: 'Betrieblicher Rohertrag',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'rohertrag', faktor: 1 },
|
||||
{ id: 'so_betr_erloese', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{ id: 'personalkosten', bezeichnung: 'Personalkosten', art: 'kosten' },
|
||||
{ id: 'raumkosten', bezeichnung: 'Raumkosten', art: 'kosten' },
|
||||
{ id: 'betriebliche_steuern', bezeichnung: 'Betriebliche Steuern', art: 'kosten' },
|
||||
{ id: 'versicherungen_beitraege', bezeichnung: 'Versicherungen/Beiträge', art: 'kosten' },
|
||||
{ id: 'besondere_kosten', bezeichnung: 'Besondere Kosten', art: 'kosten' },
|
||||
{ id: 'kfz_kosten', bezeichnung: 'Fahrzeugkosten (ohne Steuer)', art: 'kosten' },
|
||||
{ id: 'werbe_reisekosten', bezeichnung: 'Werbe-/Reisekosten', art: 'kosten' },
|
||||
{ id: 'kosten_warenabgabe', bezeichnung: 'Kosten der Warenabgabe', art: 'kosten' },
|
||||
{ id: 'abschreibungen', bezeichnung: 'Abschreibungen', art: 'kosten' },
|
||||
{ id: 'reparatur_instandhaltung', bezeichnung: 'Reparatur/Instandhaltung', art: 'kosten' },
|
||||
{ id: 'sonstige_kosten', bezeichnung: 'Sonstige Kosten', art: 'kosten' },
|
||||
{
|
||||
id: 'gesamtkosten',
|
||||
bezeichnung: 'Gesamtkosten',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
'personalkosten', 'raumkosten', 'betriebliche_steuern', 'versicherungen_beitraege',
|
||||
'besondere_kosten', 'kfz_kosten', 'werbe_reisekosten', 'kosten_warenabgabe',
|
||||
'abschreibungen', 'reparatur_instandhaltung', 'sonstige_kosten',
|
||||
].map((id) => ({ id, faktor: 1 as const })),
|
||||
},
|
||||
{
|
||||
id: 'betriebsergebnis',
|
||||
bezeichnung: 'Betriebsergebnis',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'betrieblicher_rohertrag', faktor: 1 },
|
||||
{ id: 'gesamtkosten', faktor: -1 },
|
||||
],
|
||||
},
|
||||
{ id: 'zinsaufwand', bezeichnung: 'Zinsaufwand', art: 'kosten' },
|
||||
{ id: 'so_neutraler_aufwand', bezeichnung: 'Sonstiger neutraler Aufwand', art: 'kosten' },
|
||||
{
|
||||
id: 'neutraler_aufwand',
|
||||
bezeichnung: 'Neutraler Aufwand',
|
||||
art: 'summe',
|
||||
formel: [
|
||||
{ id: 'zinsaufwand', faktor: 1 },
|
||||
{ id: 'so_neutraler_aufwand', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{ id: 'zinsertraege', bezeichnung: 'Zinserträge', art: 'erloes' },
|
||||
{ id: 'so_neutrale_ertraege', bezeichnung: 'Sonstige neutrale Erträge', art: 'erloes' },
|
||||
{
|
||||
id: 'neutraler_ertrag',
|
||||
bezeichnung: 'Neutraler Ertrag',
|
||||
art: 'summe',
|
||||
formel: [
|
||||
{ id: 'zinsertraege', faktor: 1 },
|
||||
{ id: 'so_neutrale_ertraege', faktor: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ergebnis_vor_steuern',
|
||||
bezeichnung: 'Ergebnis vor Steuern',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'betriebsergebnis', faktor: 1 },
|
||||
{ id: 'neutraler_ertrag', faktor: 1 },
|
||||
{ id: 'neutraler_aufwand', faktor: -1 },
|
||||
],
|
||||
},
|
||||
{ id: 'steuern_eink_ertrag', bezeichnung: 'Steuern Einkommen und Ertrag', art: 'kosten' },
|
||||
{
|
||||
id: 'vorlaeufiges_ergebnis',
|
||||
bezeichnung: 'Vorläufiges Ergebnis',
|
||||
art: 'summe',
|
||||
zwischensumme: true,
|
||||
formel: [
|
||||
{ id: 'ergebnis_vor_steuern', faktor: 1 },
|
||||
{ id: 'steuern_eink_ertrag', faktor: -1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface BwaZeile {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
art: BwaDefinition['art'];
|
||||
zwischensumme: boolean;
|
||||
/** Werte des ausgewählten Monats */
|
||||
monat: number;
|
||||
/** Kumuliert seit Jahresbeginn */
|
||||
kumuliert: number;
|
||||
/** Kumuliert im Vorjahreszeitraum */
|
||||
vorjahrKumuliert: number;
|
||||
/** Anteil an der Gesamtleistung in Prozent (kumuliert) */
|
||||
quoteKumuliert: number;
|
||||
}
|
||||
|
||||
export interface BwaErgebnis {
|
||||
jahr: number;
|
||||
monat: number;
|
||||
basis: 'zahlung' | 'leistung';
|
||||
zeilen: BwaZeile[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
type Salden = Map<string, number>;
|
||||
|
||||
/**
|
||||
* @param basis 'zahlung' = Ist-Sicht passend zur EÜR (§11 EStG),
|
||||
* 'leistung' = periodengerechte Sicht.
|
||||
*/
|
||||
export function berechneBwa(
|
||||
jahr: number,
|
||||
monat: number,
|
||||
basis: 'zahlung' | 'leistung' = 'zahlung',
|
||||
): BwaErgebnis {
|
||||
const konten = new Map(
|
||||
(db.prepare('SELECT * FROM konten').all() as Konto[]).map((k) => [k.nummer, k]),
|
||||
);
|
||||
|
||||
const monatSalden = salden(jahr, monat, monat, basis, konten);
|
||||
const kumSalden = salden(jahr, 1, monat, basis, konten);
|
||||
const vjSalden = salden(jahr - 1, 1, monat, basis, konten);
|
||||
|
||||
const werte = new Map<string, { m: number; k: number; v: number }>();
|
||||
for (const def of BWA_AUFBAU) {
|
||||
if (def.formel) {
|
||||
const acc = { m: 0, k: 0, v: 0 };
|
||||
for (const t of def.formel) {
|
||||
const q = werte.get(t.id);
|
||||
if (!q) continue;
|
||||
acc.m += t.faktor * q.m;
|
||||
acc.k += t.faktor * q.k;
|
||||
acc.v += t.faktor * q.v;
|
||||
}
|
||||
werte.set(def.id, acc);
|
||||
} else {
|
||||
werte.set(def.id, {
|
||||
m: monatSalden.get(def.id) ?? 0,
|
||||
k: kumSalden.get(def.id) ?? 0,
|
||||
v: vjSalden.get(def.id) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gesamtleistung = werte.get('gesamtleistung')?.k ?? 0;
|
||||
|
||||
const zeilen: BwaZeile[] = BWA_AUFBAU.map((def) => {
|
||||
const w = werte.get(def.id) ?? { m: 0, k: 0, v: 0 };
|
||||
return {
|
||||
id: def.id,
|
||||
bezeichnung: def.bezeichnung,
|
||||
art: def.art,
|
||||
zwischensumme: !!def.zwischensumme,
|
||||
monat: w.m,
|
||||
kumuliert: w.k,
|
||||
vorjahrKumuliert: w.v,
|
||||
quoteKumuliert: gesamtleistung !== 0 ? (w.k / gesamtleistung) * 100 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const hinweise: string[] = [];
|
||||
if (basis === 'zahlung') {
|
||||
hinweise.push(
|
||||
'Basis: Zahlungszeitpunkt (Ist-Prinzip) – deckungsgleich mit der EÜR. ' +
|
||||
'Offene Rechnungen sind nicht enthalten.',
|
||||
);
|
||||
} else {
|
||||
hinweise.push('Basis: Leistungszeitpunkt – periodengerecht, weicht bewusst von der EÜR ab.');
|
||||
}
|
||||
hinweise.push(
|
||||
'Abschreibungen werden monatsanteilig aus dem Anlagenverzeichnis umgelegt (1/12 der Jahres-AfA).',
|
||||
);
|
||||
|
||||
return { jahr, monat, basis, zeilen, hinweise };
|
||||
}
|
||||
|
||||
function salden(
|
||||
jahr: number,
|
||||
vonMonat: number,
|
||||
bisMonat: number,
|
||||
basis: 'zahlung' | 'leistung',
|
||||
konten: Map<string, Konto>,
|
||||
): Salden {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
const von = `${jahr}-${p(vonMonat)}-01`;
|
||||
const bis = new Date(Date.UTC(jahr, bisMonat, 0)).toISOString().slice(0, 10);
|
||||
const feld = basis === 'zahlung' ? 'zahlungsdatum' : 'leistungsdatum';
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE storniert = 0 AND ${feld} IS NOT NULL AND ${feld} BETWEEN ? AND ?`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
|
||||
const out: Salden = new Map();
|
||||
const add = (g: string | null | undefined, cent: number) => {
|
||||
if (!g || cent === 0) return;
|
||||
out.set(g, (out.get(g) ?? 0) + cent);
|
||||
};
|
||||
|
||||
for (const b of rows) {
|
||||
const konto = konten.get(b.konto);
|
||||
if (konto?.typ === 'bestand' || konto?.typ === 'privat') continue;
|
||||
// Anschaffungen von Anlagegütern sind kein Aufwand – nur die AfA.
|
||||
if (b.anlagegut_id) continue;
|
||||
|
||||
if (b.art === 'einnahme') {
|
||||
add(konto?.bwa_gruppe ?? 'umsatzerloese', b.betrag_netto);
|
||||
} else {
|
||||
const posten = konto?.euer_zeile ? POSTEN_NACH_KEY.get(konto.euer_zeile) : undefined;
|
||||
let betrag = b.vorsteuer_abziehbar ? b.betrag_netto : b.betrag_brutto;
|
||||
if (b.privatanteil_bp > 0) betrag -= anteil(betrag, b.privatanteil_bp);
|
||||
if (posten?.abziehbarBp) betrag = anteil(betrag, posten.abziehbarBp);
|
||||
add(konto?.bwa_gruppe ?? 'sonstige_kosten', betrag);
|
||||
}
|
||||
}
|
||||
|
||||
// AfA monatsanteilig
|
||||
const monate = bisMonat - vonMonat + 1;
|
||||
for (const z of anlagenverzeichnis(jahr, regimeAm)) {
|
||||
if (z.afa === 0) continue;
|
||||
add('abschreibungen', Math.round((z.afa / 12) * monate));
|
||||
if (z.restbuchwertAbgang > 0 && bisMonat === 12) {
|
||||
add('so_neutraler_aufwand', z.restbuchwertAbgang);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { db } from '../db.js';
|
||||
import { anteil } from './geld.js';
|
||||
import { EUER_POSTEN, POSTEN_NACH_KEY, zeilenNummer } from './euerZeilen.js';
|
||||
import { anlagenverzeichnis } from './afa.js';
|
||||
import { regimeAm, regimeWechselIm } from './regime.js';
|
||||
import type { Buchung, Konto } from './typen.js';
|
||||
|
||||
export interface EuerZeile {
|
||||
key: string;
|
||||
zeile: number | null;
|
||||
bezeichnung: string;
|
||||
betrag: number;
|
||||
hinweis?: string;
|
||||
/** Belegte Buchungen für die Aufriss-Ansicht */
|
||||
anzahl: number;
|
||||
}
|
||||
|
||||
export interface EuerErgebnis {
|
||||
jahr: number;
|
||||
von: string;
|
||||
bis: string;
|
||||
regimeStart: string;
|
||||
regimeEnde: string;
|
||||
wechselImJahr: { gueltig_ab: string; modus: string; grund: string }[];
|
||||
einnahmen: EuerZeile[];
|
||||
ausgaben: EuerZeile[];
|
||||
summeEinnahmen: number;
|
||||
summeAusgaben: number;
|
||||
gewinn: number;
|
||||
offenePosten: { einnahmen: number; ausgaben: number };
|
||||
/** Ausgaben auf Anlagekonten, zu denen kein Anlagegut existiert – zu prüfen */
|
||||
fehlbuchungen: { id: number; datum: string; konto: string; text: string; betrag: number }[];
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Einnahmen-Überschuss-Rechnung nach §4 Abs. 3 EStG.
|
||||
* Maßgeblich ist das Zu-/Abflussprinzip (§11 EStG): eine Buchung zählt in dem
|
||||
* Jahr, in dem sie bezahlt wurde – unbezahlte Rechnungen bleiben außen vor.
|
||||
*/
|
||||
export function berechneEuer(jahr: number): EuerErgebnis {
|
||||
const von = `${jahr}-01-01`;
|
||||
const bis = `${jahr}-12-31`;
|
||||
|
||||
const konten = new Map(
|
||||
(db.prepare('SELECT * FROM konten').all() as Konto[]).map((k) => [k.nummer, k]),
|
||||
);
|
||||
|
||||
const buchungen = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE storniert = 0 AND zahlungsdatum IS NOT NULL
|
||||
AND zahlungsdatum BETWEEN ? AND ?
|
||||
ORDER BY zahlungsdatum`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
|
||||
const summen = new Map<string, { betrag: number; anzahl: number }>();
|
||||
const addiere = (key: string, betrag: number) => {
|
||||
if (betrag === 0) return;
|
||||
const s = summen.get(key) ?? { betrag: 0, anzahl: 0 };
|
||||
s.betrag += betrag;
|
||||
s.anzahl += 1;
|
||||
summen.set(key, s);
|
||||
};
|
||||
|
||||
const hinweise: string[] = [];
|
||||
const fehlbuchungen: {
|
||||
id: number;
|
||||
datum: string;
|
||||
konto: string;
|
||||
text: string;
|
||||
betrag: number;
|
||||
}[] = [];
|
||||
|
||||
for (const b of buchungen) {
|
||||
const konto = konten.get(b.konto);
|
||||
|
||||
// Umsatzsteuer-Zahlungen an das / Erstattungen vom Finanzamt sind in der
|
||||
// EÜR eigene Posten (Bruttoverfahren der Anlage EÜR).
|
||||
if (b.konto === '3820' || b.konto === '3806' || b.konto === '3801') {
|
||||
addiere(b.art === 'ausgabe' ? 'ba_ust_zahlung' : 'be_ust_erstattung', b.betrag_brutto);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (b.art === 'einnahme') {
|
||||
const kuBuchung = b.ust_behandlung === 'kleinunternehmer';
|
||||
const zielKey = konto?.euer_zeile ?? (kuBuchung ? 'be_ku' : 'be_pflichtig');
|
||||
// Ein Konto kann auf "be_pflichtig" zeigen – wenn die Buchung aber unter
|
||||
// §19 fiel, gehört sie in die Kleinunternehmer-Zeile und umgekehrt.
|
||||
const key =
|
||||
zielKey === 'be_pflichtig' && kuBuchung
|
||||
? 'be_ku'
|
||||
: zielKey === 'be_ku' && !kuBuchung
|
||||
? 'be_pflichtig'
|
||||
: zielKey;
|
||||
addiere(key, b.betrag_netto);
|
||||
addiere('be_ust', b.betrag_ust);
|
||||
} else {
|
||||
// Anlagegüter: die Anschaffung selbst ist keine Betriebsausgabe,
|
||||
// nur die AfA. Die Vorsteuer ist dagegen sofort abziehbar.
|
||||
if (b.anlagegut_id) {
|
||||
if (b.vorsteuer_abziehbar) addiere('ba_vorsteuer', b.betrag_ust);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Buchung auf einem Anlagekonto ohne zugehöriges Anlagegut: die Anschaffung
|
||||
// würde hier in voller Höhe als Betriebsausgabe abgezogen. Der Betrag bleibt
|
||||
// unverändert – stillschweigend korrigierte Vorjahreszahlen wären schlimmer –,
|
||||
// die Buchung wird aber ausdrücklich zur Prüfung gemeldet.
|
||||
if (konto?.typ === 'anlage') {
|
||||
fehlbuchungen.push({
|
||||
id: b.id,
|
||||
datum: b.zahlungsdatum ?? b.leistungsdatum,
|
||||
konto: b.konto,
|
||||
text: b.buchungstext || b.beleg_nr,
|
||||
betrag: b.betrag_brutto,
|
||||
});
|
||||
}
|
||||
|
||||
const key = konto?.euer_zeile ?? 'ba_sonstige';
|
||||
const posten = POSTEN_NACH_KEY.get(key);
|
||||
|
||||
let basis = b.vorsteuer_abziehbar ? b.betrag_netto : b.betrag_brutto;
|
||||
if (b.privatanteil_bp > 0) basis -= anteil(basis, b.privatanteil_bp);
|
||||
if (posten?.abziehbarBp) basis = anteil(basis, posten.abziehbarBp);
|
||||
|
||||
addiere(key, basis);
|
||||
|
||||
if (b.vorsteuer_abziehbar && b.betrag_ust > 0) {
|
||||
// Vorsteuer bleibt auch bei Bewirtung zu 100 % abziehbar, wird aber um
|
||||
// einen privaten Nutzungsanteil gekürzt.
|
||||
let vst = b.betrag_ust;
|
||||
if (b.privatanteil_bp > 0) vst -= anteil(vst, b.privatanteil_bp);
|
||||
addiere('ba_vorsteuer', vst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abschreibungen aus dem Anlagenverzeichnis ---------------------------
|
||||
for (const z of anlagenverzeichnis(jahr, regimeAm)) {
|
||||
const konto = z.anlagegut.konto ? konten.get(z.anlagegut.konto) : undefined;
|
||||
let key = 'ba_afa_beweglich';
|
||||
if (z.anlagegut.afa_methode === 'gwg_sofort') key = 'ba_gwg';
|
||||
else if (z.anlagegut.afa_methode === 'sammelposten') key = 'ba_sammelposten';
|
||||
else if (konto?.nummer === '0135') key = 'ba_afa_immateriell';
|
||||
addiere(key, z.afa);
|
||||
addiere('ba_restbuchwert', z.restbuchwertAbgang);
|
||||
}
|
||||
|
||||
const regimeStart = regimeAm(von);
|
||||
const regimeEnde = regimeAm(bis);
|
||||
const wechsel = regimeWechselIm(von, bis).map((w) => ({
|
||||
gueltig_ab: w.gueltig_ab,
|
||||
modus: w.modus,
|
||||
grund: w.grund,
|
||||
}));
|
||||
|
||||
if (wechsel.length > 0) {
|
||||
hinweise.push(
|
||||
`Im Jahr ${jahr} liegt ein Wechsel der Besteuerungsform. Umsätze vor dem Wechsel sind ` +
|
||||
'in der Kleinunternehmer-Zeile ausgewiesen, danach in den umsatzsteuerpflichtigen Zeilen.',
|
||||
);
|
||||
}
|
||||
if (regimeEnde === 'kleinunternehmer') {
|
||||
hinweise.push(
|
||||
'Als Kleinunternehmer sind Betriebsausgaben mit dem Bruttobetrag angesetzt, ' +
|
||||
'da kein Vorsteuerabzug besteht (§19 Abs. 1 Satz 4 UStG).',
|
||||
);
|
||||
}
|
||||
if (fehlbuchungen.length > 0) {
|
||||
hinweise.push(
|
||||
`${fehlbuchungen.length} Buchung(en) liegen auf einem Anlagekonto, ohne dass ein Anlagegut ` +
|
||||
'hinterlegt ist. Sie sind hier in voller Höhe als Betriebsausgabe enthalten, statt über die ' +
|
||||
'Nutzungsdauer abgeschrieben zu werden. Bitte stornieren und mit Nutzungsdauer neu erfassen.',
|
||||
);
|
||||
}
|
||||
|
||||
const zeilenAus = (bereich: 'einnahmen' | 'ausgaben'): EuerZeile[] =>
|
||||
EUER_POSTEN.filter((p) => p.bereich === bereich)
|
||||
.map((p) => {
|
||||
const s = summen.get(p.key);
|
||||
return {
|
||||
key: p.key,
|
||||
zeile: zeilenNummer(jahr, p.key),
|
||||
bezeichnung: p.bezeichnung,
|
||||
betrag: s?.betrag ?? 0,
|
||||
anzahl: s?.anzahl ?? 0,
|
||||
hinweis: p.hinweis,
|
||||
};
|
||||
})
|
||||
.filter((z) => z.betrag !== 0);
|
||||
|
||||
const einnahmen = zeilenAus('einnahmen');
|
||||
const ausgaben = zeilenAus('ausgaben');
|
||||
const summeEinnahmen = einnahmen.reduce((s, z) => s + z.betrag, 0);
|
||||
const summeAusgaben = ausgaben.reduce((s, z) => s + z.betrag, 0);
|
||||
|
||||
const offen = db
|
||||
.prepare(
|
||||
`SELECT art, COALESCE(SUM(betrag_brutto),0) AS s FROM buchungen
|
||||
WHERE storniert = 0 AND zahlungsdatum IS NULL
|
||||
AND leistungsdatum BETWEEN ? AND ? GROUP BY art`,
|
||||
)
|
||||
.all(von, bis) as { art: string; s: number }[];
|
||||
|
||||
return {
|
||||
jahr,
|
||||
von,
|
||||
bis,
|
||||
regimeStart,
|
||||
regimeEnde,
|
||||
wechselImJahr: wechsel,
|
||||
einnahmen,
|
||||
ausgaben,
|
||||
summeEinnahmen,
|
||||
summeAusgaben,
|
||||
gewinn: summeEinnahmen - summeAusgaben,
|
||||
offenePosten: {
|
||||
einnahmen: offen.find((o) => o.art === 'einnahme')?.s ?? 0,
|
||||
ausgaben: offen.find((o) => o.art === 'ausgabe')?.s ?? 0,
|
||||
},
|
||||
fehlbuchungen,
|
||||
hinweise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Gliederung der Anlage EÜR.
|
||||
*
|
||||
* WICHTIG: Die amtlichen Zeilennummern der Anlage EÜR ändern sich fast jedes
|
||||
* Jahr. Der fachliche Aufbau (welcher Posten wohin gehört) ist stabil, die
|
||||
* Nummerierung nicht. Deshalb sind hier fachliche Schlüssel definiert und die
|
||||
* Zeilennummern pro Veranlagungsjahr in ZEILEN_NUMMERN gepflegt. Vor der ersten
|
||||
* Abgabe eines Jahres die Nummern gegen das aktuelle amtliche Formular prüfen
|
||||
* und ggf. hier nachziehen – die Berechnung selbst bleibt davon unberührt.
|
||||
*/
|
||||
|
||||
export type EuerBereich = 'einnahmen' | 'ausgaben';
|
||||
|
||||
export interface EuerPosten {
|
||||
key: string;
|
||||
bezeichnung: string;
|
||||
bereich: EuerBereich;
|
||||
/** Nur bei Regelbesteuerung relevant */
|
||||
nurRegelbesteuerung?: boolean;
|
||||
/** Nur bei Kleinunternehmern relevant */
|
||||
nurKleinunternehmer?: boolean;
|
||||
/** Anteil, der steuerlich abziehbar ist (Basispunkte); z.B. Bewirtung 70 % */
|
||||
abziehbarBp?: number;
|
||||
hinweis?: string;
|
||||
}
|
||||
|
||||
export const EUER_POSTEN: EuerPosten[] = [
|
||||
// ---- Betriebseinnahmen -------------------------------------------------
|
||||
{
|
||||
key: 'be_ku',
|
||||
bezeichnung: 'Betriebseinnahmen als umsatzsteuerlicher Kleinunternehmer (§19 UStG)',
|
||||
bereich: 'einnahmen',
|
||||
nurKleinunternehmer: true,
|
||||
},
|
||||
{
|
||||
key: 'be_pflichtig',
|
||||
bezeichnung: 'Umsatzsteuerpflichtige Betriebseinnahmen (netto)',
|
||||
bereich: 'einnahmen',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'be_frei',
|
||||
bezeichnung: 'Umsatzsteuerfreie und nicht steuerbare Betriebseinnahmen',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{
|
||||
key: 'be_ust',
|
||||
bezeichnung: 'Vereinnahmte Umsatzsteuer',
|
||||
bereich: 'einnahmen',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'be_ust_erstattung',
|
||||
bezeichnung: 'Vom Finanzamt erstattete und ggf. verrechnete Umsatzsteuer',
|
||||
bereich: 'einnahmen',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'be_anlagenabgang',
|
||||
bezeichnung: 'Veräußerung oder Entnahme von Anlagevermögen',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{
|
||||
key: 'be_kfz_privat',
|
||||
bezeichnung: 'Private Kfz-Nutzung',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{
|
||||
key: 'be_entnahmen',
|
||||
bezeichnung: 'Sonstige Sach-, Nutzungs- und Leistungsentnahmen',
|
||||
bereich: 'einnahmen',
|
||||
},
|
||||
{ key: 'be_sonstige', bezeichnung: 'Übrige Betriebseinnahmen', bereich: 'einnahmen' },
|
||||
|
||||
// ---- Betriebsausgaben --------------------------------------------------
|
||||
{
|
||||
key: 'ba_waren',
|
||||
bezeichnung: 'Waren, Roh- und Hilfsstoffe einschließlich Nebenkosten',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_fremdleistung', bezeichnung: 'Bezogene Fremdleistungen', bereich: 'ausgaben' },
|
||||
{ key: 'ba_personal', bezeichnung: 'Ausgaben für eigenes Personal', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_afa_beweglich',
|
||||
bezeichnung: 'AfA auf bewegliche Wirtschaftsgüter',
|
||||
bereich: 'ausgaben',
|
||||
hinweis: 'wird automatisch aus dem Anlagenverzeichnis ermittelt',
|
||||
},
|
||||
{
|
||||
key: 'ba_afa_immateriell',
|
||||
bezeichnung: 'AfA auf immaterielle Wirtschaftsgüter',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_gwg',
|
||||
bezeichnung: 'Sofortabschreibung geringwertiger Wirtschaftsgüter (GWG, §6 Abs. 2 EStG)',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_sammelposten',
|
||||
bezeichnung: 'Auflösung Sammelposten (§6 Abs. 2a EStG)',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_restbuchwert',
|
||||
bezeichnung: 'Restbuchwert ausgeschiedener Anlagegüter',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_miete', bezeichnung: 'Miete/Pacht für Geschäftsräume', bereich: 'ausgaben' },
|
||||
{ key: 'ba_raum_sonstige', bezeichnung: 'Sonstige Raumkosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_arbeitszimmer',
|
||||
bezeichnung: 'Häusliches Arbeitszimmer / Tagespauschale',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{
|
||||
key: 'ba_versicherung',
|
||||
bezeichnung: 'Beiträge, Gebühren, Abgaben und Versicherungen',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_werbung', bezeichnung: 'Werbekosten', bereich: 'ausgaben' },
|
||||
{ key: 'ba_reise', bezeichnung: 'Reisekosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_bewirtung',
|
||||
bezeichnung: 'Bewirtungsaufwendungen',
|
||||
bereich: 'ausgaben',
|
||||
abziehbarBp: 7000,
|
||||
hinweis: 'nur zu 70 % abziehbar (§4 Abs. 5 Nr. 2 EStG); die Vorsteuer bleibt zu 100 % abziehbar',
|
||||
},
|
||||
{
|
||||
key: 'ba_geschenke',
|
||||
bezeichnung: 'Geschenke an Geschäftsfreunde',
|
||||
bereich: 'ausgaben',
|
||||
hinweis: 'nur abziehbar bis 50 EUR je Empfänger und Jahr (§4 Abs. 5 Nr. 1 EStG)',
|
||||
},
|
||||
{ key: 'ba_kfz', bezeichnung: 'Kfz-Kosten und andere Fahrtkosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_buero',
|
||||
bezeichnung: 'Porto, Telefon, Bürobedarf und sonstige Betriebsmittel',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_fortbildung', bezeichnung: 'Fortbildungskosten', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_beratung',
|
||||
bezeichnung: 'Rechts- und Steuerberatung, Buchführung',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
{ key: 'ba_zinsen', bezeichnung: 'Übrige Schuldzinsen', bereich: 'ausgaben' },
|
||||
{
|
||||
key: 'ba_vorsteuer',
|
||||
bezeichnung: 'Gezahlte Vorsteuerbeträge',
|
||||
bereich: 'ausgaben',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'ba_ust_zahlung',
|
||||
bezeichnung: 'An das Finanzamt gezahlte und ggf. verrechnete Umsatzsteuer',
|
||||
bereich: 'ausgaben',
|
||||
nurRegelbesteuerung: true,
|
||||
},
|
||||
{
|
||||
key: 'ba_sonstige',
|
||||
bezeichnung: 'Übrige unbeschränkt abziehbare Betriebsausgaben',
|
||||
bereich: 'ausgaben',
|
||||
},
|
||||
];
|
||||
|
||||
export const POSTEN_NACH_KEY = new Map(EUER_POSTEN.map((p) => [p.key, p]));
|
||||
|
||||
/**
|
||||
* Amtliche Zeilennummern je Veranlagungsjahr. Bewusst separat gehalten, damit
|
||||
* ein neues Formularjahr eine reine Datenänderung ist.
|
||||
* Status: an der Formularfassung 2024/2025 orientiert – vor Abgabe verifizieren.
|
||||
*/
|
||||
export const ZEILEN_NUMMERN: Record<number, Record<string, number>> = {
|
||||
2025: {
|
||||
be_ku: 11,
|
||||
be_pflichtig: 12,
|
||||
be_frei: 13,
|
||||
be_ust: 16,
|
||||
be_ust_erstattung: 17,
|
||||
be_anlagenabgang: 18,
|
||||
be_kfz_privat: 19,
|
||||
be_entnahmen: 20,
|
||||
be_sonstige: 15,
|
||||
ba_waren: 26,
|
||||
ba_fremdleistung: 27,
|
||||
ba_personal: 28,
|
||||
ba_afa_beweglich: 31,
|
||||
ba_afa_immateriell: 30,
|
||||
ba_gwg: 34,
|
||||
ba_sammelposten: 35,
|
||||
ba_restbuchwert: 36,
|
||||
ba_miete: 38,
|
||||
ba_raum_sonstige: 39,
|
||||
ba_arbeitszimmer: 40,
|
||||
ba_versicherung: 47,
|
||||
ba_werbung: 49,
|
||||
ba_reise: 50,
|
||||
ba_bewirtung: 51,
|
||||
ba_geschenke: 52,
|
||||
ba_kfz: 61,
|
||||
ba_buero: 55,
|
||||
ba_fortbildung: 56,
|
||||
ba_beratung: 57,
|
||||
ba_zinsen: 44,
|
||||
ba_vorsteuer: 63,
|
||||
ba_ust_zahlung: 64,
|
||||
ba_sonstige: 66,
|
||||
},
|
||||
};
|
||||
|
||||
export function zeilenNummer(jahr: number, key: string): number | null {
|
||||
const jahre = Object.keys(ZEILEN_NUMMERN)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
const passend = jahre.find((j) => j <= jahr) ?? jahre[jahre.length - 1];
|
||||
if (passend === undefined) return null;
|
||||
return ZEILEN_NUMMERN[passend]?.[key] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Rechnen in ganzzahligen Cent; Steuersätze in Basispunkten (1900 = 19 %). */
|
||||
|
||||
/** Kaufmännische Rundung auf ganze Cent (halbe Cent immer aufrunden, wie §? Handelsbrauch). */
|
||||
export function runde(cent: number): number {
|
||||
return cent < 0 ? -Math.round(-cent) : Math.round(cent);
|
||||
}
|
||||
|
||||
/** Umsatzsteuer aus einem Bruttobetrag herausrechnen. */
|
||||
export function ustAusBrutto(bruttoCent: number, satzBp: number): number {
|
||||
if (satzBp <= 0) return 0;
|
||||
return runde((bruttoCent * satzBp) / (10000 + satzBp));
|
||||
}
|
||||
|
||||
/** Umsatzsteuer auf einen Nettobetrag aufschlagen. */
|
||||
export function ustAusNetto(nettoCent: number, satzBp: number): number {
|
||||
if (satzBp <= 0) return 0;
|
||||
return runde((nettoCent * satzBp) / 10000);
|
||||
}
|
||||
|
||||
export function anteil(cent: number, bp: number): number {
|
||||
return runde((cent * bp) / 10000);
|
||||
}
|
||||
|
||||
export function eur(cent: number): string {
|
||||
return (cent / 100).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
});
|
||||
}
|
||||
|
||||
/** Für ELSTER/DATEV: Betrag ohne Tausendertrennung, Komma als Dezimaltrenner. */
|
||||
export function dezimal(cent: number): string {
|
||||
return (cent / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
/** ELSTER erwartet in den UStVA-Kennzahlen Bemessungsgrundlagen als volle Euro (abgerundet). */
|
||||
export function volleEuroAbgerundet(cent: number): number {
|
||||
return cent >= 0 ? Math.floor(cent / 100) : -Math.floor(-cent / 100);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { db } from '../db.js';
|
||||
import type { Regime, RegimeEintrag, UstBehandlung } from './typen.js';
|
||||
import { ustAusBrutto, ustAusNetto } from './geld.js';
|
||||
|
||||
/**
|
||||
* Grenzen der Kleinunternehmerregelung (§19 UStG) ab dem Besteuerungszeitraum 2025.
|
||||
* Bis einschließlich 2024 galten 22.000 EUR (Vorjahr, brutto) / 50.000 EUR (Prognose).
|
||||
*/
|
||||
export const KU_GRENZEN = {
|
||||
bis2024: { vorjahr: 22_000_00, laufend: 50_000_00, laufendIstPrognose: true },
|
||||
ab2025: { vorjahr: 25_000_00, laufend: 100_000_00, laufendIstPrognose: false },
|
||||
} as const;
|
||||
|
||||
export function grenzenFuer(jahr: number) {
|
||||
return jahr >= 2025 ? KU_GRENZEN.ab2025 : KU_GRENZEN.bis2024;
|
||||
}
|
||||
|
||||
export function alleRegime(): RegimeEintrag[] {
|
||||
return db
|
||||
.prepare('SELECT * FROM steuer_regime ORDER BY gueltig_ab ASC')
|
||||
.all() as RegimeEintrag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst das umsatzsteuerliche Regime für ein Datum auf: der jüngste Eintrag,
|
||||
* dessen `gueltig_ab` nicht in der Zukunft des Datums liegt. Ohne passenden
|
||||
* Eintrag gilt Kleinunternehmer (konservativer Default für Gründer).
|
||||
*/
|
||||
export function regimeAm(datum: string): Regime {
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT modus FROM steuer_regime WHERE gueltig_ab <= ? ORDER BY gueltig_ab DESC LIMIT 1',
|
||||
)
|
||||
.get(datum) as { modus: Regime } | undefined;
|
||||
return row?.modus ?? 'kleinunternehmer';
|
||||
}
|
||||
|
||||
/** Gibt es innerhalb des Zeitraums einen Regimewechsel? Wichtig für UStVA-Hinweise. */
|
||||
export function regimeWechselIm(von: string, bis: string): RegimeEintrag[] {
|
||||
return db
|
||||
.prepare('SELECT * FROM steuer_regime WHERE gueltig_ab > ? AND gueltig_ab <= ? ORDER BY gueltig_ab')
|
||||
.all(von, bis) as RegimeEintrag[];
|
||||
}
|
||||
|
||||
export interface UstErgebnis {
|
||||
brutto: number;
|
||||
netto: number;
|
||||
ust: number;
|
||||
satzBp: number;
|
||||
behandlung: UstBehandlung;
|
||||
vorsteuerAbziehbar: boolean;
|
||||
hinweise: string[];
|
||||
}
|
||||
|
||||
export interface UstEingabe {
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
/** Maßgeblich für das Regime: der Zeitpunkt der Leistungsausführung. */
|
||||
leistungsdatum: string;
|
||||
/** Genau eines von beiden angeben. */
|
||||
brutto?: number;
|
||||
netto?: number;
|
||||
satzBp: number;
|
||||
behandlung: UstBehandlung;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentrale USt-Ermittlung. Sie erzwingt die Regeln des zum Leistungsdatum
|
||||
* geltenden Regimes – dadurch bleibt ein unterjähriger Wechsel automatisch
|
||||
* konsistent, ohne dass Altbuchungen angefasst werden müssen.
|
||||
*/
|
||||
export function berechneUst(e: UstEingabe): UstErgebnis {
|
||||
const regime = regimeAm(e.leistungsdatum);
|
||||
const hinweise: string[] = [];
|
||||
let behandlung = e.behandlung;
|
||||
let satzBp = e.satzBp;
|
||||
let vorsteuerAbziehbar = true;
|
||||
|
||||
if (regime === 'kleinunternehmer') {
|
||||
if (e.art === 'einnahme') {
|
||||
if (behandlung === 'regel' && satzBp > 0) {
|
||||
hinweise.push(
|
||||
'Zum Leistungsdatum galt die Kleinunternehmerregelung (§19 UStG): ' +
|
||||
'der Umsatz ist steuerfrei, es wurde keine Umsatzsteuer angesetzt.',
|
||||
);
|
||||
}
|
||||
behandlung = 'kleinunternehmer';
|
||||
satzBp = 0;
|
||||
} else {
|
||||
// Vorsteuerabzug ist nach §19 Abs. 1 Satz 4 UStG ausgeschlossen ->
|
||||
// die Ausgabe geht brutto in die EÜR ein.
|
||||
if (satzBp > 0 && behandlung === 'regel') {
|
||||
hinweise.push(
|
||||
'Kein Vorsteuerabzug als Kleinunternehmer – die Betriebsausgabe wird mit dem Bruttobetrag erfasst.',
|
||||
);
|
||||
}
|
||||
vorsteuerAbziehbar = false;
|
||||
// Reverse-Charge/i.g. Erwerb bleiben auch für Kleinunternehmer steuerpflichtig.
|
||||
if (behandlung !== 'reverse_charge_13b' && behandlung !== 'ig_erwerb') {
|
||||
behandlung = 'kleinunternehmer';
|
||||
satzBp = 0;
|
||||
} else {
|
||||
hinweise.push(
|
||||
'Steuerschuld des Leistungsempfängers besteht auch für Kleinunternehmer – ' +
|
||||
'eine USt-Voranmeldung ist für diesen Zeitraum abzugeben (§18 Abs. 4a UStG), ' +
|
||||
'ein Vorsteuerabzug daraus jedoch nicht möglich.',
|
||||
);
|
||||
vorsteuerAbziehbar = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (behandlung === 'kleinunternehmer') {
|
||||
hinweise.push(
|
||||
'Zum Leistungsdatum galt bereits die Regelbesteuerung – Behandlung auf "regel" gesetzt.',
|
||||
);
|
||||
behandlung = 'regel';
|
||||
}
|
||||
if (
|
||||
behandlung === 'steuerfrei_ohne_vst' ||
|
||||
behandlung === 'nicht_steuerbar'
|
||||
) {
|
||||
vorsteuerAbziehbar = behandlung === 'nicht_steuerbar';
|
||||
satzBp = 0;
|
||||
}
|
||||
if (behandlung === 'steuerfrei_mit_vst' || behandlung === 'ig_lieferung') {
|
||||
satzBp = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let brutto: number;
|
||||
let netto: number;
|
||||
let ust: number;
|
||||
|
||||
if (behandlung === 'reverse_charge_13b' || behandlung === 'ig_erwerb') {
|
||||
// Der Rechnungsbetrag ist netto; die Steuer schuldet der Leistungsempfänger.
|
||||
netto = e.netto ?? e.brutto ?? 0;
|
||||
ust = ustAusNetto(netto, satzBp);
|
||||
brutto = netto; // gezahlt wird nur der Nettobetrag
|
||||
} else if (e.brutto !== undefined) {
|
||||
brutto = e.brutto;
|
||||
ust = ustAusBrutto(brutto, satzBp);
|
||||
netto = brutto - ust;
|
||||
} else {
|
||||
netto = e.netto ?? 0;
|
||||
ust = ustAusNetto(netto, satzBp);
|
||||
brutto = netto + ust;
|
||||
}
|
||||
|
||||
return { brutto, netto, ust, satzBp, behandlung, vorsteuerAbziehbar, hinweise };
|
||||
}
|
||||
|
||||
export interface GrenzenStatus {
|
||||
jahr: number;
|
||||
vorjahresumsatz: number;
|
||||
laufenderUmsatz: number;
|
||||
grenzeVorjahr: number;
|
||||
grenzeLaufend: number;
|
||||
regimeJetzt: Regime;
|
||||
vorjahrUeberschritten: boolean;
|
||||
laufendUeberschritten: boolean;
|
||||
/** Kleinunternehmerregelung entfällt zum 1. Januar des Folgejahres */
|
||||
wechselImFolgejahr: boolean;
|
||||
warnungen: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Überwacht die §19-Grenzen. Maßgeblich ist der Gesamtumsatz nach §19 Abs. 2 UStG
|
||||
* (vereinnahmte Entgelte); hier näherungsweise die Summe der steuerbaren
|
||||
* Betriebseinnahmen nach Zuflussprinzip.
|
||||
*/
|
||||
export function grenzenStatus(jahr: number): GrenzenStatus {
|
||||
const umsatz = (j: number): number => {
|
||||
const r = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(betrag_brutto),0) AS s FROM buchungen
|
||||
WHERE art='einnahme' AND storniert=0
|
||||
AND ust_behandlung NOT IN ('nicht_steuerbar')
|
||||
AND zahlungsdatum BETWEEN ? AND ?`,
|
||||
)
|
||||
.get(`${j}-01-01`, `${j}-12-31`) as { s: number };
|
||||
return r.s;
|
||||
};
|
||||
|
||||
const g = grenzenFuer(jahr);
|
||||
const vorjahresumsatz = umsatz(jahr - 1);
|
||||
const laufenderUmsatz = umsatz(jahr);
|
||||
const regimeJetzt = regimeAm(`${jahr}-12-31`);
|
||||
const warnungen: string[] = [];
|
||||
|
||||
const vorjahrUeberschritten = vorjahresumsatz > g.vorjahr;
|
||||
const laufendUeberschritten = laufenderUmsatz > g.laufend;
|
||||
|
||||
const folgejahr = grenzenFuer(jahr + 1);
|
||||
const wechselImFolgejahr =
|
||||
regimeJetzt === 'kleinunternehmer' && laufenderUmsatz > folgejahr.vorjahr;
|
||||
|
||||
if (regimeJetzt === 'kleinunternehmer') {
|
||||
if (wechselImFolgejahr) {
|
||||
warnungen.push(
|
||||
`Der Umsatz ${jahr} liegt mit ${(laufenderUmsatz / 100).toLocaleString('de-DE')} EUR über der ` +
|
||||
`Vorjahresgrenze von ${(folgejahr.vorjahr / 100).toLocaleString('de-DE')} EUR. ` +
|
||||
`Die Kleinunternehmerregelung entfällt damit ab dem 01.01.${jahr + 1} – bitte zu diesem ` +
|
||||
'Datum einen Wechsel zur Regelbesteuerung anlegen und ab dann Umsatzsteuer ausweisen.',
|
||||
);
|
||||
}
|
||||
if (vorjahrUeberschritten) {
|
||||
warnungen.push(
|
||||
`Der Vorjahresumsatz ${jahr - 1} liegt über ${(g.vorjahr / 100).toLocaleString('de-DE')} EUR. ` +
|
||||
`Ab dem 01.01.${jahr} greift die Regelbesteuerung – bitte einen Regimewechsel zum 01.01.${jahr} anlegen.`,
|
||||
);
|
||||
}
|
||||
if (laufendUeberschritten && jahr >= 2025) {
|
||||
warnungen.push(
|
||||
`Die laufende Grenze von ${(g.laufend / 100).toLocaleString('de-DE')} EUR wurde in ${jahr} überschritten. ` +
|
||||
'Seit 2025 endet die Kleinunternehmerregelung sofort mit dem Umsatz, der die Grenze übersteigt ' +
|
||||
'(§19 Abs. 1 Satz 3 UStG) – der Regimewechsel ist unterjährig zu diesem Datum einzutragen.',
|
||||
);
|
||||
}
|
||||
if (!laufendUeberschritten && laufenderUmsatz > (g.laufend * 8) / 10) {
|
||||
warnungen.push(
|
||||
`Achtung: ${((laufenderUmsatz / g.laufend) * 100).toFixed(0)} % der laufenden Umsatzgrenze erreicht.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
jahr,
|
||||
vorjahresumsatz,
|
||||
laufenderUmsatz,
|
||||
grenzeVorjahr: g.vorjahr,
|
||||
grenzeLaufend: g.laufend,
|
||||
regimeJetzt,
|
||||
vorjahrUeberschritten,
|
||||
laufendUeberschritten,
|
||||
wechselImFolgejahr,
|
||||
warnungen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type Regime = 'kleinunternehmer' | 'regelbesteuerung';
|
||||
|
||||
export type UstBehandlung =
|
||||
| 'regel' // steuerpflichtiger Umsatz / Vorsteuerabzug
|
||||
| 'kleinunternehmer' // §19 UStG – steuerfrei, kein Vorsteuerabzug
|
||||
| 'steuerfrei_mit_vst' // z.B. §4 Nr. 1-7 UStG (Ausfuhr) – Vorsteuerabzug bleibt
|
||||
| 'steuerfrei_ohne_vst' // z.B. §4 Nr. 8-28 UStG (Heilbehandlung, Vermietung)
|
||||
| 'ig_lieferung' // innergemeinschaftliche Lieferung (Kz 41)
|
||||
| 'ig_erwerb' // innergemeinschaftlicher Erwerb (Kz 89/93 + Kz 61)
|
||||
| 'reverse_charge_13b' // Leistungsempfänger schuldet die Steuer (Kz 46/47 + Kz 67)
|
||||
| 'nicht_steuerbar'; // z.B. Leistungsort im Ausland (§3a Abs. 2), Kz 21/45
|
||||
|
||||
export type BuchungsArt = 'einnahme' | 'ausgabe';
|
||||
|
||||
export interface Buchung {
|
||||
id: number;
|
||||
art: BuchungsArt;
|
||||
belegdatum: string;
|
||||
leistungsdatum: string;
|
||||
zahlungsdatum: string | null;
|
||||
betrag_brutto: number;
|
||||
betrag_netto: number;
|
||||
betrag_ust: number;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: UstBehandlung;
|
||||
vorsteuer_abziehbar: number;
|
||||
konto: string;
|
||||
gegenkonto: string;
|
||||
beleg_nr: string;
|
||||
buchungstext: string;
|
||||
partner: string;
|
||||
privatanteil_bp: number;
|
||||
anlagegut_id: number | null;
|
||||
beleg_datei: string | null;
|
||||
storniert: number;
|
||||
storno_von: number | null;
|
||||
festgeschrieben: number;
|
||||
erstellt_am: string;
|
||||
geaendert_am: string;
|
||||
}
|
||||
|
||||
export interface Konto {
|
||||
nummer: string;
|
||||
bezeichnung: string;
|
||||
typ: 'erloes' | 'aufwand' | 'anlage' | 'privat' | 'neutral' | 'bestand';
|
||||
euer_zeile: string | null;
|
||||
bwa_gruppe: string | null;
|
||||
ustva_kz: string | null;
|
||||
ust_satz_bp: number;
|
||||
ust_behandlung: UstBehandlung;
|
||||
aktiv: number;
|
||||
sortierung: number;
|
||||
}
|
||||
|
||||
export interface Anlagegut {
|
||||
id: number;
|
||||
bezeichnung: string;
|
||||
anschaffungsdatum: string;
|
||||
akosten_netto: number;
|
||||
akosten_brutto: number;
|
||||
nutzungsdauer: number;
|
||||
afa_methode: 'linear' | 'gwg_sofort' | 'sammelposten' | 'keine';
|
||||
konto: string | null;
|
||||
abgang_datum: string | null;
|
||||
abgang_erloes: number | null;
|
||||
notiz: string;
|
||||
}
|
||||
|
||||
export interface Mandant {
|
||||
id: number;
|
||||
name: string;
|
||||
rechtsform: string;
|
||||
strasse: string;
|
||||
plz: string;
|
||||
ort: string;
|
||||
bundesland: string;
|
||||
steuernummer: string;
|
||||
ust_idnr: string;
|
||||
finanzamt_nr: string;
|
||||
taetigkeit: string;
|
||||
betriebsbeginn: string | null;
|
||||
besteuerungsart: 'ist' | 'soll';
|
||||
voranmeldungszeitraum: 'monatlich' | 'vierteljaehrlich' | 'jaehrlich';
|
||||
dauerfristverlaengerung: number;
|
||||
kontenrahmen: string;
|
||||
datev_berater_nr: string;
|
||||
datev_mandanten_nr: string;
|
||||
telefon: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RegimeEintrag {
|
||||
id: number;
|
||||
gueltig_ab: string;
|
||||
modus: Regime;
|
||||
grund: string;
|
||||
notiz: string;
|
||||
}
|
||||
|
||||
/** Zeitraum-Filter für Auswertungen */
|
||||
export interface Zeitraum {
|
||||
von: string; // YYYY-MM-DD inklusive
|
||||
bis: string; // YYYY-MM-DD inklusive
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { db } from '../db.js';
|
||||
import { volleEuroAbgerundet, ustAusNetto } from './geld.js';
|
||||
import { regimeAm, regimeWechselIm } from './regime.js';
|
||||
import type { Buchung, Konto, Mandant } from './typen.js';
|
||||
|
||||
/**
|
||||
* Umsatzsteuer-Voranmeldung (§18 UStG).
|
||||
*
|
||||
* Zeitliche Zuordnung:
|
||||
* - Ausgangsumsätze bei Soll-Versteuerung -> Leistungsdatum
|
||||
* - Ausgangsumsätze bei Ist-Versteuerung -> Zahlungsdatum (§20 UStG)
|
||||
* - Vorsteuer -> späteres von Rechnungs- und
|
||||
* Leistungsdatum, denn der Abzug setzt beides voraus (§15 Abs. 1 Nr. 1 UStG)
|
||||
* und ist von der Ist-Versteuerung unabhängig.
|
||||
*/
|
||||
|
||||
export interface UstvaZeitraum {
|
||||
jahr: number;
|
||||
/** '01'..'12' für Monate, '41'..'44' für Quartale, '00' für das Kalenderjahr */
|
||||
code: string;
|
||||
von: string;
|
||||
bis: string;
|
||||
bezeichnung: string;
|
||||
}
|
||||
|
||||
export function zeitraum(jahr: number, code: string): UstvaZeitraum {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
if (code === '00') {
|
||||
return { jahr, code, von: `${jahr}-01-01`, bis: `${jahr}-12-31`, bezeichnung: `Jahr ${jahr}` };
|
||||
}
|
||||
const n = Number(code);
|
||||
if (n >= 41 && n <= 44) {
|
||||
const q = n - 40;
|
||||
const m1 = (q - 1) * 3 + 1;
|
||||
const m3 = m1 + 2;
|
||||
return {
|
||||
jahr,
|
||||
code,
|
||||
von: `${jahr}-${p(m1)}-01`,
|
||||
bis: letzterTag(jahr, m3),
|
||||
bezeichnung: `${q}. Quartal ${jahr}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
jahr,
|
||||
code,
|
||||
von: `${jahr}-${p(n)}-01`,
|
||||
bis: letzterTag(jahr, n),
|
||||
bezeichnung: `${MONATE[n - 1]} ${jahr}`,
|
||||
};
|
||||
}
|
||||
|
||||
const MONATE = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
function letzterTag(jahr: number, monat: number): string {
|
||||
const d = new Date(Date.UTC(jahr, monat, 0));
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function moeglicheZeitraeume(m: Mandant): string[] {
|
||||
if (m.voranmeldungszeitraum === 'monatlich') {
|
||||
return Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'));
|
||||
}
|
||||
if (m.voranmeldungszeitraum === 'vierteljaehrlich') return ['41', '42', '43', '44'];
|
||||
return ['00'];
|
||||
}
|
||||
|
||||
export type Kennzahlen = Record<string, number>;
|
||||
|
||||
export interface UstvaErgebnis {
|
||||
zeitraum: UstvaZeitraum;
|
||||
besteuerungsart: 'ist' | 'soll';
|
||||
regimeStart: string;
|
||||
regimeEnde: string;
|
||||
abgabepflichtig: boolean;
|
||||
kennzahlen: Kennzahlen;
|
||||
umsatzsteuer: number;
|
||||
vorsteuer: number;
|
||||
/** Kz 83 – positiv = Zahllast, negativ = Erstattungsanspruch (in Cent) */
|
||||
zahllast: number;
|
||||
hinweise: string[];
|
||||
positionen: UstvaPosition[];
|
||||
}
|
||||
|
||||
export interface UstvaPosition {
|
||||
buchungId: number;
|
||||
datum: string;
|
||||
text: string;
|
||||
kz: string;
|
||||
bemessung: number;
|
||||
steuer: number;
|
||||
}
|
||||
|
||||
const STEUERSATZ_KZ: Record<number, { bmg: string; steuer?: string }> = {
|
||||
1900: { bmg: '81' },
|
||||
700: { bmg: '86' },
|
||||
};
|
||||
const ERWERB_KZ: Record<number, { bmg: string; steuer?: string }> = {
|
||||
1900: { bmg: '89' },
|
||||
700: { bmg: '93' },
|
||||
};
|
||||
|
||||
export function berechneUstva(jahr: number, code: string): UstvaErgebnis {
|
||||
const zr = zeitraum(jahr, code);
|
||||
const mandant = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const konten = new Map(
|
||||
(db.prepare('SELECT * FROM konten').all() as Konto[]).map((k) => [k.nummer, k]),
|
||||
);
|
||||
|
||||
const kz: Kennzahlen = {};
|
||||
const positionen: UstvaPosition[] = [];
|
||||
const hinweise: string[] = [];
|
||||
const add = (k: string, cent: number) => {
|
||||
if (cent === 0) return;
|
||||
kz[k] = (kz[k] ?? 0) + cent;
|
||||
};
|
||||
|
||||
const alle = db
|
||||
.prepare('SELECT * FROM buchungen WHERE storniert = 0')
|
||||
.all() as Buchung[];
|
||||
|
||||
const imZeitraum = (d: string | null) => !!d && d >= zr.von && d <= zr.bis;
|
||||
|
||||
for (const b of alle) {
|
||||
const konto = konten.get(b.konto);
|
||||
if (b.art === 'einnahme') {
|
||||
const stichtag =
|
||||
mandant.besteuerungsart === 'ist' ? b.zahlungsdatum : b.leistungsdatum;
|
||||
if (!imZeitraum(stichtag)) continue;
|
||||
erfasseUmsatz(b, konto);
|
||||
} else {
|
||||
// Vorsteuerabzug: Rechnung UND Leistung müssen vorliegen.
|
||||
const stichtag = b.belegdatum > b.leistungsdatum ? b.belegdatum : b.leistungsdatum;
|
||||
if (!imZeitraum(stichtag)) continue;
|
||||
erfasseVorsteuer(b, konto);
|
||||
}
|
||||
}
|
||||
|
||||
function push(b: Buchung, kzName: string, bemessung: number, steuer: number) {
|
||||
positionen.push({
|
||||
buchungId: b.id,
|
||||
datum: b.leistungsdatum,
|
||||
text: b.buchungstext || b.partner || b.beleg_nr,
|
||||
kz: kzName,
|
||||
bemessung,
|
||||
steuer,
|
||||
});
|
||||
}
|
||||
|
||||
function erfasseUmsatz(b: Buchung, konto: Konto | undefined) {
|
||||
switch (b.ust_behandlung) {
|
||||
case 'regel': {
|
||||
const map = STEUERSATZ_KZ[b.ust_satz_bp];
|
||||
if (map) {
|
||||
add(map.bmg, b.betrag_netto);
|
||||
push(b, map.bmg, b.betrag_netto, b.betrag_ust);
|
||||
} else {
|
||||
// andere Steuersätze: Bemessungsgrundlage Kz 35, Steuer Kz 36
|
||||
add('35', b.betrag_netto);
|
||||
add('36', b.betrag_ust);
|
||||
push(b, '35', b.betrag_netto, b.betrag_ust);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ig_lieferung':
|
||||
add('41', b.betrag_netto);
|
||||
push(b, '41', b.betrag_netto, 0);
|
||||
break;
|
||||
case 'steuerfrei_mit_vst':
|
||||
add('43', b.betrag_netto);
|
||||
push(b, '43', b.betrag_netto, 0);
|
||||
break;
|
||||
case 'steuerfrei_ohne_vst':
|
||||
add('48', b.betrag_netto);
|
||||
push(b, '48', b.betrag_netto, 0);
|
||||
break;
|
||||
case 'nicht_steuerbar': {
|
||||
const ziel = konto?.ustva_kz ?? '45';
|
||||
add(ziel, b.betrag_netto);
|
||||
push(b, ziel, b.betrag_netto, 0);
|
||||
break;
|
||||
}
|
||||
case 'kleinunternehmer':
|
||||
// Umsätze nach §19 UStG werden in der Voranmeldung nicht erklärt.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function erfasseVorsteuer(b: Buchung, _konto: Konto | undefined) {
|
||||
if (b.ust_behandlung === 'ig_erwerb') {
|
||||
const map = ERWERB_KZ[b.ust_satz_bp];
|
||||
const steuer = ustAusNetto(b.betrag_netto, b.ust_satz_bp);
|
||||
if (map) {
|
||||
add(map.bmg, b.betrag_netto);
|
||||
} else {
|
||||
add('95', b.betrag_netto);
|
||||
add('98', steuer);
|
||||
}
|
||||
if (b.vorsteuer_abziehbar) add('61', steuer);
|
||||
push(b, map?.bmg ?? '95', b.betrag_netto, steuer);
|
||||
return;
|
||||
}
|
||||
if (b.ust_behandlung === 'reverse_charge_13b') {
|
||||
const steuer = ustAusNetto(b.betrag_netto, b.ust_satz_bp);
|
||||
add('46', b.betrag_netto);
|
||||
add('47', steuer);
|
||||
if (b.vorsteuer_abziehbar) add('67', steuer);
|
||||
push(b, '46', b.betrag_netto, steuer);
|
||||
return;
|
||||
}
|
||||
if (b.vorsteuer_abziehbar && b.betrag_ust > 0) {
|
||||
add('66', b.betrag_ust);
|
||||
push(b, '66', b.betrag_netto, b.betrag_ust);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Steuerbeträge zusammenrechnen ---------------------------------------
|
||||
const ust19 = ustAusNetto(kz['81'] ?? 0, 1900);
|
||||
const ust7 = ustAusNetto(kz['86'] ?? 0, 700);
|
||||
const erwerb19 = ustAusNetto(kz['89'] ?? 0, 1900);
|
||||
const erwerb7 = ustAusNetto(kz['93'] ?? 0, 700);
|
||||
|
||||
const umsatzsteuer =
|
||||
ust19 + ust7 + (kz['36'] ?? 0) + erwerb19 + erwerb7 + (kz['98'] ?? 0) + (kz['47'] ?? 0);
|
||||
const vorsteuer =
|
||||
(kz['66'] ?? 0) + (kz['61'] ?? 0) + (kz['62'] ?? 0) + (kz['67'] ?? 0) + (kz['63'] ?? 0);
|
||||
|
||||
const zahllast = umsatzsteuer - vorsteuer;
|
||||
kz['83'] = zahllast;
|
||||
|
||||
const regimeStart = regimeAm(zr.von);
|
||||
const regimeEnde = regimeAm(zr.bis);
|
||||
const wechsel = regimeWechselIm(zr.von, zr.bis);
|
||||
|
||||
// §19 Abs. 1 Satz 4 UStG: Kleinunternehmer geben grundsätzlich keine
|
||||
// Voranmeldung ab – außer bei Steuerschuld nach §13b / i.g. Erwerb.
|
||||
const nurKleinunternehmer = regimeStart === 'kleinunternehmer' && regimeEnde === 'kleinunternehmer';
|
||||
const schuldetTrotzdem = (kz['47'] ?? 0) > 0 || (kz['89'] ?? 0) > 0 || (kz['93'] ?? 0) > 0;
|
||||
const abgabepflichtig = !nurKleinunternehmer || schuldetTrotzdem;
|
||||
|
||||
if (nurKleinunternehmer && !schuldetTrotzdem) {
|
||||
hinweise.push(
|
||||
'Für diesen Zeitraum galt durchgehend die Kleinunternehmerregelung. ' +
|
||||
'Eine Umsatzsteuer-Voranmeldung ist nach §19 Abs. 1 Satz 4 UStG regelmäßig nicht abzugeben.',
|
||||
);
|
||||
}
|
||||
if (nurKleinunternehmer && schuldetTrotzdem) {
|
||||
hinweise.push(
|
||||
'Trotz Kleinunternehmerregelung besteht eine Steuerschuld als Leistungsempfänger ' +
|
||||
'(§13b UStG bzw. innergemeinschaftlicher Erwerb). Die Voranmeldung ist abzugeben, ' +
|
||||
'ein Vorsteuerabzug daraus besteht nicht.',
|
||||
);
|
||||
}
|
||||
for (const w of wechsel) {
|
||||
hinweise.push(
|
||||
`Wechsel der Besteuerungsform zum ${w.gueltig_ab} (${w.modus}). ` +
|
||||
'Es sind nur die Umsätze ab diesem Datum in der Voranmeldung enthalten.',
|
||||
);
|
||||
}
|
||||
if (mandant.besteuerungsart === 'ist') {
|
||||
hinweise.push('Ist-Versteuerung (§20 UStG): maßgeblich ist der Zahlungseingang.');
|
||||
}
|
||||
if (!mandant.steuernummer) {
|
||||
hinweise.push('Es ist keine Steuernummer hinterlegt – ohne sie ist keine Übermittlung möglich.');
|
||||
}
|
||||
|
||||
return {
|
||||
zeitraum: zr,
|
||||
besteuerungsart: mandant.besteuerungsart,
|
||||
regimeStart,
|
||||
regimeEnde,
|
||||
abgabepflichtig,
|
||||
kennzahlen: kz,
|
||||
umsatzsteuer,
|
||||
vorsteuer,
|
||||
zahllast,
|
||||
hinweise,
|
||||
positionen,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bemessungsgrundlagen werden in der UStVA in vollen Euro erklärt, Steuerbeträge mit Cent. */
|
||||
export const BEMESSUNG_KZ = new Set([
|
||||
'41', '44', '49', '43', '48', '21', '45', '81', '86', '35', '77', '76', '91', '89', '93', '95', '46',
|
||||
]);
|
||||
|
||||
export function kennzahlenFuerElster(kz: Kennzahlen): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, cent] of Object.entries(kz)) {
|
||||
if (cent === 0 && k !== '83') continue;
|
||||
out[k] = BEMESSUNG_KZ.has(k)
|
||||
? String(volleEuroAbgerundet(cent))
|
||||
: (cent / 100).toFixed(2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname, isAbsolute } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { alsLatin1 } from './ustvaXml.js';
|
||||
import { datenVerzeichnis, projektWurzel } from '../pfade.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Anbindung an ERiC (ELSTER Rich Client).
|
||||
*
|
||||
* ERiC ist die einzige zugelassene Schnittstelle, um Steueranmeldungen an die
|
||||
* Finanzverwaltung zu übermitteln. Die Bibliothek ist proprietär, darf nicht
|
||||
* weitergegeben werden und muss nach Registrierung im ELSTER-Entwicklerportal
|
||||
* selbst heruntergeladen werden. Sie wird über Umgebungsvariablen eingebunden:
|
||||
*
|
||||
* ERIC_LIB_PATH Pfad zu libericapi.so / ericapi.dll / libericapi.dylib
|
||||
* ERIC_PLUGIN_PATH Pfad zum Verzeichnis "plugins2"
|
||||
* ERIC_LOG_PATH Verzeichnis für eric.log (Standard: ./data/eric-log)
|
||||
* ELSTER_ZERTIFIKAT Pfad zur .pfx-Zertifikatsdatei (ELSTER-Zertifikat)
|
||||
* ELSTER_PIN PIN zum Zertifikat
|
||||
* ELSTER_HERSTELLER_ID bei ELSTER registrierte Hersteller-ID
|
||||
* ELSTER_TESTMERKER z.B. 700000004 für Testübermittlungen ohne Wirkung
|
||||
*
|
||||
* Ist ERiC nicht eingerichtet, arbeitet die Anwendung im Simulationsmodus:
|
||||
* das XML wird erzeugt und geprüft, aber ausdrücklich nicht übermittelt.
|
||||
*/
|
||||
|
||||
const ERIC_OK = 0;
|
||||
const FLAG_VALIDIERE = 1 << 1; // 2
|
||||
const FLAG_SENDE = 1 << 2; // 4
|
||||
const FLAG_DRUCKE = 1 << 5; // 32
|
||||
|
||||
export interface EricAntwort {
|
||||
erfolg: boolean;
|
||||
simulation: boolean;
|
||||
rueckgabecode: number;
|
||||
transferticket: string | null;
|
||||
/** Von ERiC gelieferte Validierungs-/Serverantwort */
|
||||
protokoll: string;
|
||||
fehler: string[];
|
||||
hinweise: string[];
|
||||
pdfPfad?: string;
|
||||
}
|
||||
|
||||
export interface SendeOptionen {
|
||||
/** Nur prüfen, nicht übermitteln */
|
||||
nurValidieren: boolean;
|
||||
/** Zusätzlich ein Übertragungsprotokoll als PDF erzeugen */
|
||||
pdfPfad?: string;
|
||||
}
|
||||
|
||||
interface EricBindings {
|
||||
Initialise(plugin: string, log: string): number;
|
||||
Beende(): number;
|
||||
BearbeiteVorgang(
|
||||
daten: Buffer,
|
||||
datenartVersion: string,
|
||||
flags: number,
|
||||
druck: unknown,
|
||||
crypto: unknown,
|
||||
rueckgabe: unknown,
|
||||
serverantwort: unknown,
|
||||
): number;
|
||||
PufferErzeugen(): unknown;
|
||||
PufferInhalt(h: unknown): string;
|
||||
PufferFreigeben(h: unknown): number;
|
||||
ZertifikatOeffnen(pfad: string): { handle: number; pinSupport: number };
|
||||
ZertifikatSchliessen(handle: number): number;
|
||||
FehlerText(code: number): string;
|
||||
}
|
||||
|
||||
let bindings: EricBindings | null = null;
|
||||
let ladeFehler: string | null = null;
|
||||
let initialisiert = false;
|
||||
|
||||
/** Relative Angaben aus der .env beziehen sich auf die Projektwurzel. */
|
||||
function ausWurzel(pfad: string): string {
|
||||
return isAbsolute(pfad) ? pfad : resolve(projektWurzel, pfad);
|
||||
}
|
||||
|
||||
function logVerzeichnis(): string {
|
||||
const konfiguriert = process.env.ERIC_LOG_PATH;
|
||||
const p = konfiguriert ? ausWurzel(konfiguriert) : resolve(datenVerzeichnis, 'eric-log');
|
||||
mkdirSync(p, { recursive: true });
|
||||
return p;
|
||||
}
|
||||
|
||||
function ladeBindings(): EricBindings | null {
|
||||
if (bindings) return bindings;
|
||||
if (ladeFehler) return null;
|
||||
|
||||
const libPfad = process.env.ERIC_LIB_PATH;
|
||||
if (!libPfad || !existsSync(libPfad)) {
|
||||
ladeFehler = libPfad
|
||||
? `ERIC_LIB_PATH zeigt auf "${libPfad}", dort liegt keine Datei.`
|
||||
: 'ERIC_LIB_PATH ist nicht gesetzt – ERiC ist nicht eingebunden.';
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// koffi ist eine optionale Abhängigkeit; ohne ERiC wird sie nicht gebraucht.
|
||||
const koffi = require('koffi') as typeof import('koffi');
|
||||
const lib = koffi.load(libPfad);
|
||||
|
||||
const druckParameter = koffi.struct('eric_druck_parameter_t', {
|
||||
version: 'uint32',
|
||||
vorschau: 'uint32',
|
||||
ersteSeite: 'uint32',
|
||||
duplexDruck: 'uint32',
|
||||
pdfName: 'const char*',
|
||||
fussText: 'const char*',
|
||||
});
|
||||
const cryptoParameter = koffi.struct('eric_verschluesselungs_parameter_t', {
|
||||
version: 'uint32',
|
||||
zertifikatHandle: 'uint32',
|
||||
pin: 'const char*',
|
||||
abrufCode: 'const char*',
|
||||
});
|
||||
|
||||
const fnInitialise = lib.func('int EricInitialise(const char* pluginPfad, const char* logPfad)');
|
||||
const fnBeende = lib.func('int EricBeende()');
|
||||
const fnPufferErzeugen = lib.func('void* EricRueckgabepufferErzeugen()');
|
||||
const fnPufferInhalt = lib.func('const char* EricRueckgabepufferInhalt(void* handle)');
|
||||
const fnPufferFreigeben = lib.func('int EricRueckgabepufferFreigeben(void* handle)');
|
||||
const fnBearbeite = lib.func(
|
||||
'int EricBearbeiteVorgang(const char* daten, const char* datenartVersion, uint32 flags, ' +
|
||||
'const eric_druck_parameter_t* druck, ' +
|
||||
'const eric_verschluesselungs_parameter_t* crypto, void* transferHandle, ' +
|
||||
'void* rueckgabe, void* serverantwort)',
|
||||
);
|
||||
const fnZertifikat = lib.func(
|
||||
'int EricGetHandleToCertificate(_Out_ uint32* handle, _Out_ uint32* pinSupport, const char* pfad)',
|
||||
);
|
||||
const fnZertifikatZu = lib.func('int EricCloseHandleToCertificate(uint32 handle)');
|
||||
const fnFehlertext = lib.func('int EricHoleFehlerText(int fehlercode, void* puffer)');
|
||||
|
||||
void druckParameter;
|
||||
void cryptoParameter;
|
||||
|
||||
bindings = {
|
||||
Initialise: (plugin, log) => fnInitialise(plugin, log) as number,
|
||||
Beende: () => fnBeende() as number,
|
||||
BearbeiteVorgang: (daten, dv, flags, druck, crypto, rueckgabe, serverantwort) =>
|
||||
fnBearbeite(daten, dv, flags, druck, crypto, null, rueckgabe, serverantwort) as number,
|
||||
PufferErzeugen: () => fnPufferErzeugen(),
|
||||
PufferInhalt: (h) => (fnPufferInhalt(h) as string) ?? '',
|
||||
PufferFreigeben: (h) => fnPufferFreigeben(h) as number,
|
||||
ZertifikatOeffnen: (pfad) => {
|
||||
const handle: number[] = [0];
|
||||
const pinSupport: number[] = [0];
|
||||
const rc = fnZertifikat(handle, pinSupport, pfad) as number;
|
||||
if (rc !== ERIC_OK) throw new Error(`Zertifikat konnte nicht geöffnet werden (ERiC ${rc}).`);
|
||||
return { handle: handle[0]!, pinSupport: pinSupport[0]! };
|
||||
},
|
||||
ZertifikatSchliessen: (h) => fnZertifikatZu(h) as number,
|
||||
FehlerText: (code) => {
|
||||
const puffer = fnPufferErzeugen();
|
||||
try {
|
||||
fnFehlertext(code, puffer);
|
||||
return (fnPufferInhalt(puffer) as string) ?? '';
|
||||
} finally {
|
||||
fnPufferFreigeben(puffer);
|
||||
}
|
||||
},
|
||||
};
|
||||
return bindings;
|
||||
} catch (e) {
|
||||
ladeFehler = `ERiC konnte nicht geladen werden: ${(e as Error).message}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function initialisiere(b: EricBindings): void {
|
||||
if (initialisiert) return;
|
||||
const plugin = process.env.ERIC_PLUGIN_PATH ?? '';
|
||||
const rc = b.Initialise(plugin, logVerzeichnis());
|
||||
if (rc !== ERIC_OK) {
|
||||
throw new Error(`EricInitialise fehlgeschlagen (Code ${rc}). ERIC_PLUGIN_PATH prüfen.`);
|
||||
}
|
||||
initialisiert = true;
|
||||
process.once('exit', () => {
|
||||
try {
|
||||
b.Beende();
|
||||
} catch {
|
||||
/* beim Herunterfahren ignorieren */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function ericStatus(): {
|
||||
verfuegbar: boolean;
|
||||
meldung: string;
|
||||
zertifikat: boolean;
|
||||
herstellerId: boolean;
|
||||
testmodus: boolean;
|
||||
} {
|
||||
const b = ladeBindings();
|
||||
const zertPfad = process.env.ELSTER_ZERTIFIKAT;
|
||||
return {
|
||||
verfuegbar: b !== null,
|
||||
meldung: b ? `ERiC geladen von ${process.env.ERIC_LIB_PATH}` : (ladeFehler ?? 'unbekannt'),
|
||||
zertifikat: !!zertPfad && existsSync(zertPfad),
|
||||
herstellerId: !!process.env.ELSTER_HERSTELLER_ID,
|
||||
testmodus: !!process.env.ELSTER_TESTMERKER,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uebermittle(
|
||||
xml: string,
|
||||
datenartVersion: string,
|
||||
opt: SendeOptionen,
|
||||
): Promise<EricAntwort> {
|
||||
const b = ladeBindings();
|
||||
if (!b) return simuliere(xml, opt, ladeFehler ?? 'ERiC nicht verfügbar');
|
||||
|
||||
initialisiere(b);
|
||||
|
||||
const zertPfad = process.env.ELSTER_ZERTIFIKAT;
|
||||
const pin = process.env.ELSTER_PIN ?? '';
|
||||
if (!opt.nurValidieren && (!zertPfad || !existsSync(zertPfad))) {
|
||||
return {
|
||||
erfolg: false,
|
||||
simulation: false,
|
||||
rueckgabecode: -1,
|
||||
transferticket: null,
|
||||
protokoll: '',
|
||||
fehler: [
|
||||
'Für die Übermittlung wird ein ELSTER-Zertifikat benötigt. ' +
|
||||
'Bitte ELSTER_ZERTIFIKAT auf die .pfx-Datei und ELSTER_PIN setzen.',
|
||||
],
|
||||
hinweise: [],
|
||||
};
|
||||
}
|
||||
|
||||
let zertHandle = 0;
|
||||
const rueckgabe = b.PufferErzeugen();
|
||||
const serverantwort = b.PufferErzeugen();
|
||||
|
||||
try {
|
||||
let flags = FLAG_VALIDIERE;
|
||||
if (!opt.nurValidieren) flags |= FLAG_SENDE;
|
||||
if (opt.pdfPfad) flags |= FLAG_DRUCKE;
|
||||
|
||||
let crypto: unknown = null;
|
||||
if (!opt.nurValidieren && zertPfad) {
|
||||
const z = b.ZertifikatOeffnen(zertPfad);
|
||||
zertHandle = z.handle;
|
||||
crypto = { version: 2, zertifikatHandle: zertHandle, pin, abrufCode: null };
|
||||
}
|
||||
|
||||
let druck: unknown = null;
|
||||
if (opt.pdfPfad) {
|
||||
mkdirSync(dirname(opt.pdfPfad), { recursive: true });
|
||||
druck = {
|
||||
version: 4,
|
||||
vorschau: opt.nurValidieren ? 1 : 0,
|
||||
ersteSeite: 0,
|
||||
duplexDruck: 0,
|
||||
pdfName: opt.pdfPfad,
|
||||
fussText: 'Erstellt mit open-eur-webapp',
|
||||
};
|
||||
}
|
||||
|
||||
const rc = b.BearbeiteVorgang(
|
||||
alsLatin1(xml),
|
||||
datenartVersion,
|
||||
flags,
|
||||
druck,
|
||||
crypto,
|
||||
rueckgabe,
|
||||
serverantwort,
|
||||
);
|
||||
|
||||
const protokoll = b.PufferInhalt(rueckgabe);
|
||||
const antwort = b.PufferInhalt(serverantwort);
|
||||
const gesamt = [protokoll, antwort].filter(Boolean).join('\n');
|
||||
|
||||
return {
|
||||
erfolg: rc === ERIC_OK,
|
||||
simulation: false,
|
||||
rueckgabecode: rc,
|
||||
transferticket: transferticketAus(antwort),
|
||||
protokoll: gesamt,
|
||||
fehler: rc === ERIC_OK ? [] : [`ERiC-Code ${rc}: ${sicherFehlertext(b, rc)}`, ...fehlerAus(gesamt)],
|
||||
hinweise: hinweiseAus(gesamt),
|
||||
pdfPfad: opt.pdfPfad,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
erfolg: false,
|
||||
simulation: false,
|
||||
rueckgabecode: -1,
|
||||
transferticket: null,
|
||||
protokoll: '',
|
||||
fehler: [(e as Error).message],
|
||||
hinweise: [],
|
||||
};
|
||||
} finally {
|
||||
if (zertHandle) {
|
||||
try {
|
||||
b.ZertifikatSchliessen(zertHandle);
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
b.PufferFreigeben(rueckgabe);
|
||||
b.PufferFreigeben(serverantwort);
|
||||
}
|
||||
}
|
||||
|
||||
function sicherFehlertext(b: EricBindings, code: number): string {
|
||||
try {
|
||||
return b.FehlerText(code) || 'kein Klartext verfügbar';
|
||||
} catch {
|
||||
return 'kein Klartext verfügbar';
|
||||
}
|
||||
}
|
||||
|
||||
function transferticketAus(xml: string): string | null {
|
||||
return /<TransferTicket>([^<]+)<\/TransferTicket>/.exec(xml)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function fehlerAus(xml: string): string[] {
|
||||
return [...xml.matchAll(/<Text>([^<]+)<\/Text>/g)].map((m) => m[1]!).slice(0, 20);
|
||||
}
|
||||
|
||||
function hinweiseAus(xml: string): string[] {
|
||||
return [...xml.matchAll(/<Hinweis>([\s\S]*?)<\/Hinweis>/g)].map((m) => m[1]!.trim()).slice(0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulationsmodus ohne ERiC: prüft das erzeugte XML auf Wohlgeformtheit und
|
||||
* offensichtliche Lücken und legt es zur Ansicht ab. Es findet ausdrücklich
|
||||
* keine Übermittlung an die Finanzverwaltung statt.
|
||||
*/
|
||||
function simuliere(xml: string, opt: SendeOptionen, grund: string): EricAntwort {
|
||||
const fehler: string[] = [];
|
||||
const hinweise = [
|
||||
`ERiC ist nicht eingebunden (${grund}).`,
|
||||
'Es wurde NICHTS an das Finanzamt übermittelt. Die Anmeldung wurde lediglich erzeugt und geprüft.',
|
||||
'Zum echten Versand: ERiC im ELSTER-Entwicklerportal herunterladen und ERIC_LIB_PATH, ' +
|
||||
'ERIC_PLUGIN_PATH, ELSTER_ZERTIFIKAT, ELSTER_PIN sowie ELSTER_HERSTELLER_ID setzen.',
|
||||
];
|
||||
|
||||
// einfache Wohlgeformtheitsprüfung: alle Tags müssen sich schließen
|
||||
const stack: string[] = [];
|
||||
for (const m of xml.matchAll(/<(\/?)([A-Za-z][\w.-]*)[^>]*?(\/?)>/g)) {
|
||||
const [, schliessend, name, selbst] = m;
|
||||
if (selbst === '/' || name === 'xml') continue;
|
||||
if (schliessend === '/') {
|
||||
if (stack.pop() !== name) fehler.push(`XML nicht wohlgeformt bei </${name}>.`);
|
||||
} else {
|
||||
stack.push(name!);
|
||||
}
|
||||
}
|
||||
if (stack.length > 0) fehler.push(`Nicht geschlossene Elemente: ${stack.join(', ')}.`);
|
||||
if (!/<Steuernummer>\d{13}<\/Steuernummer>/.test(xml)) {
|
||||
fehler.push('Die Steuernummer liegt nicht im 13-stelligen Bundesformat vor.');
|
||||
}
|
||||
if (!/<Kz83>/.test(xml)) fehler.push('Kennzahl 83 (Zahllast) fehlt.');
|
||||
|
||||
const ablage = resolve(datenVerzeichnis, 'elster-ausgang');
|
||||
mkdirSync(ablage, { recursive: true });
|
||||
const datei = resolve(ablage, `ustva-simulation-${Date.now()}.xml`);
|
||||
writeFileSync(datei, alsLatin1(xml));
|
||||
|
||||
return {
|
||||
erfolg: fehler.length === 0,
|
||||
simulation: true,
|
||||
rueckgabecode: fehler.length === 0 ? 0 : -1,
|
||||
transferticket: null,
|
||||
protokoll: `Simulationsmodus. XML abgelegt unter ${datei}.`,
|
||||
fehler,
|
||||
hinweise: opt.nurValidieren ? hinweise.slice(0, 1).concat(hinweise[2]!) : hinweise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Umrechnung der auf dem Steuerbescheid gedruckten Landes-Steuernummer in das
|
||||
* 13-stellige bundeseinheitliche Format, das ELSTER erwartet.
|
||||
*
|
||||
* Aufbau: <2- bis 3-stelliges Landespräfix><Finanzamtsnummer><0><Bezirk><Unterscheidungsnr.><Prüfziffer>
|
||||
*/
|
||||
|
||||
export type Bundesland =
|
||||
| 'BW' | 'BY' | 'BE' | 'BB' | 'HB' | 'HH' | 'HE' | 'MV'
|
||||
| 'NI' | 'NW' | 'RP' | 'SL' | 'SN' | 'ST' | 'SH' | 'TH';
|
||||
|
||||
export const BUNDESLAENDER: { code: Bundesland; name: string }[] = [
|
||||
{ code: 'BW', name: 'Baden-Württemberg' },
|
||||
{ code: 'BY', name: 'Bayern' },
|
||||
{ code: 'BE', name: 'Berlin' },
|
||||
{ code: 'BB', name: 'Brandenburg' },
|
||||
{ code: 'HB', name: 'Bremen' },
|
||||
{ code: 'HH', name: 'Hamburg' },
|
||||
{ code: 'HE', name: 'Hessen' },
|
||||
{ code: 'MV', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'NI', name: 'Niedersachsen' },
|
||||
{ code: 'NW', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'RP', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'SL', name: 'Saarland' },
|
||||
{ code: 'SN', name: 'Sachsen' },
|
||||
{ code: 'ST', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'SH', name: 'Schleswig-Holstein' },
|
||||
{ code: 'TH', name: 'Thüringen' },
|
||||
];
|
||||
|
||||
type Regel =
|
||||
/** 10-stellige Landesnummer FFBBBUUUUP -> <praefix>FF0BBBUUUUP */
|
||||
| { typ: 'kurz'; praefix: string }
|
||||
/** 11-stellige Landesnummer mit führender Füllziffer -> wie 'kurz' nach Abschneiden */
|
||||
| { typ: 'fuellziffer'; praefix: string }
|
||||
/** Bayern: FFFBBBUUUUP -> 9FFF0BBBUUUUP */
|
||||
| { typ: 'bayern' }
|
||||
/** NRW: FFFBBBBUUUP -> 5FFF0BBBBUUUP */
|
||||
| { typ: 'nrw' };
|
||||
|
||||
const REGELN: Record<Bundesland, Regel> = {
|
||||
BW: { typ: 'kurz', praefix: '28' },
|
||||
BY: { typ: 'bayern' },
|
||||
BE: { typ: 'kurz', praefix: '11' },
|
||||
BB: { typ: 'fuellziffer', praefix: '30' },
|
||||
HB: { typ: 'kurz', praefix: '24' },
|
||||
HH: { typ: 'kurz', praefix: '22' },
|
||||
HE: { typ: 'fuellziffer', praefix: '26' },
|
||||
MV: { typ: 'fuellziffer', praefix: '40' },
|
||||
NI: { typ: 'kurz', praefix: '23' },
|
||||
NW: { typ: 'nrw' },
|
||||
RP: { typ: 'kurz', praefix: '27' },
|
||||
SL: { typ: 'kurz', praefix: '10' },
|
||||
SN: { typ: 'fuellziffer', praefix: '32' },
|
||||
ST: { typ: 'fuellziffer', praefix: '31' },
|
||||
SH: { typ: 'kurz', praefix: '21' },
|
||||
TH: { typ: 'fuellziffer', praefix: '41' },
|
||||
};
|
||||
|
||||
export class SteuernummerFehler extends Error {}
|
||||
|
||||
export function nurZiffern(s: string): string {
|
||||
return s.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function bundeseinheitlich(steuernummer: string, land: Bundesland): string {
|
||||
const z = nurZiffern(steuernummer);
|
||||
if (!z) throw new SteuernummerFehler('Es ist keine Steuernummer hinterlegt.');
|
||||
|
||||
const regel = REGELN[land];
|
||||
let ergebnis: string;
|
||||
|
||||
if (regel.typ === 'bayern') {
|
||||
if (z.length !== 11) {
|
||||
throw new SteuernummerFehler(
|
||||
`Bayerische Steuernummern haben 11 Ziffern (Format 123/456/78901), erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
ergebnis = `9${z.slice(0, 3)}0${z.slice(3)}`;
|
||||
} else if (regel.typ === 'nrw') {
|
||||
if (z.length !== 11) {
|
||||
throw new SteuernummerFehler(
|
||||
`Nordrhein-westfälische Steuernummern haben 11 Ziffern (Format 123/4567/8901), erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
ergebnis = `5${z.slice(0, 3)}0${z.slice(3)}`;
|
||||
} else {
|
||||
let kurz = z;
|
||||
if (regel.typ === 'fuellziffer') {
|
||||
if (z.length === 11) kurz = z.slice(1);
|
||||
else if (z.length !== 10) {
|
||||
throw new SteuernummerFehler(
|
||||
`Steuernummer für ${land} muss 10 oder 11 Ziffern haben, erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
} else if (z.length !== 10) {
|
||||
throw new SteuernummerFehler(
|
||||
`Steuernummer für ${land} muss 10 Ziffern haben (Format 12/345/67890), erhalten: ${z.length}.`,
|
||||
);
|
||||
}
|
||||
ergebnis = `${regel.praefix}${kurz.slice(0, 2)}0${kurz.slice(2)}`;
|
||||
}
|
||||
|
||||
if (ergebnis.length !== 13) {
|
||||
throw new SteuernummerFehler(
|
||||
`Umrechnung ergab ${ergebnis.length} statt 13 Stellen – bitte Bundesland und Steuernummer prüfen.`,
|
||||
);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/** Die ersten vier Stellen sind die Bundesfinanzamtsnummer – ELSTER-Empfänger-ID. */
|
||||
export function finanzamtNummer(bundesSteuernummer: string): string {
|
||||
return bundesSteuernummer.slice(0, 4);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Mandant } from '../domain/typen.js';
|
||||
import type { UstvaErgebnis } from '../domain/ustva.js';
|
||||
import { kennzahlenFuerElster } from '../domain/ustva.js';
|
||||
import { bundeseinheitlich, finanzamtNummer, type Bundesland } from './steuernummer.js';
|
||||
|
||||
export const HERSTELLER = {
|
||||
name: 'open-eur-webapp',
|
||||
version: '0.1.0',
|
||||
/**
|
||||
* ERiC verlangt eine bei ELSTER registrierte Hersteller-ID. Ohne gültige ID
|
||||
* lehnt ERiC die Übermittlung ab – hier über die Umgebung konfigurierbar.
|
||||
*/
|
||||
herstellerId: process.env.ELSTER_HERSTELLER_ID ?? '00000',
|
||||
};
|
||||
|
||||
/**
|
||||
* Datenart-Version für EricBearbeiteVorgang, z.B. "UStVA_2026".
|
||||
* Muss zu der im installierten ERiC enthaltenen Version passen.
|
||||
*/
|
||||
export function datenartVersion(jahr: number): string {
|
||||
return `UStVA_${jahr}`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export interface XmlOptionen {
|
||||
berichtigt?: boolean;
|
||||
/** Kz 26: Verrechnung des Erstattungsbetrags erwünscht */
|
||||
verrechnungErwuenscht?: boolean;
|
||||
/** Kz 29: Belege werden gesondert eingereicht */
|
||||
belegnachreichung?: boolean;
|
||||
/** Kz 22/23: Einzugsermächtigung soll ausnahmsweise nicht genutzt werden */
|
||||
keinLastschriftEinzug?: boolean;
|
||||
erstellungsdatum?: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface XmlErgebnis {
|
||||
xml: string;
|
||||
bundesSteuernummer: string;
|
||||
datenartVersion: string;
|
||||
warnungen: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt den ELSTER-Datenteil für eine Umsatzsteuer-Voranmeldung.
|
||||
* Den TransferHeader (Verschlüsselung, Signatur, Routing) ergänzt ERiC selbst –
|
||||
* übergeben wird nur dieses Dokument.
|
||||
*/
|
||||
export function ustvaXml(
|
||||
ergebnis: UstvaErgebnis,
|
||||
mandant: Mandant,
|
||||
opt: XmlOptionen = {},
|
||||
): XmlErgebnis {
|
||||
const warnungen: string[] = [];
|
||||
const stnr = bundeseinheitlich(mandant.steuernummer, mandant.bundesland as Bundesland);
|
||||
const jahr = ergebnis.zeitraum.jahr;
|
||||
const kz = kennzahlenFuerElster(ergebnis.kennzahlen);
|
||||
|
||||
if (HERSTELLER.herstellerId === '00000') {
|
||||
warnungen.push(
|
||||
'Es ist keine ELSTER-Hersteller-ID gesetzt (ELSTER_HERSTELLER_ID). ' +
|
||||
'ERiC weist die Übermittlung ohne registrierte ID zurück.',
|
||||
);
|
||||
}
|
||||
|
||||
const erstellung =
|
||||
opt.erstellungsdatum ?? new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
const kzZeilen: string[] = [];
|
||||
if (opt.berichtigt) kzZeilen.push(' <Kz10>1</Kz10>');
|
||||
if (opt.belegnachreichung) kzZeilen.push(' <Kz22>1</Kz22>');
|
||||
if (opt.keinLastschriftEinzug) kzZeilen.push(' <Kz26>1</Kz26>');
|
||||
if (opt.verrechnungErwuenscht) kzZeilen.push(' <Kz29>1</Kz29>');
|
||||
|
||||
// Kennzahlen in aufsteigender numerischer Reihenfolge – so erwartet es das Schema.
|
||||
for (const k of Object.keys(kz).sort((a, b) => Number(a) - Number(b))) {
|
||||
kzZeilen.push(` <Kz${k}>${kz[k]}</Kz${k}>`);
|
||||
}
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="ISO-8859-15"?>
|
||||
<Elster xmlns="http://www.elster.de/elsterxml/schema/v11">
|
||||
<TransferHeader version="11">
|
||||
<Verfahren>ElsterAnmeldung</Verfahren>
|
||||
<DatenArt>UStVA</DatenArt>
|
||||
<Vorgang>send-Auth</Vorgang>
|
||||
<Testmerker>${process.env.ELSTER_TESTMERKER ?? ''}</Testmerker>
|
||||
<HerstellerID>${esc(HERSTELLER.herstellerId)}</HerstellerID>
|
||||
<DatenLieferant>${esc(mandant.name)}</DatenLieferant>
|
||||
<Datei>
|
||||
<Verschluesselung>PKCS#7v1.5</Verschluesselung>
|
||||
<Kompression>GZIP</Kompression>
|
||||
<TransportSchluessel/>
|
||||
<Erstellung><Eric/></Erstellung>
|
||||
</Datei>
|
||||
<VersionClient>${esc(HERSTELLER.version)}</VersionClient>
|
||||
</TransferHeader>
|
||||
<DatenTeil>
|
||||
<Nutzdatenblock>
|
||||
<NutzdatenHeader version="11">
|
||||
<NutzdatenTicket>1</NutzdatenTicket>
|
||||
<Empfaenger id="F">${finanzamtNummer(stnr)}</Empfaenger>
|
||||
<Hersteller>
|
||||
<ProduktName>${esc(HERSTELLER.name)}</ProduktName>
|
||||
<ProduktVersion>${esc(HERSTELLER.version)}</ProduktVersion>
|
||||
</Hersteller>
|
||||
</NutzdatenHeader>
|
||||
<Nutzdaten>
|
||||
<Anmeldungssteuern art="UStVA" version="${jahr}">
|
||||
<DatenLieferant>
|
||||
<Name>${esc(mandant.name)}</Name>
|
||||
<Strasse>${esc(mandant.strasse)}</Strasse>
|
||||
<PLZ>${esc(mandant.plz)}</PLZ>
|
||||
<Ort>${esc(mandant.ort)}</Ort>
|
||||
<Telefon>${esc(mandant.telefon)}</Telefon>
|
||||
<Email>${esc(mandant.email)}</Email>
|
||||
</DatenLieferant>
|
||||
<Erstellungsdatum>${erstellung}</Erstellungsdatum>
|
||||
<Steuerfall>
|
||||
<Umsatzsteuervoranmeldung>
|
||||
<Jahr>${jahr}</Jahr>
|
||||
<Zeitraum>${ergebnis.zeitraum.code}</Zeitraum>
|
||||
<Steuernummer>${stnr}</Steuernummer>
|
||||
${kzZeilen.join('\n')}
|
||||
</Umsatzsteuervoranmeldung>
|
||||
</Steuerfall>
|
||||
</Anmeldungssteuern>
|
||||
</Nutzdaten>
|
||||
</Nutzdatenblock>
|
||||
</DatenTeil>
|
||||
</Elster>
|
||||
`;
|
||||
|
||||
return { xml, bundesSteuernummer: stnr, datenartVersion: datenartVersion(jahr), warnungen };
|
||||
}
|
||||
|
||||
/** ELSTER erwartet ISO-8859-15; Umlaute müssen als latin1-Bytes übergeben werden. */
|
||||
export function alsLatin1(xml: string): Buffer {
|
||||
return Buffer.from(xml, 'latin1');
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { projektWurzel } from './pfade.js';
|
||||
|
||||
/**
|
||||
* Lädt `.env` in process.env – ohne Abhängigkeit und unabhängig davon, ob der
|
||||
* Prozess über `tsx`, `node` oder `npm start` gestartet wurde.
|
||||
*
|
||||
* Dieses Modul muss als ERSTES importiert werden, weil andere Module (z.B. db.ts)
|
||||
* ihre Umgebungsvariablen beim Import auswerten. ESM wertet Importe in der
|
||||
* angegebenen Reihenfolge aus, deshalb genügt die erste Import-Zeile.
|
||||
*
|
||||
* Bereits gesetzte Variablen gewinnen: was in der Shell steht, überschreibt die
|
||||
* Datei – so lässt sich ein Wert für einen einzelnen Lauf gezielt übersteuern.
|
||||
*/
|
||||
|
||||
const KANDIDATEN = [
|
||||
process.env['EUER_ENV_DATEI'],
|
||||
resolve(projektWurzel, '.env'),
|
||||
resolve(projektWurzel, 'server/.env'),
|
||||
].filter((p): p is string => !!p);
|
||||
|
||||
function parse(inhalt: string): Record<string, string> {
|
||||
const werte: Record<string, string> = {};
|
||||
for (const rohzeile of inhalt.split(/\r?\n/)) {
|
||||
const zeile = rohzeile.trim();
|
||||
if (!zeile || zeile.startsWith('#')) continue;
|
||||
|
||||
const trenner = zeile.indexOf('=');
|
||||
if (trenner < 1) continue;
|
||||
|
||||
const schluessel = zeile.slice(0, trenner).trim().replace(/^export\s+/, '');
|
||||
let wert = zeile.slice(trenner + 1).trim();
|
||||
|
||||
// Anführungszeichen entfernen; nur außerhalb von Quotes gibt es Kommentare.
|
||||
if (
|
||||
(wert.startsWith('"') && wert.endsWith('"') && wert.length > 1) ||
|
||||
(wert.startsWith("'") && wert.endsWith("'") && wert.length > 1)
|
||||
) {
|
||||
wert = wert.slice(1, -1);
|
||||
} else {
|
||||
wert = wert.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
if (schluessel) werte[schluessel] = wert;
|
||||
}
|
||||
return werte;
|
||||
}
|
||||
|
||||
let geladeneDatei: string | null = null;
|
||||
|
||||
for (const pfad of KANDIDATEN) {
|
||||
if (!existsSync(pfad)) continue;
|
||||
try {
|
||||
for (const [k, v] of Object.entries(parse(readFileSync(pfad, 'utf8')))) {
|
||||
if (process.env[k] === undefined) process.env[k] = v;
|
||||
}
|
||||
geladeneDatei = pfad;
|
||||
break;
|
||||
} catch (e) {
|
||||
console.warn(`Warnung: ${pfad} konnte nicht gelesen werden – ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const envDatei = geladeneDatei;
|
||||
@@ -0,0 +1,225 @@
|
||||
import { db } from '../db.js';
|
||||
import { dezimal } from '../domain/geld.js';
|
||||
import type { Buchung, Konto, Mandant } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* DATEV-Format ("EXTF") – Buchungsstapel und Kontenbeschriftungen.
|
||||
* Das ist das Format, das ein Steuerberater in DATEV Rechnungswesen einliest.
|
||||
* Spezifikation: DATEV-Format Version 700 / Formatversion 13.
|
||||
*
|
||||
* Zeichensatz: DATEV erwartet ANSI (Windows-1252). Node kennt cp1252 nicht
|
||||
* direkt; latin1 deckt den deutschen Zeichenvorrat ab, lediglich das
|
||||
* Euro-Zeichen wird ersetzt (siehe `alsAnsi`).
|
||||
*/
|
||||
|
||||
const FORMAT_VERSION = 700;
|
||||
const FORMAT_KATEGORIE_BUCHUNGSSTAPEL = 21;
|
||||
const FORMATVERSION_BUCHUNGSSTAPEL = 13;
|
||||
const FORMAT_KATEGORIE_KONTENBESCHRIFTUNG = 20;
|
||||
const FORMATVERSION_KONTENBESCHRIFTUNG = 3;
|
||||
|
||||
/** Offizielle Spaltenüberschriften des Buchungsstapels (125 Felder). */
|
||||
const SPALTEN_BUCHUNGSSTAPEL = [
|
||||
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs',
|
||||
'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)',
|
||||
'BU-Schlüssel', 'Belegdatum', 'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext',
|
||||
'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt',
|
||||
'Zinssperre', 'Beleglink',
|
||||
...pärchen('Beleginfo - Art', 'Beleginfo - Inhalt', 8),
|
||||
'KOST1 - Kostenstelle', 'KOST2 - Kostenstelle', 'KOST-Menge',
|
||||
'EU-Land u. UStID (Bestimmung)', 'EU-Steuersatz (Bestimmung)', 'Abw. Versteuerungsart',
|
||||
'Sachverhalt L+L', 'Funktionsergänzung L+L', 'BU 49 Hauptfunktionstyp',
|
||||
'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung',
|
||||
...pärchen('Zusatzinformation - Art', 'Zusatzinformation- Inhalt', 20),
|
||||
'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr',
|
||||
'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp',
|
||||
'USt-Schlüssel (Anzahlungen)', 'EU-Land (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)',
|
||||
'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz',
|
||||
'Buchungs GUID', 'KOST-Datum', 'SEPA-Mandatsreferenz', 'Skontosperre',
|
||||
'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer',
|
||||
'Postensperre bis', 'Bezeichnung SoBil-Sachverhalt', 'Kennzeichen SoBil-Buchung',
|
||||
'Festschreibung', 'Leistungsdatum', 'Datum Zuord. Steuerperiode', 'Fälligkeit',
|
||||
'Generalumkehr (GU)', 'Steuersatz', 'Land', 'Abrechnungsreferenz', 'BVV-Position',
|
||||
'EU-Land u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto',
|
||||
];
|
||||
|
||||
function pärchen(a: string, b: string, n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) => [`${a} ${i + 1}`, `${b} ${i + 1}`]).flat();
|
||||
}
|
||||
|
||||
function q(v: string | number | null | undefined): string {
|
||||
if (v === null || v === undefined || v === '') return '';
|
||||
return `"${String(v).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function zahl(v: string | number | null | undefined): string {
|
||||
return v === null || v === undefined || v === '' ? '' : String(v);
|
||||
}
|
||||
|
||||
function ttmm(datum: string): string {
|
||||
return `${datum.slice(8, 10)}${datum.slice(5, 7)}`;
|
||||
}
|
||||
function ttmmjjjj(datum: string): string {
|
||||
return `${datum.slice(8, 10)}${datum.slice(5, 7)}${datum.slice(0, 4)}`;
|
||||
}
|
||||
function zeitstempel(d: Date): string {
|
||||
const p = (n: number, l = 2) => String(n).padStart(l, '0');
|
||||
return (
|
||||
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
||||
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}${p(d.getMilliseconds(), 3)}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface DatevOptionen {
|
||||
jahr: number;
|
||||
von?: string;
|
||||
bis?: string;
|
||||
/** Buchungen als festgeschrieben kennzeichnen (Feld 114) */
|
||||
festschreibung?: boolean;
|
||||
bezeichnung?: string;
|
||||
/** Ohne Automatikkonten kann der Steuersatz per BU-Schlüssel mitgegeben werden. */
|
||||
mitBuSchluessel?: boolean;
|
||||
jetzt?: Date;
|
||||
}
|
||||
|
||||
export interface DatevDatei {
|
||||
dateiname: string;
|
||||
inhalt: Buffer;
|
||||
}
|
||||
|
||||
/** DATEV-BU-Schlüssel für die gängigen Steuersätze. */
|
||||
function buSchluessel(b: Buchung): string {
|
||||
if (b.ust_satz_bp === 0) return '';
|
||||
if (b.art === 'einnahme') return b.ust_satz_bp === 700 ? '2' : '3';
|
||||
return b.ust_satz_bp === 700 ? '8' : '9';
|
||||
}
|
||||
|
||||
export function buchungsstapel(opt: DatevOptionen): DatevDatei {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const von = opt.von ?? `${opt.jahr}-01-01`;
|
||||
const bis = opt.bis ?? `${opt.jahr}-12-31`;
|
||||
const jetzt = opt.jetzt ?? new Date();
|
||||
|
||||
const buchungen = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE storniert = 0
|
||||
AND COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ?
|
||||
ORDER BY COALESCE(zahlungsdatum, leistungsdatum), id`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
|
||||
const kopf = [
|
||||
q('EXTF'),
|
||||
FORMAT_VERSION,
|
||||
FORMAT_KATEGORIE_BUCHUNGSSTAPEL,
|
||||
q('Buchungsstapel'),
|
||||
FORMATVERSION_BUCHUNGSSTAPEL,
|
||||
zeitstempel(jetzt),
|
||||
'', // importiert
|
||||
q('RE'), // Herkunfts-Kennzeichen
|
||||
q(m.name.slice(0, 25)),
|
||||
'', // importiert von
|
||||
zahl(m.datev_berater_nr || '0'),
|
||||
zahl(m.datev_mandanten_nr || '0'),
|
||||
`${opt.jahr}0101`,
|
||||
4, // Sachkontenlänge
|
||||
von.replace(/-/g, ''),
|
||||
bis.replace(/-/g, ''),
|
||||
q(opt.bezeichnung ?? `EÜR ${opt.jahr}`),
|
||||
q(''), // Diktatkürzel
|
||||
1, // Buchungstyp: Finanzbuchführung
|
||||
0, // Rechnungslegungszweck
|
||||
opt.festschreibung ? 1 : 0,
|
||||
q('EUR'),
|
||||
'', '', '', '',
|
||||
q(m.kontenrahmen),
|
||||
'', '', '',
|
||||
q('open-eur-webapp'),
|
||||
].join(';');
|
||||
|
||||
const spalten = SPALTEN_BUCHUNGSSTAPEL.map(q).join(';');
|
||||
|
||||
const zeilen = buchungen.map((b) => {
|
||||
const einnahme = b.art === 'einnahme';
|
||||
const konto = einnahme ? b.gegenkonto : b.konto;
|
||||
const gegenkonto = einnahme ? b.konto : b.gegenkonto;
|
||||
const datum = b.zahlungsdatum ?? b.leistungsdatum;
|
||||
|
||||
const felder: (string | number)[] = new Array(SPALTEN_BUCHUNGSSTAPEL.length).fill('');
|
||||
felder[0] = q(dezimal(b.betrag_brutto)); // Umsatz immer positiv
|
||||
felder[1] = q('S'); // Soll auf `Konto`
|
||||
felder[2] = q('EUR');
|
||||
felder[6] = zahl(konto);
|
||||
felder[7] = zahl(gegenkonto);
|
||||
felder[8] = opt.mitBuSchluessel ? q(buSchluessel(b)) : '';
|
||||
felder[9] = ttmm(datum);
|
||||
felder[10] = q(b.beleg_nr.slice(0, 36));
|
||||
felder[11] = q(b.partner.slice(0, 12));
|
||||
felder[13] = q(b.buchungstext.slice(0, 60));
|
||||
felder[113] = opt.festschreibung || b.festgeschrieben ? 1 : 0;
|
||||
felder[114] = ttmmjjjj(b.leistungsdatum); // Leistungsdatum
|
||||
felder[115] = ttmmjjjj(b.leistungsdatum); // Zuordnung Steuerperiode
|
||||
return felder.join(';');
|
||||
});
|
||||
|
||||
const inhalt = [kopf, spalten, ...zeilen].join('\r\n') + '\r\n';
|
||||
return {
|
||||
dateiname: `EXTF_Buchungsstapel_${opt.jahr}_${von.replace(/-/g, '')}-${bis.replace(/-/g, '')}.csv`,
|
||||
inhalt: alsAnsi(inhalt),
|
||||
};
|
||||
}
|
||||
|
||||
export function kontenbeschriftungen(opt: DatevOptionen): DatevDatei {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const konten = db.prepare('SELECT * FROM konten ORDER BY nummer').all() as Konto[];
|
||||
const jetzt = opt.jetzt ?? new Date();
|
||||
|
||||
const kopf = [
|
||||
q('EXTF'),
|
||||
FORMAT_VERSION,
|
||||
FORMAT_KATEGORIE_KONTENBESCHRIFTUNG,
|
||||
q('Kontenbeschriftungen'),
|
||||
FORMATVERSION_KONTENBESCHRIFTUNG,
|
||||
zeitstempel(jetzt),
|
||||
'',
|
||||
q('RE'),
|
||||
q(m.name.slice(0, 25)),
|
||||
'',
|
||||
zahl(m.datev_berater_nr || '0'),
|
||||
zahl(m.datev_mandanten_nr || '0'),
|
||||
`${opt.jahr}0101`,
|
||||
4,
|
||||
`${opt.jahr}0101`,
|
||||
`${opt.jahr}1231`,
|
||||
q(`Kontenbeschriftungen ${opt.jahr}`),
|
||||
'', '', '', 0,
|
||||
q('EUR'),
|
||||
'', '', '', '',
|
||||
q(m.kontenrahmen),
|
||||
'', '', '',
|
||||
q('open-eur-webapp'),
|
||||
].join(';');
|
||||
|
||||
const spalten = ['Konto', 'Kontenbeschriftung', 'Sprach-ID', 'Kontenbeschriftung lang']
|
||||
.map(q)
|
||||
.join(';');
|
||||
|
||||
const zeilen = konten.map((k) =>
|
||||
[zahl(k.nummer), q(k.bezeichnung.slice(0, 40)), q('de-DE'), q(k.bezeichnung)].join(';'),
|
||||
);
|
||||
|
||||
return {
|
||||
dateiname: `EXTF_Kontenbeschriftungen_${opt.jahr}.csv`,
|
||||
inhalt: alsAnsi([kopf, spalten, ...zeilen].join('\r\n') + '\r\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows-1252-Näherung: latin1 deckt alle deutschen Umlaute ab; das
|
||||
* Euro-Zeichen liegt in cp1252 auf 0x80 und wird ersetzt, damit keine
|
||||
* fehlerhaften Bytes im Import landen.
|
||||
*/
|
||||
export function alsAnsi(s: string): Buffer {
|
||||
return Buffer.from(s.replace(/€/g, 'EUR').replace(/[^\x00-\xFF]/g, '?'), 'latin1');
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { db } from '../db.js';
|
||||
import { dezimal } from '../domain/geld.js';
|
||||
import { zip, type ZipEintrag } from './zip.js';
|
||||
import type { Anlagegut, Buchung, Konto, Mandant } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* GDPdU-/GoBD-Datenträgerüberlassung (Format Z3, "Beschreibungsstandard für die
|
||||
* Datenträgerüberlassung" 1.0). Erzeugt wird ein ZIP mit index.xml und den
|
||||
* zugehörigen CSV-Dateien – das ist das Paket, das die Betriebsprüfung in IDEA
|
||||
* einliest. Für den laufenden Austausch mit dem Steuerberater ist dagegen der
|
||||
* DATEV-Buchungsstapel das übliche Format.
|
||||
*/
|
||||
|
||||
interface SpaltenDef {
|
||||
name: string;
|
||||
typ: 'text' | 'zahl' | 'datum';
|
||||
beschreibung?: string;
|
||||
}
|
||||
|
||||
function csvFeld(v: unknown): string {
|
||||
if (v === null || v === undefined) return '""';
|
||||
return `"${String(v).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function csv(spalten: SpaltenDef[], zeilen: unknown[][]): Buffer {
|
||||
const kopf = spalten.map((s) => csvFeld(s.name)).join(';');
|
||||
const body = zeilen.map((z) => z.map(csvFeld).join(';'));
|
||||
return Buffer.from([kopf, ...body].join('\r\n') + '\r\n', 'latin1');
|
||||
}
|
||||
|
||||
function tabellenXml(
|
||||
datei: string,
|
||||
name: string,
|
||||
beschreibung: string,
|
||||
von: string,
|
||||
bis: string,
|
||||
spalten: SpaltenDef[],
|
||||
primaerschluessel?: string,
|
||||
): string {
|
||||
const spaltenXml = spalten
|
||||
.map((s) => {
|
||||
const typ =
|
||||
s.typ === 'zahl'
|
||||
? '<Numeric><Accuracy>2</Accuracy></Numeric>'
|
||||
: s.typ === 'datum'
|
||||
? '<Date><Format>YYYY-MM-DD</Format></Date>'
|
||||
: '<AlphaNumeric/>';
|
||||
const tag = s.name === primaerschluessel ? 'VariablePrimaryKey' : 'VariableColumn';
|
||||
const beschr = s.beschreibung ? `<Description>${esc(s.beschreibung)}</Description>` : '';
|
||||
return ` <${tag}><Name>${esc(s.name)}</Name>${beschr}${typ}</${tag}>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return ` <Table>
|
||||
<URL>${esc(datei)}</URL>
|
||||
<Name>${esc(name)}</Name>
|
||||
<Description>${esc(beschreibung)}</Description>
|
||||
<Validity>
|
||||
<Range><From>${von}</From><To>${bis}</To></Range>
|
||||
<Format>YYYY-MM-DD</Format>
|
||||
</Validity>
|
||||
<DecimalSymbol>,</DecimalSymbol>
|
||||
<DigitGroupingSymbol>.</DigitGroupingSymbol>
|
||||
<VariableLength>
|
||||
<ColumnDelimiter>;</ColumnDelimiter>
|
||||
<RecordDelimiter> </RecordDelimiter>
|
||||
<TextEncapsulator>"</TextEncapsulator>
|
||||
${spaltenXml}
|
||||
</VariableLength>
|
||||
</Table>`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function gdpduExport(jahr: number): { dateiname: string; inhalt: Buffer } {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const von = `${jahr}-01-01`;
|
||||
const bis = `${jahr}-12-31`;
|
||||
|
||||
const buchungen = db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen
|
||||
WHERE COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ?
|
||||
ORDER BY COALESCE(zahlungsdatum, leistungsdatum), id`,
|
||||
)
|
||||
.all(von, bis) as Buchung[];
|
||||
const konten = db.prepare('SELECT * FROM konten ORDER BY nummer').all() as Konto[];
|
||||
const anlagen = db.prepare('SELECT * FROM anlagegueter ORDER BY anschaffungsdatum').all() as Anlagegut[];
|
||||
|
||||
const buchungSpalten: SpaltenDef[] = [
|
||||
{ name: 'ID', typ: 'text', beschreibung: 'eindeutige Satznummer' },
|
||||
{ name: 'Belegdatum', typ: 'datum' },
|
||||
{ name: 'Leistungsdatum', typ: 'datum' },
|
||||
{ name: 'Zahlungsdatum', typ: 'datum' },
|
||||
{ name: 'Belegnummer', typ: 'text' },
|
||||
{ name: 'Buchungstext', typ: 'text' },
|
||||
{ name: 'Geschaeftspartner', typ: 'text' },
|
||||
{ name: 'Art', typ: 'text', beschreibung: 'einnahme oder ausgabe' },
|
||||
{ name: 'Konto', typ: 'text' },
|
||||
{ name: 'Gegenkonto', typ: 'text' },
|
||||
{ name: 'Betrag_Brutto', typ: 'zahl' },
|
||||
{ name: 'Betrag_Netto', typ: 'zahl' },
|
||||
{ name: 'Umsatzsteuer', typ: 'zahl' },
|
||||
{ name: 'Steuersatz_Prozent', typ: 'zahl' },
|
||||
{ name: 'USt_Behandlung', typ: 'text' },
|
||||
{ name: 'Vorsteuer_abziehbar', typ: 'text' },
|
||||
{ name: 'Privatanteil_Prozent', typ: 'zahl' },
|
||||
{ name: 'Storniert', typ: 'text' },
|
||||
{ name: 'Festgeschrieben', typ: 'text' },
|
||||
{ name: 'Erfasst_am', typ: 'text' },
|
||||
];
|
||||
|
||||
const buchungZeilen = buchungen.map((b) => [
|
||||
b.id,
|
||||
b.belegdatum,
|
||||
b.leistungsdatum,
|
||||
b.zahlungsdatum ?? '',
|
||||
b.beleg_nr,
|
||||
b.buchungstext,
|
||||
b.partner,
|
||||
b.art,
|
||||
b.konto,
|
||||
b.gegenkonto,
|
||||
dezimal(b.betrag_brutto),
|
||||
dezimal(b.betrag_netto),
|
||||
dezimal(b.betrag_ust),
|
||||
(b.ust_satz_bp / 100).toFixed(2).replace('.', ','),
|
||||
b.ust_behandlung,
|
||||
b.vorsteuer_abziehbar ? 'ja' : 'nein',
|
||||
(b.privatanteil_bp / 100).toFixed(2).replace('.', ','),
|
||||
b.storniert ? 'ja' : 'nein',
|
||||
b.festgeschrieben ? 'ja' : 'nein',
|
||||
b.erstellt_am,
|
||||
]);
|
||||
|
||||
const kontenSpalten: SpaltenDef[] = [
|
||||
{ name: 'Konto', typ: 'text' },
|
||||
{ name: 'Bezeichnung', typ: 'text' },
|
||||
{ name: 'Typ', typ: 'text' },
|
||||
{ name: 'EUER_Posten', typ: 'text' },
|
||||
{ name: 'BWA_Gruppe', typ: 'text' },
|
||||
{ name: 'Steuersatz_Prozent', typ: 'zahl' },
|
||||
];
|
||||
const kontenZeilen = konten.map((k) => [
|
||||
k.nummer,
|
||||
k.bezeichnung,
|
||||
k.typ,
|
||||
k.euer_zeile ?? '',
|
||||
k.bwa_gruppe ?? '',
|
||||
(k.ust_satz_bp / 100).toFixed(2).replace('.', ','),
|
||||
]);
|
||||
|
||||
const anlagenSpalten: SpaltenDef[] = [
|
||||
{ name: 'ID', typ: 'text' },
|
||||
{ name: 'Bezeichnung', typ: 'text' },
|
||||
{ name: 'Anschaffungsdatum', typ: 'datum' },
|
||||
{ name: 'AK_Netto', typ: 'zahl' },
|
||||
{ name: 'AK_Brutto', typ: 'zahl' },
|
||||
{ name: 'Nutzungsdauer_Jahre', typ: 'zahl' },
|
||||
{ name: 'AfA_Methode', typ: 'text' },
|
||||
{ name: 'Abgang', typ: 'datum' },
|
||||
];
|
||||
const anlagenZeilen = anlagen.map((a) => [
|
||||
a.id,
|
||||
a.bezeichnung,
|
||||
a.anschaffungsdatum,
|
||||
dezimal(a.akosten_netto),
|
||||
dezimal(a.akosten_brutto),
|
||||
a.nutzungsdauer,
|
||||
a.afa_methode,
|
||||
a.abgang_datum ?? '',
|
||||
]);
|
||||
|
||||
const indexXml = `<?xml version="1.0" encoding="ISO-8859-15"?>
|
||||
<!DOCTYPE DataSet SYSTEM "gdpdu-01-09-2004.dtd">
|
||||
<DataSet>
|
||||
<Version>1.0</Version>
|
||||
<DataSupplier>
|
||||
<Name>${esc(m.name)}</Name>
|
||||
<Location>${esc(`${m.plz} ${m.ort}`.trim())}</Location>
|
||||
<Comment>Steuernummer ${esc(m.steuernummer)} – Einnahmen-Überschuss-Rechnung ${jahr}</Comment>
|
||||
</DataSupplier>
|
||||
<Media>
|
||||
<Name>Datenträgerüberlassung ${jahr}</Name>
|
||||
${tabellenXml('buchungen.csv', 'Buchungssaetze', `Einzelaufzeichnungen ${jahr}`, von, bis, buchungSpalten, 'ID')}
|
||||
${tabellenXml('konten.csv', 'Kontenplan', 'Sachkonten mit Zuordnung', von, bis, kontenSpalten, 'Konto')}
|
||||
${tabellenXml('anlagen.csv', 'Anlagenverzeichnis', 'Anlagegueter und AfA-Parameter', von, bis, anlagenSpalten, 'ID')}
|
||||
</Media>
|
||||
</DataSet>
|
||||
`;
|
||||
|
||||
const liesmich = `Datenträgerüberlassung nach GoBD (Format Z3)
|
||||
==========================================
|
||||
|
||||
Inhalt
|
||||
index.xml Beschreibungsdatei nach dem "Beschreibungsstandard für die
|
||||
Datenträgerüberlassung" (Version 1.0)
|
||||
buchungen.csv Einzelaufzeichnungen ${jahr}
|
||||
konten.csv verwendeter Kontenplan
|
||||
anlagen.csv Anlagenverzeichnis
|
||||
|
||||
Hinweise
|
||||
- Zeichensatz der CSV-Dateien: ISO-8859-15, Trennzeichen ";", Textbegrenzer '"'.
|
||||
- Beträge in Euro mit Komma als Dezimaltrennzeichen.
|
||||
- Die Datei gdpdu-01-09-2004.dtd wird von den Prüfprogrammen (z.B. IDEA)
|
||||
mitgeliefert und ist deshalb hier nicht enthalten.
|
||||
- Erzeugt mit open-eur-webapp am ${new Date().toISOString().slice(0, 10)}.
|
||||
`;
|
||||
|
||||
const eintraege: ZipEintrag[] = [
|
||||
{ name: 'index.xml', inhalt: Buffer.from(indexXml, 'latin1') },
|
||||
{ name: 'buchungen.csv', inhalt: csv(buchungSpalten, buchungZeilen) },
|
||||
{ name: 'konten.csv', inhalt: csv(kontenSpalten, kontenZeilen) },
|
||||
{ name: 'anlagen.csv', inhalt: csv(anlagenSpalten, anlagenZeilen) },
|
||||
{ name: 'LIESMICH.txt', inhalt: Buffer.from(liesmich, 'latin1') },
|
||||
];
|
||||
|
||||
return { dateiname: `GoBD-Export-${jahr}.zip`, inhalt: zip(eintraege) };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
|
||||
/** Minimaler ZIP-Writer (Deflate), damit für den Export keine weitere Abhängigkeit nötig ist. */
|
||||
|
||||
const CRC_TABELLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABELLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosZeit(d: Date): { zeit: number; datum: number } {
|
||||
return {
|
||||
zeit: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
|
||||
datum: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ZipEintrag {
|
||||
name: string;
|
||||
inhalt: Buffer;
|
||||
}
|
||||
|
||||
export function zip(eintraege: ZipEintrag[], jetzt = new Date()): Buffer {
|
||||
const { zeit, datum } = dosZeit(jetzt);
|
||||
const lokale: Buffer[] = [];
|
||||
const zentrale: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of eintraege) {
|
||||
const name = Buffer.from(e.name, 'utf8');
|
||||
const komprimiert = deflateRawSync(e.inhalt);
|
||||
const crc = crc32(e.inhalt);
|
||||
|
||||
const kopf = Buffer.alloc(30);
|
||||
kopf.writeUInt32LE(0x04034b50, 0);
|
||||
kopf.writeUInt16LE(20, 4); // Version
|
||||
kopf.writeUInt16LE(0x0800, 6); // UTF-8-Flag
|
||||
kopf.writeUInt16LE(8, 8); // Deflate
|
||||
kopf.writeUInt16LE(zeit, 10);
|
||||
kopf.writeUInt16LE(datum, 12);
|
||||
kopf.writeUInt32LE(crc, 14);
|
||||
kopf.writeUInt32LE(komprimiert.length, 18);
|
||||
kopf.writeUInt32LE(e.inhalt.length, 22);
|
||||
kopf.writeUInt16LE(name.length, 26);
|
||||
kopf.writeUInt16LE(0, 28);
|
||||
|
||||
lokale.push(kopf, name, komprimiert);
|
||||
|
||||
const zentral = Buffer.alloc(46);
|
||||
zentral.writeUInt32LE(0x02014b50, 0);
|
||||
zentral.writeUInt16LE(20, 4);
|
||||
zentral.writeUInt16LE(20, 6);
|
||||
zentral.writeUInt16LE(0x0800, 8);
|
||||
zentral.writeUInt16LE(8, 10);
|
||||
zentral.writeUInt16LE(zeit, 12);
|
||||
zentral.writeUInt16LE(datum, 14);
|
||||
zentral.writeUInt32LE(crc, 16);
|
||||
zentral.writeUInt32LE(komprimiert.length, 20);
|
||||
zentral.writeUInt32LE(e.inhalt.length, 24);
|
||||
zentral.writeUInt16LE(name.length, 28);
|
||||
zentral.writeUInt32LE(offset, 42);
|
||||
zentrale.push(zentral, name);
|
||||
|
||||
offset += kopf.length + name.length + komprimiert.length;
|
||||
}
|
||||
|
||||
const zentralBuf = Buffer.concat(zentrale);
|
||||
const ende = Buffer.alloc(22);
|
||||
ende.writeUInt32LE(0x06054b50, 0);
|
||||
ende.writeUInt16LE(eintraege.length, 8);
|
||||
ende.writeUInt16LE(eintraege.length, 10);
|
||||
ende.writeUInt32LE(zentralBuf.length, 12);
|
||||
ende.writeUInt32LE(offset, 16);
|
||||
|
||||
return Buffer.concat([Buffer.concat(lokale), zentralBuf, ende]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// muss vor allen anderen Importen stehen – siehe env.ts
|
||||
import { envDatei } from './env.js';
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { projektWurzel } from './pfade.js';
|
||||
import {
|
||||
alleMandanten,
|
||||
kontenrahmenAnlegen,
|
||||
kontextFuer,
|
||||
registerVorbereiten,
|
||||
schliesseAlleVerbindungen,
|
||||
standardMandant,
|
||||
} from './mandanten.js';
|
||||
import { mandantenRouten, mandantenScoping } from './routes/mandanten.js';
|
||||
import { stammdatenRouten } from './routes/stammdaten.js';
|
||||
import { buchungsRouten } from './routes/buchungen.js';
|
||||
import { auswertungsRouten } from './routes/auswertungen.js';
|
||||
import { elsterRouten } from './routes/elster.js';
|
||||
import { exportRouten } from './routes/export.js';
|
||||
|
||||
const start = registerVorbereiten();
|
||||
for (const eintrag of alleMandanten()) {
|
||||
// Fehlender Kontenrahmen (z.B. in einer von Hand angelegten Datei) wird ergänzt.
|
||||
const anzahl = kontenrahmenAnlegen(kontextFuer(eintrag).verbindung);
|
||||
if (anzahl > 0) console.log(`Mandant "${eintrag.name}": Kontenrahmen SKR04 mit ${anzahl} Konten angelegt.`);
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'warn' }, bodyLimit: 10 * 1024 * 1024 });
|
||||
|
||||
await app.register(cors, { origin: true, exposedHeaders: ['X-Mandant'] });
|
||||
mandantenScoping(app);
|
||||
await app.register(mandantenRouten);
|
||||
await app.register(stammdatenRouten);
|
||||
await app.register(buchungsRouten);
|
||||
await app.register(auswertungsRouten);
|
||||
await app.register(elsterRouten);
|
||||
await app.register(exportRouten);
|
||||
|
||||
app.get('/api/gesundheit', async () => ({ ok: true, version: '0.1.0' }));
|
||||
|
||||
// Im Produktivbetrieb liefert derselbe Prozess auch das gebaute Frontend aus.
|
||||
const webDist = resolve(projektWurzel, 'web/dist');
|
||||
if (existsSync(webDist)) {
|
||||
await app.register(fastifyStatic, { root: webDist });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api/')) return reply.code(404).send({ fehler: 'Unbekannter Endpunkt.' });
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
schliesseAlleVerbindungen();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
const port = Number(process.env.PORT ?? 3311);
|
||||
await app.listen({ port, host: '127.0.0.1' });
|
||||
console.log(`open-eur-webapp läuft auf http://127.0.0.1:${port}`);
|
||||
console.log(
|
||||
envDatei
|
||||
? `Konfiguration aus ${envDatei} geladen.`
|
||||
: 'Keine .env gefunden – ELSTER läuft im Simulationsmodus (Vorlage: .env.beispiel).',
|
||||
);
|
||||
if (start.uebernommen) {
|
||||
console.log('Vorhandene data/euer.sqlite als Mandant "standard" übernommen (Datei unverändert).');
|
||||
}
|
||||
if (start.angelegt) console.log('Ersten Mandanten angelegt.');
|
||||
console.log(
|
||||
`Mandanten: ${alleMandanten()
|
||||
.map((m) => `${m.slug}${m.testmandant ? ' [Test]' : ''}`)
|
||||
.join(', ')} – aktiv ohne Angabe: ${standardMandant().slug}`,
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Konto } from '../domain/typen.js';
|
||||
import { SKR03 } from './skr03.js';
|
||||
import { SKR04 } from './skr04.js';
|
||||
|
||||
export type KontenrahmenId = 'SKR03' | 'SKR04';
|
||||
|
||||
export const KONTENRAHMEN: Record<KontenrahmenId, { bezeichnung: string; konten: Konto[] }> = {
|
||||
SKR03: {
|
||||
bezeichnung: 'SKR03 – Prozessgliederungsprinzip (Erlöse Klasse 8, Aufwand Klasse 4)',
|
||||
konten: SKR03,
|
||||
},
|
||||
SKR04: {
|
||||
bezeichnung: 'SKR04 – Abschlussgliederungsprinzip (Erlöse Klasse 4, Aufwand Klasse 6)',
|
||||
konten: SKR04,
|
||||
},
|
||||
};
|
||||
|
||||
export function istKontenrahmen(wert: string): wert is KontenrahmenId {
|
||||
return wert === 'SKR03' || wert === 'SKR04';
|
||||
}
|
||||
|
||||
/**
|
||||
* Voreingestelltes Gegenkonto (Bank) je Kontenrahmen.
|
||||
*
|
||||
* Achtung, klassische Fehlerquelle: 1800 ist im SKR04 das Bankkonto, im SKR03
|
||||
* dagegen "Privatentnahmen allgemein". Ein fest verdrahtetes Gegenkonto würde
|
||||
* im SKR03 jede Zahlung als Privatentnahme buchen.
|
||||
*/
|
||||
export const STANDARD_GEGENKONTO: Record<KontenrahmenId, string> = {
|
||||
SKR03: '1200',
|
||||
SKR04: '1800',
|
||||
};
|
||||
|
||||
export function standardGegenkonto(kontenrahmen: string): string {
|
||||
return istKontenrahmen(kontenrahmen)
|
||||
? STANDARD_GEGENKONTO[kontenrahmen]
|
||||
: STANDARD_GEGENKONTO.SKR04;
|
||||
}
|
||||
|
||||
export function kontenFuer(id: string): Konto[] {
|
||||
return (istKontenrahmen(id) ? KONTENRAHMEN[id] : KONTENRAHMEN.SKR04).konten;
|
||||
}
|
||||
|
||||
export { SKR03, SKR04 };
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Konto } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* Praxisnaher Auszug aus dem SKR03 (Prozessgliederungsprinzip).
|
||||
*
|
||||
* Kontonummern und Bezeichnungen sind dem amtlichen DATEV-Dokument
|
||||
* "DATEV-Kontenrahmen nach dem Bilanzrichtlinie-Umsetzungsgesetz –
|
||||
* Standardkontenrahmen Prozessgliederungsprinzip (SKR 03), gültig für 2026"
|
||||
* entnommen. Die Zuordnung zu EÜR-Posten, BWA-Gruppen und UStVA-Kennzahlen
|
||||
* stammt dagegen aus dieser Anwendung und ist fachliche Auslegung – sie sollte
|
||||
* vor dem ersten Export mit der Steuerkanzlei abgeglichen werden.
|
||||
*
|
||||
* Anders als der SKR04 (Abschlussgliederung) ordnet der SKR03 nach dem
|
||||
* Geschäftsablauf: Erlöse liegen in Klasse 8, Aufwand in Klasse 4, Wareneingang
|
||||
* in Klasse 3. Ein Konto wie 4400 bedeutet in beiden Rahmen etwas völlig
|
||||
* anderes – deshalb lässt sich der Kontenrahmen nach dem Anlegen eines
|
||||
* Mandanten nicht mehr wechseln.
|
||||
*/
|
||||
export const SKR03: Konto[] = [
|
||||
// --- Finanz- und Bestandskonten (Gegenkonten) ---------------------------
|
||||
k('1000', 'Kasse', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1200', 'Bank', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1360', 'Geldtransit', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1576', 'Abziehbare Vorsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('1571', 'Abziehbare Vorsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('1776', 'Umsatzsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('1771', 'Umsatzsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('1780', 'Umsatzsteuer-Vorauszahlungen', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1800', 'Privatentnahmen allgemein', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1890', 'Privateinlagen', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Anlagevermögen -----------------------------------------------------
|
||||
k('0027', 'EDV-Software', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0320', 'Pkw', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0410', 'Geschäftsausstattung', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0480', 'Geringwertige Wirtschaftsgüter', 'anlage', 'ba_gwg', 'abschreibungen', 1900, 'regel'),
|
||||
k('0485', 'Wirtschaftsgüter (Sammelposten)', 'anlage', 'ba_sammelposten', 'abschreibungen', 1900, 'regel'),
|
||||
|
||||
// --- Erlöse (Klasse 8) --------------------------------------------------
|
||||
k('8192', 'Steuerfreie Erlöse Kleinunternehmer nach § 19 Abs. 1 UStG', 'erloes', 'be_ku', 'umsatzerloese', 0, 'kleinunternehmer'),
|
||||
k('8400', 'Erlöse 19 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 1900, 'regel'),
|
||||
k('8300', 'Erlöse 7 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 700, 'regel'),
|
||||
k('8125', 'Steuerfreie innergemeinschaftliche Lieferungen nach § 4 Nr. 1b UStG', 'erloes', 'be_frei', 'umsatzerloese', 0, 'ig_lieferung'),
|
||||
k('8120', 'Steuerfreie Umsätze nach § 4 Nr. 1a UStG', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_mit_vst'),
|
||||
k('8100', 'Steuerfreie Umsätze § 4 Nr. 8 ff. UStG', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_ohne_vst'),
|
||||
// Kz 21: nicht steuerbare sonstige Leistungen § 18b Satz 1 Nr. 2 UStG
|
||||
k('8336', 'Erlöse aus im anderen EU-Land steuerpflichtigen sonstigen Leistungen', 'erloes', 'be_frei', 'umsatzerloese', 0, 'nicht_steuerbar', '21'),
|
||||
k('2700', 'Andere betriebs- und/oder periodenfremde sonstige Erträge', 'erloes', 'be_sonstige', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8801', 'Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchverlust)', 'erloes', 'be_anlagenabgang', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8820', 'Erlöse aus Verkäufen Sachanlagevermögen 19 % USt (bei Buchgewinn)', 'erloes', 'be_anlagenabgang', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8924', 'Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens (Kfz-Nutzung)', 'erloes', 'be_kfz_privat', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('8905', 'Entnahme von Gegenständen ohne USt', 'erloes', 'be_entnahmen', 'so_betr_erloese', 0, 'nicht_steuerbar'),
|
||||
k('2650', 'Sonstige Zinsen und ähnliche Erträge', 'erloes', 'be_sonstige', 'zinsertraege', 0, 'steuerfrei_ohne_vst'),
|
||||
|
||||
// --- Wareneingang und Fremdleistungen (Klasse 3) ------------------------
|
||||
k('3400', 'Wareneingang 19 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('3300', 'Wareneingang 7 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 700, 'regel'),
|
||||
k('3425', 'Innergemeinschaftlicher Erwerb 19 % Vorsteuer und 19 % Umsatzsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'ig_erwerb'),
|
||||
k('3100', 'Fremdleistungen', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('3123', 'Sonstige Leistungen eines im anderen EU-Land ansässigen Unternehmers (§ 13b UStG)', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'reverse_charge_13b'),
|
||||
|
||||
// --- Personal -----------------------------------------------------------
|
||||
k('4100', 'Löhne und Gehälter', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('4130', 'Gesetzliche soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('4140', 'Freiwillige soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 1900, 'regel'),
|
||||
|
||||
// --- Abschreibungen -----------------------------------------------------
|
||||
k('4830', 'Abschreibungen auf Sachanlagen', 'aufwand', 'ba_afa_beweglich', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('4822', 'Abschreibungen auf immaterielle Vermögensgegenstände', 'aufwand', 'ba_afa_immateriell', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('4855', 'Sofortabschreibung geringwertiger Wirtschaftsgüter', 'aufwand', 'ba_gwg', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('4862', 'Abschreibungen auf den Sammelposten Wirtschaftsgüter', 'aufwand', 'ba_sammelposten', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('2310', 'Anlagenabgänge Sachanlagen (Restbuchwert bei Buchverlust)', 'aufwand', 'ba_restbuchwert', 'so_neutraler_aufwand', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Raum ---------------------------------------------------------------
|
||||
k('4210', 'Miete (unbewegliche Wirtschaftsgüter)', 'aufwand', 'ba_miete', 'raumkosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4240', 'Gas, Strom, Wasser', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('4250', 'Reinigung', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('4260', 'Instandhaltung betrieblicher Räume', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('4288', 'Aufwendungen für ein häusliches Arbeitszimmer (abziehbarer Anteil)', 'aufwand', 'ba_arbeitszimmer', 'raumkosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Versicherungen, Beiträge, Steuern ----------------------------------
|
||||
k('4360', 'Versicherungen', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4380', 'Beiträge', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4320', 'Gewerbesteuer', 'aufwand', 'ba_sonstige', 'steuern_eink_ertrag', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Fahrzeuge ----------------------------------------------------------
|
||||
k('4520', 'Fahrzeug-Versicherungen', 'aufwand', 'ba_kfz', 'kfz_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4530', 'Laufende Fahrzeug-Betriebskosten', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('4540', 'Fahrzeug-Reparaturen', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('4570', 'Mietleasing Kfz', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
|
||||
// --- Werbung und Reisekosten --------------------------------------------
|
||||
k('4600', 'Werbekosten', 'aufwand', 'ba_werbung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4650', 'Bewirtungskosten', 'aufwand', 'ba_bewirtung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4630', 'Geschenke abzugsfähig ohne § 37b EStG', 'aufwand', 'ba_geschenke', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4670', 'Reisekosten Unternehmer', 'aufwand', 'ba_reise', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('4674', 'Reisekosten Unternehmer Verpflegungsmehraufwand', 'aufwand', 'ba_reise', 'werbe_reisekosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Sonstige betriebliche Aufwendungen ---------------------------------
|
||||
k('4910', 'Porto', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4920', 'Telefon', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4930', 'Bürobedarf', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4940', 'Zeitschriften, Bücher, digitale Medien', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 700, 'regel'),
|
||||
k('4945', 'Fortbildungskosten', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4950', 'Rechts- und Beratungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4955', 'Buchführungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('4805', 'Reparaturen und Instandhaltungen von anderen Anlagen und Betriebs- und Geschäftsausstattung', 'aufwand', 'ba_sonstige', 'reparatur_instandhaltung', 1900, 'regel'),
|
||||
k('4970', 'Nebenkosten des Geldverkehrs', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4980', 'Sonstiger Betriebsbedarf', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('2100', 'Zinsen und ähnliche Aufwendungen', 'aufwand', 'ba_zinsen', 'zinsaufwand', 0, 'steuerfrei_ohne_vst'),
|
||||
];
|
||||
|
||||
function k(
|
||||
nummer: string,
|
||||
bezeichnung: string,
|
||||
typ: Konto['typ'],
|
||||
euer: string | null,
|
||||
bwa: string | null,
|
||||
satz: number,
|
||||
behandlung: Konto['ust_behandlung'],
|
||||
ustvaKz: string | null = null,
|
||||
): Konto {
|
||||
return {
|
||||
nummer,
|
||||
bezeichnung,
|
||||
typ,
|
||||
euer_zeile: euer,
|
||||
bwa_gruppe: bwa,
|
||||
ustva_kz: ustvaKz,
|
||||
ust_satz_bp: satz,
|
||||
ust_behandlung: behandlung,
|
||||
aktiv: 1,
|
||||
sortierung: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Konto } from '../domain/typen.js';
|
||||
|
||||
/**
|
||||
* Praxisnaher Auszug aus dem SKR04 für Einnahmen-Überschuss-Rechner.
|
||||
* Bewusst kompakt gehalten: Konten lassen sich in der Oberfläche ergänzen,
|
||||
* umbenennen und deaktivieren. Die Spalten `euer_zeile` und `bwa_gruppe`
|
||||
* steuern, wo ein Konto in Anlage EÜR bzw. BWA einfließt.
|
||||
*/
|
||||
export const SKR04: Konto[] = [
|
||||
// --- Finanz- und Bestandskonten (Gegenkonten) ---------------------------
|
||||
k('1600', 'Kasse', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1800', 'Bank', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1810', 'PayPal / Zahlungsdienstleister', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1360', 'Geldtransit', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('1406', 'Abziehbare Vorsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('1401', 'Abziehbare Vorsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('3806', 'Umsatzsteuer 19 %', 'bestand', null, null, 1900, 'regel'),
|
||||
k('3801', 'Umsatzsteuer 7 %', 'bestand', null, null, 700, 'regel'),
|
||||
k('3820', 'Umsatzsteuer-Vorauszahlungen', 'bestand', null, null, 0, 'nicht_steuerbar'),
|
||||
k('2100', 'Privatentnahmen allgemein', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
k('2180', 'Privateinlagen', 'privat', null, null, 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Anlagevermögen -----------------------------------------------------
|
||||
k('0135', 'Software (immateriell)', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0520', 'Pkw', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0650', 'Betriebs- und Geschäftsausstattung', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0670', 'EDV-Hardware', 'anlage', null, null, 1900, 'regel'),
|
||||
k('0675', 'Geringwertige Wirtschaftsgüter (GWG)', 'anlage', 'ba_gwg', 'abschreibungen', 1900, 'regel'),
|
||||
k('0680', 'Sammelposten bewegliche Anlagegüter', 'anlage', 'ba_sammelposten', 'abschreibungen', 1900, 'regel'),
|
||||
|
||||
// --- Erlöse -------------------------------------------------------------
|
||||
k('4200', 'Erlöse (Kleinunternehmer, §19 UStG)', 'erloes', 'be_ku', 'umsatzerloese', 0, 'kleinunternehmer'),
|
||||
k('4400', 'Erlöse 19 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 1900, 'regel'),
|
||||
k('4300', 'Erlöse 7 % USt', 'erloes', 'be_pflichtig', 'umsatzerloese', 700, 'regel'),
|
||||
k('4125', 'Steuerfreie innergemeinschaftliche Lieferungen', 'erloes', 'be_frei', 'umsatzerloese', 0, 'ig_lieferung'),
|
||||
k('4120', 'Steuerfreie Umsätze §4 Nr. 1a UStG (Ausfuhr)', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_mit_vst'),
|
||||
// Kz 21: nicht steuerbare sonstige Leistungen §18b Satz 1 Nr. 2 UStG (EU-B2B, Reverse Charge beim Kunden)
|
||||
k('4180', 'Im anderen EU-Land steuerpflichtige sonstige Leistung', 'erloes', 'be_frei', 'umsatzerloese', 0, 'nicht_steuerbar', '21'),
|
||||
k('4186', 'Steuerfreie Umsätze ohne Vorsteuerabzug (§4 Nr. 8 ff. UStG)', 'erloes', 'be_frei', 'umsatzerloese', 0, 'steuerfrei_ohne_vst'),
|
||||
k('4830', 'Sonstige betriebliche Erträge', 'erloes', 'be_sonstige', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4845', 'Erträge aus dem Abgang von Anlagevermögen', 'erloes', 'be_anlagenabgang', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4639', 'Private Kfz-Nutzung', 'erloes', 'be_kfz_privat', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4640', 'Sach-, Nutzungs- und Leistungsentnahmen', 'erloes', 'be_entnahmen', 'so_betr_erloese', 1900, 'regel'),
|
||||
k('4970', 'Zinserträge', 'erloes', 'be_sonstige', 'zinsertraege', 0, 'steuerfrei_ohne_vst'),
|
||||
|
||||
// --- Material / Fremdleistungen -----------------------------------------
|
||||
k('5200', 'Wareneingang 19 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('5300', 'Wareneingang 7 % Vorsteuer', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 700, 'regel'),
|
||||
k('5400', 'Innergemeinschaftlicher Erwerb 19 %', 'aufwand', 'ba_waren', 'mat_wareneinkauf', 1900, 'ig_erwerb'),
|
||||
k('5900', 'Fremdleistungen', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'regel'),
|
||||
k('5906', 'Leistungen nach §13b UStG (Reverse Charge)', 'aufwand', 'ba_fremdleistung', 'mat_wareneinkauf', 1900, 'reverse_charge_13b'),
|
||||
|
||||
// --- Personal -----------------------------------------------------------
|
||||
k('6000', 'Löhne und Gehälter', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('6110', 'Gesetzliche soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 0, 'nicht_steuerbar'),
|
||||
k('6130', 'Freiwillige soziale Aufwendungen', 'aufwand', 'ba_personal', 'personalkosten', 1900, 'regel'),
|
||||
|
||||
// --- Abschreibungen -----------------------------------------------------
|
||||
k('6220', 'Abschreibungen auf Sachanlagen', 'aufwand', 'ba_afa_beweglich', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6200', 'Abschreibungen auf immaterielle Vermögensgegenstände', 'aufwand', 'ba_afa_immateriell', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6260', 'Sofortabschreibung GWG', 'aufwand', 'ba_gwg', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6262', 'Auflösung Sammelposten', 'aufwand', 'ba_sammelposten', 'abschreibungen', 0, 'nicht_steuerbar'),
|
||||
k('6895', 'Restbuchwert ausgeschiedener Anlagegüter', 'aufwand', 'ba_restbuchwert', 'so_neutraler_aufwand', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Raum ---------------------------------------------------------------
|
||||
k('6310', 'Miete Geschäftsräume', 'aufwand', 'ba_miete', 'raumkosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6325', 'Heizung, Strom, Wasser', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('6330', 'Reinigung', 'aufwand', 'ba_raum_sonstige', 'raumkosten', 1900, 'regel'),
|
||||
k('6348', 'Häusliches Arbeitszimmer / Tagespauschale', 'aufwand', 'ba_arbeitszimmer', 'raumkosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Versicherungen, Beiträge, Steuern ----------------------------------
|
||||
k('6400', 'Versicherungen', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6420', 'Beiträge (Kammern, Verbände)', 'aufwand', 'ba_versicherung', 'versicherungen_beitraege', 0, 'steuerfrei_ohne_vst'),
|
||||
k('7685', 'Gewerbesteuer', 'aufwand', 'ba_sonstige', 'steuern_eink_ertrag', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Kfz ----------------------------------------------------------------
|
||||
k('6520', 'Kfz-Versicherungen', 'aufwand', 'ba_kfz', 'kfz_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6530', 'Laufende Kfz-Betriebskosten', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('6540', 'Kfz-Reparaturen', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('6570', 'Kfz-Leasing', 'aufwand', 'ba_kfz', 'kfz_kosten', 1900, 'regel'),
|
||||
k('6650', 'Reisekosten Unternehmer', 'aufwand', 'ba_reise', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('6663', 'Reisekosten Unternehmer – Verpflegungspauschale', 'aufwand', 'ba_reise', 'werbe_reisekosten', 0, 'nicht_steuerbar'),
|
||||
|
||||
// --- Werbung / Repräsentation -------------------------------------------
|
||||
k('6600', 'Werbekosten', 'aufwand', 'ba_werbung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('6640', 'Bewirtungsaufwendungen', 'aufwand', 'ba_bewirtung', 'werbe_reisekosten', 1900, 'regel'),
|
||||
k('6610', 'Geschenke an Geschäftsfreunde bis 50 EUR', 'aufwand', 'ba_geschenke', 'werbe_reisekosten', 1900, 'regel'),
|
||||
|
||||
// --- Sonstige betriebliche Aufwendungen ---------------------------------
|
||||
k('6800', 'Porto', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6805', 'Telefon und Internet', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6815', 'Bürobedarf', 'aufwand', 'ba_buero', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6820', 'Zeitschriften, Bücher, Fachliteratur', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 700, 'regel'),
|
||||
k('6821', 'Fortbildungskosten', 'aufwand', 'ba_fortbildung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6825', 'Rechts- und Beratungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6827', 'Buchführungskosten', 'aufwand', 'ba_beratung', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6830', 'Mieten für Einrichtungen / Software-Abos', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6845', 'Werkzeuge und Kleingeräte', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6855', 'Nebenkosten des Geldverkehrs', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 0, 'steuerfrei_ohne_vst'),
|
||||
k('6300', 'Sonstige betriebliche Aufwendungen', 'aufwand', 'ba_sonstige', 'sonstige_kosten', 1900, 'regel'),
|
||||
k('6470', 'Reparatur und Instandhaltung', 'aufwand', 'ba_sonstige', 'reparatur_instandhaltung', 1900, 'regel'),
|
||||
k('7310', 'Zinsaufwendungen', 'aufwand', 'ba_zinsen', 'zinsaufwand', 0, 'steuerfrei_ohne_vst'),
|
||||
];
|
||||
|
||||
function k(
|
||||
nummer: string,
|
||||
bezeichnung: string,
|
||||
typ: Konto['typ'],
|
||||
euer: string | null,
|
||||
bwa: string | null,
|
||||
satz: number,
|
||||
behandlung: Konto['ust_behandlung'],
|
||||
ustvaKz: string | null = null,
|
||||
): Konto {
|
||||
return {
|
||||
nummer,
|
||||
bezeichnung,
|
||||
typ,
|
||||
euer_zeile: euer,
|
||||
bwa_gruppe: bwa,
|
||||
ustva_kz: ustvaKz,
|
||||
ust_satz_bp: satz,
|
||||
ust_behandlung: behandlung,
|
||||
aktiv: 1,
|
||||
sortierung: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { datenVerzeichnis } from './pfade.js';
|
||||
import {
|
||||
ausProjektwurzel,
|
||||
oeffneDatenbank,
|
||||
setzeStandardKontext,
|
||||
type MandantKontext,
|
||||
} from './db.js';
|
||||
import { istKontenrahmen, kontenFuer, type KontenrahmenId } from './kontenrahmen/index.js';
|
||||
|
||||
/**
|
||||
* Register aller Mandanten. Es liegt in einer eigenen kleinen Datenbank und
|
||||
* enthält bewusst keine Buchhaltungsdaten – nur, welche Mandantendateien es
|
||||
* gibt. Die Nutzdaten stehen ausschließlich in den Mandantendateien selbst.
|
||||
*/
|
||||
|
||||
export interface MandantEintrag {
|
||||
slug: string;
|
||||
name: string;
|
||||
testmandant: number;
|
||||
datei: string;
|
||||
angelegt_am: string;
|
||||
}
|
||||
|
||||
const REGISTER_DATEI = resolve(datenVerzeichnis, 'register.sqlite');
|
||||
const MANDANTEN_ORDNER = resolve(datenVerzeichnis, 'mandanten');
|
||||
/** Datenbank aus der Zeit vor der Mandantenfähigkeit. */
|
||||
const ALTBESTAND = resolve(datenVerzeichnis, 'euer.sqlite');
|
||||
|
||||
let register: Database.Database | null = null;
|
||||
const verbindungen = new Map<string, Database.Database>();
|
||||
|
||||
function reg(): Database.Database {
|
||||
if (register) return register;
|
||||
mkdirSync(datenVerzeichnis, { recursive: true });
|
||||
register = new Database(REGISTER_DATEI);
|
||||
register.pragma('journal_mode = WAL');
|
||||
register.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mandantenregister (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
testmandant INTEGER NOT NULL DEFAULT 0,
|
||||
datei TEXT NOT NULL,
|
||||
angelegt_am TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
return register;
|
||||
}
|
||||
|
||||
export class MandantFehler extends Error {}
|
||||
|
||||
/** Aus einem Namen einen dateisystemtauglichen, stabilen Bezeichner bilden. */
|
||||
export function slugAus(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
if (!s) throw new MandantFehler('Aus dem Namen lässt sich kein Bezeichner bilden.');
|
||||
return s;
|
||||
}
|
||||
|
||||
export function alleMandanten(): MandantEintrag[] {
|
||||
return reg()
|
||||
.prepare('SELECT * FROM mandantenregister ORDER BY testmandant, name')
|
||||
.all() as MandantEintrag[];
|
||||
}
|
||||
|
||||
export function findeMandant(slug: string): MandantEintrag | undefined {
|
||||
return reg().prepare('SELECT * FROM mandantenregister WHERE slug = ?').get(slug) as
|
||||
| MandantEintrag
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function legeMandantAn(opt: {
|
||||
name: string;
|
||||
slug?: string;
|
||||
testmandant?: boolean;
|
||||
/** SKR03 oder SKR04 – nach dem Anlegen nicht mehr änderbar */
|
||||
kontenrahmen?: KontenrahmenId;
|
||||
/** false unterdrückt das Einspielen von Konten (nur für Sonderfälle) */
|
||||
ohneKonten?: boolean;
|
||||
}): MandantEintrag {
|
||||
const slug = opt.slug ? slugAus(opt.slug) : slugAus(opt.name);
|
||||
if (findeMandant(slug)) {
|
||||
throw new MandantFehler(`Ein Mandant mit dem Bezeichner "${slug}" ist bereits angelegt.`);
|
||||
}
|
||||
|
||||
const datei = `data/mandanten/${slug}.sqlite`;
|
||||
if (existsSync(ausProjektwurzel(datei))) {
|
||||
throw new MandantFehler(
|
||||
`Die Datei ${datei} existiert bereits, ist aber nicht registriert. ` +
|
||||
'Bitte prüfen und gegebenenfalls von Hand entfernen oder umbenennen.',
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(MANDANTEN_ORDNER, { recursive: true });
|
||||
const verbindung = oeffneDatenbank(datei);
|
||||
if (!opt.ohneKonten) kontenrahmenAnlegen(verbindung, opt.kontenrahmen ?? 'SKR04');
|
||||
verbindung.prepare('UPDATE mandant SET name = ? WHERE id = 1').run(opt.name);
|
||||
verbindungen.set(slug, verbindung);
|
||||
|
||||
reg()
|
||||
.prepare('INSERT INTO mandantenregister (slug, name, testmandant, datei) VALUES (?,?,?,?)')
|
||||
.run(slug, opt.name, opt.testmandant ? 1 : 0, datei);
|
||||
|
||||
return findeMandant(slug)!;
|
||||
}
|
||||
|
||||
export function benenneMandantUm(slug: string, name: string): MandantEintrag {
|
||||
const eintrag = findeMandant(slug);
|
||||
if (!eintrag) throw new MandantFehler(`Mandant "${slug}" ist nicht bekannt.`);
|
||||
reg().prepare('UPDATE mandantenregister SET name = ? WHERE slug = ?').run(name, slug);
|
||||
return findeMandant(slug)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Mandanten aus dem Register. Die Datei wird nicht gelöscht,
|
||||
* sondern mit Zeitstempel beiseitegelegt – eine versehentlich entfernte
|
||||
* Buchhaltung wäre sonst unwiederbringlich weg.
|
||||
*/
|
||||
export function entferneMandant(slug: string, zeitstempel: string): { abgelegtAls: string } {
|
||||
const eintrag = findeMandant(slug);
|
||||
if (!eintrag) throw new MandantFehler(`Mandant "${slug}" ist nicht bekannt.`);
|
||||
|
||||
verbindungen.get(slug)?.close();
|
||||
verbindungen.delete(slug);
|
||||
|
||||
const quelle = ausProjektwurzel(eintrag.datei);
|
||||
const ziel = `${quelle}.entfernt-${zeitstempel}`;
|
||||
if (existsSync(quelle)) renameSync(quelle, ziel);
|
||||
// WAL- und Shared-Memory-Dateien mitnehmen, sonst bleiben Reste liegen.
|
||||
for (const endung of ['-wal', '-shm']) {
|
||||
if (existsSync(quelle + endung)) renameSync(quelle + endung, ziel + endung);
|
||||
}
|
||||
|
||||
reg().prepare('DELETE FROM mandantenregister WHERE slug = ?').run(slug);
|
||||
return { abgelegtAls: ziel };
|
||||
}
|
||||
|
||||
/** Offene Verbindung zum Mandanten; wird zwischengespeichert. */
|
||||
export function kontextFuer(eintrag: MandantEintrag): MandantKontext {
|
||||
let verbindung = verbindungen.get(eintrag.slug);
|
||||
if (!verbindung || !verbindung.open) {
|
||||
verbindung = oeffneDatenbank(eintrag.datei);
|
||||
verbindungen.set(eintrag.slug, verbindung);
|
||||
}
|
||||
return {
|
||||
slug: eintrag.slug,
|
||||
name: eintrag.name,
|
||||
testmandant: eintrag.testmandant === 1,
|
||||
verbindung,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt den Kontenrahmen ein, sofern die Datei noch keine Konten hat.
|
||||
* Welcher Rahmen gilt, steht in den Stammdaten des Mandanten – so bleiben
|
||||
* Kontonummern und die Angabe im DATEV-Export zwingend deckungsgleich.
|
||||
*/
|
||||
export function kontenrahmenAnlegen(
|
||||
verbindung: Database.Database,
|
||||
rahmen?: KontenrahmenId,
|
||||
): number {
|
||||
const anzahl = (verbindung.prepare('SELECT COUNT(*) AS n FROM konten').get() as { n: number }).n;
|
||||
if (anzahl > 0) return 0;
|
||||
|
||||
const gespeichert = (
|
||||
verbindung.prepare('SELECT kontenrahmen FROM mandant WHERE id = 1').get() as
|
||||
| { kontenrahmen: string }
|
||||
| undefined
|
||||
)?.kontenrahmen;
|
||||
const id: KontenrahmenId =
|
||||
rahmen ?? (gespeichert && istKontenrahmen(gespeichert) ? gespeichert : 'SKR04');
|
||||
|
||||
const konten = kontenFuer(id);
|
||||
const einfuegen = verbindung.prepare(
|
||||
`INSERT INTO konten (nummer, bezeichnung, typ, euer_zeile, bwa_gruppe, ustva_kz,
|
||||
ust_satz_bp, ust_behandlung, aktiv, sortierung)
|
||||
VALUES (@nummer, @bezeichnung, @typ, @euer_zeile, @bwa_gruppe, @ustva_kz,
|
||||
@ust_satz_bp, @ust_behandlung, @aktiv, @sortierung)`,
|
||||
);
|
||||
verbindung.transaction(() => konten.forEach((k) => einfuegen.run(k)))();
|
||||
verbindung.prepare('UPDATE mandant SET kontenrahmen = ? WHERE id = 1').run(id);
|
||||
return konten.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim ersten Start nach der Umstellung: eine vorhandene data/euer.sqlite wird
|
||||
* als Mandant übernommen. Die Datei bleibt liegen, wo sie ist – kopiert oder
|
||||
* verschoben wird nichts, damit im Fehlerfall nichts verloren geht.
|
||||
*/
|
||||
export function registerVorbereiten(): { uebernommen: boolean; angelegt: boolean } {
|
||||
const vorhanden = alleMandanten();
|
||||
if (vorhanden.length > 0) return { uebernommen: false, angelegt: false };
|
||||
|
||||
if (existsSync(ALTBESTAND)) {
|
||||
reg()
|
||||
.prepare('INSERT INTO mandantenregister (slug, name, testmandant, datei) VALUES (?,?,?,?)')
|
||||
.run('standard', nameAusAltbestand(), 0, 'data/euer.sqlite');
|
||||
return { uebernommen: true, angelegt: false };
|
||||
}
|
||||
|
||||
legeMandantAn({ name: 'Mein Unternehmen' });
|
||||
return { uebernommen: false, angelegt: true };
|
||||
}
|
||||
|
||||
function nameAusAltbestand(): string {
|
||||
try {
|
||||
const alt = new Database(ALTBESTAND, { readonly: true });
|
||||
const zeile = alt.prepare('SELECT name FROM mandant WHERE id = 1').get() as
|
||||
| { name: string }
|
||||
| undefined;
|
||||
alt.close();
|
||||
return zeile?.name?.trim() || 'Bestehende Buchhaltung';
|
||||
} catch {
|
||||
return 'Bestehende Buchhaltung';
|
||||
}
|
||||
}
|
||||
|
||||
/** Standardmandant für Aufrufe ohne ausdrückliche Angabe. */
|
||||
export function standardMandant(): MandantEintrag {
|
||||
const bevorzugt = process.env['EUER_MANDANT'];
|
||||
if (bevorzugt) {
|
||||
const eintrag = findeMandant(bevorzugt);
|
||||
if (eintrag) return eintrag;
|
||||
throw new MandantFehler(
|
||||
`EUER_MANDANT verweist auf "${bevorzugt}", ein solcher Mandant ist nicht registriert.`,
|
||||
);
|
||||
}
|
||||
const echte = alleMandanten().filter((m) => m.testmandant === 0);
|
||||
const eintrag = echte[0] ?? alleMandanten()[0];
|
||||
if (!eintrag) throw new MandantFehler('Es ist kein Mandant angelegt.');
|
||||
return eintrag;
|
||||
}
|
||||
|
||||
/** Für Skripte: einen Mandanten dauerhaft als Kontext setzen. */
|
||||
export function waehleMandantFuerSkript(slug?: string): MandantKontext {
|
||||
registerVorbereiten();
|
||||
const eintrag = slug
|
||||
? (findeMandant(slug) ?? (() => { throw new MandantFehler(`Mandant "${slug}" ist nicht bekannt.`); })())
|
||||
: standardMandant();
|
||||
const kontext = kontextFuer(eintrag);
|
||||
setzeStandardKontext(kontext);
|
||||
return kontext;
|
||||
}
|
||||
|
||||
export function schliesseAlleVerbindungen(): void {
|
||||
for (const v of verbindungen.values()) {
|
||||
try {
|
||||
v.close();
|
||||
} catch {
|
||||
/* beim Herunterfahren ignorieren */
|
||||
}
|
||||
}
|
||||
verbindungen.clear();
|
||||
register?.close();
|
||||
register = null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Die Projektwurzel wird aus dem Ort dieser Datei abgeleitet, nicht aus
|
||||
* process.cwd(). Damit zeigen relative Pfade aus der .env immer auf dieselbe
|
||||
* Stelle – gleich, ob der Prozess aus dem Wurzelverzeichnis, aus server/ oder
|
||||
* aus einem Editor heraus gestartet wurde.
|
||||
*
|
||||
* Quelltext liegt unter <wurzel>/server/src, der Build unter <wurzel>/server/dist.
|
||||
*/
|
||||
const hier = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const projektWurzel = resolve(hier, '../..');
|
||||
|
||||
/** Ablage für Datenbank, ERiC-Protokolle und erzeugte ELSTER-Dateien. */
|
||||
export const datenVerzeichnis = resolve(projektWurzel, 'data');
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db } from '../db.js';
|
||||
import { berechneEuer } from '../domain/euer.js';
|
||||
import { berechneBwa, BWA_AUFBAU } from '../domain/bwa.js';
|
||||
import { berechneUstva, moeglicheZeitraeume, zeitraum } from '../domain/ustva.js';
|
||||
import { anlagenverzeichnis } from '../domain/afa.js';
|
||||
import { grenzenStatus, regimeAm } from '../domain/regime.js';
|
||||
import { EUER_POSTEN } from '../domain/euerZeilen.js';
|
||||
import type { Mandant } from '../domain/typen.js';
|
||||
|
||||
export async function auswertungsRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/euer/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
return berechneEuer(Number(jahr));
|
||||
});
|
||||
|
||||
app.get('/api/euer/:jahr/anlagen', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
return anlagenverzeichnis(Number(jahr), regimeAm);
|
||||
});
|
||||
|
||||
app.get('/api/euer/posten', async () => EUER_POSTEN);
|
||||
|
||||
app.get('/api/bwa/:jahr/:monat', async (req) => {
|
||||
const { jahr, monat } = req.params as { jahr: string; monat: string };
|
||||
const { basis } = req.query as { basis?: 'zahlung' | 'leistung' };
|
||||
return berechneBwa(Number(jahr), Number(monat), basis ?? 'zahlung');
|
||||
});
|
||||
|
||||
app.get('/api/bwa/aufbau', async () => BWA_AUFBAU);
|
||||
|
||||
app.get('/api/ustva/:jahr/:zeitraum', async (req) => {
|
||||
const { jahr, zeitraum: z } = req.params as { jahr: string; zeitraum: string };
|
||||
const ergebnis = berechneUstva(Number(jahr), z);
|
||||
const gemeldet = db
|
||||
.prepare('SELECT id, status, transferticket, uebermittelt_am, berichtigung FROM ustva_meldungen WHERE jahr = ? AND zeitraum = ? ORDER BY berichtigung DESC')
|
||||
.all(Number(jahr), z);
|
||||
return { ...ergebnis, meldungen: gemeldet };
|
||||
});
|
||||
|
||||
app.get('/api/ustva/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
const codes = moeglicheZeitraeume(m);
|
||||
return codes.map((c) => {
|
||||
const e = berechneUstva(Number(jahr), c);
|
||||
return {
|
||||
code: c,
|
||||
bezeichnung: zeitraum(Number(jahr), c).bezeichnung,
|
||||
zahllast: e.zahllast,
|
||||
abgabepflichtig: e.abgabepflichtig,
|
||||
umsatzsteuer: e.umsatzsteuer,
|
||||
vorsteuer: e.vorsteuer,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Kennzahlen für die Startseite */
|
||||
app.get('/api/dashboard/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const j = Number(jahr);
|
||||
const euer = berechneEuer(j);
|
||||
const grenzen = grenzenStatus(j);
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
|
||||
const monatlich = db
|
||||
.prepare(
|
||||
`SELECT substr(zahlungsdatum, 1, 7) AS monat, art,
|
||||
COALESCE(SUM(CASE WHEN vorsteuer_abziehbar = 1 THEN betrag_netto ELSE betrag_brutto END), 0) AS summe
|
||||
FROM buchungen
|
||||
WHERE storniert = 0 AND zahlungsdatum BETWEEN ? AND ?
|
||||
GROUP BY monat, art ORDER BY monat`,
|
||||
)
|
||||
.all(`${j}-01-01`, `${j}-12-31`) as { monat: string; art: string; summe: number }[];
|
||||
|
||||
const verlauf = Array.from({ length: 12 }, (_, i) => {
|
||||
const monat = `${j}-${String(i + 1).padStart(2, '0')}`;
|
||||
return {
|
||||
monat,
|
||||
einnahmen: monatlich.find((r) => r.monat === monat && r.art === 'einnahme')?.summe ?? 0,
|
||||
ausgaben: monatlich.find((r) => r.monat === monat && r.art === 'ausgabe')?.summe ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const offen = db
|
||||
.prepare(
|
||||
`SELECT art, COUNT(*) AS anzahl, COALESCE(SUM(betrag_brutto),0) AS summe
|
||||
FROM buchungen WHERE storniert = 0 AND zahlungsdatum IS NULL GROUP BY art`,
|
||||
)
|
||||
.all() as { art: string; anzahl: number; summe: number }[];
|
||||
|
||||
return {
|
||||
jahr: j,
|
||||
regime: regimeAm(new Date().toISOString().slice(0, 10)),
|
||||
besteuerungsart: m.besteuerungsart,
|
||||
voranmeldungszeitraum: m.voranmeldungszeitraum,
|
||||
einnahmen: euer.summeEinnahmen,
|
||||
ausgaben: euer.summeAusgaben,
|
||||
gewinn: euer.gewinn,
|
||||
verlauf,
|
||||
offen,
|
||||
grenzen,
|
||||
hinweise: euer.hinweise,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db, protokolliere } from '../db.js';
|
||||
import { berechneUst, regimeAm } from '../domain/regime.js';
|
||||
import { afaFuer, methodeVorschlag } from '../domain/afa.js';
|
||||
import { standardGegenkonto } from '../kontenrahmen/index.js';
|
||||
import type { Anlagegut, Buchung, Konto, Mandant, UstBehandlung } from '../domain/typen.js';
|
||||
|
||||
/** Bankkonto des aktiven Kontenrahmens – 1800 ist NICHT rahmenübergreifend die Bank. */
|
||||
function vorbelegtesGegenkonto(): string {
|
||||
const m = db.prepare('SELECT kontenrahmen FROM mandant WHERE id = 1').get() as
|
||||
| Pick<Mandant, 'kontenrahmen'>
|
||||
| undefined;
|
||||
return standardGegenkonto(m?.kontenrahmen ?? 'SKR04');
|
||||
}
|
||||
|
||||
interface BuchungEingabe {
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
belegdatum: string;
|
||||
leistungsdatum?: string;
|
||||
zahlungsdatum?: string | null;
|
||||
brutto?: number;
|
||||
netto?: number;
|
||||
ust_satz_bp?: number;
|
||||
ust_behandlung?: UstBehandlung;
|
||||
konto: string;
|
||||
gegenkonto?: string;
|
||||
beleg_nr?: string;
|
||||
buchungstext?: string;
|
||||
partner?: string;
|
||||
privatanteil_bp?: number;
|
||||
anlagegut_id?: number | null;
|
||||
/** Anlagegut direkt aus der Buchung anlegen */
|
||||
anlage?: { bezeichnung: string; nutzungsdauer: number; afa_methode?: Anlagegut['afa_methode'] };
|
||||
}
|
||||
|
||||
export class AnlageFehler extends Error {}
|
||||
|
||||
function normalisiere(e: BuchungEingabe) {
|
||||
const konto = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(e.konto) as Konto | undefined;
|
||||
if (!konto) throw new Error(`Konto ${e.konto} ist nicht bekannt.`);
|
||||
|
||||
const leistungsdatum = e.leistungsdatum || e.belegdatum;
|
||||
const behandlung = e.ust_behandlung ?? konto.ust_behandlung;
|
||||
const satz = e.ust_satz_bp ?? konto.ust_satz_bp;
|
||||
|
||||
const ust = berechneUst({
|
||||
art: e.art,
|
||||
leistungsdatum,
|
||||
brutto: e.brutto,
|
||||
netto: e.netto,
|
||||
satzBp: satz,
|
||||
behandlung,
|
||||
});
|
||||
|
||||
return { konto, leistungsdatum, ust };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bemessungsgrundlage der Abschreibung: netto bei Vorsteuerabzug, sonst brutto –
|
||||
* ohne Vorsteuerabzug gehört die Umsatzsteuer zu den Anschaffungskosten.
|
||||
*/
|
||||
function bemessungsgrundlage(ust: { netto: number; brutto: number; vorsteuerAbziehbar: boolean }): number {
|
||||
return ust.vorsteuerAbziehbar ? ust.netto : ust.brutto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob eine Ausgabe ins Anlagevermögen gehört, sagt das Konto (Kontotyp 'anlage') –
|
||||
* nicht der Betrag. Ein abnutzbares Wirtschaftsgut bleibt eines, auch wenn es
|
||||
* billig ist, und eine teure Werbekampagne wird dadurch keines. Der Betrag
|
||||
* entscheidet erst über die Abschreibungsmethode.
|
||||
*/
|
||||
export function istAnlagekonto(konto: Konto): boolean {
|
||||
return konto.typ === 'anlage';
|
||||
}
|
||||
|
||||
interface AnlagePlan {
|
||||
bezeichnung: string;
|
||||
nutzungsdauer: number;
|
||||
methode: Anlagegut['afa_methode'];
|
||||
bemessung: number;
|
||||
/** true, wenn die Nutzungsdauer für die gewählte Methode zwingend ist */
|
||||
nutzungsdauerNoetig: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt, wie das Anlagegut abgeschrieben würde. Wirft, wenn für die lineare
|
||||
* AfA die Nutzungsdauer fehlt – sonst würde die Buchung stillschweigend mit
|
||||
* einer geratenen Nutzungsdauer im Anlagenverzeichnis landen.
|
||||
*/
|
||||
function planeAnlage(
|
||||
e: BuchungEingabe,
|
||||
konto: Konto,
|
||||
ust: { netto: number; brutto: number; vorsteuerAbziehbar: boolean },
|
||||
): AnlagePlan {
|
||||
const bemessung = bemessungsgrundlage(ust);
|
||||
const methode = e.anlage?.afa_methode ?? methodeVorschlag(bemessung);
|
||||
const nutzungsdauer = Number(e.anlage?.nutzungsdauer ?? 0);
|
||||
const nutzungsdauerNoetig = methode === 'linear';
|
||||
|
||||
if (nutzungsdauerNoetig && !(nutzungsdauer > 0)) {
|
||||
throw new AnlageFehler(
|
||||
`Konto ${konto.nummer} (${konto.bezeichnung}) ist ein Anlagekonto. Bei Anschaffungskosten von ` +
|
||||
`${(bemessung / 100).toLocaleString('de-DE', { minimumFractionDigits: 2 })} EUR ist linear ` +
|
||||
'über die Nutzungsdauer abzuschreiben – bitte die betriebsgewöhnliche Nutzungsdauer in Jahren ' +
|
||||
'angeben (Feld "anlage.nutzungsdauer"). Für einen Sofortabzug ist ein Aufwandskonto zu wählen.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
bezeichnung: e.anlage?.bezeichnung || e.buchungstext || konto.bezeichnung,
|
||||
// GWG und Sammelposten haben eine feste Abschreibungsdauer; der Wert ist dort ohne Bedeutung.
|
||||
nutzungsdauer: nutzungsdauer > 0 ? nutzungsdauer : methode === 'sammelposten' ? 5 : 1,
|
||||
methode,
|
||||
bemessung,
|
||||
nutzungsdauerNoetig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buchungsRouten(app: FastifyInstance): Promise<void> {
|
||||
/** Live-Vorschau für das Erfassungsformular – rechnet, ohne zu speichern. */
|
||||
app.post('/api/buchungen/vorschau', async (req, reply) => {
|
||||
try {
|
||||
const e = req.body as BuchungEingabe;
|
||||
const { konto, leistungsdatum, ust } = normalisiere(e);
|
||||
|
||||
// Die Vorschau zeigt die Aktivierung an, verlangt aber noch keine
|
||||
// Nutzungsdauer – sonst blinkt beim Tippen dauernd eine Fehlermeldung auf.
|
||||
const aktivierung =
|
||||
e.art === 'ausgabe' && (istAnlagekonto(konto) || !!e.anlage)
|
||||
? (() => {
|
||||
const bemessung = bemessungsgrundlage(ust);
|
||||
const methode = e.anlage?.afa_methode ?? methodeVorschlag(bemessung);
|
||||
return {
|
||||
erforderlich: istAnlagekonto(konto),
|
||||
methode,
|
||||
bemessung,
|
||||
nutzungsdauerNoetig: methode === 'linear',
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
return {
|
||||
regime: regimeAm(leistungsdatum),
|
||||
konto: { nummer: konto.nummer, bezeichnung: konto.bezeichnung, typ: konto.typ },
|
||||
brutto: ust.brutto,
|
||||
netto: ust.netto,
|
||||
ust: ust.ust,
|
||||
ust_satz_bp: ust.satzBp,
|
||||
ust_behandlung: ust.behandlung,
|
||||
vorsteuer_abziehbar: ust.vorsteuerAbziehbar,
|
||||
aktivierung,
|
||||
hinweise: ust.hinweise,
|
||||
};
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ fehler: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/buchungen', async (req) => {
|
||||
const qy = req.query as {
|
||||
jahr?: string; von?: string; bis?: string; art?: string;
|
||||
konto?: string; suche?: string; nurOffen?: string; limit?: string;
|
||||
};
|
||||
const bedingungen: string[] = ['1=1'];
|
||||
const werte: (string | number)[] = [];
|
||||
|
||||
if (qy.jahr) {
|
||||
bedingungen.push("COALESCE(zahlungsdatum, leistungsdatum) BETWEEN ? AND ?");
|
||||
werte.push(`${qy.jahr}-01-01`, `${qy.jahr}-12-31`);
|
||||
}
|
||||
if (qy.von) { bedingungen.push('COALESCE(zahlungsdatum, leistungsdatum) >= ?'); werte.push(qy.von); }
|
||||
if (qy.bis) { bedingungen.push('COALESCE(zahlungsdatum, leistungsdatum) <= ?'); werte.push(qy.bis); }
|
||||
if (qy.art) { bedingungen.push('art = ?'); werte.push(qy.art); }
|
||||
if (qy.konto) { bedingungen.push('konto = ?'); werte.push(qy.konto); }
|
||||
if (qy.nurOffen === '1') bedingungen.push('zahlungsdatum IS NULL');
|
||||
if (qy.suche) {
|
||||
bedingungen.push('(buchungstext LIKE ? OR partner LIKE ? OR beleg_nr LIKE ?)');
|
||||
const s = `%${qy.suche}%`;
|
||||
werte.push(s, s, s);
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(qy.limit ?? 500), 5000);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM buchungen WHERE ${bedingungen.join(' AND ')}
|
||||
ORDER BY COALESCE(zahlungsdatum, leistungsdatum) DESC, id DESC LIMIT ?`,
|
||||
)
|
||||
.all(...werte, limit) as Buchung[];
|
||||
});
|
||||
|
||||
app.post('/api/buchungen', async (req, reply) => {
|
||||
try {
|
||||
const e = req.body as BuchungEingabe;
|
||||
const { konto, leistungsdatum, ust } = normalisiere(e);
|
||||
|
||||
let anlagegutId = e.anlagegut_id ?? null;
|
||||
// Ein Anlagekonto erzwingt die Aktivierung, auch ohne ausdrückliche Angabe –
|
||||
// sonst würde die Anschaffung stillschweigend voll als Betriebsausgabe abgezogen.
|
||||
const alsAnlage = e.art === 'ausgabe' && !anlagegutId && (!!e.anlage || istAnlagekonto(konto));
|
||||
|
||||
if (alsAnlage) {
|
||||
const plan = planeAnlage(e, konto, ust);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO anlagegueter
|
||||
(bezeichnung, anschaffungsdatum, akosten_netto, akosten_brutto,
|
||||
nutzungsdauer, afa_methode, konto)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
plan.bezeichnung, leistungsdatum, ust.netto, ust.brutto,
|
||||
plan.nutzungsdauer, plan.methode, konto.nummer,
|
||||
);
|
||||
anlagegutId = Number(info.lastInsertRowid);
|
||||
ust.hinweise.push(
|
||||
plan.methode === 'gwg_sofort'
|
||||
? 'Als geringwertiges Wirtschaftsgut erfasst und im Anschaffungsjahr voll abgesetzt (§6 Abs. 2 EStG).'
|
||||
: plan.methode === 'sammelposten'
|
||||
? 'In den Sammelposten eingestellt und über fünf Jahre aufgelöst (§6 Abs. 2a EStG).'
|
||||
: `Im Anlagevermögen aktiviert und linear über ${plan.nutzungsdauer} Jahre abgeschrieben.`,
|
||||
);
|
||||
}
|
||||
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO buchungen
|
||||
(art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto, betrag_netto,
|
||||
betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto,
|
||||
beleg_nr, buchungstext, partner, privatanteil_bp, anlagegut_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
e.art, e.belegdatum, leistungsdatum, e.zahlungsdatum || null,
|
||||
ust.brutto, ust.netto, ust.ust, ust.satzBp, ust.behandlung,
|
||||
ust.vorsteuerAbziehbar ? 1 : 0, konto.nummer,
|
||||
e.gegenkonto || vorbelegtesGegenkonto(), e.beleg_nr ?? '', e.buchungstext ?? '',
|
||||
e.partner ?? '', e.privatanteil_bp ?? 0, anlagegutId,
|
||||
);
|
||||
|
||||
const gespeichert = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(info.lastInsertRowid);
|
||||
protokolliere('buchungen', info.lastInsertRowid, 'angelegt', null, gespeichert);
|
||||
return { ...(gespeichert as Buchung), hinweise: ust.hinweise };
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ fehler: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/buchungen/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const vorher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id) as Buchung | undefined;
|
||||
if (!vorher) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' });
|
||||
if (vorher.festgeschrieben) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
'Die Buchung ist festgeschrieben und darf nach GoBD nicht mehr geändert werden. ' +
|
||||
'Bitte stornieren und neu erfassen.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const e = { ...vorher, ...(req.body as BuchungEingabe) } as BuchungEingabe & Buchung;
|
||||
const { konto, leistungsdatum, ust } = normalisiere({
|
||||
...e,
|
||||
brutto: (req.body as BuchungEingabe).brutto ?? vorher.betrag_brutto,
|
||||
});
|
||||
db.prepare(
|
||||
`UPDATE buchungen SET art=?, belegdatum=?, leistungsdatum=?, zahlungsdatum=?,
|
||||
betrag_brutto=?, betrag_netto=?, betrag_ust=?, ust_satz_bp=?, ust_behandlung=?,
|
||||
vorsteuer_abziehbar=?, konto=?, gegenkonto=?, beleg_nr=?, buchungstext=?, partner=?,
|
||||
privatanteil_bp=?, geaendert_am=datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
e.art, e.belegdatum, leistungsdatum, e.zahlungsdatum || null,
|
||||
ust.brutto, ust.netto, ust.ust, ust.satzBp, ust.behandlung,
|
||||
ust.vorsteuerAbziehbar ? 1 : 0, konto.nummer, e.gegenkonto || vorbelegtesGegenkonto(),
|
||||
e.beleg_nr ?? '', e.buchungstext ?? '', e.partner ?? '', e.privatanteil_bp ?? 0, id,
|
||||
);
|
||||
const nachher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id);
|
||||
protokolliere('buchungen', id, 'geaendert', vorher, nachher);
|
||||
return { ...(nachher as Buchung), hinweise: ust.hinweise };
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ fehler: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/** Zahlung nachtragen – erst damit wird die Buchung für die EÜR wirksam. */
|
||||
app.post('/api/buchungen/:id/zahlung', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { zahlungsdatum } = req.body as { zahlungsdatum: string | null };
|
||||
const vorher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id);
|
||||
if (!vorher) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' });
|
||||
db.prepare("UPDATE buchungen SET zahlungsdatum = ?, geaendert_am = datetime('now') WHERE id = ?")
|
||||
.run(zahlungsdatum, id);
|
||||
const nachher = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id);
|
||||
protokolliere('buchungen', id, 'zahlung', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
|
||||
/**
|
||||
* GoBD-konforme Korrektur: statt zu löschen wird eine Gegenbuchung erzeugt
|
||||
* und der ursprüngliche Satz als storniert markiert.
|
||||
*/
|
||||
app.post('/api/buchungen/:id/storno', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const b = db.prepare('SELECT * FROM buchungen WHERE id = ?').get(id) as Buchung | undefined;
|
||||
if (!b) return reply.code(404).send({ fehler: 'Buchung nicht gefunden.' });
|
||||
if (b.storniert) return reply.code(409).send({ fehler: 'Die Buchung ist bereits storniert.' });
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('UPDATE buchungen SET storniert = 1 WHERE id = ?').run(id);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO buchungen
|
||||
(art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto, betrag_netto,
|
||||
betrag_ust, ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto,
|
||||
beleg_nr, buchungstext, partner, privatanteil_bp, anlagegut_id, storniert, storno_von)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?)`,
|
||||
)
|
||||
.run(
|
||||
b.art, b.belegdatum, b.leistungsdatum, b.zahlungsdatum,
|
||||
-b.betrag_brutto, -b.betrag_netto, -b.betrag_ust, b.ust_satz_bp, b.ust_behandlung,
|
||||
b.vorsteuer_abziehbar, b.konto, b.gegenkonto, b.beleg_nr,
|
||||
`Storno zu Buchung ${b.id}: ${b.buchungstext}`, b.partner, b.privatanteil_bp,
|
||||
b.anlagegut_id, b.id,
|
||||
);
|
||||
return info.lastInsertRowid;
|
||||
});
|
||||
const stornoId = tx();
|
||||
protokolliere('buchungen', id, 'storniert', b, { stornoId });
|
||||
return { ok: true, stornoId };
|
||||
});
|
||||
|
||||
app.post('/api/buchungen/festschreiben', async (req) => {
|
||||
const { bis } = req.body as { bis: string };
|
||||
const info = db
|
||||
.prepare(
|
||||
`UPDATE buchungen SET festgeschrieben = 1
|
||||
WHERE festgeschrieben = 0 AND COALESCE(zahlungsdatum, leistungsdatum) <= ?`,
|
||||
)
|
||||
.run(bis);
|
||||
protokolliere('buchungen', 'sammel', 'festgeschrieben', null, { bis, anzahl: info.changes });
|
||||
return { anzahl: info.changes, bis };
|
||||
});
|
||||
|
||||
// ---- Anlagevermögen -----------------------------------------------------
|
||||
app.get('/api/anlagen', async (req) => {
|
||||
const { jahr } = req.query as { jahr?: string };
|
||||
const gueter = db.prepare('SELECT * FROM anlagegueter ORDER BY anschaffungsdatum').all() as Anlagegut[];
|
||||
const j = Number(jahr ?? new Date().getFullYear());
|
||||
return gueter.map((a) => ({
|
||||
...a,
|
||||
afa: afaFuer(a, j, regimeAm(a.anschaffungsdatum) === 'kleinunternehmer'),
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/anlagen', async (req) => {
|
||||
const a = req.body as Anlagegut;
|
||||
const methode = a.afa_methode ?? methodeVorschlag(a.akosten_netto);
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO anlagegueter
|
||||
(bezeichnung, anschaffungsdatum, akosten_netto, akosten_brutto, nutzungsdauer,
|
||||
afa_methode, konto, notiz)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
a.bezeichnung, a.anschaffungsdatum, a.akosten_netto, a.akosten_brutto,
|
||||
a.nutzungsdauer, methode, a.konto ?? null, a.notiz ?? '',
|
||||
);
|
||||
protokolliere('anlagegueter', info.lastInsertRowid, 'angelegt', null, a);
|
||||
return db.prepare('SELECT * FROM anlagegueter WHERE id = ?').get(info.lastInsertRowid);
|
||||
});
|
||||
|
||||
app.put('/api/anlagen/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const a = req.body as Partial<Anlagegut>;
|
||||
const vorher = db.prepare('SELECT * FROM anlagegueter WHERE id = ?').get(id);
|
||||
const felder = (['bezeichnung', 'anschaffungsdatum', 'akosten_netto', 'akosten_brutto',
|
||||
'nutzungsdauer', 'afa_methode', 'konto', 'abgang_datum', 'abgang_erloes', 'notiz'] as const)
|
||||
.filter((f) => f in a);
|
||||
if (felder.length > 0) {
|
||||
db.prepare(`UPDATE anlagegueter SET ${felder.map((f) => `${f} = ?`).join(', ')} WHERE id = ?`)
|
||||
.run(...felder.map((f) => a[f] as string | number | null), id);
|
||||
}
|
||||
const nachher = db.prepare('SELECT * FROM anlagegueter WHERE id = ?').get(id);
|
||||
protokolliere('anlagegueter', id, 'aktualisiert', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { resolve } from 'node:path';
|
||||
import { datenVerzeichnis } from '../pfade.js';
|
||||
import { db, protokolliere } from '../db.js';
|
||||
import { berechneUstva } from '../domain/ustva.js';
|
||||
import { ustvaXml, type XmlOptionen } from '../elster/ustvaXml.js';
|
||||
import { ericStatus, uebermittle } from '../elster/eric.js';
|
||||
import { SteuernummerFehler } from '../elster/steuernummer.js';
|
||||
import type { Mandant } from '../domain/typen.js';
|
||||
|
||||
function mandant(): Mandant {
|
||||
return db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
}
|
||||
|
||||
export async function elsterRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/elster/status', async (req) => {
|
||||
const s = ericStatus();
|
||||
const testmandant = req.mandant?.testmandant ?? false;
|
||||
// Aus einem Testmandanten darf nur mit gesetztem Testmerker gesendet werden –
|
||||
// sonst ginge Spielmaterial als echte Anmeldung an das Finanzamt.
|
||||
const testmandantSperre = testmandant && !s.testmodus;
|
||||
return {
|
||||
...s,
|
||||
testmandant,
|
||||
testmandantSperre,
|
||||
mandant: req.mandant ? { slug: req.mandant.slug, name: req.mandant.name } : null,
|
||||
bereitZumSenden: s.verfuegbar && s.zertifikat && s.herstellerId && !testmandantSperre,
|
||||
anleitung: [
|
||||
'ERiC im ELSTER-Entwicklerportal (elster.de/elsterweb/entwickler) registrieren und herunterladen.',
|
||||
'ERIC_LIB_PATH auf libericapi.so und ERIC_PLUGIN_PATH auf das Verzeichnis plugins2 setzen.',
|
||||
'ELSTER-Zertifikat (.pfx) unter ELSTER_ZERTIFIKAT ablegen und ELSTER_PIN setzen.',
|
||||
'Registrierte Hersteller-ID unter ELSTER_HERSTELLER_ID hinterlegen.',
|
||||
'Für Probeübermittlungen ohne steuerliche Wirkung ELSTER_TESTMERKER=700000004 setzen.',
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/** XML-Vorschau, ohne ERiC – zum Nachvollziehen dessen, was übermittelt würde. */
|
||||
app.get('/api/elster/ustva/:jahr/:zeitraum/xml', async (req, reply) => {
|
||||
const { jahr, zeitraum } = req.params as { jahr: string; zeitraum: string };
|
||||
const opt = req.query as XmlOptionen;
|
||||
try {
|
||||
const ergebnis = berechneUstva(Number(jahr), zeitraum);
|
||||
const x = ustvaXml(ergebnis, mandant(), opt);
|
||||
return {
|
||||
xml: x.xml,
|
||||
datenartVersion: x.datenartVersion,
|
||||
bundesSteuernummer: x.bundesSteuernummer,
|
||||
warnungen: [...x.warnungen, ...ergebnis.hinweise],
|
||||
zahllast: ergebnis.zahllast,
|
||||
abgabepflichtig: ergebnis.abgabepflichtig,
|
||||
};
|
||||
} catch (e) {
|
||||
const code = e instanceof SteuernummerFehler ? 400 : 500;
|
||||
return reply.code(code).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/elster/ustva/:jahr/:zeitraum/validieren', async (req, reply) => {
|
||||
const { jahr, zeitraum } = req.params as { jahr: string; zeitraum: string };
|
||||
const opt = (req.body ?? {}) as XmlOptionen;
|
||||
try {
|
||||
const ergebnis = berechneUstva(Number(jahr), zeitraum);
|
||||
const x = ustvaXml(ergebnis, mandant(), opt);
|
||||
const antwort = await uebermittle(x.xml, x.datenartVersion, { nurValidieren: true });
|
||||
speichere(Number(jahr), zeitraum, ergebnis, x.xml, opt, antwort.erfolg ? 'validiert' : 'fehler', antwort);
|
||||
return { ...antwort, warnungen: x.warnungen, zahllast: ergebnis.zahllast };
|
||||
} catch (e) {
|
||||
return reply.code(400).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Echte Übermittlung. Sie ist gegenüber dem Finanzamt verbindlich und nicht
|
||||
* zurückzunehmen – deshalb muss der Aufruf ausdrücklich bestätigt werden.
|
||||
*/
|
||||
app.post('/api/elster/ustva/:jahr/:zeitraum/senden', async (req, reply) => {
|
||||
const { jahr, zeitraum } = req.params as { jahr: string; zeitraum: string };
|
||||
const body = (req.body ?? {}) as XmlOptionen & { bestaetigt?: boolean };
|
||||
|
||||
if (!body.bestaetigt) {
|
||||
return reply.code(428).send({
|
||||
fehler:
|
||||
'Die Übermittlung an das Finanzamt ist verbindlich und kann nicht zurückgenommen werden. ' +
|
||||
'Bitte mit "bestaetigt": true erneut aufrufen.',
|
||||
});
|
||||
}
|
||||
|
||||
if (req.mandant?.testmandant && !process.env['ELSTER_TESTMERKER']) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
`"${req.mandant.name}" ist als Testmandant angelegt – aus ihm wird nichts an das Finanzamt ` +
|
||||
'übermittelt. Für Probeübermittlungen ohne steuerliche Wirkung ELSTER_TESTMERKER=700000004 ' +
|
||||
'setzen, für den Echtbetrieb den realen Mandanten wählen.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ergebnis = berechneUstva(Number(jahr), zeitraum);
|
||||
if (!ergebnis.abgabepflichtig && !body.berichtigt) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
'Für diesen Zeitraum besteht keine Abgabepflicht (durchgehend Kleinunternehmer ohne ' +
|
||||
'Steuerschuld als Leistungsempfänger). Falls das Finanzamt dennoch eine Voranmeldung ' +
|
||||
'angefordert hat, bitte die Abgabepflicht in den Stammdaten klären.',
|
||||
});
|
||||
}
|
||||
|
||||
const x = ustvaXml(ergebnis, mandant(), body);
|
||||
const pdfPfad = resolve(datenVerzeichnis, `elster-ausgang/UStVA-${jahr}-${zeitraum}.pdf`);
|
||||
const antwort = await uebermittle(x.xml, x.datenartVersion, {
|
||||
nurValidieren: false,
|
||||
pdfPfad,
|
||||
});
|
||||
|
||||
const status = antwort.simulation ? 'entwurf' : antwort.erfolg ? 'uebermittelt' : 'fehler';
|
||||
const id = speichere(Number(jahr), zeitraum, ergebnis, x.xml, body, status, antwort);
|
||||
protokolliere('ustva_meldungen', id, status, null, {
|
||||
transferticket: antwort.transferticket,
|
||||
simulation: antwort.simulation,
|
||||
});
|
||||
|
||||
return { ...antwort, meldungId: id, warnungen: x.warnungen, zahllast: ergebnis.zahllast };
|
||||
} catch (e) {
|
||||
return reply.code(400).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/elster/meldungen', async () =>
|
||||
db.prepare('SELECT id, jahr, zeitraum, zahllast, status, berichtigung, transferticket, erstellt_am, uebermittelt_am FROM ustva_meldungen ORDER BY jahr DESC, zeitraum DESC').all(),
|
||||
);
|
||||
|
||||
app.get('/api/elster/meldungen/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
return db.prepare('SELECT * FROM ustva_meldungen WHERE id = ?').get(id);
|
||||
});
|
||||
}
|
||||
|
||||
function speichere(
|
||||
jahr: number,
|
||||
zeitraum: string,
|
||||
ergebnis: ReturnType<typeof berechneUstva>,
|
||||
xml: string,
|
||||
opt: XmlOptionen,
|
||||
status: string,
|
||||
antwort: Awaited<ReturnType<typeof uebermittle>>,
|
||||
): number {
|
||||
const berichtigung = opt.berichtigt ? 1 : 0;
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO ustva_meldungen
|
||||
(jahr, zeitraum, kennzahlen, zahllast, status, berichtigung, xml, transferticket,
|
||||
eric_protokoll, uebermittelt_am)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(jahr, zeitraum, berichtigung) DO UPDATE SET
|
||||
kennzahlen = excluded.kennzahlen, zahllast = excluded.zahllast,
|
||||
status = excluded.status, xml = excluded.xml,
|
||||
transferticket = COALESCE(excluded.transferticket, ustva_meldungen.transferticket),
|
||||
eric_protokoll = excluded.eric_protokoll,
|
||||
uebermittelt_am = COALESCE(excluded.uebermittelt_am, ustva_meldungen.uebermittelt_am)`,
|
||||
)
|
||||
.run(
|
||||
jahr, zeitraum, JSON.stringify(ergebnis.kennzahlen), ergebnis.zahllast, status,
|
||||
berichtigung, xml, antwort.transferticket,
|
||||
[antwort.protokoll, ...antwort.fehler, ...antwort.hinweise].filter(Boolean).join('\n'),
|
||||
status === 'uebermittelt' ? new Date().toISOString() : null,
|
||||
);
|
||||
if (info.lastInsertRowid) return Number(info.lastInsertRowid);
|
||||
const row = db
|
||||
.prepare('SELECT id FROM ustva_meldungen WHERE jahr = ? AND zeitraum = ? AND berichtigung = ?')
|
||||
.get(jahr, zeitraum, berichtigung) as { id: number };
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buchungsstapel, kontenbeschriftungen, alsAnsi } from '../export/datev.js';
|
||||
import { gdpduExport } from '../export/gdpdu.js';
|
||||
import { zip } from '../export/zip.js';
|
||||
import { berechneEuer } from '../domain/euer.js';
|
||||
import { berechneBwa } from '../domain/bwa.js';
|
||||
import { dezimal } from '../domain/geld.js';
|
||||
|
||||
export async function exportRouten(app: FastifyInstance): Promise<void> {
|
||||
/** DATEV-Buchungsstapel + Kontenbeschriftungen als ZIP für den Steuerberater. */
|
||||
app.get('/api/export/datev/:jahr', async (req, reply) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const q = req.query as { von?: string; bis?: string; festschreibung?: string; buSchluessel?: string };
|
||||
const opt = {
|
||||
jahr: Number(jahr),
|
||||
von: q.von,
|
||||
bis: q.bis,
|
||||
festschreibung: q.festschreibung === '1',
|
||||
mitBuSchluessel: q.buSchluessel === '1',
|
||||
};
|
||||
const stapel = buchungsstapel(opt);
|
||||
const konten = kontenbeschriftungen(opt);
|
||||
const paket = zip([
|
||||
{ name: stapel.dateiname, inhalt: stapel.inhalt },
|
||||
{ name: konten.dateiname, inhalt: konten.inhalt },
|
||||
{
|
||||
name: 'LIESMICH.txt',
|
||||
inhalt: alsAnsi(
|
||||
`DATEV-Export ${jahr}\r\n` +
|
||||
`Format: EXTF, Version 700, Buchungsstapel Formatversion 13\r\n` +
|
||||
`Zeichensatz: ANSI (Windows-1252), Trennzeichen ";"\r\n\r\n` +
|
||||
`Die Buchungen sind nach dem Zahlungszeitpunkt sortiert (Einnahmen-Überschuss-Rechnung).\r\n` +
|
||||
`Das Leistungsdatum steht in Feld 115, die Zuordnung zur Steuerperiode in Feld 116.\r\n` +
|
||||
(opt.mitBuSchluessel
|
||||
? 'BU-Schlüssel sind gesetzt – Automatikkonten im Ziel-Kontenrahmen beachten.\r\n'
|
||||
: 'Ohne BU-Schlüssel: die Steuer wird über die Automatikkonten des SKR abgeleitet.\r\n'),
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'application/zip')
|
||||
.header('Content-Disposition', `attachment; filename="DATEV-Export-${jahr}.zip"`)
|
||||
.send(paket);
|
||||
});
|
||||
|
||||
/** Einzelne Buchungsstapel-Datei (falls der Berater nur die CSV möchte). */
|
||||
app.get('/api/export/datev/:jahr/buchungsstapel.csv', async (req, reply) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const datei = buchungsstapel({ jahr: Number(jahr) });
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv; charset=windows-1252')
|
||||
.header('Content-Disposition', `attachment; filename="${datei.dateiname}"`)
|
||||
.send(datei.inhalt);
|
||||
});
|
||||
|
||||
/** GoBD-Datenträgerüberlassung (GDPdU, Format Z3) für die Betriebsprüfung. */
|
||||
app.get('/api/export/gdpdu/:jahr', async (req, reply) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
const datei = gdpduExport(Number(jahr));
|
||||
return reply
|
||||
.header('Content-Type', 'application/zip')
|
||||
.header('Content-Disposition', `attachment; filename="${datei.dateiname}"`)
|
||||
.send(datei.inhalt);
|
||||
});
|
||||
|
||||
app.get('/api/export/euer/:jahr.csv', async (req, reply) => {
|
||||
const jahr = Number((req.params as Record<string, string>)['jahr']);
|
||||
const e = berechneEuer(jahr);
|
||||
const zeilen = [
|
||||
'Bereich;Zeile;Posten;Betrag',
|
||||
...e.einnahmen.map((z) => `Betriebseinnahmen;${z.zeile ?? ''};"${z.bezeichnung}";${dezimal(z.betrag)}`),
|
||||
`Summe;;"Summe der Betriebseinnahmen";${dezimal(e.summeEinnahmen)}`,
|
||||
...e.ausgaben.map((z) => `Betriebsausgaben;${z.zeile ?? ''};"${z.bezeichnung}";${dezimal(z.betrag)}`),
|
||||
`Summe;;"Summe der Betriebsausgaben";${dezimal(e.summeAusgaben)}`,
|
||||
`Ergebnis;;"Gewinn / Verlust";${dezimal(e.gewinn)}`,
|
||||
].join('\r\n');
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv; charset=windows-1252')
|
||||
.header('Content-Disposition', `attachment; filename="EUER-${jahr}.csv"`)
|
||||
.send(alsAnsi(zeilen + '\r\n'));
|
||||
});
|
||||
|
||||
app.get('/api/export/bwa/:jahr/:monat.csv', async (req, reply) => {
|
||||
const p = req.params as Record<string, string>;
|
||||
const jahr = Number(p['jahr']);
|
||||
const monat = Number(p['monat']);
|
||||
const b = berechneBwa(jahr, monat, (req.query as { basis?: 'zahlung' | 'leistung' }).basis ?? 'zahlung');
|
||||
const zeilen = [
|
||||
'Zeile;Monat;Kumuliert;Vorjahr kumuliert;Quote %',
|
||||
...b.zeilen.map(
|
||||
(z) =>
|
||||
`"${z.bezeichnung}";${dezimal(z.monat)};${dezimal(z.kumuliert)};` +
|
||||
`${dezimal(z.vorjahrKumuliert)};${z.quoteKumuliert.toFixed(1).replace('.', ',')}`,
|
||||
),
|
||||
].join('\r\n');
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv; charset=windows-1252')
|
||||
.header('Content-Disposition', `attachment; filename="BWA-${jahr}-${monat}.csv"`)
|
||||
.send(alsAnsi(zeilen + '\r\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { imMandantKontext, type MandantKontext } from '../db.js';
|
||||
import { istKontenrahmen, KONTENRAHMEN, type KontenrahmenId } from '../kontenrahmen/index.js';
|
||||
import {
|
||||
alleMandanten,
|
||||
benenneMandantUm,
|
||||
entferneMandant,
|
||||
findeMandant,
|
||||
kontextFuer,
|
||||
legeMandantAn,
|
||||
MandantFehler,
|
||||
standardMandant,
|
||||
} from '../mandanten.js';
|
||||
|
||||
/** Diese Pfade arbeiten mandantenübergreifend und brauchen keinen Kontext. */
|
||||
const OHNE_MANDANT = ['/api/mandanten', '/api/kontenrahmen', '/api/gesundheit'];
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
mandant?: MandantKontext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bindet jeden Request an genau einen Mandanten.
|
||||
*
|
||||
* Der Mandant kommt aus dem Header `X-Mandant` oder – für Download-Links, die
|
||||
* keine Header setzen können – aus dem Query-Parameter `mandant`. Der Handler
|
||||
* läuft anschließend innerhalb von imMandantKontext, sodass jeder db-Zugriff
|
||||
* automatisch auf der richtigen Datei landet.
|
||||
*/
|
||||
export function mandantenScoping(app: FastifyInstance): void {
|
||||
app.addHook('onRequest', (req: FastifyRequest, reply: FastifyReply, weiter: () => void) => {
|
||||
if (OHNE_MANDANT.some((p) => req.url.split('?')[0]?.startsWith(p))) return weiter();
|
||||
|
||||
const ausHeader = req.headers['x-mandant'];
|
||||
const ausQuery = (req.query as { mandant?: string } | undefined)?.mandant;
|
||||
const gewuenscht = (Array.isArray(ausHeader) ? ausHeader[0] : ausHeader) || ausQuery;
|
||||
|
||||
try {
|
||||
const eintrag = gewuenscht ? findeMandant(gewuenscht) : standardMandant();
|
||||
if (!eintrag) {
|
||||
return reply.code(404).send({
|
||||
fehler: `Der Mandant "${gewuenscht}" ist nicht bekannt. Verfügbar: ${alleMandanten()
|
||||
.map((m) => m.slug)
|
||||
.join(', ')}`,
|
||||
});
|
||||
}
|
||||
const kontext = kontextFuer(eintrag);
|
||||
req.mandant = kontext;
|
||||
reply.header('X-Mandant', kontext.slug);
|
||||
// weiter() innerhalb von run() aufrufen: die restliche Verarbeitung des
|
||||
// Requests läuft dadurch im Kontext dieses Mandanten.
|
||||
imMandantKontext(kontext, weiter);
|
||||
} catch (e) {
|
||||
reply.code(e instanceof MandantFehler ? 400 : 500).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function mandantenRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/kontenrahmen', async () =>
|
||||
Object.entries(KONTENRAHMEN).map(([id, r]) => ({ id, bezeichnung: r.bezeichnung, konten: r.konten.length })),
|
||||
);
|
||||
|
||||
app.get('/api/mandanten', async () => {
|
||||
const standard = (() => {
|
||||
try {
|
||||
return standardMandant().slug;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return {
|
||||
standard,
|
||||
mandanten: alleMandanten().map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
testmandant: m.testmandant === 1,
|
||||
angelegt_am: m.angelegt_am,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/mandanten', async (req, reply) => {
|
||||
const b = req.body as {
|
||||
name?: string; slug?: string; testmandant?: boolean; kontenrahmen?: string;
|
||||
};
|
||||
if (!b.name?.trim()) {
|
||||
return reply.code(400).send({ fehler: 'Ein Name ist erforderlich.' });
|
||||
}
|
||||
try {
|
||||
if (b.kontenrahmen && !istKontenrahmen(b.kontenrahmen)) {
|
||||
return reply.code(400).send({ fehler: `Unbekannter Kontenrahmen "${b.kontenrahmen}".` });
|
||||
}
|
||||
const eintrag = legeMandantAn({
|
||||
name: b.name.trim(),
|
||||
slug: b.slug,
|
||||
testmandant: b.testmandant,
|
||||
kontenrahmen: b.kontenrahmen as KontenrahmenId | undefined,
|
||||
});
|
||||
return {
|
||||
slug: eintrag.slug,
|
||||
name: eintrag.name,
|
||||
testmandant: eintrag.testmandant === 1,
|
||||
hinweis: b.testmandant
|
||||
? 'Testmandant angelegt. Aus ihm ist keine echte ELSTER-Übermittlung möglich.'
|
||||
: `Mandant angelegt, Kontenrahmen ${b.kontenrahmen ?? 'SKR04'} eingerichtet.`,
|
||||
};
|
||||
} catch (e) {
|
||||
return reply.code(e instanceof MandantFehler ? 409 : 500).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mandanten/:slug', async (req, reply) => {
|
||||
const { slug } = req.params as { slug: string };
|
||||
const b = req.body as { name?: string };
|
||||
if (!b.name?.trim()) return reply.code(400).send({ fehler: 'Ein Name ist erforderlich.' });
|
||||
try {
|
||||
const eintrag = benenneMandantUm(slug, b.name.trim());
|
||||
return { slug: eintrag.slug, name: eintrag.name };
|
||||
} catch (e) {
|
||||
return reply.code(404).send({ fehler: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Entfernen ist eine schwer umkehrbare Sache – deshalb muss der Name genau
|
||||
* getippt werden, und die Datei wird nur beiseitegelegt, nicht gelöscht.
|
||||
*/
|
||||
app.delete('/api/mandanten/:slug', async (req, reply) => {
|
||||
const { slug } = req.params as { slug: string };
|
||||
const b = (req.body ?? {}) as { bestaetigterName?: string };
|
||||
const eintrag = findeMandant(slug);
|
||||
if (!eintrag) return reply.code(404).send({ fehler: `Mandant "${slug}" ist nicht bekannt.` });
|
||||
|
||||
if (b.bestaetigterName !== eintrag.name) {
|
||||
return reply.code(428).send({
|
||||
fehler:
|
||||
`Zum Entfernen bitte den Namen des Mandanten exakt bestätigen ("${eintrag.name}"). ` +
|
||||
'Die Datei wird dabei nicht gelöscht, sondern mit Zeitstempel beiseitegelegt.',
|
||||
});
|
||||
}
|
||||
if (alleMandanten().length === 1) {
|
||||
return reply.code(409).send({ fehler: 'Der letzte verbleibende Mandant kann nicht entfernt werden.' });
|
||||
}
|
||||
|
||||
const zeitstempel = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const { abgelegtAls } = entferneMandant(slug, zeitstempel);
|
||||
return { ok: true, abgelegtAls };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db, protokolliere } from '../db.js';
|
||||
import { alleRegime, grenzenStatus, regimeAm } from '../domain/regime.js';
|
||||
import { BUNDESLAENDER, bundeseinheitlich, SteuernummerFehler, type Bundesland } from '../elster/steuernummer.js';
|
||||
import { standardGegenkonto } from '../kontenrahmen/index.js';
|
||||
import type { Konto, Mandant } from '../domain/typen.js';
|
||||
|
||||
/*
|
||||
* `kontenrahmen` fehlt hier bewusst: das Feld wird beim Anlegen des Mandanten
|
||||
* aus dem tatsächlich eingespielten Kontenrahmen gesetzt. Wäre es frei
|
||||
* änderbar, könnte im Kopf des DATEV-Exports "SKR03" stehen, während die
|
||||
* Buchungszeilen SKR04-Nummern enthalten – der Import beim Steuerberater liefe
|
||||
* durch und verbuchte z.B. Erlöse als Aufwand, weil dieselbe Nummer in beiden
|
||||
* Rahmen etwas anderes bedeutet.
|
||||
*/
|
||||
const MANDANT_FELDER = [
|
||||
'name', 'rechtsform', 'strasse', 'plz', 'ort', 'bundesland', 'steuernummer',
|
||||
'ust_idnr', 'finanzamt_nr', 'taetigkeit', 'betriebsbeginn', 'besteuerungsart',
|
||||
'voranmeldungszeitraum', 'dauerfristverlaengerung',
|
||||
'datev_berater_nr', 'datev_mandanten_nr', 'telefon', 'email',
|
||||
] as const;
|
||||
|
||||
export async function stammdatenRouten(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/mandant', async () => {
|
||||
const m = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
let bundesSteuernummer: string | null = null;
|
||||
let steuernummerFehler: string | null = null;
|
||||
try {
|
||||
bundesSteuernummer = bundeseinheitlich(m.steuernummer, m.bundesland as Bundesland);
|
||||
} catch (e) {
|
||||
steuernummerFehler = e instanceof SteuernummerFehler ? e.message : String(e);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
bundesSteuernummer,
|
||||
steuernummerFehler,
|
||||
bundeslaender: BUNDESLAENDER,
|
||||
standardGegenkonto: standardGegenkonto(m.kontenrahmen),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/mandant', async (req, reply) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const vorher = db.prepare('SELECT * FROM mandant WHERE id = 1').get() as Mandant;
|
||||
|
||||
if ('kontenrahmen' in body && body['kontenrahmen'] !== vorher.kontenrahmen) {
|
||||
return reply.code(409).send({
|
||||
fehler:
|
||||
`Der Kontenrahmen (${vorher.kontenrahmen}) lässt sich nachträglich nicht wechseln – ` +
|
||||
'alle vorhandenen Buchungen tragen Kontonummern dieses Rahmens, und dieselbe Nummer ' +
|
||||
'bedeutet in SKR03 und SKR04 etwas anderes. Für den anderen Rahmen bitte einen neuen ' +
|
||||
'Mandanten anlegen.',
|
||||
});
|
||||
}
|
||||
const felder = MANDANT_FELDER.filter((f) => f in body);
|
||||
if (felder.length > 0) {
|
||||
db.prepare(
|
||||
`UPDATE mandant SET ${felder.map((f) => `${f} = ?`).join(', ')} WHERE id = 1`,
|
||||
).run(...felder.map((f) => body[f] as string | number));
|
||||
}
|
||||
const nachher = db.prepare('SELECT * FROM mandant WHERE id = 1').get();
|
||||
protokolliere('mandant', 1, 'aktualisiert', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
|
||||
// ---- Besteuerungs-Regime ------------------------------------------------
|
||||
app.get('/api/regime', async () => {
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
return { eintraege: alleRegime(), aktuell: regimeAm(heute), stichtag: heute };
|
||||
});
|
||||
|
||||
app.post('/api/regime', async (req, reply) => {
|
||||
const b = req.body as { gueltig_ab: string; modus: string; grund?: string; notiz?: string };
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(b.gueltig_ab ?? '')) {
|
||||
return reply.code(400).send({ fehler: 'gueltig_ab muss im Format JJJJ-MM-TT vorliegen.' });
|
||||
}
|
||||
if (b.modus !== 'kleinunternehmer' && b.modus !== 'regelbesteuerung') {
|
||||
return reply.code(400).send({ fehler: 'Unbekannter Modus.' });
|
||||
}
|
||||
const info = db
|
||||
.prepare('INSERT INTO steuer_regime (gueltig_ab, modus, grund, notiz) VALUES (?,?,?,?)')
|
||||
.run(b.gueltig_ab, b.modus, b.grund ?? '', b.notiz ?? '');
|
||||
protokolliere('steuer_regime', info.lastInsertRowid, 'angelegt', null, b);
|
||||
|
||||
const hinweise: string[] = [];
|
||||
if (!b.gueltig_ab.endsWith('-01-01')) {
|
||||
hinweise.push(
|
||||
'Der Wechsel liegt nicht auf dem 1. Januar. Das ist nur in bestimmten Fällen richtig – ' +
|
||||
'etwa beim Überschreiten der 100.000-EUR-Grenze im laufenden Jahr (§19 Abs. 1 Satz 3 UStG) ' +
|
||||
'oder bei Betriebseröffnung.',
|
||||
);
|
||||
}
|
||||
hinweise.push(
|
||||
'Bestehende Buchungen bleiben unverändert; ihre umsatzsteuerliche Behandlung richtet sich ' +
|
||||
'weiterhin nach dem Regime zu ihrem Leistungsdatum. Buchungen nach dem Wechseldatum sind ' +
|
||||
'gegebenenfalls neu zu erfassen.',
|
||||
);
|
||||
return { id: info.lastInsertRowid, hinweise };
|
||||
});
|
||||
|
||||
app.delete('/api/regime/:id', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const vorher = db.prepare('SELECT * FROM steuer_regime WHERE id = ?').get(id);
|
||||
db.prepare('DELETE FROM steuer_regime WHERE id = ?').run(id);
|
||||
protokolliere('steuer_regime', id, 'geloescht', vorher, null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/regime/grenzen/:jahr', async (req) => {
|
||||
const { jahr } = req.params as { jahr: string };
|
||||
return grenzenStatus(Number(jahr));
|
||||
});
|
||||
|
||||
// ---- Konten -------------------------------------------------------------
|
||||
app.get('/api/konten', async (req) => {
|
||||
const { alle } = req.query as { alle?: string };
|
||||
const sql = alle
|
||||
? 'SELECT * FROM konten ORDER BY nummer'
|
||||
: 'SELECT * FROM konten WHERE aktiv = 1 ORDER BY nummer';
|
||||
return db.prepare(sql).all() as Konto[];
|
||||
});
|
||||
|
||||
app.put('/api/konten/:nummer', async (req) => {
|
||||
const { nummer } = req.params as { nummer: string };
|
||||
const b = req.body as Partial<Konto>;
|
||||
const vorher = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(nummer);
|
||||
const felder = (['bezeichnung', 'typ', 'euer_zeile', 'bwa_gruppe', 'ustva_kz',
|
||||
'ust_satz_bp', 'ust_behandlung', 'aktiv'] as const).filter((f) => f in b);
|
||||
if (felder.length > 0) {
|
||||
db.prepare(`UPDATE konten SET ${felder.map((f) => `${f} = ?`).join(', ')} WHERE nummer = ?`)
|
||||
.run(...felder.map((f) => b[f] as string | number | null), nummer);
|
||||
}
|
||||
const nachher = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(nummer);
|
||||
protokolliere('konten', nummer, 'aktualisiert', vorher, nachher);
|
||||
return nachher;
|
||||
});
|
||||
|
||||
app.post('/api/konten', async (req, reply) => {
|
||||
const b = req.body as Konto;
|
||||
if (!b.nummer || !b.bezeichnung) {
|
||||
return reply.code(400).send({ fehler: 'Nummer und Bezeichnung sind erforderlich.' });
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO konten (nummer, bezeichnung, typ, euer_zeile, bwa_gruppe, ustva_kz,
|
||||
ust_satz_bp, ust_behandlung, aktiv) VALUES (?,?,?,?,?,?,?,?,1)`,
|
||||
).run(
|
||||
b.nummer, b.bezeichnung, b.typ ?? 'aufwand', b.euer_zeile ?? null,
|
||||
b.bwa_gruppe ?? null, b.ustva_kz ?? null, b.ust_satz_bp ?? 1900,
|
||||
b.ust_behandlung ?? 'regel',
|
||||
);
|
||||
protokolliere('konten', b.nummer, 'angelegt', null, b);
|
||||
return db.prepare('SELECT * FROM konten WHERE nummer = ?').get(b.nummer);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import './env.js'; // muss vor db.js stehen (EUER_DB)
|
||||
import { db } from './db.js';
|
||||
import {
|
||||
findeMandant,
|
||||
kontenrahmenAnlegen,
|
||||
legeMandantAn,
|
||||
waehleMandantFuerSkript,
|
||||
} from './mandanten.js';
|
||||
import { berechneUst } from './domain/regime.js';
|
||||
|
||||
/**
|
||||
* Beispieldaten: ein Einzelunternehmen, das 2025 Kleinunternehmer ist und zum
|
||||
* 01.01.2026 in die Regelbesteuerung wechselt. Damit lässt sich der Wechsel
|
||||
* in EÜR, UStVA und BWA unmittelbar nachvollziehen.
|
||||
*
|
||||
* npm run seed -- legt die Beispieldaten an
|
||||
* npm run seed -- reset -- löscht vorher alle Bewegungsdaten
|
||||
*/
|
||||
|
||||
/*
|
||||
* Zielmandant über --mandant=<bezeichner>. Existiert er nicht, wird er als
|
||||
* Testmandant angelegt – Beispieldaten gehören nicht in eine echte Buchhaltung.
|
||||
*/
|
||||
const gewuenscht = process.argv.find((a) => a.startsWith('--mandant='))?.split('=')[1];
|
||||
if (gewuenscht && !findeMandant(gewuenscht)) {
|
||||
legeMandantAn({ name: `Testmandant (${gewuenscht})`, slug: gewuenscht, testmandant: true });
|
||||
console.log(`Testmandant "${gewuenscht}" angelegt.`);
|
||||
}
|
||||
const kontext = waehleMandantFuerSkript(gewuenscht);
|
||||
kontenrahmenAnlegen(kontext.verbindung);
|
||||
console.log(`Zielmandant: ${kontext.name} (${kontext.slug})${kontext.testmandant ? ' [Test]' : ''}`);
|
||||
|
||||
if (!kontext.testmandant && !process.argv.includes('--auch-echt')) {
|
||||
console.error(
|
||||
`\nAbbruch: "${kontext.name}" ist kein Testmandant.\n` +
|
||||
'Beispieldaten in einer echten Buchhaltung verfälschen EÜR und Voranmeldung.\n\n' +
|
||||
' npm run seed -- --mandant=test legt einen Testmandanten an und befüllt ihn\n' +
|
||||
' npm run seed -- --auch-echt erzwingt das Befüllen des aktuellen Mandanten\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const reset = process.argv.includes('reset');
|
||||
if (reset) {
|
||||
db.exec('DELETE FROM buchungen; DELETE FROM anlagegueter; DELETE FROM steuer_regime; DELETE FROM ustva_meldungen;');
|
||||
}
|
||||
|
||||
/*
|
||||
* Ohne diese Sperre fügt ein zweiter Aufruf die Beispieldaten erneut ein und
|
||||
* verdoppelt sämtliche Auswertungen. Vorhandene Daten werden nie stillschweigend
|
||||
* überschrieben – dafür gibt es `reset`.
|
||||
*/
|
||||
const vorhandene = (db.prepare('SELECT COUNT(*) AS n FROM buchungen').get() as { n: number }).n;
|
||||
if (vorhandene > 0) {
|
||||
console.error(
|
||||
`Abbruch: Die Datenbank enthält bereits ${vorhandene} Buchungen.\n` +
|
||||
'Ein erneutes Einspielen der Beispieldaten würde alle Auswertungen verfälschen.\n\n' +
|
||||
' npm run seed -- reset löscht alle Bewegungsdaten und legt die Beispieldaten neu an\n\n' +
|
||||
'Achtung: `reset` verwirft auch selbst erfasste Buchungen.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE mandant SET name=?, strasse=?, plz=?, ort=?, bundesland=?, steuernummer=?,
|
||||
taetigkeit=?, betriebsbeginn=?, besteuerungsart=?, voranmeldungszeitraum=?,
|
||||
telefon=?, email=?, datev_berater_nr=?, datev_mandanten_nr=? WHERE id=1`,
|
||||
).run(
|
||||
'Beispiel Einzelunternehmen', 'Musterweg 1', '80331', 'München', 'BY',
|
||||
'123/456/78901', 'IT-Dienstleistungen', '2024-01-15', 'ist', 'vierteljaehrlich',
|
||||
'089 1234567', 'kontakt@beispiel.de', '1234567', '54321',
|
||||
);
|
||||
|
||||
if ((db.prepare('SELECT COUNT(*) AS n FROM steuer_regime').get() as { n: number }).n === 0) {
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus, grund, notiz) VALUES (?,?,?,?)').run(
|
||||
'2024-01-15', 'kleinunternehmer', 'Betriebseröffnung', 'Anwendung des §19 UStG ab Gründung',
|
||||
);
|
||||
db.prepare('INSERT INTO steuer_regime (gueltig_ab, modus, grund, notiz) VALUES (?,?,?,?)').run(
|
||||
'2026-01-01', 'regelbesteuerung', 'Vorjahresumsatz über 25.000 EUR',
|
||||
'Wechsel zum Jahresbeginn, da die Vorjahresgrenze 2025 überschritten wurde',
|
||||
);
|
||||
}
|
||||
|
||||
interface Zeile {
|
||||
art: 'einnahme' | 'ausgabe';
|
||||
datum: string;
|
||||
brutto: number;
|
||||
konto: string;
|
||||
text: string;
|
||||
partner?: string;
|
||||
satz?: number;
|
||||
anlage?: { bezeichnung: string; nutzungsdauer: number };
|
||||
}
|
||||
|
||||
const zeilen: Zeile[] = [
|
||||
// 2025 – Kleinunternehmer
|
||||
...monatlich(2025, '4200', 'Beratungsleistung', 'Kundschaft GmbH', 2_400_00),
|
||||
{ art: 'ausgabe', datum: '2025-02-10', brutto: 59_50, konto: '6805', text: 'Internetanschluss Februar' },
|
||||
{ art: 'ausgabe', datum: '2025-03-05', brutto: 149_00, konto: '6815', text: 'Bürobedarf' },
|
||||
{ art: 'ausgabe', datum: '2025-06-20', brutto: 890_00, konto: '6825', text: 'Rechtsberatung Vertragsprüfung' },
|
||||
{ art: 'ausgabe', datum: '2025-09-01', brutto: 1_190_00, konto: '6600', text: 'Website-Relaunch' },
|
||||
{
|
||||
art: 'ausgabe', datum: '2025-04-12', brutto: 2_380_00, konto: '0670',
|
||||
text: 'Notebook', anlage: { bezeichnung: 'Notebook 16"', nutzungsdauer: 3 },
|
||||
},
|
||||
|
||||
// 2026 – Regelbesteuerung
|
||||
...monatlich(2026, '4400', 'Beratungsleistung', 'Kundschaft GmbH', 3_570_00),
|
||||
{ art: 'einnahme', datum: '2026-03-18', brutto: 1_200_00, konto: '4180', text: 'Beratung Kunde in Österreich', partner: 'AT-Kunde GmbH' },
|
||||
{ art: 'ausgabe', datum: '2026-01-15', brutto: 59_50, konto: '6805', text: 'Internetanschluss Januar' },
|
||||
{ art: 'ausgabe', datum: '2026-02-15', brutto: 59_50, konto: '6805', text: 'Internetanschluss Februar' },
|
||||
{ art: 'ausgabe', datum: '2026-02-28', brutto: 1_785_00, konto: '5900', text: 'Fremdleistung Grafik' },
|
||||
{ art: 'ausgabe', datum: '2026-03-31', brutto: 476_00, konto: '6827', text: 'Buchführung 1. Quartal' },
|
||||
{ art: 'ausgabe', datum: '2026-04-10', brutto: 238_00, konto: '6640', text: 'Geschäftsessen mit Kundschaft' },
|
||||
{ art: 'ausgabe', datum: '2026-05-05', brutto: 1_000_00, konto: '5906', text: 'Werbeleistung EU-Anbieter (§13b)' },
|
||||
];
|
||||
|
||||
const einfuegen = db.prepare(
|
||||
`INSERT INTO buchungen
|
||||
(art, belegdatum, leistungsdatum, zahlungsdatum, betrag_brutto, betrag_netto, betrag_ust,
|
||||
ust_satz_bp, ust_behandlung, vorsteuer_abziehbar, konto, gegenkonto, beleg_nr,
|
||||
buchungstext, partner, anlagegut_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
|
||||
let nr = 1;
|
||||
db.transaction(() => {
|
||||
for (const z of zeilen) {
|
||||
const konto = db.prepare('SELECT * FROM konten WHERE nummer = ?').get(z.konto) as
|
||||
| { ust_satz_bp: number; ust_behandlung: string }
|
||||
| undefined;
|
||||
if (!konto) continue;
|
||||
|
||||
const ust = berechneUst({
|
||||
art: z.art,
|
||||
leistungsdatum: z.datum,
|
||||
brutto: z.brutto,
|
||||
satzBp: z.satz ?? konto.ust_satz_bp,
|
||||
behandlung: konto.ust_behandlung as never,
|
||||
});
|
||||
|
||||
let anlageId: number | null = null;
|
||||
if (z.anlage) {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO anlagegueter (bezeichnung, anschaffungsdatum, akosten_netto,
|
||||
akosten_brutto, nutzungsdauer, afa_methode, konto)
|
||||
VALUES (?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(z.anlage.bezeichnung, z.datum, ust.netto, ust.brutto, z.anlage.nutzungsdauer, 'linear', z.konto);
|
||||
anlageId = Number(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
einfuegen.run(
|
||||
z.art, z.datum, z.datum, z.datum, ust.brutto, ust.netto, ust.ust, ust.satzBp,
|
||||
ust.behandlung, ust.vorsteuerAbziehbar ? 1 : 0, z.konto, '1800',
|
||||
`${z.art === 'einnahme' ? 'RE' : 'ER'}-${String(nr++).padStart(4, '0')}`,
|
||||
z.text, z.partner ?? '', anlageId,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
function monatlich(jahr: number, konto: string, text: string, partner: string, brutto: number): Zeile[] {
|
||||
return Array.from({ length: 12 }, (_, i) => ({
|
||||
art: 'einnahme' as const,
|
||||
datum: `${jahr}-${String(i + 1).padStart(2, '0')}-28`,
|
||||
brutto,
|
||||
konto,
|
||||
text: `${text} ${String(i + 1).padStart(2, '0')}/${jahr}`,
|
||||
partner,
|
||||
}));
|
||||
}
|
||||
|
||||
const anzahl = (db.prepare('SELECT COUNT(*) AS n FROM buchungen').get() as { n: number }).n;
|
||||
console.log(`Beispieldaten angelegt. Buchungen in der Datenbank: ${anzahl}`);
|
||||
console.log('Regimewechsel: Kleinunternehmer ab 15.01.2024, Regelbesteuerung ab 01.01.2026.');
|
||||
Reference in New Issue
Block a user