117 lines
6.4 KiB
TypeScript
117 lines
6.4 KiB
TypeScript
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<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),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
}
|