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:
87
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal file
87
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user