From e9697ef19773bb92aa786f3286310509ff2f83bc Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 4 Nov 2026 11:24:28 +0100 Subject: [PATCH] feat(accounting): Pflichtangaben der E-Rechnung vor dem Fertigstellen durchsetzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/mcp/tools/accounting.ts | 5 +- .../modules/einvoice/buildZugferdInvoice.ts | 66 +++++++++++---- .../einvoice/outgoingZugferd.service.ts | 51 ++++++++---- .../src/modules/serialexecution.service.ts | 9 ++- backend/src/routes/functions.ts | 15 +++- backend/tests/zugferdInvoice.test.ts | 53 ++++++++++-- frontend/pages/createDocument/edit/[[id]].vue | 80 ++++++++++++++++--- 7 files changed, 224 insertions(+), 55 deletions(-) diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index d8ea15c8..b38460a4 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -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) diff --git a/backend/src/modules/einvoice/buildZugferdInvoice.ts b/backend/src/modules/einvoice/buildZugferdInvoice.ts index b08e7861..f7119986 100644 --- a/backend/src/modules/einvoice/buildZugferdInvoice.ts +++ b/backend/src/modules/einvoice/buildZugferdInvoice.ts @@ -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() 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 = `59SEPA-Lastschrift` - 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 = `58SEPA-Überweisung` + (iban ? `${escapeXml(iban)}${el("ram:AccountName", accountName)}` : "") + (iban && bic ? `${escapeXml(bic)}` : "") + @@ -338,5 +370,5 @@ export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceCon `${precedingInvoice}` + `` - return { xml, warnings, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } } + return { xml, issues, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } } } diff --git a/backend/src/modules/einvoice/outgoingZugferd.service.ts b/backend/src/modules/einvoice/outgoingZugferd.service.ts index a400c061..bfa21c1b 100644 --- a/backend/src/modules/einvoice/outgoingZugferd.service.ts +++ b/backend/src/modules/einvoice/outgoingZugferd.service.ts @@ -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 } } diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index 91f15c30..715ac94c 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -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."); diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index abbf19af..d8068154 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -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 diff --git a/backend/tests/zugferdInvoice.test.ts b/backend/tests/zugferdInvoice.test.ts index 8aa8b97d..5c69d591 100644 --- a/backend/tests/zugferdInvoice.test.ts +++ b/backend/tests/zugferdInvoice.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import test from "node:test" import { PDFDocument, StandardFonts } from "pdf-lib" -import { buildZugferdInvoiceXml, countryCode, unitCode, ZugferdInvoiceContext } from "../src/modules/einvoice/buildZugferdInvoice" +import { buildZugferdInvoiceXml, checkZugferdRequirements, countryCode, unitCode, ZugferdInvoiceContext } from "../src/modules/einvoice/buildZugferdInvoice" import { embedZugferdXml } from "../src/modules/einvoice/embedZugferd" import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice" import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice" @@ -32,6 +32,7 @@ const context = (overrides: Partial = {}): ZugferdInvoice const invoice = (overrides: any = {}) => ({ id: 1, type: "invoices", + customer: 5, documentNumber: "RE-2026-0042", documentDate: "2026-11-04", deliveryDateType: "Lieferdatum", @@ -55,9 +56,9 @@ const invoice = (overrides: any = {}) => ({ const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`]*)?>([^<]*)`, "g"))].map((match) => match[1]) test("erzeugt eine EN-16931-Rechnung mit konsistenten Summen", () => { - const { xml, warnings, totals } = buildZugferdInvoiceXml(invoice(), context()) + const { xml, issues, totals } = buildZugferdInvoiceXml(invoice(), context()) - assert.deepEqual(warnings, []) + assert.deepEqual(issues, []) // 23,45 + 81,00 + 20,00 - 5,00 (optionale Position ausgeschlossen) assert.equal(totals.lineNet, 119.45) // 19 %: 99,45 -> 18,90; 7 %: 20,00 -> 1,40 @@ -142,18 +143,15 @@ test("§ 13b und Kleinunternehmer verwenden die passenden Steuerkategorien", () const smallBusiness = buildZugferdInvoiceXml(invoice({ taxType: "19 UStG", rows: [{ pos: "1", mode: "free", text: "Beratung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context()) assert.match(smallBusiness.xml, /Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/) assert.match(smallBusiness.xml, /E<\/ram:CategoryCode>/) - - const missingVat = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Privat", infoData: { city: "X" } } })) - assert.ok(missingVat.warnings.some((warning) => warning.includes("USt-IdNr. des Kunden"))) }) test("Leistungszeitraum, Lastschrift und Skonto werden strukturiert übertragen", () => { - const { xml, warnings } = buildZugferdInvoiceXml(invoice({ + const { xml, issues } = buildZugferdInvoiceXml(invoice({ deliveryDateType: "Leistungszeitraum", deliveryDate: "2026-10-01", deliveryDateEnd: "2026-10-31", payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7, }), context({ sepaMandate: { reference: "MANDAT-1" } })) - assert.deepEqual(warnings, []) + assert.deepEqual(issues, []) assert.match(xml, /20261001/) assert.match(xml, /DE98ZZZ09999999999<\/ram:CreditorReferenceID>/) assert.match(xml, /59<\/ram:TypeCode>/) @@ -170,3 +168,42 @@ test("ordnet Länder und Einheiten den Codelisten zu", () => { assert.equal(unitCode({ short: "m²" }), "MTK") assert.equal(unitCode({ short: "unbekannt" }), "C62") }) + +const breakingMessages = (document: any, ctx: ZugferdInvoiceContext, requireNumber = false) => + checkZugferdRequirements(document, ctx, { requireNumber }).filter((issue) => issue.type === "breaking").map((issue) => issue.message) + +test("Pflichtangaben: vollständiger Beleg hat keine blockierenden Fehler", () => { + assert.deepEqual(breakingMessages(invoice(), context(), true), []) + assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context()), []) + assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context(), true), ["E-Rechnung: Die Rechnungsnummer fehlt"]) +}) + +test("Pflichtangaben: fehlende Firmendaten, Kundenanschrift und IBAN blockieren", () => { + const ctx = context({ + tenant: { businessInfo: { name: "Muster GmbH", street: "", zip: "12345", city: "Musterstadt" } }, + }) + const messages = breakingMessages(invoice({ address: { street: "Kundenweg 5", zip: "", city: "Kundenstadt" } }), ctx) + + assert.ok(messages.some((message) => message.includes("Firmenanschrift"))) + assert.ok(messages.some((message) => message.includes("weder eine USt-IdNr. noch eine Steuernummer"))) + assert.ok(messages.some((message) => message.includes("Anschrift des Kunden"))) + assert.ok(messages.some((message) => message.includes("keine IBAN"))) + // Ein Bankkonto mit IBAN genügt anstelle der IBAN in den Firmendaten + const withAccount = context({ bankAccounts: [{ iban: "DE02120300000000202051" }] }) + withAccount.tenant.businessInfo.iban = "" + assert.deepEqual(breakingMessages(invoice(), withAccount), []) +}) + +test("Pflichtangaben: § 13b und Lastschrift verlangen ihre Zusatzangaben", () => { + const reverseCharge = breakingMessages(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Bau AG", infoData: {} } })) + assert.deepEqual(reverseCharge, ["E-Rechnung: Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein"]) + + const directDebit = breakingMessages(invoice({ payment_type: "direct-debit" }), context({ tenant: { ...context().tenant, creditorId: "" } })) + assert.deepEqual(directDebit, [ + "E-Rechnung: Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt", + "E-Rechnung: Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt", + ]) + + const noDate = checkZugferdRequirements(invoice({ deliveryDateType: "Kein Lieferdatum anzeigen" }), context()) + assert.deepEqual(noDate.map((issue) => issue.type), ["info"]) +}) diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 22fe87a2..0b05aacf 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -1131,8 +1131,54 @@ const checkAddressZip = async () => { } } +// Pflichtangaben der E-Rechnung (ZUGFeRD) prüft das Backend, da Firmen- und Kundendaten dort vollständig vorliegen +const eInvoiceDocumentTypes = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"] +const eInvoiceIssues = ref([]) +const eInvoiceCheckSource = computed(() => { + if (!loaded.value || isTemplateMode.value || !eInvoiceDocumentTypes.includes(itemInfo.value.type)) return null + const item = itemInfo.value + return JSON.stringify({ + type: item.type, + customer: item.customer, + contact: item.contact, + project: item.project, + contract: item.contract, + address: item.address, + rows: item.rows, + taxType: item.taxType, + payment_type: item.payment_type, + outgoingsepamandate: item.outgoingsepamandate, + deliveryDateType: item.deliveryDateType, + usedAdvanceInvoices: item.usedAdvanceInvoices, + createddocument: item.createddocument, + created_by: item.created_by, + }) +}) + +const refreshEInvoiceIssues = async () => { + const source = eInvoiceCheckSource.value + if (!source) { + eInvoiceIssues.value = [] + return eInvoiceIssues.value + } + try { + const result = await useNuxtApp().$api("/api/functions/einvoice/check", { + method: "POST", + body: { document: JSON.parse(source) }, + }) + // Nur übernehmen, wenn sich der Beleg während der Anfrage nicht geändert hat + if (source === eInvoiceCheckSource.value) eInvoiceIssues.value = result?.issues || [] + } catch (error) { + console.error(error) + eInvoiceIssues.value = [{ message: "E-Rechnung: Die Pflichtangaben konnten nicht geprüft werden", type: "breaking" }] + } + return eInvoiceIssues.value +} + +watchDebounced(eInvoiceCheckSource, refreshEInvoiceIssues, { debounce: 500, immediate: true }) + const findDocumentErrors = computed(() => { - let errors = [] + let errors = [...eInvoiceIssues.value] if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"}) if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"}) @@ -2025,6 +2071,18 @@ const saveFinalizedCostCentres = async () => { const selectedTab = ref("0") const closeDocument = async () => { + // Vor Vorschau und Fertigstellung mit dem aktuellen Stand prüfen, nicht mit dem letzten (entprellten) Ergebnis + await refreshEInvoiceIssues() + const breakingErrors = findDocumentErrors.value.filter(i => i.type === "breaking") + if (breakingErrors.length > 0) { + selectedTab.value = "0" + toast.add({ + title: "Beleg kann nicht fertiggestellt werden", + description: breakingErrors.map(i => i.message).join(" · "), + color: "error", + }) + return + } if(selectedTab.value === "0") { await generateDocument() @@ -2034,7 +2092,16 @@ const closeDocument = async () => { await saveDocument("Gebucht") - await uploadGeneratedDocument() + try { + await uploadGeneratedDocument() + } catch (error) { + console.error(error) + toast.add({ + title: "PDF bzw. E-Rechnung konnte nicht erstellt werden", + description: error?.data?.error || error?.message, + color: "error", + }) + } guard.value = false @@ -2052,13 +2119,8 @@ const uploadGeneratedDocument = async () => { await generateDocument({ createdDocumentId: itemInfo.value.id, onResult: (result) => { - if (result?.einvoiceWarnings?.length) { - toast.add({ - title: result.einvoice ? "E-Rechnung mit Hinweisen erstellt" : "E-Rechnung konnte nicht erstellt werden", - description: result.einvoiceWarnings.join(" · "), - color: "warning", - }) - } + const hints = (result?.einvoiceIssues || []).map(i => i.message) + if (hints.length) toast.add({ title: "E-Rechnung mit Hinweisen erstellt", description: hints.join(" · "), color: "warning" }) }, })