@accounter/server
Version:
Accounter GraphQL server
267 lines (233 loc) • 9.09 kB
text/typescript
/**
* 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);
}