@accounter/server
Version:
Accounter GraphQL server
351 lines (314 loc) • 13 kB
text/typescript
/**
* Charges Matcher Provider
*
* Provides database-integrated charge matching functionality using the Injector pattern.
* Integrates with existing modules: charges, transactions, and documents.
*/
import { subYears } from 'date-fns';
import { CONTEXT, Inject, Injectable, Scope } from 'graphql-modules';
import { dateToTimelessDateString } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { mergeChargesExecutor } from '../../charges/helpers/merge-charges.helper.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import {
isAccountingDocument,
isInvoice,
isReceipt,
} from '../../documents/helpers/common.helper.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,
type AutoMatchChargesResult,
type ChargeMatchesResult,
type ChargeWithData,
type DocumentCharge,
type TransactionCharge,
} from '../types.js';
import { determineMergeDirection, processChargeForAutoMatch } from './auto-match.provider.js';
import { aggregateDocuments } from './document-aggregator.js';
import { findMatches, type MatchResult } 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.
*/
({
scope: Scope.Operation,
})
export class ChargesMatcherProvider {
constructor(
private adminContextProvider: AdminContextProvider,
private chargesProvider: ChargesProvider,
private transactionsProvider: TransactionsProvider,
private documentsProvider: DocumentsProvider,
(CONTEXT) private context: GraphQLModules.ModuleContext,
) {}
/**
* Find potential matches for an unmatched charge
*
* @param chargeId - ID of the unmatched charge to find matches for
* @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: string): Promise<ChargeMatchesResult> {
const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext();
// Step 1: Load source charge data
const sourceCharge = await this.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 this.transactionsProvider.transactionsByChargeIDLoader.load(chargeId);
const sourceDocuments = (
await this.documentsProvider.getDocumentsByChargeIdLoader.load(chargeId)
).filter(doc => isAccountingDocument(doc.type));
// 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: Date;
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, ownerId);
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 this.chargesProvider.getChargesByFilters({
ownerIds: [ownerId],
fromAnyDate: dateToTimelessDateString(windowStart),
toAnyDate: dateToTimelessDateString(windowEnd),
});
// Step 6: Load transactions and documents for all candidate charges
const candidateChargesWithData: Array<TransactionCharge | DocumentCharge> = [];
await Promise.all(
candidateCharges.map(async candidate => {
// Skip the source charge itself
if (candidate.id === chargeId) {
return;
}
const candidateTransactionsPromise =
this.transactionsProvider.transactionsByChargeIDLoader.load(candidate.id);
const candidateDocumentsPromise = this.documentsProvider.getDocumentsByChargeIdLoader
.load(candidate.id)
.then(docs => docs.filter(doc => isAccountingDocument(doc.type)));
const [candidateTransactions, candidateDocuments] = await Promise.all([
candidateTransactionsPromise,
candidateDocumentsPromise,
]);
const candidateInvoiceDocuments = candidateDocuments.filter(doc => isInvoice(doc.type));
const candidateReceiptDocuments = candidateDocuments.filter(doc => isReceipt(doc.type));
const hasTxs = candidateTransactions && candidateTransactions.length > 0;
const hasDocs = candidateDocuments && candidateDocuments.length > 0;
const hasInvoiceDocs = candidateInvoiceDocuments && candidateInvoiceDocuments.length > 0;
const hasReceiptDocs = candidateReceiptDocuments && candidateReceiptDocuments.length > 0;
// Only include unmatched charges (not both types)
if (hasTxs && !hasReceiptDocs) {
candidateChargesWithData.push({
chargeId: candidate.id,
transactions: candidateTransactions,
});
}
if (hasDocs && !hasTxs) {
if (hasReceiptDocs) {
candidateChargesWithData.push({
chargeId: candidate.id,
documents: candidateReceiptDocuments,
});
} else if (hasInvoiceDocs) {
candidateChargesWithData.push({
chargeId: candidate.id,
documents: candidateInvoiceDocuments,
});
} else {
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: TransactionCharge | DocumentCharge;
if (hasTransactions) {
sourceChargeData = {
chargeId,
transactions: sourceTransactions,
};
} else {
sourceChargeData = {
chargeId,
documents: sourceDocuments,
};
}
// Step 8: Call core findMatches function
const matches: MatchResult[] = await findMatches(
sourceChargeData,
candidateChargesWithData,
ownerId,
this.context.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.
*
* @returns Summary of matches made, skipped charges, and errors
*/
async autoMatchCharges(): Promise<AutoMatchChargesResult> {
const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext();
// Step 1: Load all charges for this user
const prevYear = dateToTimelessDateString(subYears(new Date(), 1));
const allCharges = await this.chargesProvider.getChargesByFilters({
ownerIds: [ownerId],
fromAnyDate: prevYear,
});
// Step 2: Load transactions and documents for all charges
const chargesWithData: ChargeWithData[] = [];
const mergedChargeIds = new Set<string>(); // Track merged charges to exclude from processing
await Promise.all(
allCharges.map(async charge => {
const transactionsPromise = this.transactionsProvider.transactionsByChargeIDLoader.load(
charge.id,
);
const documentsPromise = this.documentsProvider.getDocumentsByChargeIdLoader
.load(charge.id)
.then(docs => docs.filter(doc => isAccountingDocument(doc.type)));
const [transactions, documents] = await Promise.all([
transactionsPromise,
documentsPromise,
]);
chargesWithData.push({
chargeId: charge.id,
ownerId: charge.owner_id ?? ownerId,
type: ChargeType.TRANSACTION_ONLY, // Will be determined by processChargeForAutoMatch
description: charge.user_description ?? undefined,
transactions,
documents,
});
}),
);
// Step 3: Filter to get only unmatched charges
const unmatchedCharges = chargesWithData.filter(charge => {
const hasTx = charge.transactions && charge.transactions.length > 0;
const hasAccountingDocs =
charge.documents &&
charge.documents.filter(doc => isAccountingDocument(doc.type)).length > 0;
const hasReceipts = charge.documents?.some(doc => isReceipt(doc.type));
return (hasTx && !hasReceipts) || (!hasTx && hasAccountingDocs);
});
// Step 4: Process each unmatched charge
const result: AutoMatchChargesResult = {
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,
ownerId,
this.context.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,
this.context.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;
}
}