UNPKG

@accounter/server

Version:
267 lines (233 loc) • 9.09 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 type { Injector } from 'graphql-modules'; import { isWithinDateWindow } from '../helpers/candidate-filter.helper.js'; import type { DocumentCharge, MatchScore, TransactionCharge } from '../types.js'; import { aggregateDocuments } from './document-aggregator.js'; import { scoreMatch } from './match-scorer.provider.js'; import { aggregateTransactions } from './transaction-aggregator.js'; /** * Match result with score and metadata */ export interface MatchResult { chargeId: string; confidenceScore: number; components: { amount: number; currency: number; business: number; date: number; }; dateProximity?: number; // Days between earliest tx date and latest doc date (for tie-breaking) gentleMode?: boolean; // Whether gentle client scoring applied } /** * Options for findMatches function */ export interface FindMatchesOptions { maxMatches?: number; // Default 5 dateWindowMonths?: number; // Default 12 } /** * Type guard: check if charge is TransactionCharge */ function isTransactionCharge( charge: TransactionCharge | DocumentCharge, ): charge is TransactionCharge { return 'transactions' in charge && charge.transactions.length > 0; } /** * Type guard: check if charge is DocumentCharge */ function isDocumentCharge(charge: TransactionCharge | DocumentCharge): charge is DocumentCharge { return 'documents' in charge && charge.documents.length > 0; } /** * Validate that source charge is unmatched (has only one type) */ function validateUnmatchedCharge(charge: TransactionCharge | DocumentCharge): void { 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: TransactionCharge | DocumentCharge, userId: string, ): void { 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: TransactionCharge, docCharge: DocumentCharge): number { // 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: TransactionCharge | DocumentCharge, candidateCharges: Array<TransactionCharge | DocumentCharge>, userId: string, injector: Injector, options?: FindMatchesOptions, ): Promise<MatchResult[]> { 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: Date; 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: Date; 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: Array< MatchResult & { _txCharge?: TransactionCharge; _docCharge?: DocumentCharge } > = []; for (const candidate of windowFilteredCandidates) { try { let matchScore: MatchScore; let txCharge: TransactionCharge; let docCharge: DocumentCharge; if (isSourceTransaction) { txCharge = sourceCharge; docCharge = candidate as DocumentCharge; matchScore = await scoreMatch(txCharge, docCharge, userId, injector); } else { txCharge = candidate as TransactionCharge; 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); }