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, checkZugferdRequirements, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext, ZugferdIssue } from "./buildZugferdInvoice" import { embedZugferdXml } from "./embedZugferd" 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") /** Ausgangs-E-Rechnungen sind pro Mandant ausdrücklich zu aktivieren. */ async function outgoingEInvoiceEnabled(server: FastifyInstance, tenantId: number) { const [tenant] = await server.db.select({ businessInfo: tenants.businessInfo }).from(tenants) .where(eq(tenants.id, tenantId)).limit(1) if (!tenant) throw new Error("Mandant nicht gefunden") return (tenant.businessInfo as any)?.outgoingEInvoiceEnabled === true } /** 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[] if (!await outgoingEInvoiceEnabled(server, tenantId)) 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 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 { 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), } } /** * Bei aktivierter Ausgangs-E-Rechnung wird die PDF zur 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, issues: [] as ZugferdIssue[], einvoice: false } if (!await outgoingEInvoiceEnabled(server, tenantId)) 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 } }