import { string } from 'alga-js' import { AMOUNT_TOLERANCE, EUR_CURRENCY_ID, computeOpenAmounts, fetchLinesForStatements, normalizeForDocNoScan, odataLit } from './bankReconciliation' /** * Match-suggestion engine for bank statement lines (Bankabgleich). * Shared by * GET /api/accounting/bank-reconciliation/{id}/suggestions (one statement) * GET /api/accounting/bank-reconciliation/suggestions?ids=… (many statements — "Alle" view) * * Signals per line×invoice: * docNo invoice DocumentNo (normalized, len>=5) found in remittance text * amount openAmt == |line amount| (±0.005) * ibanPartner counterpart IBAN -> c_bp_bankaccount -> same partner * namePartner token overlap of counterpart vs partner name * Tiers: high = docNo + 1 more | medium = amount + partner | low = single signal * * The open-invoice set (+ allocations) is fetched ONCE per call, so computing * for 50 statements costs about the same as for one — which is why the "Alle" * view must use the multi-id route instead of N per-statement calls. */ const LEGAL_SUFFIXES = new Set(['gmbh', 'ag', 'ug', 'kg', 'ohg', 'ek', 'e.k', 'co', 'inc', 'ltd', 'bv', 'sarl', 'srl', 'und', '&', 'die', 'der']) const nameTokens = (name: any): Set => { return new Set( String(name || '') .toLowerCase() .replace(/[^\p{L}\p{N} ]/gu, ' ') .split(/\s+/) .filter(t => t.length >= 2 && !LEGAL_SUFFIXES.has(t)) ) } const namesSimilar = (a: any, b: any): boolean => { const ta = nameTokens(a) const tb = nameTokens(b) if (!ta.size || !tb.size) return false let hits = 0 for (const t of ta) { if (tb.has(t)) hits++ } return hits >= Math.max(1, Math.ceil(Math.min(ta.size, tb.size) / 2)) } const FEE_MEMO_REGEX = /entgelt|abschluss|geb(ü|ue)hr|kontof(ü|ue)hrung|auszug|porto|rechnungsabschluss|verwahrentgelt/i export const fetchOpenInvoices = async (event: any, token: any, isSOTrx: boolean, q: string = '') => { let filter = `IsSOTrx eq ${isSOTrx} and IsPaid eq 'N' and (DocStatus eq 'CO' or DocStatus eq 'CL') and C_Currency_ID eq ${EUR_CURRENCY_ID}` if (q) { filter += ` and contains(DocumentNo,${odataLit(q)})` } const res: any = await event.context.fetch( `models/c_invoice?$filter=${string.urlEncode(filter)}` + `&$select=C_Invoice_ID,DocumentNo,GrandTotal,DateInvoiced,C_BPartner_ID,AD_Org_ID` + `&$expand=C_BPartner_ID($select=Name)` + `&$orderby=${string.urlEncode('DateInvoiced desc')}&$top=${q ? 50 : 500}`, 'GET', token, null ) return res?.records || [] } /** Free invoice search for the match modal (openAmt computed for the results). */ export const searchOpenInvoices = async (event: any, token: any, q: string, direction: any) => { const isSOTrx = direction !== 'debit' const invoices = await fetchOpenInvoices(event, token, isSOTrx, q) const openAmounts = await computeOpenAmounts(event, token, invoices) return invoices .filter((inv: any) => (openAmounts[inv.id] ?? 0) > AMOUNT_TOLERANCE) .map((inv: any) => ({ invoiceId: inv.id, documentNo: inv.DocumentNo || '', partnerId: inv.C_BPartner_ID?.id || null, partnerName: inv.C_BPartner_ID?.Name || inv.C_BPartner_ID?.identifier || '', grandTotal: inv.GrandTotal, openAmt: openAmounts[inv.id], dateInvoiced: inv.DateInvoiced || '' })) } /** * Suggestions for ALL unmatched lines of the given statements. * Returns { [lineId]: candidate[] } (max 5 candidates per line). */ export const computeLineSuggestions = async ( event: any, token: any, statementIds: number[] ): Promise> => { const rawLines = await fetchLinesForStatements( event, token, statementIds, '$select=C_BankStatementLine_ID,StmtAmt,EftPayee,EftPayeeAccount,EftMemo,Description,ReferenceNo,C_Payment_ID,C_Charge_ID' ) const lines = rawLines.filter((l: any) => !l.C_Payment_ID?.id && !l.C_Charge_ID?.id) if (!lines.length) { return {} } const hasCredits = lines.some((l: any) => (l.StmtAmt || 0) > 0) const hasDebits = lines.some((l: any) => (l.StmtAmt || 0) < 0) const [arInvoices, apInvoices] = await Promise.all([ hasCredits ? fetchOpenInvoices(event, token, true) : Promise.resolve([]), hasDebits ? fetchOpenInvoices(event, token, false) : Promise.resolve([]) ]) const allInvoices = [...arInvoices, ...apInvoices] const openAmounts = await computeOpenAmounts(event, token, allInvoices) // Partner-IBAN index from counterpart IBANs (c_bp_bankaccount) const ibans = Array.from(new Set( lines.map((l: any) => String(l.EftPayeeAccount || '').toUpperCase().replace(/\s/g, '')).filter((v: string) => v.length >= 15) )) const ibanToPartner: Record = {} for (let i = 0; i < ibans.length; i += 40) { const chunk = ibans.slice(i, i + 40) const filter = chunk.map((iban: string) => `IBAN eq ${odataLit(iban)}`).join(' OR ') try { const res: any = await event.context.fetch( `models/c_bp_bankaccount?$filter=${string.urlEncode(filter)}&$select=C_BPartner_ID,IBAN&$top=200`, 'GET', token, null ) for (const rec of (res?.records || [])) { const iban = String(rec.IBAN || '').toUpperCase().replace(/\s/g, '') if (iban && rec.C_BPartner_ID?.id) { ibanToPartner[iban] = rec.C_BPartner_ID.id } } } catch (err) { // no IBAN index for this chunk — signal simply won't fire } } // Precompute normalized doc numbers once const arSet = new Set(arInvoices) const invoiceMeta = allInvoices .filter((inv: any) => (openAmounts[inv.id] ?? 0) > AMOUNT_TOLERANCE) .map((inv: any) => ({ inv, isSOTrx: arSet.has(inv), normDocNo: normalizeForDocNoScan(inv.DocumentNo), normDocNoNoZeros: normalizeForDocNoScan(inv.DocumentNo).replace(/^([A-Z]*)0+/, '$1'), openAmt: openAmounts[inv.id] })) const suggestions: Record = {} const tierRank: Record = { high: 0, medium: 1, low: 2 } for (const line of lines) { const amount = Number(line.StmtAmt || 0) const absAmount = Math.abs(amount) const isCredit = amount > 0 const scanText = normalizeForDocNoScan( (line.EftMemo || '') + ' ' + (line.Description || '') + ' ' + (line.ReferenceNo || '') ) const lineIban = String(line.EftPayeeAccount || '').toUpperCase().replace(/\s/g, '') const ibanPartnerId = ibanToPartner[lineIban] || null const candidates: any[] = [] for (const meta of invoiceMeta) { if (meta.isSOTrx !== isCredit) continue const signals = { docNo: meta.normDocNo.length >= 5 && ( scanText.includes(meta.normDocNo) || (meta.normDocNoNoZeros.length >= 5 && scanText.includes(meta.normDocNoNoZeros)) ), amount: Math.abs(meta.openAmt - absAmount) <= AMOUNT_TOLERANCE, ibanPartner: !!ibanPartnerId && meta.inv.C_BPartner_ID?.id === ibanPartnerId, namePartner: namesSimilar(line.EftPayee, meta.inv.C_BPartner_ID?.Name || meta.inv.C_BPartner_ID?.identifier) } const signalCount = Object.values(signals).filter(Boolean).length if (signalCount === 0) continue let confidence = 'low' if (signals.docNo && signalCount >= 2) { confidence = 'high' } else if (signals.amount && (signals.ibanPartner || signals.namePartner)) { confidence = 'medium' } candidates.push({ type: 'invoice', invoiceId: meta.inv.id, documentNo: meta.inv.DocumentNo || '', partnerId: meta.inv.C_BPartner_ID?.id || null, partnerName: meta.inv.C_BPartner_ID?.Name || meta.inv.C_BPartner_ID?.identifier || '', grandTotal: meta.inv.GrandTotal, openAmt: meta.openAmt, dateInvoiced: meta.inv.DateInvoiced || '', confidence, signals }) } candidates.sort((a, b) => (tierRank[a.confidence] - tierRank[b.confidence]) || String(b.dateInvoiced).localeCompare(String(a.dateInvoiced)) ) const top = candidates.slice(0, 5) // Fee heuristic for unexplained debits if (!isCredit) { const counterpartEmpty = !String(line.EftPayee || '').trim() const looksLikeFee = counterpartEmpty || FEE_MEMO_REGEX.test(String(line.EftMemo || '') + ' ' + String(line.EftPayee || '')) if (looksLikeFee && !top.some(c => c.confidence === 'high')) { top.unshift({ type: 'charge', confidence: 'medium', signals: { fee: true } }) } } if (top.length) { suggestions[line.id] = top } } return suggestions }