@accounter/server
Version:
The test suite is split into three Vitest projects for efficiency and isolation:
260 lines • 13 kB
JavaScript
/**
* Charges Matcher Provider
*
* Provides database-integrated charge matching functionality using the Injector pattern.
* Integrates with existing modules: charges, transactions, and documents.
*/
import { __decorate } from "tslib";
import { subYears } from 'date-fns';
import { Injectable, Scope } from 'graphql-modules';
import { dateToTimelessDateString } from '../../../shared/helpers/index.js';
import { mergeChargesExecutor } from '../../charges/helpers/merge-charges.helper.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { DocumentsProvider } from '../../documents/providers/documents.provider.js';
import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js';
import { validateChargeIsUnmatched } from '../helpers/charge-validator.helper.js';
import { ChargeType, } from '../types.js';
import { determineMergeDirection, processChargeForAutoMatch } from './auto-match.provider.js';
import { aggregateDocuments } from './document-aggregator.js';
import { findMatches } from './single-match.provider.js';
import { aggregateTransactions } from './transaction-aggregator.js';
/**
* Charges Matcher Provider
*
* Provides high-level charge matching operations with database integration.
* Uses the Injector pattern to access existing providers from other modules.
*/
let ChargesMatcherProvider = class ChargesMatcherProvider {
/**
* Find potential matches for an unmatched charge
*
* @param chargeId - ID of the unmatched charge to find matches for
* @param injector - GraphQL modules injector for provider access
* @returns Top 5 matches ordered by confidence score
* @throws Error if charge not found
* @throws Error if charge is already matched
* @throws Error if charge data is invalid
*/
async findMatchesForCharge(chargeId, context) {
const { adminContext: { defaultAdminBusinessId: adminBusinessId }, injector, } = context;
// Get current user ID from context
if (!adminBusinessId) {
throw new Error('Admin business not found in context');
}
// Get providers from injector
const chargesProvider = injector.get(ChargesProvider);
const transactionsProvider = injector.get(TransactionsProvider);
const documentsProvider = injector.get(DocumentsProvider);
// Step 1: Load source charge data
const sourceCharge = await chargesProvider.getChargeByIdLoader.load(chargeId);
if (!sourceCharge || sourceCharge instanceof Error) {
throw new Error(`Source charge not found: ${chargeId}`);
}
// Step 2: Load transactions and documents for source charge
const sourceTransactions = (await transactionsProvider.transactionsByChargeIDLoader.load(chargeId));
const sourceDocuments = (await documentsProvider.getDocumentsByChargeIdLoader.load(chargeId));
// Step 3: Validate source charge is unmatched
const sourceChargeWithData = {
...sourceCharge,
transactions: sourceTransactions,
documents: sourceDocuments,
};
validateChargeIsUnmatched(sourceChargeWithData);
// Step 4: Determine reference date and date window from source charge
let referenceDate;
const hasTransactions = sourceTransactions && sourceTransactions.length > 0;
if (hasTransactions) {
// Use earliest transaction event_date
const aggregated = aggregateTransactions(sourceTransactions);
referenceDate = aggregated.date;
}
else {
// Use latest document date
const aggregated = aggregateDocuments(sourceDocuments, adminBusinessId);
referenceDate = aggregated.date;
}
// Step 5: Load candidate charges from database
// Use 12-month window centered on reference date
const windowStart = new Date(referenceDate);
windowStart.setMonth(windowStart.getMonth() - 12);
const windowEnd = new Date(referenceDate);
windowEnd.setMonth(windowEnd.getMonth() + 12);
const candidateCharges = await chargesProvider.getChargesByFilters({
ownerIds: [adminBusinessId],
fromAnyDate: dateToTimelessDateString(windowStart),
toAnyDate: dateToTimelessDateString(windowEnd),
});
// Step 6: Load transactions and documents for all candidate charges
const candidateChargesWithData = [];
await Promise.all(candidateCharges.map(async (candidate) => {
// Skip the source charge itself
if (candidate.id === chargeId) {
return;
}
const candidateTransactionsPromise = transactionsProvider.transactionsByChargeIDLoader.load(candidate.id);
const candidateDocumentsPromise = documentsProvider.getDocumentsByChargeIdLoader.load(candidate.id);
const [candidateTransactions, candidateDocuments] = await Promise.all([
candidateTransactionsPromise,
candidateDocumentsPromise,
]);
const hasTxs = candidateTransactions && candidateTransactions.length > 0;
const hasDocs = candidateDocuments && candidateDocuments.length > 0;
// Only include unmatched charges (not both types)
if (hasTxs && !hasDocs) {
candidateChargesWithData.push({
chargeId: candidate.id,
transactions: candidateTransactions,
});
}
else if (hasDocs && !hasTxs) {
candidateChargesWithData.push({
chargeId: candidate.id,
documents: candidateDocuments,
});
}
// Skip matched charges (have both) and empty charges (have neither)
}));
// Step 7: Build source charge object for findMatches
let sourceChargeData;
if (hasTransactions) {
sourceChargeData = {
chargeId,
transactions: sourceTransactions,
};
}
else {
sourceChargeData = {
chargeId,
documents: sourceDocuments,
};
}
// Step 8: Call core findMatches function
const matches = await findMatches(sourceChargeData, candidateChargesWithData, adminBusinessId, injector, {
maxMatches: 5,
dateWindowMonths: 12,
});
// Step 9: Format and return result
return {
matches: matches.map(match => ({
chargeId: match.chargeId,
confidenceScore: match.confidenceScore,
})),
};
}
/**
* Auto-match all unmatched charges
*
* Automatically merges charges that have a single high-confidence match (≥0.95).
* Skips charges with multiple high-confidence matches (ambiguous).
* Processes all unmatched charges and returns a summary of actions taken.
*
* @param injector - GraphQL modules injector for provider access
* @param context - GraphQL context with user information
* @returns Summary of matches made, skipped charges, and errors
*/
async autoMatchCharges(context) {
const { adminContext: { defaultAdminBusinessId: adminBusinessId }, injector, } = context;
// Get current user ID from context
if (!adminBusinessId) {
throw new Error('Admin business not found in context');
}
// Get providers from injector
const chargesProvider = injector.get(ChargesProvider);
const transactionsProvider = injector.get(TransactionsProvider);
const documentsProvider = injector.get(DocumentsProvider);
// Step 1: Load all charges for this user
const prevYear = dateToTimelessDateString(subYears(new Date(), 1));
const allCharges = await chargesProvider.getChargesByFilters({
ownerIds: [adminBusinessId],
fromAnyDate: prevYear,
});
// Step 2: Load transactions and documents for all charges
const chargesWithData = [];
const mergedChargeIds = new Set(); // Track merged charges to exclude from processing
await Promise.all(allCharges.map(async (charge) => {
const transactionsPromise = transactionsProvider.transactionsByChargeIDLoader.load(charge.id);
const documentsPromise = documentsProvider.getDocumentsByChargeIdLoader.load(charge.id);
const [transactions, documents] = await Promise.all([
transactionsPromise,
documentsPromise,
]);
chargesWithData.push({
chargeId: charge.id,
ownerId: charge.owner_id ?? adminBusinessId,
type: ChargeType.TRANSACTION_ONLY, // Will be determined by processChargeForAutoMatch
description: charge.user_description ?? undefined,
transactions: transactions || [],
documents: documents || [],
});
}));
// Step 3: Filter to get only unmatched charges
const unmatchedCharges = chargesWithData.filter(charge => {
const hasTx = charge.transactions && charge.transactions.length > 0;
const hasDocs = charge.documents && charge.documents.length > 0;
return (hasTx && !hasDocs) || (!hasTx && hasDocs);
});
// Step 4: Process each unmatched charge
const result = {
totalMatches: 0,
mergedCharges: [],
skippedCharges: [],
errors: [],
};
for (const sourceCharge of unmatchedCharges) {
// Skip if this charge was already merged in this run
if (mergedChargeIds.has(sourceCharge.chargeId)) {
continue;
}
try {
// Get candidates (exclude already merged charges)
const candidates = chargesWithData.filter(c => c.chargeId !== sourceCharge.chargeId && !mergedChargeIds.has(c.chargeId));
// Process this charge for auto-match
const processResult = await processChargeForAutoMatch(sourceCharge, candidates, adminBusinessId, injector);
if (processResult.status === 'matched' && processResult.match) {
// Found a single high-confidence match - execute merge
const matchedChargeId = processResult.match.chargeId;
const matchedCharge = chargesWithData.find(c => c.chargeId === matchedChargeId);
if (!matchedCharge) {
result.errors.push(`Matched charge ${matchedChargeId} not found in charge pool for ${sourceCharge.chargeId}`);
continue;
}
// Determine merge direction
const [sourceToMerge, targetToKeep] = determineMergeDirection(sourceCharge, matchedCharge);
try {
// Execute merge via existing merge functionality
await mergeChargesExecutor([sourceToMerge.chargeId], targetToKeep.chargeId, injector);
// Track successful merge
result.totalMatches++;
result.mergedCharges.push({
chargeId: targetToKeep.chargeId,
confidenceScore: processResult.match.confidenceScore,
});
// Mark both charges as processed (merged away charge and kept charge)
mergedChargeIds.add(sourceToMerge.chargeId);
mergedChargeIds.add(targetToKeep.chargeId); // Don't process the kept charge again
}
catch (mergeError) {
result.errors.push(`Failed to merge ${sourceToMerge.chargeId} into ${targetToKeep.chargeId}: ${mergeError instanceof Error ? mergeError.message : String(mergeError)}`);
}
}
else if (processResult.status === 'skipped') {
// Multiple high-confidence matches - ambiguous
result.skippedCharges.push(sourceCharge.chargeId);
}
// status === 'no-match': do nothing, silently skip
}
catch (error) {
// Capture error but continue processing other charges
result.errors.push(`Error processing charge ${sourceCharge.chargeId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return result;
}
};
ChargesMatcherProvider = __decorate([
Injectable({
scope: Scope.Operation,
})
], ChargesMatcherProvider);
export { ChargesMatcherProvider };
//# sourceMappingURL=charges-matcher.provider.js.map