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 { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
|
||||||
import { withImages } from "../result"
|
import { withImages } from "../result"
|
||||||
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
||||||
|
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
|
||||||
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
||||||
import { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
|
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) }
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
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)
|
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
||||||
|
|
||||||
payload.state = "Gebucht"
|
payload.state = "Gebucht"
|
||||||
payload.documentNumber = result.usedNumber
|
payload.documentNumber = result.usedNumber
|
||||||
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
|
||||||
|
|
||||||
const [updated] = await context.server.db
|
const [updated] = await context.server.db
|
||||||
.update(createddocuments)
|
.update(createddocuments)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type ZugferdInvoiceContext = {
|
|||||||
|
|
||||||
export type ZugferdInvoiceResult = {
|
export type ZugferdInvoiceResult = {
|
||||||
xml: string
|
xml: string
|
||||||
warnings: string[]
|
issues: ZugferdIssue[]
|
||||||
totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number }
|
totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +144,53 @@ function taxRegistrations(vatId: unknown, taxNumber?: unknown) {
|
|||||||
].join("")
|
].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 {
|
export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceContext): ZugferdInvoiceResult {
|
||||||
const warnings: string[] = []
|
|
||||||
const businessInfo = context.tenant?.businessInfo || {}
|
const businessInfo = context.tenant?.businessInfo || {}
|
||||||
const customer = context.customer || {}
|
const customer = context.customer || {}
|
||||||
const customerInfo = customer.infoData || {}
|
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.
|
// Stornorechnungen tragen negative Preise; als Gutschrift (381) werden die Beträge positiv ausgewiesen.
|
||||||
const sign = document.type === "cancellationInvoices" ? -1 : 1
|
const sign = document.type === "cancellationInvoices" ? -1 : 1
|
||||||
|
|
||||||
if (!text(document.documentNumber)) warnings.push("Rechnungsnummer fehlt")
|
const issues = checkZugferdRequirements(document, context, { requireNumber: true })
|
||||||
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 groups = new Map<string, TaxGroup>()
|
const groups = new Map<string, TaxGroup>()
|
||||||
const lines: string[] = []
|
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 taxGroups = [...groups.values()]
|
||||||
const lineNet = round(taxGroups.reduce((sum, group) => sum + group.basis, 0))
|
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))
|
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 = ""
|
let paymentMeans = ""
|
||||||
if (document.payment_type === "direct-debit") {
|
if (document.payment_type === "direct-debit") {
|
||||||
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>59</ram:TypeCode><ram:Information>SEPA-Lastschrift</ram:Information></ram:SpecifiedTradeSettlementPaymentMeans>`
|
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) {
|
} 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>` +
|
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 ? `<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>` : "") +
|
(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:SpecifiedTradeSettlementHeaderMonetarySummation>${precedingInvoice}` +
|
||||||
`</ram:ApplicableHeaderTradeSettlement></rsm:SupplyChainTradeTransaction></rsm:CrossIndustryInvoice>`
|
`</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,
|
tenants,
|
||||||
units,
|
units,
|
||||||
} from "../../../db/schema"
|
} 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"
|
import { embedZugferdXml } from "./embedZugferd"
|
||||||
|
|
||||||
export const isZugferdDocument = (document: any) =>
|
export const isZugferdDocumentType = (type: unknown) => ZUGFERD_DOCUMENT_TYPES.includes(String(type))
|
||||||
Boolean(document && ZUGFERD_DOCUMENT_TYPES.includes(document.type) && document.documentNumber)
|
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) => {
|
const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
|
||||||
if (id === null || id === undefined || id === "") return null
|
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
|
* Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehlende Pflichtangaben führen zu
|
||||||
* die Belegerstellung nicht: dann wird die unveränderte PDF mit einer Warnung zurückgegeben.
|
* EInvoiceRequirementsError, damit keine Rechnung ohne gültige E-Rechnung abgelegt wird.
|
||||||
*/
|
*/
|
||||||
export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) {
|
export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) {
|
||||||
if (!isZugferdDocument(document)) return { pdf, warnings: [] as string[], einvoice: false }
|
if (!isZugferdDocument(document)) return { pdf, issues: [] as ZugferdIssue[], einvoice: false }
|
||||||
try {
|
const context = await loadZugferdContext(server, tenantId, document)
|
||||||
const context = await loadZugferdContext(server, tenantId, document)
|
const { xml, issues } = buildZugferdInvoiceXml(document, context)
|
||||||
const { xml, warnings } = buildZugferdInvoiceXml(document, context)
|
const blocking = breakingIssues(issues)
|
||||||
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
|
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
|
||||||
if (warnings.length) server.log.warn({ documentId: document.id, warnings }, "E-Rechnung mit Hinweisen erzeugt")
|
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
|
||||||
return { pdf: result, warnings, einvoice: true }
|
return { pdf: result, issues, 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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { saveFile } from "../utils/files";
|
|||||||
import {FastifyInstance} from "fastify";
|
import {FastifyInstance} from "fastify";
|
||||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
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 { documentTemplateHandlebars } from "../utils/handlebars";
|
||||||
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
||||||
|
|
||||||
@@ -202,6 +202,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
const einvoiceErrors: string[] = [];
|
||||||
|
|
||||||
// 5. Loop durch Dokumente
|
// 5. Loop durch Dokumente
|
||||||
for (const doc of documents) {
|
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
|
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(
|
const pdfData = await getCloseData(
|
||||||
server,
|
server,
|
||||||
doc,
|
doc,
|
||||||
@@ -274,6 +278,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
} catch (innerErr) {
|
} catch (innerErr) {
|
||||||
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
if (innerErr instanceof EInvoiceRequirementsError) einvoiceErrors.push(`Beleg ${doc.id}: ${innerErr.message}`);
|
||||||
// Optional: Status des einzelnen Dokuments auf Error setzen
|
// 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,
|
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 };
|
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
|
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 timezone from "dayjs/plugin/timezone.js";
|
||||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||||
import {citys, createddocuments, files} from "../../db/schema";
|
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 {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
@@ -137,7 +137,7 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
|
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
|
||||||
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
|
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") {
|
} else if(type === "timesheet") {
|
||||||
pdf = await createTimeSheetPDF(
|
pdf = await createTimeSheetPDF(
|
||||||
@@ -150,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
return pdf // Fastify wandelt automatisch in JSON
|
return pdf // Fastify wandelt automatisch in JSON
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof EInvoiceRequirementsError) {
|
||||||
|
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
|
||||||
|
}
|
||||||
console.log(err)
|
console.log(err)
|
||||||
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
||||||
if (unsupportedCharacterMessage) {
|
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) => {
|
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
||||||
const { numberrange } = req.params as { numberrange: string };
|
const { numberrange } = req.params as { numberrange: string };
|
||||||
const tenant = (req as any).user.tenant_id
|
const tenant = (req as any).user.tenant_id
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import test from "node:test"
|
import test from "node:test"
|
||||||
import { PDFDocument, StandardFonts } from "pdf-lib"
|
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 { embedZugferdXml } from "../src/modules/einvoice/embedZugferd"
|
||||||
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
|
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
|
||||||
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
|
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
|
||||||
@@ -32,6 +32,7 @@ const context = (overrides: Partial<ZugferdInvoiceContext> = {}): ZugferdInvoice
|
|||||||
const invoice = (overrides: any = {}) => ({
|
const invoice = (overrides: any = {}) => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
type: "invoices",
|
type: "invoices",
|
||||||
|
customer: 5,
|
||||||
documentNumber: "RE-2026-0042",
|
documentNumber: "RE-2026-0042",
|
||||||
documentDate: "2026-11-04",
|
documentDate: "2026-11-04",
|
||||||
deliveryDateType: "Lieferdatum",
|
deliveryDateType: "Lieferdatum",
|
||||||
@@ -55,9 +56,9 @@ const invoice = (overrides: any = {}) => ({
|
|||||||
const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`<ram:${name}(?:\\s[^>]*)?>([^<]*)</ram:${name}>`, "g"))].map((match) => match[1])
|
const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`<ram:${name}(?:\\s[^>]*)?>([^<]*)</ram:${name}>`, "g"))].map((match) => match[1])
|
||||||
|
|
||||||
test("erzeugt eine EN-16931-Rechnung mit konsistenten Summen", () => {
|
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)
|
// 23,45 + 81,00 + 20,00 - 5,00 (optionale Position ausgeschlossen)
|
||||||
assert.equal(totals.lineNet, 119.45)
|
assert.equal(totals.lineNet, 119.45)
|
||||||
// 19 %: 99,45 -> 18,90; 7 %: 20,00 -> 1,40
|
// 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())
|
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, /<ram:ExemptionReason>Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/)
|
assert.match(smallBusiness.xml, /<ram:ExemptionReason>Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/)
|
||||||
assert.match(smallBusiness.xml, /<ram:CategoryCode>E<\/ram:CategoryCode>/)
|
assert.match(smallBusiness.xml, /<ram:CategoryCode>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", () => {
|
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",
|
deliveryDateType: "Leistungszeitraum", deliveryDate: "2026-10-01", deliveryDateEnd: "2026-10-31",
|
||||||
payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7,
|
payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7,
|
||||||
}), context({ sepaMandate: { reference: "MANDAT-1" } }))
|
}), context({ sepaMandate: { reference: "MANDAT-1" } }))
|
||||||
|
|
||||||
assert.deepEqual(warnings, [])
|
assert.deepEqual(issues, [])
|
||||||
assert.match(xml, /<ram:BillingSpecifiedPeriod><ram:StartDateTime><udt:DateTimeString format="102">20261001/)
|
assert.match(xml, /<ram:BillingSpecifiedPeriod><ram:StartDateTime><udt:DateTimeString format="102">20261001/)
|
||||||
assert.match(xml, /<ram:CreditorReferenceID>DE98ZZZ09999999999<\/ram:CreditorReferenceID>/)
|
assert.match(xml, /<ram:CreditorReferenceID>DE98ZZZ09999999999<\/ram:CreditorReferenceID>/)
|
||||||
assert.match(xml, /<ram:TypeCode>59<\/ram:TypeCode>/)
|
assert.match(xml, /<ram:TypeCode>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: "m²" }), "MTK")
|
||||||
assert.equal(unitCode({ short: "unbekannt" }), "C62")
|
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"])
|
||||||
|
})
|
||||||
|
|||||||
@@ -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(() => {
|
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.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"})
|
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 selectedTab = ref("0")
|
||||||
|
|
||||||
const closeDocument = async () => {
|
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") {
|
if(selectedTab.value === "0") {
|
||||||
await generateDocument()
|
await generateDocument()
|
||||||
@@ -2034,7 +2092,16 @@ const closeDocument = async () => {
|
|||||||
|
|
||||||
await saveDocument("Gebucht")
|
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
|
guard.value = false
|
||||||
|
|
||||||
@@ -2052,13 +2119,8 @@ const uploadGeneratedDocument = async () => {
|
|||||||
await generateDocument({
|
await generateDocument({
|
||||||
createdDocumentId: itemInfo.value.id,
|
createdDocumentId: itemInfo.value.id,
|
||||||
onResult: (result) => {
|
onResult: (result) => {
|
||||||
if (result?.einvoiceWarnings?.length) {
|
const hints = (result?.einvoiceIssues || []).map(i => i.message)
|
||||||
toast.add({
|
if (hints.length) toast.add({ title: "E-Rechnung mit Hinweisen erstellt", description: hints.join(" · "), color: "warning" })
|
||||||
title: result.einvoice ? "E-Rechnung mit Hinweisen erstellt" : "E-Rechnung konnte nicht erstellt werden",
|
|
||||||
description: result.einvoiceWarnings.join(" · "),
|
|
||||||
color: "warning",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user