UNPKG

@accounter/server

Version:
197 lines • 8.37 kB
/** * Single-Match Provider * * Implements the core single-match logic for finding potential charge matches. * This is a pure function implementation without database dependencies. */ import { isWithinDateWindow } from '../helpers/candidate-filter.helper.js'; import { aggregateDocuments } from './document-aggregator.js'; import { scoreMatch } from './match-scorer.provider.js'; import { aggregateTransactions } from './transaction-aggregator.js'; /** * Type guard: check if charge is TransactionCharge */ function isTransactionCharge(charge) { return 'transactions' in charge && charge.transactions.length > 0; } /** * Type guard: check if charge is DocumentCharge */ function isDocumentCharge(charge) { return 'documents' in charge && charge.documents.length > 0; } /** * Validate that source charge is unmatched (has only one type) */ function validateUnmatchedCharge(charge) { const hasTx = 'transactions' in charge && charge.transactions && charge.transactions.length > 0; const hasDocs = 'documents' in charge && charge.documents && charge.documents.length > 0; if (hasTx && hasDocs) { throw new Error(`Source charge ${charge.chargeId} is already matched (contains both transactions and documents)`); } if (!hasTx && !hasDocs) { throw new Error(`Source charge ${charge.chargeId} has no transactions or documents`); } } /** * Validate that source charge can be aggregated successfully */ function validateSourceAggregation(charge, userId) { try { if (isTransactionCharge(charge)) { aggregateTransactions(charge.transactions); } else { aggregateDocuments(charge.documents, userId); } } catch (error) { throw new Error(`Source charge ${charge.chargeId} failed validation: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } /** * Calculate date proximity between transaction and document charges * Used for tie-breaking when confidence scores are equal * * @param txCharge - Transaction charge * @param docCharge - Document charge * @returns Number of days between earliest transaction date and latest document date */ function calculateDateProximity(txCharge, docCharge) { // Get earliest transaction event_date const earliestTxDate = txCharge.transactions.reduce((earliest, tx) => { return tx.event_date < earliest ? tx.event_date : earliest; }, txCharge.transactions[0].event_date); // Get latest document date const latestDocDate = docCharge.documents.reduce((latest, doc) => { if (!doc.date) return latest; return doc.date > latest ? doc.date : latest; }, docCharge.documents[0].date); // Calculate day difference const diffMs = Math.abs(earliestTxDate.getTime() - latestDocDate.getTime()); return Math.floor(diffMs / (1000 * 60 * 60 * 24)); } /** * Find top matches for an unmatched charge * * @param sourceCharge - The unmatched charge (transactions OR documents) * @param candidateCharges - All potential match candidates * @param userId - Current user ID * @param options - Optional configuration (maxMatches, dateWindowMonths, injector) * @returns Top matches sorted by confidence * @throws Error if source charge is matched or has validation issues */ export async function findMatches(sourceCharge, candidateCharges, userId, injector, options) { const maxMatches = options?.maxMatches ?? 5; const dateWindowMonths = options?.dateWindowMonths ?? 12; // Step 1: Validate source charge is unmatched validateUnmatchedCharge(sourceCharge); // Step 2: Validate source charge aggregation validateSourceAggregation(sourceCharge, userId); // Step 3: Determine source type and filter candidates by complementary type const isSourceTransaction = isTransactionCharge(sourceCharge); const complementaryCandidates = candidateCharges.filter(candidate => { if (isSourceTransaction) { return isDocumentCharge(candidate); } return isTransactionCharge(candidate); }); // Step 4: Get source date for window filtering let sourceDate; if (isSourceTransaction) { // Use earliest event_date from transactions sourceDate = sourceCharge.transactions.reduce((earliest, tx) => { return tx.event_date < earliest ? tx.event_date : earliest; }, sourceCharge.transactions[0].event_date); } else { // Use latest date from documents sourceDate = sourceCharge.documents.reduce((latest, doc) => { if (!doc.date) return latest; return doc.date > latest ? doc.date : latest; }, sourceCharge.documents[0].date); } // Step 5: Filter candidates by date window const windowFilteredCandidates = complementaryCandidates.filter(candidate => { let candidateDate; if (isTransactionCharge(candidate)) { candidateDate = candidate.transactions.reduce((earliest, tx) => { return tx.event_date < earliest ? tx.event_date : earliest; }, candidate.transactions[0].event_date); } else { candidateDate = candidate.documents.reduce((latest, doc) => { if (!doc.date) return latest; return doc.date > latest ? doc.date : latest; }, candidate.documents[0].date); } return isWithinDateWindow(sourceDate, candidateDate, dateWindowMonths); }); // Step 6: Filter candidates using candidate filter logic // Note: Additional filtering (is_fee, null checks) is handled by aggregators // which will throw errors for invalid data // Step 7: Exclude candidates with same chargeId const sameChargeCandidate = windowFilteredCandidates.find(c => c.chargeId === sourceCharge.chargeId); if (sameChargeCandidate) { throw new Error(`Candidate charge ${sameChargeCandidate.chargeId} has the same ID as source charge`); } // Step 8: Score all remaining candidates const scoredCandidates = []; for (const candidate of windowFilteredCandidates) { try { let matchScore; let txCharge; let docCharge; if (isSourceTransaction) { txCharge = sourceCharge; docCharge = candidate; matchScore = await scoreMatch(txCharge, docCharge, userId, injector); } else { txCharge = candidate; docCharge = sourceCharge; matchScore = await scoreMatch(txCharge, docCharge, userId, injector); } // Calculate date proximity for tie-breaking const dateProximity = calculateDateProximity(txCharge, docCharge); scoredCandidates.push({ chargeId: candidate.chargeId, confidenceScore: matchScore.confidenceScore, components: matchScore.components, dateProximity, gentleMode: matchScore.gentleMode === true, _txCharge: txCharge, _docCharge: docCharge, }); } catch { // Skip candidates that fail scoring (e.g., mixed currencies, invalid data) // This is expected behavior - not all candidates will be scoreable continue; } } // Step 9: Sort by confidence descending, then by date proximity tie-breaker scoredCandidates.sort((a, b) => { // Primary: confidence score (descending) if (a.confidenceScore !== b.confidenceScore) { return b.confidenceScore - a.confidenceScore; } // Tie-breaker: // - If both in gentle mode: prefer earlier document (larger proximity) // - Otherwise: prefer closer dates (smaller proximity) const aProx = a.dateProximity ?? Infinity; const bProx = b.dateProximity ?? Infinity; if (a.gentleMode && b.gentleMode) { return bProx - aProx; } return aProx - bProx; }); // Step 10: Return top N matches const topMatches = scoredCandidates.slice(0, maxMatches); // Clean up temporary fields return topMatches.map(({ _txCharge, _docCharge, ...match }) => match); } //# sourceMappingURL=single-match.provider.js.map