import refreshTokenHelper from "../../utils/refreshTokenHelper" import forceLogoutHelper from "../../utils/forceLogoutHelper" import errorHandlingHelper from "../../utils/errorHandlingHelper" import maybeZugferdPdf from "../../utils/zugferd/maybeZugferdPdf" import toIdempiereDate from "../../utils/idempiereDate" import { lexFetch, lexAuthHeaders, createVoucher, uploadFileToVoucher } from "../../utils/lexofficeClient" /** * Throughput design (measured on prod, see git history of this file): * one invoice = ~1.2 s iDempiere PDF render + ~2.5 s of Lexware calls, and Lexware's * upload calls simply take ~2 s each on their side (the 202 is returned before their * async OCR — there is nothing to wait for or poll). So instead of processing invoices * strictly one after another with fixed sleeps, INVOICE_CONCURRENCY invoices are * worked on at once: while one is waiting on Lexware, the others render their PDFs. * All Lexware traffic goes through `lexFetch`, whose process-wide throttle enforces * the 2 req/s limit (that throttle replaced the former hard-coded 500 ms sleeps). */ const INVOICE_CONCURRENCY = 3 /** * Invoice ids currently being uploaded by THIS process. Two overlapping requests * (double-click, two tabs, two users) that both pass the `isUploadToLexoffice` * check before either has marked the invoice would otherwise upload it twice. * Process-local; prod runs one PM2 instance so this covers the realistic case. */ const inProgress = new Set() const round2 = (n: number): number => Math.round((Number(n) || 0) * 100) / 100 // Log collector for debugging const createLogger = () => { const logs: string[] = [] return { log: (message: string) => { const timestamp = new Date().toISOString() logs.push(`[${timestamp}] ${message}`) }, getLogs: () => logs } } type Logger = ReturnType /** Prefixes every line with the invoice id so interleaved concurrent logs stay readable. */ const childLogger = (parent: Logger, invoiceId: number | string): Logger => ({ log: (message: string) => parent.log(`[inv ${invoiceId}] ${message}`), getLogs: parent.getLogs, }) // Helper to make lexoffice JSON API calls (throttled via lexFetch) const lexofficeRequest = async (endpoint: string, method: string, body?: any, logger?: Logger) => { const options: RequestInit = { method, headers: lexAuthHeaders(!!body), } if (body) { options.body = JSON.stringify(body) } logger?.log(`Lexoffice API: ${method} ${endpoint}`) const response = await lexFetch(endpoint, options) if (!response.ok) { const errorText = await response.text() logger?.log(`Lexoffice API error (${response.status}): ${errorText}`) throw new Error(`Lexoffice API error (${response.status}): ${errorText}`) } const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { const data = await response.json() logger?.log(`Lexoffice API response: ${JSON.stringify(data).substring(0, 200)}...`) return data } const locationHeader = response.headers.get('Location') if (locationHeader) { const id = locationHeader.split('/').pop() logger?.log(`Lexoffice API: Created resource with ID ${id}`) return { id, status: response.status } } return { status: response.status, statusText: response.statusText } } // Query lexoffice contacts by name const findContact = async (name: string, logger?: Logger): Promise => { try { if (name && name.length >= 3) { logger?.log(`Searching for contact: "${name}"`) const result = await lexofficeRequest(`/v1/contacts?name=${encodeURIComponent(name)}`, 'GET', undefined, logger) if (result?.content?.length > 0) { logger?.log(`Found existing contact: ${result.content[0].id}`) return result.content[0] } logger?.log(`No contact found for: "${name}"`) } return null } catch (err: any) { logger?.log(`Error searching contact: ${err.message}`) return null } } // Create a new contact in lexoffice const createLexofficeContact = async (partner: any, logger?: Logger): Promise => { const partnerName = partner.Name || '' const partnerName2 = partner.Name2 || '' const isCustomer = partner.IsCustomer === true || partner.IsCustomer === 'Y' const isVendor = partner.IsVendor === true || partner.IsVendor === 'Y' logger?.log(`Creating contact for: "${partnerName}" (Customer: ${isCustomer}, Vendor: ${isVendor})`) const roles: any = {} if (isCustomer) roles.customer = {} if (isVendor) roles.vendor = {} if (!isCustomer && !isVendor) roles.customer = {} let contactData: any = { version: 0, roles } if (partner.EMail) { contactData.emailAddresses = { business: [partner.EMail] } } // Use "LogShip" instead of "iDempiere", include customer number (Value) contactData.note = `LogShip Customer#: ${partner.Value || ''} (ID: ${partner.id || ''})` if (partnerName2) { const nameParts = partnerName2.trim().split(' ') contactData.company = { name: partnerName, contactPersons: [{ firstName: nameParts[0] || '', lastName: nameParts.slice(1).join(' ') || partnerName2 }] } } else { const companyIndicators = ['GmbH', 'AG', 'KG', 'OHG', 'Ltd', 'Inc', 'LLC', 'UG', 'e.K.', 'Co.', '&', 'mbH'] const isCompany = companyIndicators.some(indicator => partnerName.includes(indicator)) if (isCompany) { contactData.company = { name: partnerName } } else { const nameParts = partnerName.trim().split(' ') if (nameParts.length > 1) { contactData.person = { firstName: nameParts[0], lastName: nameParts.slice(1).join(' ') } } else { contactData.person = { lastName: partnerName } } } } logger?.log(`Contact data: ${JSON.stringify(contactData)}`) const result = await lexofficeRequest('/v1/contacts', 'POST', contactData, logger) return result } // Upload file to lexoffice as voucher (generic Files endpoint — plain, uncategorized drop). // Returns 202 as soon as the file is stored; Lexware's OCR runs asynchronously afterwards. const uploadFileToLexoffice = async (fileBuffer: Buffer, fileName: string, logger?: Logger): Promise => { logger?.log(`Uploading file: ${fileName} (${fileBuffer.length} bytes)`) // Use native FormData and Blob for Node.js 18+ const blob = new Blob([fileBuffer], { type: 'application/pdf' }) const formData = new FormData() formData.append('file', blob, fileName) formData.append('type', 'voucher') const response = await lexFetch('/v1/files', { method: 'POST', headers: lexAuthHeaders(false), body: formData }) if (!response.ok) { const errorText = await response.text() logger?.log(`File upload failed (${response.status}): ${errorText}`) throw new Error(`File upload failed (${response.status}): ${errorText}`) } const locationHeader = response.headers.get('Location') let responseData: any = {} try { responseData = await response.json() } catch (e) {} const fileId = responseData.id || locationHeader?.split('/').pop() logger?.log(`File uploaded successfully: ${fileId}`) return { id: fileId, ...responseData } } /** * Resolve (find-or-create) the Lexoffice contact for a partner and persist its UUID on * c_bpartner. Returns undefined when no contact could be resolved (fail-soft — the * caller then falls back to the plain, contact-less file drop). */ const resolveLexofficeContact = async (event: any, token: any, partner: any, partnerId: any, logger: Logger): Promise => { if (partner.lexware_contact_uuid) { logger.log(`Partner already has lexware_contact_uuid: ${partner.lexware_contact_uuid}`) return partner.lexware_contact_uuid } let lexofficeContactId: string | undefined = undefined // Try to find existing contact by name in Lexoffice const existingContact = await findContact(partner.Name, logger) if (existingContact) { lexofficeContactId = existingContact.id } else { // Create new contact try { const newContact = await createLexofficeContact(partner, logger) lexofficeContactId = newContact.id } catch (contactErr: any) { logger.log(`Could not create contact for ${partner.Name}: ${contactErr.message}`) } } // Save lexoffice contact UUID back to C_BPartner if (lexofficeContactId) { try { logger.log(`Saving lexware_contact_uuid "${lexofficeContactId}" to c_bpartner ${partnerId}`) await event.context.fetch( `models/c_bpartner/${partnerId}`, 'PUT', token, { lexware_contact_uuid: lexofficeContactId } ) logger.log(`Successfully saved lexware_contact_uuid to c_bpartner`) } catch (saveErr: any) { logger.log(`Could not save lexware_contact_uuid to c_bpartner: ${saveErr.message}`) } } return lexofficeContactId } const handleFunc = async (event: any, authToken: any = null) => { const logger = createLogger() let data: any = {} const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) // Dedupe: with concurrent workers a duplicated id in the request would otherwise // be picked up by two workers at once (serial processing used to mask this). const invoiceIds: number[] = [...new Set((body.ids || []).map((id: any) => Number(id)).filter((id: number) => id > 0))] // Optional Lexoffice posting-category UUID (from the "set a category?" modal). When // present, invoices are booked as a categorized+tax-rated voucher instead of a plain // file drop — see the categoryId branch below. Applies uniformly to every invoice in // this call (single or bulk upload share the same modal/category selection). const categoryId: string | undefined = body.categoryId || undefined logger.log(`Starting Lexoffice upload for invoice IDs: ${JSON.stringify(invoiceIds)}${categoryId ? ` with category ${categoryId}` : ''}`) if (!invoiceIds.length) { return { status: 400, message: 'No invoice IDs provided', logs: logger.getLogs() } } const errors: string[] = [] const skipped: string[] = [] // Per-id outcome so bulk callers (e.g. the settlement pages) can update their // rows in place without re-fetching everything. const uploadedIds: number[] = [] const skippedIds: number[] = [] let uploadedCount = 0 // Per-partner contact resolution shared across the concurrent workers: several // invoices of the same partner (typical for settlement runs) must resolve/create the // Lexoffice contact exactly ONCE, otherwise concurrent workers would each search, // miss, and create duplicate contacts before the UUID is saved on the partner. const contactByPartner = new Map>() const resolveContactOnce = (partner: any, partnerId: any, log: Logger) => { const key = String(partnerId) let pending = contactByPartner.get(key) if (!pending) { pending = resolveLexofficeContact(event, token, partner, partnerId, log) contactByPartner.set(key, pending) } return pending } const processInvoice = async (invoiceId: number) => { const log = childLogger(logger, invoiceId) if (inProgress.has(invoiceId)) { const msg = `Invoice ${invoiceId} is already being uploaded by another request - skipped` log.log(msg) errors.push(msg) return } inProgress.add(invoiceId) try { await processInvoiceInner(invoiceId, log) } finally { inProgress.delete(invoiceId) } } const processInvoiceInner = async (invoiceId: number, log: Logger) => { try { log.log(`--- Processing invoice ID: ${invoiceId} ---`) // Fetch invoice (no $expand) const invoice: any = await event.context.fetch( `models/c_invoice/${invoiceId}`, 'GET', token, null ) if (!invoice) { const msg = `Invoice ${invoiceId} not found` log.log(msg) errors.push(msg) return } log.log(`Invoice found: ${invoice.DocumentNo} (DocBaseType: ${invoice.DocBaseType?.id || invoice.DocBaseType})`) // Check if already uploaded to Lexoffice if (invoice.isUploadToLexoffice === true || invoice.isUploadToLexoffice === 'Y') { const msg = `Invoice ${invoice.DocumentNo} already uploaded to Lexoffice - skipping` log.log(msg) skipped.push(msg) skippedIds.push(Number(invoiceId)) return } // Fetch partner separately const partnerId = invoice.C_BPartner_ID?.id || invoice.C_BPartner_ID if (!partnerId) { const msg = `Invoice ${invoice.DocumentNo}: No business partner found` log.log(msg) errors.push(msg) return } log.log(`Fetching partner ID: ${partnerId}`) const partner: any = await event.context.fetch( `models/c_bpartner/${partnerId}`, 'GET', token, null ) if (!partner) { const msg = `Invoice ${invoice.DocumentNo}: Could not fetch partner` log.log(msg) errors.push(msg) return } log.log(`Partner: ${partner.Name} (Value: ${partner.Value}, IsCustomer: ${partner.IsCustomer}, IsVendor: ${partner.IsVendor})`) // Step 1: Find or create contact in Lexoffice (once per partner per run) const lexofficeContactId = await resolveContactOnce(partner, partnerId, log) // Step 2: Fetch invoice PDF and upload to Lexoffice try { log.log(`Fetching PDF for invoice ${invoiceId}`) const pdfResponse: any = await event.context.fetch( `models/c_invoice/${invoiceId}/print?$report_type=PDF`, 'GET', token, null ) if (pdfResponse?.reportFile) { const pdfBuffer = Buffer.from(pdfResponse.reportFile, 'base64') log.log(`PDF generated: ${pdfBuffer.length} bytes`) // Lexoffice always gets the ZUGFeRD hybrid PDF, regardless of the partner's // IsZugPferdInvoice flag (force: true). Still fail-soft: falls back to the // plain PDF on any fetch/build error. const uploadBuffer = await maybeZugferdPdf(event, token, invoiceId, pdfBuffer, { force: true }) if (uploadBuffer !== pdfBuffer) { log.log(`ZUGFeRD hybrid PDF generated: ${uploadBuffer.length} bytes`) } // Determine type from DocBaseType const docBaseType = invoice.DocBaseType?.id || invoice.DocBaseType?.identifier || invoice.DocBaseType || '' const isOutgoing = docBaseType === 'ARI' || docBaseType === 'ARC' const typePrefix = isOutgoing ? 'AR' : 'AP' const fileName = `${typePrefix}_${invoice.DocumentNo}_${partner.Name || 'Unknown'}.pdf` log.log(`DocBaseType: ${docBaseType}, Type: ${typePrefix}`) // Step 2a (optional): book a categorized, tax-rated voucher via the Vouchers // API instead of a plain file drop, when the caller opted into a category // (the "set a category?" modal). The tax rate/breakdown is ALWAYS read from // iDempiere's own c_invoicetax rows — never picked manually — so it matches // what iDempiere and the ZUGFeRD XML already show (see maybeZugferdPdf.ts's // matching fix for why c_invoicetax, not a recomputed rate*basis, is used). let uploadResult: { id: string } | null = null if (categoryId) { try { const invTaxRes: any = await event.context.fetch( `models/c_invoicetax?$filter=C_Invoice_ID eq ${invoiceId}&$expand=C_Tax_ID&$top=100`, 'GET', token, null ) const voucherItems = (invTaxRes?.records || []) .map((t: any) => { const rate = Number(t?.C_Tax_ID?.Rate) || 0 const taxAmount = round2(Number(t?.TaxAmt) || 0) const basis = round2(Number(t?.TaxBaseAmt) || 0) return { amount: round2(basis + taxAmount), taxAmount, taxRatePercent: rate, categoryId } }) .filter((item: any) => item.amount > 0 || item.taxAmount > 0) if (!voucherItems.length) { throw new Error('No c_invoicetax rows found — cannot build a tax-rate breakdown') } if (!lexofficeContactId) { throw new Error('No Lexoffice contact resolved for this partner') } const voucherType = docBaseType === 'ARC' ? 'salescreditnote' : (isOutgoing ? 'salesinvoice' : 'purchaseinvoice') const totalGrossAmount = round2(voucherItems.reduce((s: number, i: any) => s + i.amount, 0)) const totalTaxAmount = round2(voucherItems.reduce((s: number, i: any) => s + i.taxAmount, 0)) log.log(`Booking voucher (type=${voucherType}) with category ${categoryId}: gross=${totalGrossAmount}, tax=${totalTaxAmount}, items=${JSON.stringify(voucherItems)}`) const voucher = await createVoucher({ type: voucherType, voucherStatus: 'open', voucherNumber: invoice.DocumentNo, voucherDate: new Date(invoice.DateInvoiced || invoice.DateAcct || Date.now()).toISOString(), contactId: lexofficeContactId, totalGrossAmount, totalTaxAmount, taxType: 'gross', remark: `LogShip invoice ${invoice.DocumentNo}`, voucherItems, }) await uploadFileToVoucher(voucher.id, uploadBuffer, fileName) log.log(`Booked voucher ${voucher.id} for invoice ${invoice.DocumentNo} with category ${categoryId}`) uploadResult = { id: voucher.id } } catch (voucherErr: any) { log.log(`Category booking failed for invoice ${invoice.DocumentNo}, falling back to plain upload: ${voucherErr.message}`) } } // Plain file upload — today's default path, and the fallback when the // category booking above wasn't requested or failed (fail-soft: the document // must still reach Lexoffice even when it can't be pre-categorized). if (!uploadResult) { uploadResult = await uploadFileToLexoffice(uploadBuffer, fileName, log) } // Step 3: Mark invoice as uploaded in iDempiere try { // no milliseconds — iDempiere rejects ".123Z" datetimes const uploadDate = toIdempiereDate(new Date()) log.log(`Marking invoice ${invoiceId} as uploaded to Lexoffice`) await event.context.fetch( `models/c_invoice/${invoiceId}`, 'PUT', token, { isUploadToLexoffice: true, UploadDateLexoffice: uploadDate } ) log.log(`Successfully marked invoice as uploaded`) } catch (markErr: any) { log.log(`Could not mark invoice as uploaded: ${markErr.message}`) } log.log(`SUCCESS: Invoice ${invoice.DocumentNo} uploaded to Lexoffice with file ID: ${uploadResult.id}`) uploadedCount++ uploadedIds.push(Number(invoiceId)) } else { const msg = `Invoice ${invoice.DocumentNo}: Could not generate PDF (reportFile is missing)` log.log(msg) errors.push(msg) } } catch (pdfErr: any) { const msg = `Invoice ${invoice.DocumentNo}: ${pdfErr.message || 'Failed to upload'}` log.log(msg) errors.push(msg) } } catch (err: any) { const msg = `Error processing invoice ${invoiceId}: ${err.message || 'Unknown error'}` log.log(msg) errors.push(msg) } } // Worker pool: INVOICE_CONCURRENCY invoices in flight, each fully isolated // (processInvoice never throws — every failure is recorded per invoice). const queue = [...invoiceIds] const workerCount = Math.min(INVOICE_CONCURRENCY, queue.length) await Promise.all(Array.from({ length: workerCount }, async () => { while (queue.length) { const nextId = queue.shift() if (nextId === undefined) break await processInvoice(nextId) } })) // Keep the per-id outcome lists in the caller's input order (workers finish in // arbitrary order). const inputOrder = new Map(invoiceIds.map((id, idx) => [Number(id), idx])) const byInput = (a: number, b: number) => (inputOrder.get(a) ?? 0) - (inputOrder.get(b) ?? 0) uploadedIds.sort(byInput) skippedIds.sort(byInput) logger.log(`=== SUMMARY: Uploaded ${uploadedCount} invoice(s), ${skipped.length} skipped, ${errors.length} error(s) ===`) // Output all logs to console for debugging console.log('\n========== LEXOFFICE UPLOAD LOGS ==========') logger.getLogs().forEach(log => console.log(log)) console.log('============================================\n') if (uploadedCount > 0) { data = { status: 200, message: `Successfully uploaded ${uploadedCount} invoice(s) to Lexoffice${skipped.length > 0 ? `, ${skipped.length} skipped (already uploaded)` : ''}`, uploadedCount, uploadedIds, skippedCount: skipped.length, skippedIds, uploadDate: new Date().toISOString(), errors: errors.length > 0 ? errors : undefined, skipped: skipped.length > 0 ? skipped : undefined, logs: logger.getLogs() } } else if (skipped.length > 0 && errors.length === 0) { data = { status: 200, message: `All ${skipped.length} invoice(s) were already uploaded to Lexoffice`, uploadedCount: 0, uploadedIds, skippedCount: skipped.length, skippedIds, skipped, logs: logger.getLogs() } } else { data = { status: 400, message: 'No invoices were uploaded to Lexoffice', uploadedIds, skippedIds, errors, skipped: skipped.length > 0 ? skipped : undefined, logs: logger.getLogs() } } return data } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })