UNPKG

@accounter/server

Version:
127 lines 5.05 kB
/** * Deduce the counterparty (the matched business that is not the owner) from * the issuer/recipient match results. * * Returns null when neither side matched, when the only match is the owner * itself, or when both sides matched non-owner businesses (ambiguous). */ function deduceCounterpartyId(ownerId, issuerId, recipientId) { if (issuerId === ownerId) { return recipientId === ownerId ? null : recipientId; } if (recipientId === ownerId) { return issuerId; } // Neither side is the owner: a single match is the counterparty; two // non-owner matches leave the counterparty ambiguous. if (issuerId && recipientId) { return null; } return issuerId ?? recipientId; } function normalizeLocality(locality) { const normalized = locality?.trim().toLowerCase(); return normalized || null; } /** * A NULL extracted VAT amount is ambiguous: "not stated" vs "no VAT". When the * counterparty is recognized and located in a different locality than the * owner, the document carries no local VAT — resolve the amount to 0. * * In every other case (VAT already extracted, owner/counterparty unknown, * locality missing or identical) the original value is returned unchanged. */ export function applyForeignCounterpartyVatDefault(vatAmount, owner, suggestedIssuer, suggestedRecipient, businesses) { const ownerLocality = owner ? normalizeLocality(owner.locality) : null; if (vatAmount !== null || !owner || !ownerLocality) { return vatAmount; } const counterpartyId = deduceCounterpartyId(owner.id, suggestedIssuer, suggestedRecipient); if (!counterpartyId) { return vatAmount; } const counterparty = businesses.find(b => b.id === counterpartyId); const counterpartyLocality = normalizeLocality(counterparty?.locality); if (counterpartyLocality && counterpartyLocality !== ownerLocality) { return 0; } return vatAmount; } function normalizeVat(vat) { return vat.replace(/[\s-]/g, ''); } const LEGAL_SUFFIXES = /\b(בע"מ|בעמ|בע'מ|ltd\.?|inc\.?|llc\.?|corp\.?|limited|incorporated|plc)\b/gi; const MIN_MATCH_LENGTH = 3; function normalizeText(text) { return text .toLowerCase() .replace(LEGAL_SUFFIXES, '') .replace(/[״׳"'.,\-_()[\]]/g, '') .replace(/\s+/g, ' ') .trim(); } function isWordMatch(word, text) { return ` ${text} `.includes(` ${word} `); } /** * Attempt to match an extracted name/VAT to a known business. * * Priority: * 1. VAT exact match — definitive for Israeli invoices. * 2. Name exact / substring match (case-insensitive, both Latin and Hebrew). * 3. suggestion_data.phrases substring match, sorted by priority descending. * * Returns the matching business UUID, or null if no confident match. */ export function matchBusiness(extractedName, extractedVatNumber, businesses) { if (!businesses.length) return null; // 1. VAT exact match if (extractedVatNumber) { const normalizedVat = normalizeVat(extractedVatNumber); for (const b of businesses) { if (b.vat_number && normalizeVat(b.vat_number) === normalizedVat) { return b.id; } } } if (!extractedName) return null; const normalizedName = normalizeText(extractedName); if (normalizedName.length < MIN_MATCH_LENGTH) return null; // 2. Name exact / whole-word substring match. // Raw `.includes()` is avoided because it produces false positives for short names: // e.g. "גיל" (3 chars) would match "גילת" via substring. Padding both sides with // spaces restricts matching to whole-word boundaries after normalization. for (const b of businesses) { const names = [b.name, b.hebrew_name].filter((n) => n != null); for (const name of names) { const normalizedBizName = normalizeText(name); if (normalizedBizName.length < MIN_MATCH_LENGTH) continue; if (normalizedBizName === normalizedName || isWordMatch(normalizedName, normalizedBizName) || isWordMatch(normalizedBizName, normalizedName)) { return b.id; } } } // 3. Suggestion phrases, sorted by priority descending const sorted = [...businesses].sort((a, b) => (b.suggestion_data?.priority ?? 0) - (a.suggestion_data?.priority ?? 0)); for (const b of sorted) { const phrases = b.suggestion_data?.phrases ?? []; for (const phrase of phrases) { const normalizedPhrase = normalizeText(phrase); if (normalizedPhrase.length < MIN_MATCH_LENGTH) continue; if (normalizedName === normalizedPhrase || isWordMatch(normalizedName, normalizedPhrase) || isWordMatch(normalizedPhrase, normalizedName)) { return b.id; } } } return null; } //# sourceMappingURL=business-matcher.helper.js.map