/** * Gated ZUGFeRD orchestrator — the single entry point used by every route that * transmits an invoice PDF (email, Lexoffice upload, Amazon upload). * * const outBuffer = await maybeZugferdPdf(event, token, invoiceId, pdfBuffer) * * Behaviour: * - Resolve the invoice's partner and its `IsZugPferdInvoice` flag. * - Flag false / column missing / any error → return `plainPdfBuffer` unchanged * (today's behaviour is fully preserved — fail-soft, never blocks send/upload). * - Flag true, OR `opts.force: true` → gather invoice data (lines, tax, buyer + seller * address/VAT), build the EN16931 CII XML and merge it into the iDempiere PDF. * `force` bypasses the partner flag (used by the Lexoffice upload, which always wants * the hybrid PDF) but not the fail-soft error handling below — a build/fetch error * still falls back to the plain PDF. * * No PDF is built from scratch: `plainPdfBuffer` is iDempiere's own invoice PDF; * we only generate the XML and embed it. * * `gatherZugferdData` / `INVOICE_EXPAND` are exported so the diagnostic preview * route can build the XML for any invoice without the flag gate. */ import buildInvoiceXml, { type ZugferdInvoiceData, type ZugferdLine, type ZugferdTaxBreakdown } from './buildInvoiceXml' import embedZugferdXml from './embedXml' /** * Top-level expands only. iDempiere rejects a nested $expand inside a master * expand ("Expanding a master only support the $select query operator"), so the * address chain (bpartner_location → c_location → c_country) is resolved with a * follow-up c_location fetch instead (see resolveBpLocationAddress). */ export const INVOICE_EXPAND = 'C_BPartner_ID,C_BPartner_Location_ID,C_Currency_ID,C_Order_ID,AD_Org_ID,C_PaymentTerm_ID' const isZugFlag = (v: any): boolean => v === true || v === 'Y' const num = (v: any): number => Number(v) || 0 const round2 = (n: number): number => Math.round((Number(n) || 0) * 100) / 100 const round4 = (n: number): number => Math.round((Number(n) || 0) * 10000) / 10000 /** Add `days` to a YYYY-MM-DD / ISO date string; returns YYYY-MM-DD ('' if unparseable). */ const addDays = (dateStr: string, days: number): string => { const m = String(dateStr || '').match(/(\d{4})-(\d{2})-(\d{2})/) if (!m) return '' const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))) d.setUTCDate(d.getUTCDate() + (Number(days) || 0)) return d.toISOString().slice(0, 10) } const fkVal = (v: any): any => (v && typeof v === 'object' ? (v.id ?? v.identifier) : v) type ZugAddress = { street: string; city: string; postcode: string; countryCode: string } const EMPTY_ADDRESS: ZugAddress = { street: '', city: '', postcode: '', countryCode: 'DE' } /** Fetch a c_location (+ its country) and map it to a ZUGFeRD postal address. * `$expand=C_Country_ID` here is a top-level expand on the primary resource, * which iDempiere allows (the rejected case is an expand nested in an expand). */ const fetchAddress = async (event: any, token: any, cLocationId: any): Promise => { if (!cLocationId) return { ...EMPTY_ADDRESS } try { const loc: any = await event.context.fetch(`models/c_location/${cLocationId}?$expand=C_Country_ID`, 'GET', token, null) if (!loc?.id) return { ...EMPTY_ADDRESS } const country = loc?.C_Country_ID || {} return { street: [loc?.Address1, loc?.Address2].filter(Boolean).join(' ') || '', city: loc?.City || '', postcode: loc?.Postal || '', countryCode: country?.CountryCode || 'DE', } } catch (e) { console.error('[ZUGFeRD] c_location lookup failed:', e) return { ...EMPTY_ADDRESS } } } /** Resolve a postal address from a c_bpartner_location reference. The ref may be * an already-expanded record (carries C_Location_ID) or just an { id } stub, in * which case the bpartner_location is fetched to find its C_Location_ID first. */ const resolveBpLocationAddress = async (event: any, token: any, bpLocRef: any): Promise => { let cLocId = fkVal(bpLocRef?.C_Location_ID) if (!cLocId) { const bpLocId = fkVal(bpLocRef) if (bpLocId) { try { const bpLoc: any = await event.context.fetch(`models/c_bpartner_location/${bpLocId}`, 'GET', token, null) cLocId = fkVal(bpLoc?.C_Location_ID) } catch (e) { console.error('[ZUGFeRD] c_bpartner_location lookup failed:', e) } } } return fetchAddress(event, token, cLocId) } /** * Seller postal address: scan the org partner's c_bpartner_locations (each → * c_location) and return the first one that actually has a city; falls back to * the first resolvable address. Tolerates an org partner with several locations * where only one carries the full registered address. */ const resolveSellerAddress = async (event: any, token: any, bpId: any): Promise => { if (!bpId) return { ...EMPTY_ADDRESS } try { const res: any = await event.context.fetch( `models/c_bpartner_location?$filter=C_BPartner_ID eq ${bpId}&$orderby=c_bpartner_location_id asc&$top=10`, 'GET', token, null, ) const recs: any[] = res?.records || [] let firstAddr: ZugAddress | null = null for (const r of recs) { const addr = await fetchAddress(event, token, fkVal(r?.C_Location_ID)) if (!firstAddr) firstAddr = addr if (addr.city) return addr } return firstAddr || { ...EMPTY_ADDRESS } } catch (e) { console.error('[ZUGFeRD] seller address resolution failed:', e) return { ...EMPTY_ADDRESS } } } /** * Resolve the SELLER = the c_bpartner linked to the organization * (ad_org.C_BPartner_ID). The seller is the org's partner — NOT the invoice's * C_BPartner_ID (that is the buyer/customer). Prefers the app's dedicated * org→bpartner endpoint (CLAUDE.md recipe); falls back to a direct fetch. * Returns the full c_bpartner record (incl. TaxID) or {}. */ const resolveOrgBpartner = async (event: any, token: any, orgId: any): Promise => { // 1) Dedicated endpoint: GET /api/admin/organizations/{id}/bpartner try { const bp: any = await $fetch(`/api/admin/organizations/${orgId}/bpartner`, { headers: { cookie: getHeader(event, 'cookie') || '' }, }) if (bp?.id) return bp } catch (e) { console.error('[ZUGFeRD] org→bpartner endpoint failed, falling back to direct fetch:', e) } // 2) Direct fallback: ad_org → C_BPartner_ID → c_bpartner try { const orgRes: any = await event.context.fetch(`models/ad_org/${orgId}`, 'GET', token, null) const bpId = fkVal(orgRes?.C_BPartner_ID) if (bpId) { const bp: any = await event.context.fetch(`models/c_bpartner/${bpId}`, 'GET', token, null) if (bp?.id) return bp } else { console.error(`[ZUGFeRD] Org ${orgId} has no linked C_BPartner_ID.`) } } catch (e) { console.error('[ZUGFeRD] org→bpartner direct fallback failed:', e) } return {} } /** True when the (already expanded) invoice's partner opted into ZUGFeRD. */ export const isZugferdEnabled = (inv: any): boolean => { const partner = inv?.C_BPartner_ID || {} return isZugFlag(partner?.IsZugPferdInvoice ?? partner?.isZugPferdInvoice) } /** * Human-readable warnings about master data that makes the e-invoice invalid or * incomplete (things the user must fix in iDempiere, not in code). The most * important is a missing seller VAT id — without it BR-S-02 / BR-CO-26 fail. */ export const zugferdDataWarnings = (data: ZugferdInvoiceData): string[] => { // Only genuine blockers that make the e-invoice INVALID (hard EN16931 errors). // Soft notices (missing Handelsregisternummer, incomplete address) are intentionally // omitted — they're warnings on some validators but Lexware accepts the invoice. const w: string[] = [] if (!data?.seller?.vatId) { w.push("Umsatzsteuer-ID des Verkäufers fehlt – bitte C_BPartner.TaxID am Organisations-Geschäftspartner (ad_org.C_BPartner_ID) pflegen. Ohne sie ist die E-Rechnung ungültig (BR-S-02 / BR-CO-26).") } if (!data?.seller?.name) w.push('Name des Verkäufers (Organisations-Geschäftspartner) fehlt.') if (!data?.buyer?.name) w.push('Name des Rechnungsempfängers fehlt.') if (!(data?.lines?.length)) w.push('Die Rechnung enthält keine Positionen.') return w } /** * Map an invoice (already fetched with INVOICE_EXPAND) + its lines/tax/seller * into the normalized model the XML builder consumes. Does NOT check the flag. */ export const gatherZugferdData = async (event: any, token: any, inv: any): Promise => { const invoiceId = inv?.id const partner = inv?.C_BPartner_ID || {} const [linesRes, invoiceTaxRes]: [any, any] = await Promise.all([ event.context.fetch( `models/c_invoiceline?$filter=C_Invoice_ID eq ${invoiceId}&$expand=M_Product_ID,C_Tax_ID&$orderby=Line asc&$top=1000`, 'GET', token, null, ), event.context.fetch( `models/c_invoicetax?$filter=C_Invoice_ID eq ${invoiceId}&$expand=C_Tax_ID&$top=100`, 'GET', token, null, ).catch((e: any) => { console.error('[ZUGFeRD] c_invoicetax lookup failed, falling back to computed VAT amounts:', e) return null }), ]) // iDempiere stores tax-inclusive ("Brutto") line amounts as gross; EN16931 wants // every line/total NET. When tax is included, convert each line back to net. const taxIncluded = inv?.IsTaxIncluded === true || inv?.IsTaxIncluded === 'Y' // Seller = the c_bpartner linked to the INVOICE's organization // (AD_Org_ID on the invoice → ad_org.C_BPartner_ID → c_bpartner). Its TaxID is the // seller's Umsatzsteuer-ID (BT-31, required by BR-S-02). Fail-soft per fetch. const orgId = fkVal(inv?.AD_Org_ID) let sellerBp: any = {} let sellerVatId = '' let sellerLegalRegId = '' let paymentIban = '' if (orgId) { // Seller c_bpartner = ad_org.C_BPartner_ID (via the dedicated endpoint). sellerBp = await resolveOrgBpartner(event, token, orgId) // iDempiere OData casing varies by model — accept TaxID / taxID / taxId. sellerVatId = String(sellerBp?.TaxID ?? sellerBp?.taxID ?? sellerBp?.taxId ?? '').trim() // BT-30 Handelsregisternummer — optional custom column `hrg_number` on the org // partner; emitted only when present (no notice when absent). sellerLegalRegId = String( sellerBp?.hrg_number ?? sellerBp?.Hrg_Number ?? sellerBp?.HRG_Number ?? sellerBp?.hrgNumber ?? '', ).trim() // Fallback: organization-level tax id (AD_OrgInfo.TaxID) when the bpartner has none. if (!sellerVatId) { try { const orgInfoRes: any = await event.context.fetch( `models/ad_orginfo?$filter=AD_Org_ID eq ${orgId}&$top=1`, 'GET', token, null, ) const oi: any = orgInfoRes?.records?.[0] || {} sellerVatId = String(oi?.TaxID ?? oi?.taxID ?? oi?.taxId ?? '').trim() } catch { /* org-level VAT is a best-effort fallback */ } } try { const bankRes: any = await event.context.fetch( `models/c_bankaccount?$filter=IsDefault eq true and AD_Org_ID eq ${orgId}&$top=1`, 'GET', token, null, ) paymentIban = bankRes?.records?.[0]?.IBAN || '' } catch { /* IBAN is optional */ } } console.log(`[ZUGFeRD] seller resolved: org=${orgId} bp="${sellerBp?.Name || ''}" vatId="${sellerVatId}" hrb="${sellerLegalRegId}"`) // --- lines (net-consistent; BT-146 price kept >= 0, sign carried on the qty) --- const lineRecords: any[] = linesRes?.records || [] const lines: ZugferdLine[] = lineRecords.map((r: any, i: number) => { const rate = num(r?.C_Tax_ID?.Rate) const rawAmt = num(r?.LineNetAmt) // Convert gross→net for tax-inclusive invoices so BT-131 is a true net amount. const netLine = taxIncluded ? rawAmt / (1 + rate / 100) : rawAmt const absQty = Math.abs(num(r?.QtyInvoiced ?? r?.QtyEntered ?? 1)) || 1 // BT-146 (item net price) must NOT be negative — keep it >= 0, sign the quantity. const unitPrice = round4(Math.abs(netLine) / absQty) const billedQty = netLine < 0 ? -absQty : absQty const lineTotal = round2(unitPrice * billedQty) return { lineId: num(r?.Line) || i + 1, name: r?.M_Product_ID?.Name || r?.M_Product_ID?.identifier || r?.Description || '-', qty: billedQty, unitCode: 'C62', netPrice: unitPrice, lineTotal, taxRate: rate, taxCategory: rate > 0 ? 'S' : 'Z', } }) // --- VAT breakdown derived from the EMITTED line amounts, so BR-S-08 / BR-CO-13 / // BR-CO-10 reconcile to the cent (mixing iDempiere's gross+net was the bug). --- const byRate = new Map() for (const l of lines) { const b = byRate.get(l.taxRate) || { rate: l.taxRate, basis: 0, amount: 0, category: l.taxCategory } b.basis = round2(b.basis + l.lineTotal) byRate.set(l.taxRate, b) } // iDempiere's own c_invoicetax.TaxAmt per rate is the actual amount charged. For a // tax-inclusive invoice iDempiere derives it as grossLineAmt - netBasis, NOT // basis*rate/100 — e.g. a EUR16.88 gross line at 19% stores TaxBaseAmt=14.18 / // TaxAmt=2.70, while 14.18*19% rounds to 2.69. Recomputing the amount below made the // XML's grand total EUR0.01 short of what iDempiere/the customer actually see, so // prefer iDempiere's own amount per rate (within EN16931's documented 0.01 rounding // tolerance for BR-CO-17) and only fall back to the recomputed amount when no // matching c_invoicetax row exists (e.g. lookup failure). `basis` always stays the // recomputed line-sum so BR-CO-13/BR-CO-10 keep holding regardless. const idempiereTaxByRate = new Map() for (const t of invoiceTaxRes?.records || []) { idempiereTaxByRate.set(num(t?.C_Tax_ID?.Rate), num(t?.TaxAmt)) } const taxes: ZugferdTaxBreakdown[] = Array.from(byRate.values()).map(b => { const idempiereAmt = idempiereTaxByRate.get(b.rate) return { ...b, amount: idempiereAmt !== undefined ? round2(idempiereAmt) : round2((b.basis * b.rate) / 100), } }) // --- totals (computed from our own net figures → internally consistent) --- const lineTotal = round2(lines.reduce((s, l) => s + l.lineTotal, 0)) const taxBasisTotal = round2(taxes.reduce((s, t) => s + t.basis, 0)) const taxTotal = round2(taxes.reduce((s, t) => s + t.amount, 0)) const grandTotal = round2(taxBasisTotal + taxTotal) const docBaseType = String(fkVal(inv?.DocBaseType) || '') const isCredit = docBaseType === 'ARC' || docBaseType === 'APC' // Payment terms / due date (BR-CO-25) and delivery date (BR-FX-EN-04). const issueDate = inv?.DateInvoiced || inv?.DateAcct || '' const netDays = num(inv?.C_PaymentTerm_ID?.NetDays) const dueDate = inv?.C_PaymentTerm_ID ? addDays(issueDate, netDays) : '' const paymentTermName = inv?.C_PaymentTerm_ID?.Name || inv?.C_PaymentTerm_ID?.identifier || '' const paymentTerms = paymentTermName || (dueDate ? `Zahlbar bis ${dueDate}` : 'Zahlbar sofort nach Erhalt') // BT-29 seller identifier — any string; guarantees BR-CO-26 even with no VAT ID. const sellerId = sellerBp?.Value || sellerBp?.TaxID || (sellerBp?.id ? `BP-${sellerBp.id}` : '') // Resolve postal addresses via follow-up c_location fetches (no nested expand). const [sellerAddress, buyerAddress] = await Promise.all([ resolveSellerAddress(event, token, sellerBp?.id), resolveBpLocationAddress(event, token, inv?.C_BPartner_Location_ID), ]) return { documentNo: inv?.DocumentNo || String(invoiceId), issueDate, dueDate: dueDate || undefined, paymentTerms, deliveryDate: issueDate, typeCode: isCredit ? '381' : '380', currency: inv?.C_Currency_ID?.identifier || 'EUR', orderRef: inv?.POReference || inv?.C_Order_ID?.DocumentNo || '', seller: { name: sellerBp?.Name || '', id: sellerId || undefined, legalRegId: sellerLegalRegId || undefined, vatId: sellerVatId, ...sellerAddress, }, buyer: { name: partner?.Name || partner?.identifier || '', vatId: String(partner?.TaxID ?? partner?.taxID ?? partner?.taxId ?? '').trim(), ...buyerAddress, }, lines, taxes, lineTotal, taxBasisTotal, taxTotal, grandTotal, duePayable: grandTotal, paymentIban: paymentIban || undefined, } } /** * @param event h3 event (provides event.context.fetch to iDempiere) * @param token current bearer token * @param invoiceId c_invoice id * @param plainPdfBuffer iDempiere's invoice PDF (decoded from base64) * @param opts.force bypass the partner flag and always build the hybrid PDF * @returns hybrid ZUGFeRD PDF when flagged/forced, else the input buffer */ export const maybeZugferdPdf = async ( event: any, token: any, invoiceId: number | string, plainPdfBuffer: Buffer, opts: { force?: boolean } = {}, ): Promise => { try { // One fetch resolves the gate (partner flag) + most of the buyer data. const inv: any = await event.context.fetch( `models/c_invoice/${invoiceId}?$expand=${INVOICE_EXPAND}`, 'GET', token, null, ) if (!opts.force && !isZugferdEnabled(inv)) { return plainPdfBuffer } const data = await gatherZugferdData(event, token, inv) const xml = buildInvoiceXml(data) const hybrid = await embedZugferdXml(plainPdfBuffer, xml) console.log(`[ZUGFeRD] Embedded factur-x.xml into invoice ${data.documentNo} (${hybrid.length} bytes)`) return hybrid } catch (err) { console.error(`[ZUGFeRD] Generation failed for invoice ${invoiceId}, sending plain PDF:`, err) return plainPdfBuffer } } export default maybeZugferdPdf