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