feat(accounting): Pflichtangaben der E-Rechnung vor dem Fertigstellen durchsetzen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m27s

Fehlende Pflichtangaben (Firmendaten, Steuer-ID, Kundenanschrift, IBAN,
§ 13b-USt-Id, Lastschriftmandat) erscheinen im Editor als blockierende
Fehler und verhindern Vorschau und Fertigstellung. Serienrechnungslauf,
MCP-Finalisierung und PDF-Erzeugung prüfen serverseitig vor der
Nummernvergabe bzw. Ablage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 11:24:28 +01:00
parent 549b469a41
commit e9697ef197
7 changed files with 224 additions and 55 deletions

View File

@@ -34,6 +34,7 @@ import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
import { withImages } from "../result"
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
import { outgoingStatusTools } from "./outgoingStatus"
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
@@ -919,11 +920,13 @@ export const accountingTools: McpTool[] = [
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
// Pflichtangaben der E-Rechnung vor der Nummernvergabe durchsetzen
await assertOutgoingEInvoiceRequirements(context.server, context.tenantId, { ...existing, ...payload })
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
payload.state = "Gebucht"
payload.documentNumber = result.usedNumber
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
const [updated] = await context.server.db
.update(createddocuments)

View File

@@ -28,7 +28,7 @@ export type ZugferdInvoiceContext = {
export type ZugferdInvoiceResult = {
xml: string
warnings: string[]
issues: ZugferdIssue[]
totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number }
}
@@ -144,8 +144,53 @@ function taxRegistrations(vatId: unknown, taxNumber?: unknown) {
].join("")
}
export type ZugferdIssue = { message: string, type: "breaking" | "info" }
const sellerIban = (context: ZugferdInvoiceContext) => {
const businessInfo = context.tenant?.businessInfo || {}
const account = context.bankAccounts.find((item) => !item.archived && !item.expired && item.iban)
return text(businessInfo.iban || account?.iban).replace(/\s+/g, "")
}
/**
* Pflichtangaben einer E-Rechnung (EN 16931 / § 14 UStG). "breaking" verhindert das Fertigstellen,
* "info" ist ein Hinweis. requireNumber prüft zusätzlich Nummer und Datum, die erst beim Fertigstellen entstehen.
*/
export function checkZugferdRequirements(document: any, context: ZugferdInvoiceContext, options: { requireNumber?: boolean } = {}): ZugferdIssue[] {
const issues: ZugferdIssue[] = []
const breaking = (message: string) => issues.push({ message: `E-Rechnung: ${message}`, type: "breaking" })
const info = (message: string) => issues.push({ message: `E-Rechnung: ${message}`, type: "info" })
const businessInfo = context.tenant?.businessInfo || {}
const customer = context.customer || {}
const customerInfo = customer.infoData || {}
const address = document.address || {}
if (options.requireNumber && !text(document.documentNumber)) breaking("Die Rechnungsnummer fehlt")
if (options.requireNumber && !date102(document.documentDate)) breaking("Das Rechnungsdatum fehlt")
if (!text(businessInfo.name || context.tenant?.name)) breaking("Der Firmenname fehlt in den Firmendaten (Einstellungen > Firmendaten)")
if (!text(businessInfo.street) || !text(businessInfo.zip) || !text(businessInfo.city)) breaking("Die Firmenanschrift ist in den Firmendaten unvollständig (Straße, PLZ, Ort)")
if (!text(businessInfo.vatId) && !text(businessInfo.taxNumber)) breaking("In den Firmendaten ist weder eine USt-IdNr. noch eine Steuernummer hinterlegt")
if (document.customer && !text(customer.name)) breaking("Der Kunde hat keinen Namen")
if (document.customer && (!text(address.street ?? customerInfo.street) || !text(address.zip ?? customerInfo.zip) || !text(address.city ?? customerInfo.city))) {
breaking("Die Anschrift des Kunden ist unvollständig (Straße, PLZ, Ort)")
}
if (document.taxType === "13b UStG") {
if (document.customer && !text(customerInfo.ustid)) breaking("Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein")
if (!text(businessInfo.vatId)) breaking("Für § 13b UStG muss in den Firmendaten eine USt-IdNr. hinterlegt sein")
}
if (!(document.rows || []).some(isItemRow)) breaking("Der Beleg enthält keine abrechenbaren Positionen")
if (document.payment_type === "direct-debit") {
if (!text(context.tenant?.creditorId)) breaking("Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt")
if (!text(context.sepaMandate?.reference)) breaking("Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt")
} else if ((document.payment_type === "transfer" || !document.payment_type) && !sellerIban(context)) {
breaking("Für Überweisung ist keine IBAN hinterlegt (Firmendaten oder Bankkonto)")
}
if (text(document.deliveryDateType) === "Kein Lieferdatum anzeigen") info("Es wird kein Liefer- oder Leistungsdatum angegeben (§ 14 UStG)")
return issues
}
export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceContext): ZugferdInvoiceResult {
const warnings: string[] = []
const businessInfo = context.tenant?.businessInfo || {}
const customer = context.customer || {}
const customerInfo = customer.infoData || {}
@@ -153,15 +198,7 @@ export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceCon
// Stornorechnungen tragen negative Preise; als Gutschrift (381) werden die Beträge positiv ausgewiesen.
const sign = document.type === "cancellationInvoices" ? -1 : 1
if (!text(document.documentNumber)) warnings.push("Rechnungsnummer fehlt")
if (!date102(document.documentDate)) warnings.push("Rechnungsdatum fehlt")
if (!text(businessInfo.name)) warnings.push("Firmenname des Verkäufers fehlt (Einstellungen > Firmendaten)")
if (!text(businessInfo.street) || !text(businessInfo.zip) || !text(businessInfo.city)) warnings.push("Anschrift des Verkäufers ist unvollständig")
if (!text(businessInfo.vatId) && !text(businessInfo.taxNumber)) warnings.push("Weder USt-IdNr. noch Steuernummer des Verkäufers hinterlegt")
if (!text(customer.name)) warnings.push("Name des Kunden fehlt")
if (!text(address.city) && !text(customerInfo.city)) warnings.push("Anschrift des Kunden ist unvollständig")
if (document.taxType === "13b UStG" && !text(customerInfo.ustid)) warnings.push("Für § 13b UStG ist die USt-IdNr. des Kunden erforderlich")
if (document.taxType === "13b UStG" && !text(businessInfo.vatId)) warnings.push("Für § 13b UStG ist die USt-IdNr. des Verkäufers erforderlich")
const issues = checkZugferdRequirements(document, context, { requireNumber: true })
const groups = new Map<string, TaxGroup>()
const lines: string[] = []
@@ -214,8 +251,6 @@ export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceCon
)
}
if (!lines.length) warnings.push("Der Beleg enthält keine abrechenbaren Positionen")
const taxGroups = [...groups.values()]
const lineNet = round(taxGroups.reduce((sum, group) => sum + group.basis, 0))
const taxTotal = round(taxGroups.reduce((sum, group) => sum + round(group.basis * group.rate / 100), 0))
@@ -233,10 +268,7 @@ export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceCon
let paymentMeans = ""
if (document.payment_type === "direct-debit") {
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>59</ram:TypeCode><ram:Information>SEPA-Lastschrift</ram:Information></ram:SpecifiedTradeSettlementPaymentMeans>`
if (!text(context.tenant?.creditorId)) warnings.push("Für SEPA-Lastschrift ist keine Gläubiger-ID hinterlegt")
if (!text(context.sepaMandate?.reference)) warnings.push("Für SEPA-Lastschrift ist keine Mandatsreferenz hinterlegt")
} else if (document.payment_type === "transfer" || !document.payment_type) {
if (!iban) warnings.push("Für Überweisung ist keine IBAN des Verkäufers hinterlegt")
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>58</ram:TypeCode><ram:Information>SEPA-Überweisung</ram:Information>` +
(iban ? `<ram:PayeePartyCreditorFinancialAccount><ram:IBANID>${escapeXml(iban)}</ram:IBANID>${el("ram:AccountName", accountName)}</ram:PayeePartyCreditorFinancialAccount>` : "") +
(iban && bic ? `<ram:PayeeSpecifiedCreditorFinancialInstitution><ram:BICID>${escapeXml(bic)}</ram:BICID></ram:PayeeSpecifiedCreditorFinancialInstitution>` : "") +
@@ -338,5 +370,5 @@ export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceCon
`</ram:SpecifiedTradeSettlementHeaderMonetarySummation>${precedingInvoice}` +
`</ram:ApplicableHeaderTradeSettlement></rsm:SupplyChainTradeTransaction></rsm:CrossIndustryInvoice>`
return { xml, warnings, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } }
return { xml, issues, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } }
}

View File

@@ -14,11 +14,34 @@ import {
tenants,
units,
} from "../../../db/schema"
import { buildZugferdInvoiceXml, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext } from "./buildZugferdInvoice"
import { buildZugferdInvoiceXml, checkZugferdRequirements, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext, ZugferdIssue } from "./buildZugferdInvoice"
import { embedZugferdXml } from "./embedZugferd"
export const isZugferdDocument = (document: any) =>
Boolean(document && ZUGFERD_DOCUMENT_TYPES.includes(document.type) && document.documentNumber)
export const isZugferdDocumentType = (type: unknown) => ZUGFERD_DOCUMENT_TYPES.includes(String(type))
export const isZugferdDocument = (document: any) => Boolean(document && isZugferdDocumentType(document.type) && document.documentNumber)
export class EInvoiceRequirementsError extends Error {
statusCode = 422
code = "EINVOICE_REQUIREMENTS"
constructor(public issues: ZugferdIssue[]) {
super(`Die E-Rechnung kann nicht erstellt werden: ${issues.map((issue) => issue.message.replace(/^E-Rechnung: /, "")).join("; ")}`)
}
}
export const breakingIssues = (issues: ZugferdIssue[]) => issues.filter((issue) => issue.type === "breaking")
/** Prüft die Pflichtangaben eines (auch ungespeicherten) Belegs. Andere Belegarten liefern keine Hinweise. */
export async function checkOutgoingEInvoice(server: FastifyInstance, tenantId: number, document: any, options: { requireNumber?: boolean } = {}) {
if (!document || !isZugferdDocumentType(document.type)) return [] as ZugferdIssue[]
const context = await loadZugferdContext(server, tenantId, document)
return checkZugferdRequirements(document, context, options)
}
/** Wirft EInvoiceRequirementsError, wenn Pflichtangaben fehlen. */
export async function assertOutgoingEInvoiceRequirements(server: FastifyInstance, tenantId: number, document: any, options: { requireNumber?: boolean } = {}) {
const blocking = breakingIssues(await checkOutgoingEInvoice(server, tenantId, document, options))
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
}
const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
if (id === null || id === undefined || id === "") return null
@@ -69,19 +92,15 @@ export async function loadZugferdContext(server: FastifyInstance, tenantId: numb
}
/**
* Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehler bei der E-Rechnung blockieren
* die Belegerstellung nicht: dann wird die unveränderte PDF mit einer Warnung zurückgegeben.
* Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehlende Pflichtangaben führen zu
* EInvoiceRequirementsError, damit keine Rechnung ohne gültige E-Rechnung abgelegt wird.
*/
export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) {
if (!isZugferdDocument(document)) return { pdf, warnings: [] as string[], einvoice: false }
try {
const context = await loadZugferdContext(server, tenantId, document)
const { xml, warnings } = buildZugferdInvoiceXml(document, context)
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
if (warnings.length) server.log.warn({ documentId: document.id, warnings }, "E-Rechnung mit Hinweisen erzeugt")
return { pdf: result, warnings, einvoice: true }
} catch (error) {
server.log.error({ err: error, documentId: document.id }, "E-Rechnung konnte nicht erzeugt werden")
return { pdf, warnings: ["E-Rechnung konnte nicht erzeugt werden"], einvoice: false }
}
if (!isZugferdDocument(document)) return { pdf, issues: [] as ZugferdIssue[], einvoice: false }
const context = await loadZugferdContext(server, tenantId, document)
const { xml, issues } = buildZugferdInvoiceXml(document, context)
const blocking = breakingIssues(issues)
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
return { pdf: result, issues, einvoice: true }
}

View File

@@ -9,7 +9,7 @@ import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
import { attachZugferdToPdf } from "./einvoice/outgoingZugferd.service";
import { assertOutgoingEInvoiceRequirements, attachZugferdToPdf, EInvoiceRequirementsError } from "./einvoice/outgoingZugferd.service";
import { documentTemplateHandlebars } from "../utils/handlebars";
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
@@ -202,6 +202,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
let successCount = 0;
let errorCount = 0;
const einvoiceErrors: string[] = [];
// 5. Loop durch Dokumente
for (const doc of documents) {
@@ -217,6 +218,9 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
]);
// Pflichtangaben vor der Nummernvergabe prüfen, damit keine Lücken im Nummernkreis entstehen
await assertOutgoingEInvoiceRequirements(server, tenantId, doc);
const pdfData = await getCloseData(
server,
doc,
@@ -274,6 +278,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
} catch (innerErr) {
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
errorCount++;
if (innerErr instanceof EInvoiceRequirementsError) einvoiceErrors.push(`Beleg ${doc.id}: ${innerErr.message}`);
// Optional: Status des einzelnen Dokuments auf Error setzen
}
}
@@ -283,7 +288,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.${einvoiceErrors.length ? ` ${einvoiceErrors.join(" ")}` : ""}`);
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
} catch (error) {
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");

View File

@@ -19,7 +19,7 @@ import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, createddocuments, files} from "../../db/schema";
import { attachZugferdToPdf } from "../modules/einvoice/outgoingZugferd.service";
import { attachZugferdToPdf, checkOutgoingEInvoice, EInvoiceRequirementsError } from "../modules/einvoice/outgoingZugferd.service";
import {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
@@ -137,7 +137,7 @@ export default async function functionRoutes(server: FastifyInstance) {
.limit(1)
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceWarnings: result.warnings }
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceIssues: result.issues }
}
} else if(type === "timesheet") {
pdf = await createTimeSheetPDF(
@@ -150,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
if (err instanceof EInvoiceRequirementsError) {
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
}
console.log(err)
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
if (unsupportedCharacterMessage) {
@@ -163,6 +166,14 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
// Prüft die Pflichtangaben der E-Rechnung für den aktuellen (ggf. ungespeicherten) Stand aus dem Editor
server.post("/functions/einvoice/check", async (req, reply) => {
const { document } = (req.body || {}) as { document?: any }
if (!document || typeof document !== "object") return reply.code(400).send({ error: "document fehlt" })
const issues = await checkOutgoingEInvoice(server, (req as any).user.tenant_id, document)
return { issues }
})
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
const { numberrange } = req.params as { numberrange: string };
const tenant = (req as any).user.tenant_id