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,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');
|
||||
}
|
||||
Reference in New Issue
Block a user