feat(accounting): Pflichtangaben der E-Rechnung vor dem Fertigstellen durchsetzen
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:
@@ -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)
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user