@accounter/server
Version:
Accounter GraphQL server
127 lines • 5.05 kB
JavaScript
/**
* 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