import { string } from 'alga-js' /** * Shared constants + helpers for the bank reconciliation (Bankabgleich) routes. * Verified against prod iDempiere: * - C_DocType 1000008 = "AR Receipt" (ARR), 1000009 = "AP Payment" (APP) * - C_Currency 102 = EUR * The bank account is NOT a constant here: reconciliation derives it from the * statement being reconciled (Volksbank 1000000, PayPal 1000001, …). * NB QuickPaymentModal elsewhere still hardcodes 1000000 — out of scope. */ export const DOCTYPE_AR_RECEIPT = 1000008 export const DOCTYPE_AP_PAYMENT = 1000009 export const EUR_CURRENCY_ID = 102 export const AMOUNT_TOLERANCE = 0.005 // Max |difference| offered as "Differenz ausbuchen" (write-off / Skonto) export const WRITEOFF_MAX = 1.00 /** OData string literal: single quotes doubled. */ export const odataLit = (value: any): string => { return "'" + String(value).replace(/'/g, "''") + "'" } /** Uppercase + strip separators so document numbers survive remittance formatting. */ export const normalizeForDocNoScan = (text: any): string => { return String(text || '').toUpperCase().replace(/[\s\-\/\.\_]/g, '') } /** * Open amount per invoice: |GrandTotal| − |Σ allocations of COMPLETED payments|. * Same algorithm as server/api/invoices/ffn-unpaid-count.get.ts — IsPaid lags * behind allocations, so the allocation sum is authoritative. * Returns { [invoiceId]: openAmt } for the given invoice records * (records need id + GrandTotal). */ export const computeOpenAmounts = async ( event: any, token: any, invoices: any[] ): Promise> => { const openAmounts: Record = {} if (!invoices.length) return openAmounts const allocationSums: Record = {} const ids = invoices.map((inv: any) => inv.id) // `C_Invoice_ID in (...)` is supported (proven in ffn-unpaid-count.get.ts); // chunk to keep URLs bounded. for (let i = 0; i < ids.length; i += 45) { const chunk = ids.slice(i, i + 45) try { const res: any = await event.context.fetch( `models/c_allocationline?$filter=${string.urlEncode(`C_Invoice_ID in (${chunk.join(',')})`)}` + `&$select=C_Invoice_ID,Amount&$expand=C_Payment_ID($select=DocStatus)&$top=1000`, 'GET', token, null ) for (const alloc of (res?.records || [])) { const invoiceId = alloc.C_Invoice_ID?.id const paymentDocStatus = alloc.C_Payment_ID?.DocStatus?.id || alloc.C_Payment_ID?.DocStatus || '' if (invoiceId && paymentDocStatus === 'CO') { allocationSums[invoiceId] = (allocationSums[invoiceId] || 0) + (alloc.Amount || 0) } } } catch (err) { // allocation fetch failure -> treat chunk as unallocated (invoices stay "open", // which only ever over-offers candidates, never hides real matches) } } for (const invoice of invoices) { const grandTotal = Math.abs(invoice.GrandTotal || 0) const paid = Math.abs(allocationSums[invoice.id] || 0) openAmounts[invoice.id] = Math.round((grandTotal - paid) * 100) / 100 } return openAmounts } /** Map a raw c_bankstatementline record to the API line shape used by all routes. */ export const presentStatementLine = (line: any) => { const paymentRef = line.C_Payment_ID const chargeRef = line.C_Charge_ID const partnerRef = line.C_BPartner_ID return { id: line.id, line: line.Line, date: line.StatementLineDate, valutaDate: line.ValutaDate || null, amount: line.StmtAmt ?? 0, trxAmt: line.TrxAmt ?? 0, chargeAmt: line.ChargeAmt ?? 0, counterpart: line.EftPayee || '', iban: line.EftPayeeAccount || '', memo: line.EftMemo || line.Description || '', reference: line.ReferenceNo || '', matched: paymentRef?.id ? 'payment' : (chargeRef?.id ? 'charge' : null), payment: paymentRef?.id ? { id: paymentRef.id, documentNo: paymentRef.DocumentNo || paymentRef.identifier || '', payAmt: paymentRef.PayAmt, docStatus: paymentRef.DocStatus?.id || paymentRef.DocStatus || '', isReceipt: paymentRef.IsReceipt ?? null, isReconciled: paymentRef.IsReconciled ?? null } : null, charge: chargeRef?.id ? { id: chargeRef.id, name: chargeRef.Name || chargeRef.identifier || '' } : null, partner: partnerRef?.id ? { id: partnerRef.id, name: partnerRef.Name || partnerRef.identifier || '' } : null } } export const LINE_SELECT = '$select=C_BankStatementLine_ID,Line,StatementLineDate,ValutaDate,StmtAmt,TrxAmt,ChargeAmt,InterestAmt,' + 'Description,EftPayee,EftPayeeAccount,EftMemo,ReferenceNo,C_Payment_ID,C_Charge_ID,C_BPartner_ID' + '&$expand=C_Payment_ID($select=DocumentNo,PayAmt,DocStatus,IsReceipt,IsReconciled),' + 'C_Charge_ID($select=Name),C_BPartner_ID($select=Name)' /** LINE_SELECT + the statement header (for cross-statement "all lines" views). */ export const LINE_SELECT_WITH_STATEMENT = LINE_SELECT.replace('C_BPartner_ID&$expand=', 'C_BPartner_ID,C_BankStatement_ID&$expand=') + ',C_BankStatement_ID($select=DocumentNo,Name,StatementDate,DocStatus)' /** * Fetch c_bankstatementline records of MANY statements in one go: * `C_BankStatement_ID in (...)`, chunked (40 ids per request) and paged * (`$top`/`$skip`) so neither URL length nor iDempiere's page size caps the * result. `selectAndExpand` is a ready `$select=...&$expand=...` string. * Hard-capped at `maxRows` lines as a runaway guard (oldest statements first). */ export const fetchLinesForStatements = async ( event: any, token: any, statementIds: number[], selectAndExpand: string, maxRows: number = 5000 ): Promise => { const ids = Array.from(new Set(statementIds.map((v) => Number(v)).filter((v) => v > 0))) const out: any[] = [] if (!ids.length) return out const PAGE = 500 for (let i = 0; i < ids.length && out.length < maxRows; i += 40) { const chunk = ids.slice(i, i + 40) const filter = chunk.length === 1 ? `C_BankStatement_ID eq ${chunk[0]}` : `C_BankStatement_ID in (${chunk.join(',')})` let skip = 0 while (out.length < maxRows) { const res: any = await event.context.fetch( `models/c_bankstatementline?$filter=${string.urlEncode(filter)}` + `&${selectAndExpand}&$orderby=${string.urlEncode('C_BankStatement_ID,Line')}&$top=${PAGE}&$skip=${skip}`, 'GET', token, null ) const records = res?.records || [] out.push(...records) if (records.length < PAGE) break skip += PAGE } } return out.slice(0, maxRows) } /** Parse `?ids=1,2,3` (or an array) into a bounded list of positive ints. */ export const parseIdList = (raw: any, max: number = 100): number[] => { const parts = Array.isArray(raw) ? raw : String(raw ?? '').split(',') const ids = parts.map((v: any) => Number(String(v).trim())).filter((v: number) => Number.isInteger(v) && v > 0) return Array.from(new Set(ids)).slice(0, max) }