UNPKG

@accounter/server

Version:
690 lines (555 loc) 20.1 kB
import { normalizeNumericReference } from '../../../shared/helpers/numeric-reference.js'; import type { IGetChargesByIdsResult } from '../../charges/types.js'; import type { IGetReferenceMergeCandidatesResult } from '../types.js'; const WIDE_DATE_DIFF_MILLISECONDS = 2_592_000_000; // 30 days const ACCEPTABLE_DATE_DIFF_MILLISECONDS = 86_400_000; // 1 day const MIN_DESCRIPTION_LENGTH = 5; const REPEATED_PAYMENT_MIN_DAYS = 7; const AMOUNT_EPSILON = 0.01; const WIDE_WINDOW_SAME_DIRECTION_MAX_RELATIVE_AMOUNT_DIFF = 0.05; const FEE_ASSOCIATION_MAX_DAYS = 7; const AUXILIARY_COMPONENT_MAX_RATIO = 0.02; const RECURRING_PAYMENT_MAX_RELATIVE_AMOUNT_DIFF = 0.1; const EMBEDDED_REFERENCE_MIN_DIGITS = 8; type CandidateWithCharge = { transaction: IGetReferenceMergeCandidatesResult; charge: IGetChargesByIdsResult; }; type ChargeMatchGroup = { charge: IGetChargesByIdsResult; transactions: IGetReferenceMergeCandidatesResult[]; }; export type MergeChargePlan = { reference: string; baseChargeId: string; chargeIdsToMerge: string[]; }; type BuildMergeChargePlanParams = { candidates: readonly IGetReferenceMergeCandidatesResult[]; chargeById: ReadonlyMap<string, IGetChargesByIdsResult>; }; type BuildMergeChargePlanResult = { plans: MergeChargePlan[]; errors: string[]; }; export function buildMergeChargesByTransactionReferencePlan({ candidates, chargeById, }: BuildMergeChargePlanParams): BuildMergeChargePlanResult { const errors: string[] = []; const referenceMap = groupCandidatesByReference({ candidates, chargeById, errors }); const rawPlans: MergeChargePlan[] = []; for (const [reference, referenceCandidates] of referenceMap.entries()) { const clusters = getConnectedCandidateClusters(referenceCandidates); for (const cluster of clusters) { const plan = buildClusterMergePlan(reference, cluster); if (plan) { rawPlans.push(plan); } } } const { plans, errors: conflictErrors } = consolidateMergePlans(rawPlans); return { plans, errors: [...errors, ...conflictErrors], }; } function groupCandidatesByReference({ candidates, chargeById, errors, }: { candidates: readonly IGetReferenceMergeCandidatesResult[]; chargeById: ReadonlyMap<string, IGetChargesByIdsResult>; errors: string[]; }) { const referenceMap = new Map<string, CandidateWithCharge[]>(); for (const candidate of candidates) { const sourceReference = candidate.source_reference?.trim(); if (!sourceReference) { errors.push(`Skipping transaction ID=${candidate.id}: reference is missing`); continue; } const charge = chargeById.get(candidate.charge_id); if (!charge) { errors.push( `Skipping transaction ID=${candidate.id}: charge ${candidate.charge_id} not found`, ); continue; } const references = getCandidateReferenceKeys(candidate, charge, sourceReference); for (const reference of references) { if (!referenceMap.has(reference)) { referenceMap.set(reference, []); } referenceMap.get(reference)?.push({ transaction: candidate, charge }); } } return referenceMap; } function getCandidateReferenceKeys( candidate: IGetReferenceMergeCandidatesResult, charge: IGetChargesByIdsResult, sourceReference: string, ) { const references = new Set<string>([sourceReference]); const normalizedSourceReference = normalizeNumericReference(sourceReference); if (normalizedSourceReference) { references.add(normalizedSourceReference); } if (isForeignSecuritiesAliasCandidate(candidate, charge)) { for (const embeddedReference of getEmbeddedReferenceAliases(candidate)) { references.add(embeddedReference); } } return references; } function isForeignSecuritiesAliasCandidate( candidate: IGetReferenceMergeCandidatesResult, charge: IGetChargesByIdsResult, ) { return candidate.source_origin === 'POALIM' && isForeignSecuritiesCandidate(candidate, charge); } function isForeignSecuritiesCandidate( candidate: IGetReferenceMergeCandidatesResult, charge: IGetChargesByIdsResult, ) { if (charge.type === 'FOREIGN_SECURITIES') { return true; } const normalizedSourceDescription = normalizeDescription(candidate.source_description); if (!normalizedSourceDescription) { return false; } const normalizedWithoutQuotes = normalizedSourceDescription.replace(/["'׳״]/g, ''); return normalizedSourceDescription.includes('fsec') || normalizedWithoutQuotes.includes('ניעז'); } function getEmbeddedReferenceAliases(candidate: IGetReferenceMergeCandidatesResult) { const references = new Set<string>(); const descriptions = [candidate.source_description, candidate.origin_user_description]; for (const description of descriptions) { if (!description) { continue; } const numericRuns = description.match(/\d+/g) ?? []; for (const numericRun of numericRuns) { if (numericRun.length < EMBEDDED_REFERENCE_MIN_DIGITS || !numericRun.startsWith('0')) { continue; } const normalizedReference = normalizeNumericReference(numericRun); if (normalizedReference) { references.add(normalizedReference); } } } return references; } function getConnectedCandidateClusters(candidates: CandidateWithCharge[]): CandidateWithCharge[][] { if (candidates.length <= 1) { return [candidates]; } const neighbors = new Map<number, Set<number>>(); for (let index = 0; index < candidates.length; index++) { neighbors.set(index, new Set<number>()); } for (let left = 0; left < candidates.length; left++) { for (let right = left + 1; right < candidates.length; right++) { if (isPotentialMatch(candidates[left], candidates[right])) { neighbors.get(left)?.add(right); neighbors.get(right)?.add(left); } } } const clusters: CandidateWithCharge[][] = []; const visited = new Set<number>(); for (let start = 0; start < candidates.length; start++) { if (visited.has(start)) { continue; } const stack = [start]; const cluster: CandidateWithCharge[] = []; while (stack.length > 0) { const current = stack.pop(); if (current === undefined || visited.has(current)) { continue; } visited.add(current); cluster.push(candidates[current]); for (const neighbor of neighbors.get(current) ?? []) { if (!visited.has(neighbor)) { stack.push(neighbor); } } } clusters.push(cluster); } return clusters; } function isPotentialMatch(left: CandidateWithCharge, right: CandidateWithCharge) { if (isCrossConversionBoundary(left, right)) { return false; } if (isForeignSecuritiesCrossReferencePair(left, right)) { const isSameEventDate = isSameUtcDate( left.transaction.event_date, right.transaction.event_date, ); if (!isSameEventDate) { return false; } } const isBothConversion = isConversionCandidate(left) && isConversionCandidate(right); const leftTransaction = left.transaction; const rightTransaction = right.transaction; const leftTime = leftTransaction.event_date.getTime(); const rightTime = rightTransaction.event_date.getTime(); const diffInMilliseconds = Math.abs(leftTime - rightTime); if ( leftTransaction.is_fee && rightTransaction.is_fee && diffInMilliseconds > ACCEPTABLE_DATE_DIFF_MILLISECONDS ) { return false; } if (isLikelyDistinctSameReferencePayment(leftTransaction, rightTransaction)) { return false; } if (diffInMilliseconds <= ACCEPTABLE_DATE_DIFF_MILLISECONDS) { return true; } if (isBothConversion) { return false; } const isWithinWideRange = diffInMilliseconds <= WIDE_DATE_DIFF_MILLISECONDS; if (!isWithinWideRange) { return false; } if (isFeeAssociationMatch(leftTransaction, rightTransaction, diffInMilliseconds)) { return true; } const sourceDescriptionMatches = descriptionsMatch( leftTransaction.source_description, rightTransaction.source_description, ); const originDescriptionMatches = descriptionsMatch( leftTransaction.origin_user_description, rightTransaction.origin_user_description, ); const hasTextMatch = sourceDescriptionMatches || originDescriptionMatches; if (!hasTextMatch) { return false; } if (isLikelyRecurringPayment(leftTransaction, rightTransaction, diffInMilliseconds)) { return false; } return true; } function isForeignSecuritiesCrossReferencePair( left: CandidateWithCharge, right: CandidateWithCharge, ) { const leftTransaction = left.transaction; const rightTransaction = right.transaction; if (leftTransaction.source_reference === rightTransaction.source_reference) { return false; } return ( isForeignSecuritiesAliasCandidate(leftTransaction, left.charge) && isForeignSecuritiesAliasCandidate(rightTransaction, right.charge) ); } function isSameUtcDate(left: Date, right: Date) { return ( left.getUTCFullYear() === right.getUTCFullYear() && left.getUTCMonth() === right.getUTCMonth() && left.getUTCDate() === right.getUTCDate() ); } function isCrossConversionBoundary(left: CandidateWithCharge, right: CandidateWithCharge) { return isConversionCandidate(left) !== isConversionCandidate(right); } function isConversionCandidate(candidate: CandidateWithCharge) { if (candidate.charge.type === 'CONVERSION') { return true; } const normalizedDescription = normalizeDescription(candidate.transaction.source_description); if (!normalizedDescription) { return false; } return ( normalizedDescription.includes('conversion') || normalizedDescription.includes('convert') || normalizedDescription.includes('קונברסיה') || normalizedDescription.includes('המרה') || normalizedDescription.includes('המרת') ); } function isFeeAssociationMatch( left: IGetReferenceMergeCandidatesResult, right: IGetReferenceMergeCandidatesResult, diffInMilliseconds: number, ) { if (diffInMilliseconds > FEE_ASSOCIATION_MAX_DAYS * ACCEPTABLE_DATE_DIFF_MILLISECONDS) { return false; } return left.is_fee !== right.is_fee; } function isLikelyRecurringPayment( left: IGetReferenceMergeCandidatesResult, right: IGetReferenceMergeCandidatesResult, diffInMilliseconds: number, ) { if (!isLikelyRepeatedPaymentInterval(diffInMilliseconds)) { return false; } const leftAmount = Number.parseFloat(left.amount); const rightAmount = Number.parseFloat(right.amount); if (!Number.isFinite(leftAmount) || !Number.isFinite(rightAmount)) { return false; } if (Math.sign(leftAmount) !== Math.sign(rightAmount)) { return false; } const minMagnitude = Math.min(Math.abs(leftAmount), Math.abs(rightAmount)); if (minMagnitude <= AMOUNT_EPSILON) { return false; } const relativeDiff = Math.abs(leftAmount - rightAmount) / minMagnitude; if (relativeDiff > RECURRING_PAYMENT_MAX_RELATIVE_AMOUNT_DIFF) { return false; } const leftSourceDescription = normalizeDescription(left.source_description); const rightSourceDescription = normalizeDescription(right.source_description); if ( leftSourceDescription && rightSourceDescription && leftSourceDescription === rightSourceDescription ) { return true; } const leftOriginDescription = normalizeDescription(left.origin_user_description); const rightOriginDescription = normalizeDescription(right.origin_user_description); return !!( leftOriginDescription && rightOriginDescription && leftOriginDescription === rightOriginDescription ); } function isLikelyDistinctSameReferencePayment( left: IGetReferenceMergeCandidatesResult, right: IGetReferenceMergeCandidatesResult, ) { const leftAmount = Number.parseFloat(left.amount); const rightAmount = Number.parseFloat(right.amount); if (!Number.isFinite(leftAmount) || !Number.isFinite(rightAmount)) { return false; } const leftIsFee = left.is_fee; const rightIsFee = right.is_fee; if (leftIsFee || rightIsFee) { return false; } const isSameDirection = Math.sign(leftAmount) === Math.sign(rightAmount); if ( !isSameDirection || Math.abs(leftAmount) <= AMOUNT_EPSILON || Math.abs(rightAmount) <= AMOUNT_EPSILON ) { return false; } const absoluteDiff = Math.abs(leftAmount - rightAmount); const minMagnitude = Math.min(Math.abs(leftAmount), Math.abs(rightAmount)); const maxMagnitude = Math.max(Math.abs(leftAmount), Math.abs(rightAmount)); if (minMagnitude / maxMagnitude <= AUXILIARY_COMPONENT_MAX_RATIO) { return false; } const relativeDiff = absoluteDiff / minMagnitude; return relativeDiff > WIDE_WINDOW_SAME_DIRECTION_MAX_RELATIVE_AMOUNT_DIFF; } function isLikelyRepeatedPaymentInterval(diffInMilliseconds: number) { const diffInDays = diffInMilliseconds / ACCEPTABLE_DATE_DIFF_MILLISECONDS; return diffInDays >= REPEATED_PAYMENT_MIN_DAYS; } function descriptionsMatch(left: string | null, right: string | null) { const leftNormalized = normalizeDescription(left); const rightNormalized = normalizeDescription(right); if ( !leftNormalized || !rightNormalized || leftNormalized.length < MIN_DESCRIPTION_LENGTH || rightNormalized.length < MIN_DESCRIPTION_LENGTH ) { return false; } return leftNormalized.includes(rightNormalized) || rightNormalized.includes(leftNormalized); } function normalizeDescription(value: string | null): string | null { if (!value) { return null; } return value.trim().toLowerCase().replace(/\s+/g, ' '); } function buildClusterMergePlan( reference: string, cluster: CandidateWithCharge[], ): MergeChargePlan | null { const chargeMatches = getChargeMatches(cluster); if (chargeMatches.length <= 1) { return null; } if (hasConflictingMainReferences(chargeMatches)) { return null; } chargeMatches.sort(compareChargeMatchCandidates); const [mainChargeMatch, ...otherChargeMatches] = chargeMatches; const chargeIdsToMerge = otherChargeMatches.map(match => match.charge.id); if (chargeIdsToMerge.length === 0) { return null; } return { reference, baseChargeId: mainChargeMatch.charge.id, chargeIdsToMerge, }; } function getChargeMatches(cluster: CandidateWithCharge[]): ChargeMatchGroup[] { const matchesByChargeId = new Map<string, ChargeMatchGroup>(); for (const { charge, transaction } of cluster) { if (!matchesByChargeId.has(charge.id)) { matchesByChargeId.set(charge.id, { charge, transactions: [], }); } matchesByChargeId.get(charge.id)?.transactions.push(transaction); } return Array.from(matchesByChargeId.values()); } function hasConflictingMainReferences(chargeMatches: ChargeMatchGroup[]) { const normalizedMainReferences = chargeMatches .map(match => getNormalizedMainReference(match.transactions)) .filter((reference): reference is string => !!reference); if (normalizedMainReferences.length <= 1) { return false; } return new Set(normalizedMainReferences).size > 1; } function getNormalizedMainReference(transactions: IGetReferenceMergeCandidatesResult[]) { const nonFeeReferences = new Set<string>(); for (const transaction of transactions) { if (transaction.is_fee) { continue; } const sourceReference = transaction.source_reference?.trim(); if (!sourceReference) { continue; } nonFeeReferences.add(normalizeReferenceForComparison(sourceReference)); } if (nonFeeReferences.size === 0) { return null; } if (nonFeeReferences.size === 1) { return nonFeeReferences.values().next().value ?? null; } // Multiple distinct non-fee references inside one charge is ambiguous. return Array.from(nonFeeReferences).sort().join('|'); } function normalizeReferenceForComparison(reference: string) { return normalizeNumericReference(reference) ?? reference; } function compareChargeMatchCandidates(left: ChargeMatchGroup, right: ChargeMatchGroup) { const leftIsPreferred = isPreferredMainChargeCandidate(left); const rightIsPreferred = isPreferredMainChargeCandidate(right); if (leftIsPreferred !== rightIsPreferred) { return Number(rightIsPreferred) - Number(leftIsPreferred); } const leftTransactionCount = left.transactions.length; const rightTransactionCount = right.transactions.length; if (leftTransactionCount !== rightTransactionCount) { return rightTransactionCount - leftTransactionCount; } const createdAtDiff = left.charge.created_at.getTime() - right.charge.created_at.getTime(); if (createdAtDiff !== 0) { return createdAtDiff; } return left.charge.id.localeCompare(right.charge.id); } function isPreferredMainChargeCandidate(candidate: ChargeMatchGroup) { const hasNonFeeTransaction = candidate.transactions.some(transaction => !transaction.is_fee); const isUnlinkedCharge = candidate.charge.user_description?.toLowerCase().includes('unlinked from charge') ?? false; return hasNonFeeTransaction && !isUnlinkedCharge; } function consolidateMergePlans(rawPlans: MergeChargePlan[]): BuildMergeChargePlanResult { const errors: string[] = []; const referenceByBaseChargeId = new Map<string, string>(); const targetToBaseChargeId = new Map<string, string>(); const involvedChargeIds = new Set<string>(); for (const plan of rawPlans) { const { reference, baseChargeId } = plan; involvedChargeIds.add(baseChargeId); const effectiveBaseChargeId = getUltimateBaseChargeId(baseChargeId, targetToBaseChargeId); referenceByBaseChargeId.set( effectiveBaseChargeId, referenceByBaseChargeId.get(effectiveBaseChargeId) ?? reference, ); for (const targetChargeId of plan.chargeIdsToMerge) { involvedChargeIds.add(targetChargeId); if (targetChargeId === effectiveBaseChargeId) { continue; } const directAssignedBaseChargeId = targetToBaseChargeId.get(targetChargeId); const assignedRootChargeId = getUltimateBaseChargeId(targetChargeId, targetToBaseChargeId); if (directAssignedBaseChargeId && assignedRootChargeId !== effectiveBaseChargeId) { errors.push( `Skipping reference "${reference}": charge ${targetChargeId} is already scheduled to merge into ${assignedRootChargeId}`, ); continue; } if (assignedRootChargeId === effectiveBaseChargeId) { continue; } targetToBaseChargeId.set(assignedRootChargeId, effectiveBaseChargeId); } } const targetsByBaseChargeId = new Map<string, Set<string>>(); for (const chargeId of involvedChargeIds) { const resolvedBaseChargeId = getUltimateBaseChargeId(chargeId, targetToBaseChargeId); if (resolvedBaseChargeId === chargeId) { continue; } if (!targetsByBaseChargeId.has(resolvedBaseChargeId)) { targetsByBaseChargeId.set(resolvedBaseChargeId, new Set<string>()); } targetsByBaseChargeId.get(resolvedBaseChargeId)?.add(chargeId); } const plans = Array.from(targetsByBaseChargeId.entries()) .sort(([leftBaseId], [rightBaseId]) => leftBaseId.localeCompare(rightBaseId)) .map(([baseChargeId, targetChargeIds]) => ({ reference: referenceByBaseChargeId.get(baseChargeId) ?? 'unknown', baseChargeId, chargeIdsToMerge: Array.from(targetChargeIds).sort((leftId, rightId) => leftId.localeCompare(rightId), ), })); return { plans, errors, }; } function getUltimateBaseChargeId( chargeId: string, targetToBaseChargeId: Map<string, string>, ): string { const traversedChargeIds: string[] = []; let currentChargeId = chargeId; while (targetToBaseChargeId.has(currentChargeId)) { traversedChargeIds.push(currentChargeId); currentChargeId = targetToBaseChargeId.get(currentChargeId) ?? currentChargeId; } for (const traversedChargeId of traversedChargeIds) { targetToBaseChargeId.set(traversedChargeId, currentChargeId); } return currentChargeId; }