feat(accounting): Ausgangsrechnungen als ZUGFeRD-E-Rechnung (EN 16931) erzeugen

Rechnungen, Abschlags-, Storno- und Serienrechnungen erhalten beim
Fertigstellen ein eingebettetes factur-x.xml (CII, Profil EN 16931) samt
Factur-X-XMP-Metadaten. Das XML wird serverseitig aus dem gespeicherten
Beleg erzeugt; fehlende Pflichtangaben werden als Hinweis gemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 10:15:23 +01:00
parent bd768e404d
commit 549b469a41
9 changed files with 726 additions and 8 deletions

View File

@@ -0,0 +1,87 @@
import { FastifyInstance } from "fastify"
import { and, eq, inArray } from "drizzle-orm"
import {
authProfiles,
bankaccounts,
contacts,
contracts,
createddocuments,
customers,
outgoingsepamandates,
products,
projects,
services,
tenants,
units,
} from "../../../db/schema"
import { buildZugferdInvoiceXml, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext } from "./buildZugferdInvoice"
import { embedZugferdXml } from "./embedZugferd"
export const isZugferdDocument = (document: any) =>
Boolean(document && ZUGFERD_DOCUMENT_TYPES.includes(document.type) && document.documentNumber)
const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
if (id === null || id === undefined || id === "") return null
const [row] = await server.db.select().from(table).where(and(eq(table.id, Number(id)), eq(table.tenant, tenantId))).limit(1)
return row || null
}
export async function loadZugferdContext(server: FastifyInstance, tenantId: number, document: any): Promise<ZugferdInvoiceContext> {
const rows = document.rows || []
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
const profileUser = document.contactPerson || document.created_by
const [[tenant], customer, contact, project, contract, linkedDocument, sepaMandate, unitRows, productRows, serviceRows, bankAccountRows, advanceRows, profileRows] = await Promise.all([
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
byId(server, customers, document.customer, tenantId),
byId(server, contacts, document.contact, tenantId),
byId(server, projects, document.project, tenantId),
byId(server, contracts, document.contract, tenantId),
byId(server, createddocuments, document.createddocument ?? document.linkedDocument, tenantId),
byId(server, outgoingsepamandates, document.outgoingsepamandate, tenantId),
server.db.select().from(units),
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
profileUser
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, profileUser), eq(authProfiles.tenant_id, tenantId))).limit(1)
: [],
])
return {
tenant,
customer,
contact,
contactPerson: (profileRows as any[])[0] || null,
project,
contract,
linkedDocument,
sepaMandate,
units: unitRows,
products: productRows as any[],
services: serviceRows as any[],
bankAccounts: bankAccountRows,
advanceInvoices: advanceIds.map((id) => (advanceRows as any[]).find((invoice) => invoice.id === id)).filter(Boolean),
}
}
/**
* 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.
*/
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 }
}
}