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 FUZZY match of counterpart vs partner name (see nameSimilarity): brand word, * prefixes, accents, filler words ignored — "AMAZON PAYMENTS EUROPE S.C.A." * matches "Amazon EU S.à r.l., Niederlassung Deutschland"; also the brand word * found in the remittance text when the counterpart field is empty * date statement line date close to the invoice date (−5 … +60 days) * * A candidate is only suggested when AT LEAST 2 of the 4 CRITERIA match — document number, * amount, partner (IBAN or name count as ONE criterion) and date. One matching field alone * (e.g. "same amount as some open invoice") produced too many wrong suggestions. * Ranking = score, best first: docNo 50 · amount 30 · IBAN 25 / name 10–25 (by similarity) · * date 0–15 (by closeness); ties → newer invoice. Up to 8 candidates per line: the list page * shows the first one, the match modal the whole ranked list. * Tiers: high = docNo + 1 more, or amount + partner + date | medium = amount + one more | * low = partner + date only (no amount: partial payment / collective payment) * * 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. */ // legal forms + filler words that say nothing about WHO the partner is const NAME_STOPWORDS = new Set([ 'gmbh', 'mbh', 'ag', 'ug', 'kg', 'kgaa', 'ohg', 'ek', 'gbr', 'co', 'cokg', 'inc', 'ltd', 'llc', 'plc', 'corp', 'bv', 'nv', 'sa', 'sl', 'sas', 'spa', 'sarl', 'srl', 'sca', 'se', 'ab', 'as', 'oy', 'und', 'and', 'the', 'die', 'der', 'das', 'von', 'de', 'la', 'le', 'niederlassung', 'zweigniederlassung', 'deutschland', 'germany', 'europe', 'europa', 'eu', 'international', 'intl', 'global', 'services', 'service', 'payments', 'payment', 'holding', 'group', 'gruppe', 'company', 'handel', 'handels', 'vertrieb', 'vertriebs', // generic leading adjectives — must never act as the "brand" ("Deutsche Bank" is not "Deutsche Post") 'deutsche', 'deutscher', 'deutsches', 'german', 'erste', 'neue', 'allgemeine', 'vereinigte', 'national', 'nationale' ]) // lower-case, German umlauts spelled out FIRST ("Müller" = "Mueller"), then remaining accents stripped const foldText = (v: any): string => String(v || '').toLowerCase() .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') .normalize('NFD').replace(/[\u0300-\u036f]/g, '') /** Significant name tokens in order (the first one is usually the brand / family name). */ const nameTokens = (name: any): string[] => { const out: string[] = [] for (const t of foldText(name).replace(/[^a-z0-9 ]/g, ' ').split(/\s+/)) { if (t.length >= 2 && !NAME_STOPWORDS.has(t) && !out.includes(t)) out.push(t) } return out } // equal, or one is a prefix of the other ("amazon" ~ "amazonpayments") — prefixes only from 4 letters const tokenMatch = (x: string, y: string): boolean => x === y || (Math.min(x.length, y.length) >= 4 && (x.startsWith(y) || y.startsWith(x))) /** * 0 … 1 similarity of two party names — deliberately NOT exact: bank counterpart names are * abbreviated, upper-cased, carry other legal forms or a different group entity. * - share of matching tokens (relative to the shorter name), * - brand match: the first significant tokens match (≥ 4 letters) → at least 0.7, * - the other name's brand token appears inside the run-together text ("AMAZONPAYMENTS") → 0.6. */ export const nameSimilarity = (a: any, b: any): number => { const ta = nameTokens(a) const tb = nameTokens(b) if (!ta.length || !tb.length) return 0 const hits = ta.filter(x => tb.some(y => tokenMatch(x, y))).length let score = hits / Math.min(ta.length, tb.length) if (Math.min(ta[0].length, tb[0].length) >= 4 && tokenMatch(ta[0], tb[0])) score = Math.max(score, 0.7) const ca = foldText(a).replace(/[^a-z0-9]/g, '') const cb = foldText(b).replace(/[^a-z0-9]/g, '') if ((tb[0].length >= 5 && ca.includes(tb[0])) || (ta[0].length >= 5 && cb.includes(ta[0]))) score = Math.max(score, 0.6) return Math.min(1, score) } const NAME_MATCH_MIN = 0.5 /** Partner's brand token (≥ 5 letters) inside free text — for lines without a counterpart name. */ const brandInText = (partnerName: any, foldedCompactText: string): boolean => { const brand = nameTokens(partnerName)[0] return !!brand && brand.length >= 5 && foldedCompactText.includes(brand) } const DATE_WINDOW_BEFORE = 5 // payment up to 5 days BEFORE the invoice date (prepayment / dated later) const DATE_WINDOW_AFTER = 60 // … or up to 60 days after it const dayNumber = (v: any): number | null => { const m = String(v || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? Math.floor(Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000) : null } 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[] } — best match first, max 8 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,StatementLineDate,ValutaDate,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 lineDay = dayNumber(line.StatementLineDate) ?? dayNumber(line.ValutaDate) const payeeName = String(line.EftPayee || '').trim() const memoCompact = payeeName ? '' : foldText((line.EftMemo || '') + ' ' + (line.Description || '')).replace(/[^a-z0-9]/g, '') const candidates: any[] = [] for (const meta of invoiceMeta) { if (meta.isSOTrx !== isCredit) continue const partnerName = meta.inv.C_BPartner_ID?.Name || meta.inv.C_BPartner_ID?.identifier || '' const nameScore = payeeName ? nameSimilarity(payeeName, partnerName) : (brandInText(partnerName, memoCompact) ? 0.5 : 0) const invDay = dayNumber(meta.inv.DateInvoiced) const dateGap = lineDay != null && invDay != null ? lineDay - invDay : null 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: nameScore >= NAME_MATCH_MIN, date: dateGap != null && dateGap >= -DATE_WINDOW_BEFORE && dateGap <= DATE_WINDOW_AFTER } // the 4 criteria — IBAN and name are two ways to identify the SAME thing (the partner) const partner = signals.ibanPartner || signals.namePartner const matchCount = [signals.docNo, signals.amount, partner, signals.date].filter(Boolean).length if (matchCount < 2) continue const dateCloseness = signals.date ? 1 - Math.abs(dateGap as number) / DATE_WINDOW_AFTER : 0 const score = Math.round( (signals.docNo ? 50 : 0) + (signals.amount ? 30 : 0) + (signals.ibanPartner ? 25 : (signals.namePartner ? 10 + 15 * nameScore : 0)) + 15 * dateCloseness ) let confidence = 'low' if ((signals.docNo && matchCount >= 2) || (signals.amount && partner && signals.date)) { confidence = 'high' } else if (signals.amount) { confidence = 'medium' } candidates.push({ type: 'invoice', invoiceId: meta.inv.id, documentNo: meta.inv.DocumentNo || '', partnerId: meta.inv.C_BPartner_ID?.id || null, partnerName, grandTotal: meta.inv.GrandTotal, openAmt: meta.openAmt, dateInvoiced: meta.inv.DateInvoiced || '', confidence, signals, matchCount, score, nameScore: Math.round(nameScore * 100) / 100, dateGapDays: dateGap }) } // best / nearest match first candidates.sort((a, b) => (b.score - a.score) || (tierRank[a.confidence] - tierRank[b.confidence]) || String(b.dateInvoiced).localeCompare(String(a.dateInvoiced)) ) const top = candidates.slice(0, 8) // 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 }