import { string } from 'alga-js' import refreshTokenHelper from "../../../utils/refreshTokenHelper" import forceLogoutHelper from "../../../utils/forceLogoutHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import fetchHelper from "../../../utils/fetchHelper" import { getAmazonAccessToken, listFinancialEventsForGroup, flattenFinancialEvents } from "../../../utils/amazonSpApi" import { withOptionalCsvExportCols, readCsvExportMark, isCsvExportColumnsAvailable } from "../../../utils/lexofficeCsvExportMark" // READ-ONLY: matches a set of Amazon order ids to internal c_order records. // Only GETs orders to show the reference/match indicator — writes nothing. // Looks up by amazon_order_id first, then ExternalOrderId for any still unmatched. type OrderRef = { id: number; documentNo: string; csvExported: boolean; csvExportDate: string } const matchOrders = async (event: any, token: any, orderIds: string[]) => { const map: Record = {} const ids = orderIds.filter(Boolean) if (!ids.length) return { map, truncated: false } const CAP = 3000 const truncated = ids.length > CAP const work = truncated ? ids.slice(0, CAP) : ids const lookup = async (field: string, pending: string[]) => { const CHUNK = 45 for (let i = 0; i < pending.length; i += CHUNK) { const chunk = pending.slice(i, i + CHUNK) const filter = chunk.map(id => `${field} eq '${String(id).replace(/'/g, "''")}'`).join(' OR ') // The lexoffice CSV-export marker columns are optional (fail-soft until // they exist on C_Order — see server/utils/lexofficeCsvExportMark.ts). const res: any = await withOptionalCsvExportCols((extraCols) => fetchHelper( event, `models/c_order?$filter=${string.urlEncode(filter)}&$select=${string.urlEncode(['C_Order_ID', 'DocumentNo', 'amazon_order_id', 'ExternalOrderId', ...extraCols].join(','))}&$top=200`, 'GET', token, null )) for (const rec of (res?.records || [])) { const csv = readCsvExportMark(rec) const entry: OrderRef = { id: rec.id, documentNo: rec.DocumentNo || '', csvExported: csv.exported, csvExportDate: csv.date } // Register under both ids so a hit on either field resolves the order. if (rec.amazon_order_id) map[String(rec.amazon_order_id)] = entry if (rec.ExternalOrderId) map[String(rec.ExternalOrderId)] = entry } } } // Pass 1: amazon_order_id await lookup('amazon_order_id', work) // Pass 2: ExternalOrderId for whatever is still unmatched const stillMissing = work.filter(id => !map[id]) if (stillMissing.length) await lookup('ExternalOrderId', stillMissing) return { map, truncated } } type InvoiceRef = { invoiceId: number documentNo: string grandTotal: number docStatus: string lexofficeUploaded: boolean lexofficeUploadDate: string } // For matched orders, find any linked c_invoice so we can show the invoice no/link. // Prefers a Completed/Closed invoice over a draft when an order has several. // Mirrors the orders-check endpoint's lookupInvoices. const lookupInvoices = async (event: any, token: any, internalOrderIds: number[]) => { const map: Record = {} const ids = [...new Set(internalOrderIds.filter(Boolean))] if (!ids.length) return map const CHUNK = 45 for (let i = 0; i < ids.length; i += CHUNK) { const chunk = ids.slice(i, i + CHUNK) // Only live invoices count as "invoice exists" — exclude voided/reversed ones // (iDempiere keeps them IsActive=true). Whitelist: DR / CO / CL. const filter = chunk .map(id => `((C_Order_ID eq ${id}) and (DocStatus eq 'CO' or DocStatus eq 'CL' or DocStatus eq 'DR'))`) .join(' OR ') const res: any = await fetchHelper( event, `models/c_invoice?$filter=${string.urlEncode(filter)}&$select=${string.urlEncode('C_Invoice_ID,DocumentNo,GrandTotal,DocStatus,C_Order_ID,isUploadToLexoffice,UploadDateLexoffice')}&$top=400`, 'GET', token, null ) for (const rec of (res?.records || [])) { const oid = rec.C_Order_ID?.id ?? rec.C_Order_ID if (oid == null) continue const docStatus = rec.DocStatus?.id || rec.DocStatus?.identifier || rec.DocStatus || '' const ref: InvoiceRef = { invoiceId: rec.id ?? rec.C_Invoice_ID, documentNo: rec.DocumentNo || '', grandTotal: typeof rec.GrandTotal === 'number' ? rec.GrandTotal : Number(rec.GrandTotal || 0), docStatus, // Lexoffice upload status of the invoice (same columns /sales/invoices shows) lexofficeUploaded: rec.isUploadToLexoffice === true || rec.isUploadToLexoffice === 'Y' || rec.IsUploadToLexoffice === true || rec.IsUploadToLexoffice === 'Y', lexofficeUploadDate: rec.UploadDateLexoffice || rec.uploadDateLexoffice || '' } const existing = map[oid] const isFinal = (s: string) => { const u = String(s || '').toUpperCase() return u === 'CO' || u === 'CL' || u.includes('COMPLET') || u.includes('CLOSED') } if (!existing || (!isFinal(existing.docStatus) && isFinal(docStatus))) { map[oid] = ref } } } return map } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const query = getQuery(event) const orderSourceId = query.orderSourceId // `reportId` holds the Finances API's FinancialEventGroupId (see settlements.get.ts). const groupId = query.reportId ? String(query.reportId) : null if (!orderSourceId) return { status: 400, message: 'orderSourceId is required' } if (!groupId) return { status: 400, message: 'reportId is required' } const orderSource: any = await fetchHelper(event, `models/c_ordersource/${orderSourceId}`, 'GET', token, null) if (!orderSource) return { status: 404, message: 'Order source not found' } const marketplaceIdentifier = String(orderSource?.Marketplace?.identifier || '').toLowerCase() const isAmazon = marketplaceIdentifier === 'amazon' || String(orderSource?.Marketplace?.id) === '4' || String(orderSource?.marketplace) === '4' if (!isAmazon) return { status: 400, message: `Order source "${orderSource?.Name || orderSourceId}" is not an Amazon marketplace` } if (!orderSource?.marketplace_key || !orderSource?.marketplace_secret || !orderSource?.marketplace_token) { return { status: 400, message: 'Order source is missing Amazon SP-API credentials' } } const accessToken = await getAmazonAccessToken( orderSource.marketplace_key, orderSource.marketplace_secret, orderSource.marketplace_token ) const pages = await listFinancialEventsForGroup(accessToken, groupId) const detailRows = flattenFinancialEvents(pages) // The Finances API has no "get one group by id" endpoint (only the list used // by settlements.get.ts), so the period + payout total are passed through by // the frontend from that same list response rather than looked up again here. // This isn't a trust issue: the reconciliation check below independently sums // the actual line items fetched above and compares them against this figure — // any mismatch (tampered, stale, or otherwise wrong) surfaces there exactly // like a real Amazon-side discrepancy would. const settlement = { settlementId: groupId, periodStart: query.periodStart ? String(query.periodStart) : '', periodEnd: query.periodEnd ? String(query.periodEnd) : '', depositDate: query.fundTransferDate ? String(query.fundTransferDate) : '', totalAmount: query.totalAmount != null && query.totalAmount !== '' ? Number(query.totalAmount) : 0, currency: query.currency ? String(query.currency) : 'EUR' } // READ-ONLY match to internal orders (best-effort; never blocks the view). const distinctOrderIds = [...new Set(detailRows.map(r => r.orderId).filter(Boolean))] let matchTruncated = false let matchError: string | null = null let matchedCount = 0 try { const { map, truncated } = await matchOrders(event, token, distinctOrderIds) matchTruncated = truncated for (const row of detailRows) { const hit = row.orderId ? map[row.orderId] : null ;(row as any).internalOrderId = hit?.id ?? null ;(row as any).internalDocumentNo = hit?.documentNo ?? '' ;(row as any).matched = !!hit ;(row as any).csvExported = !!hit?.csvExported ;(row as any).csvExportDate = hit?.csvExportDate || '' } matchedCount = distinctOrderIds.filter(id => map[id]).length // Best-effort: resolve a linked invoice for every matched order so the grid // can show a Rechnung column (never blocks the view if it fails). try { const matchedInternalIds = [...new Set( detailRows.map((r: any) => r.internalOrderId).filter(Boolean) )] as number[] const invoiceMap = await lookupInvoices(event, token, matchedInternalIds) for (const row of detailRows) { const inv = (row as any).internalOrderId ? invoiceMap[(row as any).internalOrderId] : null ;(row as any).invoiceExists = !!inv ;(row as any).invoiceId = inv?.invoiceId || null ;(row as any).invoiceDocumentNo = inv?.documentNo || '' ;(row as any).invoiceTotal = inv?.grandTotal ?? null ;(row as any).invoiceDocStatus = inv?.docStatus || '' ;(row as any).invoiceLexofficeUploaded = !!inv?.lexofficeUploaded ;(row as any).invoiceLexofficeDate = inv?.lexofficeUploadDate || '' } } catch { for (const row of detailRows) { ;(row as any).invoiceExists = false ;(row as any).invoiceId = null ;(row as any).invoiceDocumentNo = '' ;(row as any).invoiceLexofficeUploaded = false ;(row as any).invoiceLexofficeDate = '' } } } catch (e: any) { matchError = e?.message || 'Order matching failed' for (const row of detailRows) { ;(row as any).internalOrderId = null ;(row as any).internalDocumentNo = '' ;(row as any).matched = false ;(row as any).invoiceExists = false ;(row as any).invoiceId = null ;(row as any).invoiceDocumentNo = '' ;(row as any).invoiceLexofficeUploaded = false ;(row as any).invoiceLexofficeDate = '' ;(row as any).csvExported = false ;(row as any).csvExportDate = '' } } // Category sums + reconciliation (sum of ALL detail amounts should ≈ payout total). const categorySums: Record = { umsaetze: 0, retouren: 0, amazonFees: 0, fbaFees: 0, werbekosten: 0, sonstiges: 0 } let detailSum = 0 for (const r of detailRows) { detailSum += r.amount if (categorySums[r.category] !== undefined) categorySums[r.category] += r.amount } const summary = { ...categorySums, auszahlung: settlement.totalAmount, lineCount: detailRows.length, ordersWithId: distinctOrderIds.length, matchedOrders: matchedCount, unmatchedOrders: distinctOrderIds.length - matchedCount, matchTruncated, matchError, reconciliation: { detailSum: Math.round(detailSum * 100) / 100, payout: settlement.totalAmount, difference: Math.round((settlement.totalAmount - detailSum) * 100) / 100 } } // csvExportAvailable: whether C_Order carries the lexoffice CSV-export marker // columns (frontend hides the marker/filters/reset until they exist). return { status: 200, settlement, summary, rows: detailRows, csvExportAvailable: isCsvExportColumnsAvailable() } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { // Surface SP-API errors (status-carrying) verbatim instead of treating them // as an iDempiere auth failure. if (err?.status && err?.message) { return { status: err.status, message: err.message, spapi: err.spapi } } try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })