UNPKG

@accounter/server

Version:
171 lines (154 loc) 5.42 kB
export type BusinessMatchData = { id: string; name: string | null; hebrew_name: string | null; vat_number: string | null; suggestion_data: { phrases?: string[]; priority?: number } | null; locality: string | null; }; export type OwnerMatchInfo = { id: string; locality: string | null; }; /** * 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: string, issuerId: string | null, recipientId: string | null, ): string | null { 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: string | null | undefined): string | null { 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: number | null, owner: OwnerMatchInfo | undefined, suggestedIssuer: string | null, suggestedRecipient: string | null, businesses: BusinessMatchData[], ): number | null { 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: string): string { 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: string): string { return text .toLowerCase() .replace(LEGAL_SUFFIXES, '') .replace(/[״׳"'.,\-_()[\]]/g, '') .replace(/\s+/g, ' ') .trim(); } function isWordMatch(word: string, text: string): boolean { 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: string | null | undefined, extractedVatNumber: string | null | undefined, businesses: BusinessMatchData[], ): string | null { 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 is string => 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; }