UNPKG

@accounter/server

Version:
284 lines 14.2 kB
/** * Charges Matcher Provider * * Provides database-integrated charge matching functionality using the Injector pattern. * Integrates with existing modules: charges, transactions, and documents. */ import { __decorate, __metadata, __param } from "tslib"; 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, } 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 { adminContextProvider; chargesProvider; transactionsProvider; documentsProvider; context; constructor(adminContextProvider, chargesProvider, transactionsProvider, documentsProvider, context) { this.adminContextProvider = adminContextProvider; this.chargesProvider = chargesProvider; this.transactionsProvider = transactionsProvider; this.documentsProvider = documentsProvider; this.context = context; } /** * 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) { 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; 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 = []; 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; if (hasTransactions) { sourceChargeData = { chargeId, transactions: sourceTransactions, }; } else { sourceChargeData = { chargeId, documents: sourceDocuments, }; } // Step 8: Call core findMatches function const matches = 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() { 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 = []; const mergedChargeIds = new Set(); // 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 = { 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; } }; ChargesMatcherProvider = __decorate([ Injectable({ scope: Scope.Operation, }), __param(4, Inject(CONTEXT)), __metadata("design:paramtypes", [AdminContextProvider, ChargesProvider, TransactionsProvider, DocumentsProvider, Object]) ], ChargesMatcherProvider); export { ChargesMatcherProvider }; //# sourceMappingURL=charges-matcher.provider.js.map