@accounter/server 0.0.8-alpha-20251102200443-d7162b8ce1dfc629b8b454df17dcec9ed005a052 → 0.0.8-alpha-20251102213150-c9d936f545d5351df0dc5326c2623266f1ad1f46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -7
- package/dist/green-invoice-graphql/src/mesh-artifacts/index.d.ts +1 -1
- package/dist/server/src/__generated__/types.d.ts +77 -0
- package/dist/server/src/__generated__/types.js.map +1 -1
- package/dist/server/src/modules/charges-matcher/__generated__/types.d.ts +68 -0
- package/dist/server/src/modules/charges-matcher/__generated__/types.js +7 -0
- package/dist/server/src/modules/charges-matcher/__generated__/types.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/amount-confidence.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/amount-confidence.test.js +218 -0
- package/dist/server/src/modules/charges-matcher/__tests__/amount-confidence.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/auto-match-integration.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/auto-match-integration.test.js +645 -0
- package/dist/server/src/modules/charges-matcher/__tests__/auto-match-integration.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/auto-match.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/auto-match.test.js +530 -0
- package/dist/server/src/modules/charges-matcher/__tests__/auto-match.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/business-confidence.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/business-confidence.test.js +143 -0
- package/dist/server/src/modules/charges-matcher/__tests__/business-confidence.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/candidate-filter.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/candidate-filter.test.js +186 -0
- package/dist/server/src/modules/charges-matcher/__tests__/candidate-filter.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/charge-validator.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/charge-validator.test.js +301 -0
- package/dist/server/src/modules/charges-matcher/__tests__/charge-validator.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/currency-confidence.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/currency-confidence.test.js +127 -0
- package/dist/server/src/modules/charges-matcher/__tests__/currency-confidence.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/date-confidence.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/date-confidence.test.js +246 -0
- package/dist/server/src/modules/charges-matcher/__tests__/date-confidence.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-aggregator.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-aggregator.test.js +475 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-aggregator.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-amount.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-amount.test.js +287 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-amount.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-business.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-business.test.js +151 -0
- package/dist/server/src/modules/charges-matcher/__tests__/document-business.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/match-scorer.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/match-scorer.test.js +550 -0
- package/dist/server/src/modules/charges-matcher/__tests__/match-scorer.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/overall-confidence.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/overall-confidence.test.js +410 -0
- package/dist/server/src/modules/charges-matcher/__tests__/overall-confidence.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.js +504 -0
- package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/single-match.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/single-match.test.js +483 -0
- package/dist/server/src/modules/charges-matcher/__tests__/single-match.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/test-helpers.d.ts +46 -0
- package/dist/server/src/modules/charges-matcher/__tests__/test-helpers.js +143 -0
- package/dist/server/src/modules/charges-matcher/__tests__/test-helpers.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/test-infrastructure.spec.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/test-infrastructure.spec.js +137 -0
- package/dist/server/src/modules/charges-matcher/__tests__/test-infrastructure.spec.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/transaction-aggregator.test.d.ts +1 -0
- package/dist/server/src/modules/charges-matcher/__tests__/transaction-aggregator.test.js +415 -0
- package/dist/server/src/modules/charges-matcher/__tests__/transaction-aggregator.test.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/amount-confidence.helper.d.ts +7 -0
- package/dist/server/src/modules/charges-matcher/helpers/amount-confidence.helper.js +70 -0
- package/dist/server/src/modules/charges-matcher/helpers/amount-confidence.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/business-confidence.helper.d.ts +7 -0
- package/dist/server/src/modules/charges-matcher/helpers/business-confidence.helper.js +19 -0
- package/dist/server/src/modules/charges-matcher/helpers/business-confidence.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/candidate-filter.helper.d.ts +24 -0
- package/dist/server/src/modules/charges-matcher/helpers/candidate-filter.helper.js +45 -0
- package/dist/server/src/modules/charges-matcher/helpers/candidate-filter.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/charge-validator.helper.d.ts +33 -0
- package/dist/server/src/modules/charges-matcher/helpers/charge-validator.helper.js +65 -0
- package/dist/server/src/modules/charges-matcher/helpers/charge-validator.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/currency-confidence.helper.d.ts +7 -0
- package/dist/server/src/modules/charges-matcher/helpers/currency-confidence.helper.js +18 -0
- package/dist/server/src/modules/charges-matcher/helpers/currency-confidence.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/date-confidence.helper.d.ts +7 -0
- package/dist/server/src/modules/charges-matcher/helpers/date-confidence.helper.js +35 -0
- package/dist/server/src/modules/charges-matcher/helpers/date-confidence.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/document-amount.helper.d.ts +49 -0
- package/dist/server/src/modules/charges-matcher/helpers/document-amount.helper.js +58 -0
- package/dist/server/src/modules/charges-matcher/helpers/document-amount.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/document-business.helper.d.ts +13 -0
- package/dist/server/src/modules/charges-matcher/helpers/document-business.helper.js +37 -0
- package/dist/server/src/modules/charges-matcher/helpers/document-business.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/helpers/overall-confidence.helper.d.ts +42 -0
- package/dist/server/src/modules/charges-matcher/helpers/overall-confidence.helper.js +77 -0
- package/dist/server/src/modules/charges-matcher/helpers/overall-confidence.helper.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/index.d.ts +3 -0
- package/dist/server/src/modules/charges-matcher/index.js +15 -0
- package/dist/server/src/modules/charges-matcher/index.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/providers/auto-match.provider.d.ts +48 -0
- package/dist/server/src/modules/charges-matcher/providers/auto-match.provider.js +133 -0
- package/dist/server/src/modules/charges-matcher/providers/auto-match.provider.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/providers/charges-matcher.provider.d.ts +38 -0
- package/dist/server/src/modules/charges-matcher/providers/charges-matcher.provider.js +248 -0
- package/dist/server/src/modules/charges-matcher/providers/charges-matcher.provider.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/providers/document-aggregator.d.ts +61 -0
- package/dist/server/src/modules/charges-matcher/providers/document-aggregator.js +153 -0
- package/dist/server/src/modules/charges-matcher/providers/document-aggregator.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/providers/match-scorer.provider.d.ts +25 -0
- package/dist/server/src/modules/charges-matcher/providers/match-scorer.provider.js +114 -0
- package/dist/server/src/modules/charges-matcher/providers/match-scorer.provider.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/providers/single-match.provider.d.ts +39 -0
- package/dist/server/src/modules/charges-matcher/providers/single-match.provider.js +189 -0
- package/dist/server/src/modules/charges-matcher/providers/single-match.provider.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/providers/transaction-aggregator.d.ts +54 -0
- package/dist/server/src/modules/charges-matcher/providers/transaction-aggregator.js +93 -0
- package/dist/server/src/modules/charges-matcher/providers/transaction-aggregator.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.d.ts +2 -0
- package/dist/server/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.js +22 -0
- package/dist/server/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.d.ts +2 -0
- package/dist/server/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.js +24 -0
- package/dist/server/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/resolvers/index.d.ts +2 -0
- package/dist/server/src/modules/charges-matcher/resolvers/index.js +11 -0
- package/dist/server/src/modules/charges-matcher/resolvers/index.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.d.ts +2 -0
- package/dist/server/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.js +47 -0
- package/dist/server/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.js.map +1 -0
- package/dist/server/src/modules/charges-matcher/types.d.ts +179 -0
- package/dist/server/src/modules/charges-matcher/types.js +14 -0
- package/dist/server/src/modules/charges-matcher/types.js.map +1 -0
- package/dist/server/src/modules/documents/resolvers/document-suggestions.resolver.js +2 -2
- package/dist/server/src/modules/documents/resolvers/document-suggestions.resolver.js.map +1 -1
- package/dist/server/src/modules-app.js +2 -0
- package/dist/server/src/modules-app.js.map +1 -1
- package/dist/server/src/shared/types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/__generated__/types.ts +87 -0
- package/src/modules/charges-matcher/README.md +279 -0
- package/src/modules/charges-matcher/__generated__/types.ts +71 -0
- package/src/modules/charges-matcher/__tests__/amount-confidence.test.ts +260 -0
- package/src/modules/charges-matcher/__tests__/auto-match-integration.test.ts +714 -0
- package/src/modules/charges-matcher/__tests__/auto-match.test.ts +621 -0
- package/src/modules/charges-matcher/__tests__/business-confidence.test.ts +177 -0
- package/src/modules/charges-matcher/__tests__/candidate-filter.test.ts +238 -0
- package/src/modules/charges-matcher/__tests__/charge-validator.test.ts +374 -0
- package/src/modules/charges-matcher/__tests__/currency-confidence.test.ts +164 -0
- package/src/modules/charges-matcher/__tests__/date-confidence.test.ts +291 -0
- package/src/modules/charges-matcher/__tests__/document-aggregator.test.ts +614 -0
- package/src/modules/charges-matcher/__tests__/document-amount.test.ts +352 -0
- package/src/modules/charges-matcher/__tests__/document-business.test.ts +192 -0
- package/src/modules/charges-matcher/__tests__/match-scorer.test.ts +659 -0
- package/src/modules/charges-matcher/__tests__/overall-confidence.test.ts +502 -0
- package/src/modules/charges-matcher/__tests__/single-match-integration.test.ts +556 -0
- package/src/modules/charges-matcher/__tests__/single-match.test.ts +608 -0
- package/src/modules/charges-matcher/__tests__/test-helpers.ts +174 -0
- package/src/modules/charges-matcher/__tests__/test-infrastructure.spec.ts +177 -0
- package/src/modules/charges-matcher/__tests__/transaction-aggregator.test.ts +547 -0
- package/src/modules/charges-matcher/documentation/README.md +331 -0
- package/src/modules/charges-matcher/documentation/SPEC.md +1503 -0
- package/src/modules/charges-matcher/documentation/TODO.md +799 -0
- package/src/modules/charges-matcher/helpers/amount-confidence.helper.ts +88 -0
- package/src/modules/charges-matcher/helpers/business-confidence.helper.ts +23 -0
- package/src/modules/charges-matcher/helpers/candidate-filter.helper.ts +56 -0
- package/src/modules/charges-matcher/helpers/charge-validator.helper.ts +100 -0
- package/src/modules/charges-matcher/helpers/currency-confidence.helper.ts +22 -0
- package/src/modules/charges-matcher/helpers/date-confidence.helper.ts +41 -0
- package/src/modules/charges-matcher/helpers/document-amount.helper.ts +77 -0
- package/src/modules/charges-matcher/helpers/document-business.helper.ts +54 -0
- package/src/modules/charges-matcher/helpers/overall-confidence.helper.ts +90 -0
- package/src/modules/charges-matcher/index.ts +17 -0
- package/src/modules/charges-matcher/providers/auto-match.provider.ts +176 -0
- package/src/modules/charges-matcher/providers/charges-matcher.provider.ts +322 -0
- package/src/modules/charges-matcher/providers/document-aggregator.ts +211 -0
- package/src/modules/charges-matcher/providers/match-scorer.provider.ts +154 -0
- package/src/modules/charges-matcher/providers/single-match.provider.ts +252 -0
- package/src/modules/charges-matcher/providers/transaction-aggregator.ts +131 -0
- package/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.ts +23 -0
- package/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.ts +25 -0
- package/src/modules/charges-matcher/resolvers/index.ts +12 -0
- package/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.ts +47 -0
- package/src/modules/charges-matcher/types.ts +200 -0
- package/src/modules/documents/resolvers/document-suggestions.resolver.ts +2 -2
- package/src/modules-app.ts +2 -0
- package/src/shared/types/index.ts +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document Aggregator
|
|
3
|
+
*
|
|
4
|
+
* Aggregates multiple documents from a single charge into a unified representation
|
|
5
|
+
* for matching purposes. Handles type priority filtering, business extraction,
|
|
6
|
+
* amount normalization, currency validation, date selection, and description concatenation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { currency } from '@modules/documents/types.js';
|
|
10
|
+
import { normalizeDocumentAmount, type DocumentType } from '../helpers/document-amount.helper.js';
|
|
11
|
+
import { extractDocumentBusiness } from '../helpers/document-business.helper.js';
|
|
12
|
+
import { AggregatedDocument } from '../types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal document interface for aggregation
|
|
16
|
+
* Based on database schema from accounter_schema.documents
|
|
17
|
+
* Note: Documents use charge_id for the FK
|
|
18
|
+
*/
|
|
19
|
+
export interface Document {
|
|
20
|
+
id: string; // UUID
|
|
21
|
+
charge_id: string | null; // UUID (actual FK name in DB)
|
|
22
|
+
creditor_id: string | null; // UUID
|
|
23
|
+
debtor_id: string | null; // UUID
|
|
24
|
+
currency_code: currency | null; // Currency type
|
|
25
|
+
date: Date | null;
|
|
26
|
+
total_amount: number | null; // double precision in DB
|
|
27
|
+
type: DocumentType;
|
|
28
|
+
serial_number: string | null;
|
|
29
|
+
image_url: string | null;
|
|
30
|
+
file_url: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Accounting document types (used for type priority filtering)
|
|
35
|
+
*/
|
|
36
|
+
const INVOICE_TYPES: DocumentType[] = ['INVOICE', 'CREDIT_INVOICE'];
|
|
37
|
+
const RECEIPT_TYPES: DocumentType[] = ['RECEIPT', 'INVOICE_RECEIPT'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if document type is an invoice or credit invoice
|
|
41
|
+
*/
|
|
42
|
+
function isInvoiceType(type: DocumentType): boolean {
|
|
43
|
+
return INVOICE_TYPES.includes(type);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if document type is a receipt or invoice-receipt
|
|
48
|
+
*/
|
|
49
|
+
function isReceiptType(type: DocumentType): boolean {
|
|
50
|
+
return RECEIPT_TYPES.includes(type);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Aggregate multiple documents into a single representation
|
|
55
|
+
*
|
|
56
|
+
* Per specification (section 4.2):
|
|
57
|
+
* 1. Filter by type priority: if both invoices AND receipts exist, use only invoices
|
|
58
|
+
* 2. Extract business ID from each document (creditor_id/debtor_id)
|
|
59
|
+
* 3. Normalize each amount based on business role and document type
|
|
60
|
+
* 4. Validate non-empty array
|
|
61
|
+
* 5. Check for mixed currencies → throw error
|
|
62
|
+
* 6. Check for multiple non-null business IDs → throw error
|
|
63
|
+
* 7. Sum all normalized amounts
|
|
64
|
+
* 8. Select latest date
|
|
65
|
+
* 9. Concatenate serial_number or file names with line breaks
|
|
66
|
+
* 10. Determine document type for result (use first after filtering)
|
|
67
|
+
*
|
|
68
|
+
* @param documents - Array of documents from a charge (use charge_id field for FK)
|
|
69
|
+
* @param adminBusinessId - Current admin business UUID for business extraction
|
|
70
|
+
* @returns Aggregated document data
|
|
71
|
+
*
|
|
72
|
+
* @throws {Error} If documents array is empty
|
|
73
|
+
* @throws {Error} If multiple different currencies exist
|
|
74
|
+
* @throws {Error} If multiple different non-null business IDs exist
|
|
75
|
+
* @throws {Error} If business extraction fails (propagates from extractDocumentBusiness)
|
|
76
|
+
* @throws {Error} If no valid date found in any document
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* const aggregated = aggregateDocuments([
|
|
80
|
+
* { total_amount: 100, currency_code: 'USD', creditor_id: 'b1', debtor_id: 'u1', type: 'INVOICE', ... },
|
|
81
|
+
* { total_amount: 50, currency_code: 'USD', creditor_id: 'b1', debtor_id: 'u1', type: 'INVOICE', ... }
|
|
82
|
+
* ], 'u1');
|
|
83
|
+
* // Returns aggregated with normalized amounts summed
|
|
84
|
+
*/
|
|
85
|
+
export function aggregateDocuments(
|
|
86
|
+
documents: Document[],
|
|
87
|
+
adminBusinessId: string,
|
|
88
|
+
): Omit<AggregatedDocument, 'businessIsCreditor'> {
|
|
89
|
+
// Validate non-empty input
|
|
90
|
+
if (!documents || documents.length === 0) {
|
|
91
|
+
throw new Error('Cannot aggregate documents: array is empty');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Apply type priority filtering
|
|
95
|
+
const hasInvoices = documents.some(d => isInvoiceType(d.type));
|
|
96
|
+
const hasReceipts = documents.some(d => isReceiptType(d.type));
|
|
97
|
+
|
|
98
|
+
let filteredDocuments = documents;
|
|
99
|
+
if (hasInvoices && hasReceipts) {
|
|
100
|
+
// If both invoices and receipts exist, use only invoices
|
|
101
|
+
filteredDocuments = documents.filter(d => isInvoiceType(d.type));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate we still have documents after filtering
|
|
105
|
+
if (filteredDocuments.length === 0) {
|
|
106
|
+
throw new Error('Cannot aggregate documents: no valid documents after type priority filtering');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Extract business info and normalize amounts for each document
|
|
110
|
+
const processedDocuments = filteredDocuments.map(doc => {
|
|
111
|
+
const businessInfo = extractDocumentBusiness(doc.creditor_id, doc.debtor_id, adminBusinessId);
|
|
112
|
+
const normalizedAmount = normalizeDocumentAmount(
|
|
113
|
+
doc.total_amount ?? 0,
|
|
114
|
+
businessInfo.isBusinessCreditor,
|
|
115
|
+
doc.type,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
document: doc,
|
|
120
|
+
businessInfo,
|
|
121
|
+
normalizedAmount,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Validate single currency
|
|
126
|
+
const currencies = new Set(
|
|
127
|
+
processedDocuments
|
|
128
|
+
.map(p => p.document.currency_code)
|
|
129
|
+
.filter((code): code is currency => code !== null && code !== undefined),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (currencies.size === 0) {
|
|
133
|
+
throw new Error('Cannot aggregate documents: all documents have null currency_code');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (currencies.size > 1) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Cannot aggregate documents: multiple currencies found (${Array.from(currencies).join(', ')})`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate single non-null business ID
|
|
143
|
+
const businessIds = processedDocuments
|
|
144
|
+
.map(p => p.businessInfo.businessId)
|
|
145
|
+
.filter((id): id is string => id !== null && id !== undefined);
|
|
146
|
+
|
|
147
|
+
const uniqueBusinessIds = new Set(businessIds);
|
|
148
|
+
if (uniqueBusinessIds.size > 1) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Cannot aggregate documents: multiple business IDs found (${Array.from(uniqueBusinessIds).join(', ')})`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sum normalized amounts
|
|
155
|
+
const totalAmount = processedDocuments.reduce((sum, p) => sum + p.normalizedAmount, 0);
|
|
156
|
+
|
|
157
|
+
// Get common currency (safe since we validated single currency)
|
|
158
|
+
const currency = Array.from(currencies)[0];
|
|
159
|
+
|
|
160
|
+
// Get business ID (single non-null or null if all null)
|
|
161
|
+
const businessId = uniqueBusinessIds.size === 1 ? Array.from(uniqueBusinessIds)[0] : null;
|
|
162
|
+
|
|
163
|
+
// Get latest date
|
|
164
|
+
const dates = filteredDocuments
|
|
165
|
+
.map(d => d.date)
|
|
166
|
+
.filter((date): date is Date => date !== null && date !== undefined);
|
|
167
|
+
|
|
168
|
+
if (dates.length === 0) {
|
|
169
|
+
throw new Error('Cannot aggregate documents: all documents have null date');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const latestDate = dates.reduce((latest, d) => {
|
|
173
|
+
return d > latest ? d : latest;
|
|
174
|
+
}, dates[0]);
|
|
175
|
+
|
|
176
|
+
// Concatenate descriptions (serial numbers, file names, or IDs)
|
|
177
|
+
const descriptions = filteredDocuments
|
|
178
|
+
.map(d => {
|
|
179
|
+
// Prefer serial_number, fallback to file_url or image_url name, or document ID
|
|
180
|
+
if (d.serial_number && d.serial_number.trim() !== '') {
|
|
181
|
+
return d.serial_number.trim();
|
|
182
|
+
}
|
|
183
|
+
if (d.file_url) {
|
|
184
|
+
// Extract filename from URL
|
|
185
|
+
const fileName = d.file_url.split('/').pop() || d.file_url;
|
|
186
|
+
return fileName;
|
|
187
|
+
}
|
|
188
|
+
if (d.image_url) {
|
|
189
|
+
// Extract filename from URL
|
|
190
|
+
const fileName = d.image_url.split('/').pop() || d.image_url;
|
|
191
|
+
return fileName;
|
|
192
|
+
}
|
|
193
|
+
// Fallback to document ID
|
|
194
|
+
return `Doc-${d.id.substring(0, 8)}`;
|
|
195
|
+
})
|
|
196
|
+
.filter(desc => desc !== null && desc !== undefined && desc.trim() !== '');
|
|
197
|
+
|
|
198
|
+
const description = descriptions.length > 0 ? descriptions.join('\n') : '';
|
|
199
|
+
|
|
200
|
+
// Determine document type (use first document after filtering)
|
|
201
|
+
const type = filteredDocuments[0].type;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
amount: totalAmount,
|
|
205
|
+
currency,
|
|
206
|
+
businessId,
|
|
207
|
+
date: latestDate,
|
|
208
|
+
type,
|
|
209
|
+
description,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { document_type } from '@modules/documents/types.js';
|
|
2
|
+
import { calculateAmountConfidence } from '../helpers/amount-confidence.helper.js';
|
|
3
|
+
import { calculateBusinessConfidence } from '../helpers/business-confidence.helper.js';
|
|
4
|
+
import { calculateCurrencyConfidence } from '../helpers/currency-confidence.helper.js';
|
|
5
|
+
import { calculateDateConfidence } from '../helpers/date-confidence.helper.js';
|
|
6
|
+
import { calculateOverallConfidence } from '../helpers/overall-confidence.helper.js';
|
|
7
|
+
import type {
|
|
8
|
+
AggregatedDocument,
|
|
9
|
+
AggregatedTransaction,
|
|
10
|
+
ConfidenceScores,
|
|
11
|
+
DocumentCharge,
|
|
12
|
+
MatchScore,
|
|
13
|
+
TransactionCharge,
|
|
14
|
+
} from '../types.js';
|
|
15
|
+
import { aggregateDocuments } from './document-aggregator.js';
|
|
16
|
+
import { aggregateTransactions } from './transaction-aggregator.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Select appropriate transaction date based on document type
|
|
20
|
+
* Per specification:
|
|
21
|
+
* - INVOICE/CREDIT_INVOICE: Use event_date
|
|
22
|
+
* - RECEIPT/INVOICE_RECEIPT: Use debit_date (debit_timestamp or debit_date)
|
|
23
|
+
* - OTHER/PROFORMA/UNPROCESSED: Calculate both and use better score
|
|
24
|
+
*
|
|
25
|
+
* @param transaction - Aggregated transaction data
|
|
26
|
+
* @param documentType - Document type being matched
|
|
27
|
+
* @returns The date to use for matching
|
|
28
|
+
*/
|
|
29
|
+
export function selectTransactionDate(
|
|
30
|
+
transaction: AggregatedTransaction,
|
|
31
|
+
documentType: document_type,
|
|
32
|
+
): Date {
|
|
33
|
+
switch (documentType) {
|
|
34
|
+
case 'INVOICE':
|
|
35
|
+
case 'CREDIT_INVOICE':
|
|
36
|
+
case 'RECEIPT':
|
|
37
|
+
case 'INVOICE_RECEIPT':
|
|
38
|
+
// For invoices, use event_date
|
|
39
|
+
return transaction.date;
|
|
40
|
+
|
|
41
|
+
case 'OTHER':
|
|
42
|
+
case 'PROFORMA':
|
|
43
|
+
case 'UNPROCESSED':
|
|
44
|
+
// For flexible types, use event_date as default
|
|
45
|
+
// (caller should calculate both and use better score)
|
|
46
|
+
return transaction.date;
|
|
47
|
+
|
|
48
|
+
default:
|
|
49
|
+
// Fallback to event_date for unknown types
|
|
50
|
+
return transaction.date;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Score a potential match between transaction charge and document charge
|
|
56
|
+
* Aggregates data from both charges and calculates confidence score
|
|
57
|
+
*
|
|
58
|
+
* @param txCharge - Transaction charge to match
|
|
59
|
+
* @param docCharge - Document charge candidate
|
|
60
|
+
* @param userId - Current user UUID for business extraction
|
|
61
|
+
* @returns Match score with confidence and component breakdown
|
|
62
|
+
* @throws Error if aggregation fails (mixed currencies, multiple businesses, etc.)
|
|
63
|
+
*/
|
|
64
|
+
export function scoreMatch(
|
|
65
|
+
txCharge: TransactionCharge,
|
|
66
|
+
docCharge: DocumentCharge,
|
|
67
|
+
userId: string,
|
|
68
|
+
): MatchScore {
|
|
69
|
+
// Aggregate transaction data
|
|
70
|
+
const aggregatedTransaction = aggregateTransactions(txCharge.transactions);
|
|
71
|
+
|
|
72
|
+
// Aggregate document data (includes business extraction and amount normalization)
|
|
73
|
+
const aggregatedDocument = aggregateDocuments(docCharge.documents, userId);
|
|
74
|
+
|
|
75
|
+
// // For flexible document types, try both dates and use the better score
|
|
76
|
+
// if (
|
|
77
|
+
// aggregatedDocument.type === 'OTHER' ||
|
|
78
|
+
// aggregatedDocument.type === 'PROFORMA' ||
|
|
79
|
+
// aggregatedDocument.type === 'UNPROCESSED'
|
|
80
|
+
// ) {
|
|
81
|
+
// // Calculate score with event_date
|
|
82
|
+
// const scoreWithEventDate = calculateScoreWithDate(
|
|
83
|
+
// aggregatedTransaction,
|
|
84
|
+
// aggregatedDocument,
|
|
85
|
+
// aggregatedTransaction.date,
|
|
86
|
+
// docCharge.chargeId,
|
|
87
|
+
// );
|
|
88
|
+
|
|
89
|
+
// // Calculate score with debit_date (if available)
|
|
90
|
+
// if (aggregatedTransaction.debitDate) {
|
|
91
|
+
// const scoreWithDebitDate = calculateScoreWithDate(
|
|
92
|
+
// aggregatedTransaction,
|
|
93
|
+
// aggregatedDocument,
|
|
94
|
+
// aggregatedTransaction.debitDate,
|
|
95
|
+
// docCharge.chargeId,
|
|
96
|
+
// );
|
|
97
|
+
|
|
98
|
+
// // Return the better score
|
|
99
|
+
// return scoreWithEventDate.confidenceScore > scoreWithDebitDate.confidenceScore
|
|
100
|
+
// ? scoreWithEventDate
|
|
101
|
+
// : scoreWithDebitDate;
|
|
102
|
+
// }
|
|
103
|
+
|
|
104
|
+
// return scoreWithEventDate;
|
|
105
|
+
// }
|
|
106
|
+
|
|
107
|
+
// For specific document types, use the appropriate date
|
|
108
|
+
const transactionDate = selectTransactionDate(aggregatedTransaction, aggregatedDocument.type);
|
|
109
|
+
|
|
110
|
+
return calculateScoreWithDate(
|
|
111
|
+
aggregatedTransaction,
|
|
112
|
+
aggregatedDocument,
|
|
113
|
+
transactionDate,
|
|
114
|
+
docCharge.chargeId,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Helper function to calculate match score with a specific transaction date
|
|
120
|
+
* @param transaction - Aggregated transaction
|
|
121
|
+
* @param document - Aggregated document
|
|
122
|
+
* @param transactionDate - Date to use from transaction
|
|
123
|
+
* @param chargeId - Document charge ID
|
|
124
|
+
* @returns Match score
|
|
125
|
+
*/
|
|
126
|
+
function calculateScoreWithDate(
|
|
127
|
+
transaction: Omit<AggregatedTransaction, 'debitDate'>,
|
|
128
|
+
document: Omit<AggregatedDocument, 'businessIsCreditor'>,
|
|
129
|
+
transactionDate: Date,
|
|
130
|
+
chargeId: string,
|
|
131
|
+
): MatchScore {
|
|
132
|
+
// Calculate individual confidence scores
|
|
133
|
+
const amountScore = calculateAmountConfidence(transaction.amount, document.amount);
|
|
134
|
+
const currencyScore = calculateCurrencyConfidence(transaction.currency, document.currency);
|
|
135
|
+
const businessScore = calculateBusinessConfidence(transaction.businessId, document.businessId);
|
|
136
|
+
const dateScore = calculateDateConfidence(transactionDate, document.date);
|
|
137
|
+
|
|
138
|
+
// Create components object
|
|
139
|
+
const components: ConfidenceScores = {
|
|
140
|
+
amount: amountScore,
|
|
141
|
+
currency: currencyScore,
|
|
142
|
+
business: businessScore,
|
|
143
|
+
date: dateScore,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Calculate overall confidence
|
|
147
|
+
const confidenceScore = calculateOverallConfidence(components);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
chargeId,
|
|
151
|
+
confidenceScore,
|
|
152
|
+
components,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-Match Provider
|
|
3
|
+
*
|
|
4
|
+
* Implements the core single-match logic for finding potential charge matches.
|
|
5
|
+
* This is a pure function implementation without database dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isWithinDateWindow } from '../helpers/candidate-filter.helper.js';
|
|
9
|
+
import type { DocumentCharge, MatchScore, TransactionCharge } from '../types.js';
|
|
10
|
+
import { aggregateDocuments } from './document-aggregator.js';
|
|
11
|
+
import { scoreMatch } from './match-scorer.provider.js';
|
|
12
|
+
import { aggregateTransactions } from './transaction-aggregator.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Match result with score and metadata
|
|
16
|
+
*/
|
|
17
|
+
export interface MatchResult {
|
|
18
|
+
chargeId: string;
|
|
19
|
+
confidenceScore: number;
|
|
20
|
+
components: {
|
|
21
|
+
amount: number;
|
|
22
|
+
currency: number;
|
|
23
|
+
business: number;
|
|
24
|
+
date: number;
|
|
25
|
+
};
|
|
26
|
+
dateProximity?: number; // Days between earliest tx date and latest doc date (for tie-breaking)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for findMatches function
|
|
31
|
+
*/
|
|
32
|
+
export interface FindMatchesOptions {
|
|
33
|
+
maxMatches?: number; // Default 5
|
|
34
|
+
dateWindowMonths?: number; // Default 12
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Type guard: check if charge is TransactionCharge
|
|
39
|
+
*/
|
|
40
|
+
function isTransactionCharge(
|
|
41
|
+
charge: TransactionCharge | DocumentCharge,
|
|
42
|
+
): charge is TransactionCharge {
|
|
43
|
+
return 'transactions' in charge && charge.transactions.length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Type guard: check if charge is DocumentCharge
|
|
48
|
+
*/
|
|
49
|
+
function isDocumentCharge(charge: TransactionCharge | DocumentCharge): charge is DocumentCharge {
|
|
50
|
+
return 'documents' in charge && charge.documents.length > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate that source charge is unmatched (has only one type)
|
|
55
|
+
*/
|
|
56
|
+
function validateUnmatchedCharge(charge: TransactionCharge | DocumentCharge): void {
|
|
57
|
+
const hasTx = 'transactions' in charge && charge.transactions && charge.transactions.length > 0;
|
|
58
|
+
const hasDocs = 'documents' in charge && charge.documents && charge.documents.length > 0;
|
|
59
|
+
|
|
60
|
+
if (hasTx && hasDocs) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Source charge ${charge.chargeId} is already matched (contains both transactions and documents)`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!hasTx && !hasDocs) {
|
|
67
|
+
throw new Error(`Source charge ${charge.chargeId} has no transactions or documents`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate that source charge can be aggregated successfully
|
|
73
|
+
*/
|
|
74
|
+
function validateSourceAggregation(
|
|
75
|
+
charge: TransactionCharge | DocumentCharge,
|
|
76
|
+
userId: string,
|
|
77
|
+
): void {
|
|
78
|
+
try {
|
|
79
|
+
if (isTransactionCharge(charge)) {
|
|
80
|
+
aggregateTransactions(charge.transactions);
|
|
81
|
+
} else {
|
|
82
|
+
aggregateDocuments(charge.documents, userId);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Source charge ${charge.chargeId} failed validation: ${error instanceof Error ? error.message : String(error)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Calculate date proximity between transaction and document charges
|
|
93
|
+
* Used for tie-breaking when confidence scores are equal
|
|
94
|
+
*
|
|
95
|
+
* @param txCharge - Transaction charge
|
|
96
|
+
* @param docCharge - Document charge
|
|
97
|
+
* @returns Number of days between earliest transaction date and latest document date
|
|
98
|
+
*/
|
|
99
|
+
function calculateDateProximity(txCharge: TransactionCharge, docCharge: DocumentCharge): number {
|
|
100
|
+
// Get earliest transaction event_date
|
|
101
|
+
const earliestTxDate = txCharge.transactions.reduce((earliest, tx) => {
|
|
102
|
+
return tx.event_date < earliest ? tx.event_date : earliest;
|
|
103
|
+
}, txCharge.transactions[0].event_date);
|
|
104
|
+
|
|
105
|
+
// Get latest document date
|
|
106
|
+
const latestDocDate = docCharge.documents.reduce((latest, doc) => {
|
|
107
|
+
if (!doc.date) return latest;
|
|
108
|
+
return doc.date > latest ? doc.date : latest;
|
|
109
|
+
}, docCharge.documents[0].date!);
|
|
110
|
+
|
|
111
|
+
// Calculate day difference
|
|
112
|
+
const diffMs = Math.abs(earliestTxDate.getTime() - latestDocDate.getTime());
|
|
113
|
+
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Find top matches for an unmatched charge
|
|
118
|
+
*
|
|
119
|
+
* @param sourceCharge - The unmatched charge (transactions OR documents)
|
|
120
|
+
* @param candidateCharges - All potential match candidates
|
|
121
|
+
* @param userId - Current user ID
|
|
122
|
+
* @param options - Optional configuration (maxMatches, dateWindowMonths)
|
|
123
|
+
* @returns Top matches sorted by confidence
|
|
124
|
+
* @throws Error if source charge is matched or has validation issues
|
|
125
|
+
*/
|
|
126
|
+
export function findMatches(
|
|
127
|
+
sourceCharge: TransactionCharge | DocumentCharge,
|
|
128
|
+
candidateCharges: Array<TransactionCharge | DocumentCharge>,
|
|
129
|
+
userId: string,
|
|
130
|
+
options?: FindMatchesOptions,
|
|
131
|
+
): MatchResult[] {
|
|
132
|
+
const maxMatches = options?.maxMatches ?? 5;
|
|
133
|
+
const dateWindowMonths = options?.dateWindowMonths ?? 12;
|
|
134
|
+
|
|
135
|
+
// Step 1: Validate source charge is unmatched
|
|
136
|
+
validateUnmatchedCharge(sourceCharge);
|
|
137
|
+
|
|
138
|
+
// Step 2: Validate source charge aggregation
|
|
139
|
+
validateSourceAggregation(sourceCharge, userId);
|
|
140
|
+
|
|
141
|
+
// Step 3: Determine source type and filter candidates by complementary type
|
|
142
|
+
const isSourceTransaction = isTransactionCharge(sourceCharge);
|
|
143
|
+
const complementaryCandidates = candidateCharges.filter(candidate => {
|
|
144
|
+
if (isSourceTransaction) {
|
|
145
|
+
return isDocumentCharge(candidate);
|
|
146
|
+
}
|
|
147
|
+
return isTransactionCharge(candidate);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Step 4: Get source date for window filtering
|
|
151
|
+
let sourceDate: Date;
|
|
152
|
+
if (isSourceTransaction) {
|
|
153
|
+
// Use earliest event_date from transactions
|
|
154
|
+
sourceDate = sourceCharge.transactions.reduce((earliest, tx) => {
|
|
155
|
+
return tx.event_date < earliest ? tx.event_date : earliest;
|
|
156
|
+
}, sourceCharge.transactions[0].event_date);
|
|
157
|
+
} else {
|
|
158
|
+
// Use latest date from documents
|
|
159
|
+
sourceDate = sourceCharge.documents.reduce((latest, doc) => {
|
|
160
|
+
if (!doc.date) return latest;
|
|
161
|
+
return doc.date > latest ? doc.date : latest;
|
|
162
|
+
}, sourceCharge.documents[0].date!);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step 5: Filter candidates by date window
|
|
166
|
+
const windowFilteredCandidates = complementaryCandidates.filter(candidate => {
|
|
167
|
+
let candidateDate: Date;
|
|
168
|
+
|
|
169
|
+
if (isTransactionCharge(candidate)) {
|
|
170
|
+
candidateDate = candidate.transactions.reduce((earliest, tx) => {
|
|
171
|
+
return tx.event_date < earliest ? tx.event_date : earliest;
|
|
172
|
+
}, candidate.transactions[0].event_date);
|
|
173
|
+
} else {
|
|
174
|
+
candidateDate = candidate.documents.reduce((latest, doc) => {
|
|
175
|
+
if (!doc.date) return latest;
|
|
176
|
+
return doc.date > latest ? doc.date : latest;
|
|
177
|
+
}, candidate.documents[0].date!);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return isWithinDateWindow(sourceDate, candidateDate, dateWindowMonths);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Step 6: Filter candidates using candidate filter logic
|
|
184
|
+
// Note: Additional filtering (is_fee, null checks) is handled by aggregators
|
|
185
|
+
// which will throw errors for invalid data
|
|
186
|
+
|
|
187
|
+
// Step 7: Exclude candidates with same chargeId
|
|
188
|
+
const sameChargeCandidate = windowFilteredCandidates.find(
|
|
189
|
+
c => c.chargeId === sourceCharge.chargeId,
|
|
190
|
+
);
|
|
191
|
+
if (sameChargeCandidate) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Candidate charge ${sameChargeCandidate.chargeId} has the same ID as source charge`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Step 8: Score all remaining candidates
|
|
198
|
+
const scoredCandidates: Array<
|
|
199
|
+
MatchResult & { _txCharge?: TransactionCharge; _docCharge?: DocumentCharge }
|
|
200
|
+
> = [];
|
|
201
|
+
|
|
202
|
+
for (const candidate of windowFilteredCandidates) {
|
|
203
|
+
try {
|
|
204
|
+
let matchScore: MatchScore;
|
|
205
|
+
let txCharge: TransactionCharge;
|
|
206
|
+
let docCharge: DocumentCharge;
|
|
207
|
+
|
|
208
|
+
if (isSourceTransaction) {
|
|
209
|
+
txCharge = sourceCharge;
|
|
210
|
+
docCharge = candidate as DocumentCharge;
|
|
211
|
+
matchScore = scoreMatch(txCharge, docCharge, userId);
|
|
212
|
+
} else {
|
|
213
|
+
txCharge = candidate as TransactionCharge;
|
|
214
|
+
docCharge = sourceCharge;
|
|
215
|
+
matchScore = scoreMatch(txCharge, docCharge, userId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Calculate date proximity for tie-breaking
|
|
219
|
+
const dateProximity = calculateDateProximity(txCharge, docCharge);
|
|
220
|
+
|
|
221
|
+
scoredCandidates.push({
|
|
222
|
+
chargeId: candidate.chargeId,
|
|
223
|
+
confidenceScore: matchScore.confidenceScore,
|
|
224
|
+
components: matchScore.components,
|
|
225
|
+
dateProximity,
|
|
226
|
+
_txCharge: txCharge,
|
|
227
|
+
_docCharge: docCharge,
|
|
228
|
+
});
|
|
229
|
+
} catch {
|
|
230
|
+
// Skip candidates that fail scoring (e.g., mixed currencies, invalid data)
|
|
231
|
+
// This is expected behavior - not all candidates will be scoreable
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Step 9: Sort by confidence descending, then by date proximity ascending (tie-breaker)
|
|
237
|
+
scoredCandidates.sort((a, b) => {
|
|
238
|
+
// Primary: confidence score (descending)
|
|
239
|
+
if (a.confidenceScore !== b.confidenceScore) {
|
|
240
|
+
return b.confidenceScore - a.confidenceScore;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Tie-breaker: date proximity (ascending - closer dates win)
|
|
244
|
+
return (a.dateProximity ?? Infinity) - (b.dateProximity ?? Infinity);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Step 10: Return top N matches
|
|
248
|
+
const topMatches = scoredCandidates.slice(0, maxMatches);
|
|
249
|
+
|
|
250
|
+
// Clean up temporary fields
|
|
251
|
+
return topMatches.map(({ _txCharge, _docCharge, ...match }) => match);
|
|
252
|
+
}
|