@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/dist/green-invoice-graphql/src/mesh-artifacts/index.d.ts +1 -1
  3. package/dist/server/src/__generated__/types.d.ts +77 -0
  4. package/dist/server/src/__generated__/types.js.map +1 -1
  5. package/dist/server/src/modules/charges-matcher/__generated__/types.d.ts +68 -0
  6. package/dist/server/src/modules/charges-matcher/__generated__/types.js +7 -0
  7. package/dist/server/src/modules/charges-matcher/__generated__/types.js.map +1 -0
  8. package/dist/server/src/modules/charges-matcher/__tests__/amount-confidence.test.d.ts +1 -0
  9. package/dist/server/src/modules/charges-matcher/__tests__/amount-confidence.test.js +218 -0
  10. package/dist/server/src/modules/charges-matcher/__tests__/amount-confidence.test.js.map +1 -0
  11. package/dist/server/src/modules/charges-matcher/__tests__/auto-match-integration.test.d.ts +1 -0
  12. package/dist/server/src/modules/charges-matcher/__tests__/auto-match-integration.test.js +645 -0
  13. package/dist/server/src/modules/charges-matcher/__tests__/auto-match-integration.test.js.map +1 -0
  14. package/dist/server/src/modules/charges-matcher/__tests__/auto-match.test.d.ts +1 -0
  15. package/dist/server/src/modules/charges-matcher/__tests__/auto-match.test.js +530 -0
  16. package/dist/server/src/modules/charges-matcher/__tests__/auto-match.test.js.map +1 -0
  17. package/dist/server/src/modules/charges-matcher/__tests__/business-confidence.test.d.ts +1 -0
  18. package/dist/server/src/modules/charges-matcher/__tests__/business-confidence.test.js +143 -0
  19. package/dist/server/src/modules/charges-matcher/__tests__/business-confidence.test.js.map +1 -0
  20. package/dist/server/src/modules/charges-matcher/__tests__/candidate-filter.test.d.ts +1 -0
  21. package/dist/server/src/modules/charges-matcher/__tests__/candidate-filter.test.js +186 -0
  22. package/dist/server/src/modules/charges-matcher/__tests__/candidate-filter.test.js.map +1 -0
  23. package/dist/server/src/modules/charges-matcher/__tests__/charge-validator.test.d.ts +1 -0
  24. package/dist/server/src/modules/charges-matcher/__tests__/charge-validator.test.js +301 -0
  25. package/dist/server/src/modules/charges-matcher/__tests__/charge-validator.test.js.map +1 -0
  26. package/dist/server/src/modules/charges-matcher/__tests__/currency-confidence.test.d.ts +1 -0
  27. package/dist/server/src/modules/charges-matcher/__tests__/currency-confidence.test.js +127 -0
  28. package/dist/server/src/modules/charges-matcher/__tests__/currency-confidence.test.js.map +1 -0
  29. package/dist/server/src/modules/charges-matcher/__tests__/date-confidence.test.d.ts +1 -0
  30. package/dist/server/src/modules/charges-matcher/__tests__/date-confidence.test.js +246 -0
  31. package/dist/server/src/modules/charges-matcher/__tests__/date-confidence.test.js.map +1 -0
  32. package/dist/server/src/modules/charges-matcher/__tests__/document-aggregator.test.d.ts +1 -0
  33. package/dist/server/src/modules/charges-matcher/__tests__/document-aggregator.test.js +475 -0
  34. package/dist/server/src/modules/charges-matcher/__tests__/document-aggregator.test.js.map +1 -0
  35. package/dist/server/src/modules/charges-matcher/__tests__/document-amount.test.d.ts +1 -0
  36. package/dist/server/src/modules/charges-matcher/__tests__/document-amount.test.js +287 -0
  37. package/dist/server/src/modules/charges-matcher/__tests__/document-amount.test.js.map +1 -0
  38. package/dist/server/src/modules/charges-matcher/__tests__/document-business.test.d.ts +1 -0
  39. package/dist/server/src/modules/charges-matcher/__tests__/document-business.test.js +151 -0
  40. package/dist/server/src/modules/charges-matcher/__tests__/document-business.test.js.map +1 -0
  41. package/dist/server/src/modules/charges-matcher/__tests__/match-scorer.test.d.ts +1 -0
  42. package/dist/server/src/modules/charges-matcher/__tests__/match-scorer.test.js +550 -0
  43. package/dist/server/src/modules/charges-matcher/__tests__/match-scorer.test.js.map +1 -0
  44. package/dist/server/src/modules/charges-matcher/__tests__/overall-confidence.test.d.ts +1 -0
  45. package/dist/server/src/modules/charges-matcher/__tests__/overall-confidence.test.js +410 -0
  46. package/dist/server/src/modules/charges-matcher/__tests__/overall-confidence.test.js.map +1 -0
  47. package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.d.ts +1 -0
  48. package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.js +504 -0
  49. package/dist/server/src/modules/charges-matcher/__tests__/single-match-integration.test.js.map +1 -0
  50. package/dist/server/src/modules/charges-matcher/__tests__/single-match.test.d.ts +1 -0
  51. package/dist/server/src/modules/charges-matcher/__tests__/single-match.test.js +483 -0
  52. package/dist/server/src/modules/charges-matcher/__tests__/single-match.test.js.map +1 -0
  53. package/dist/server/src/modules/charges-matcher/__tests__/test-helpers.d.ts +46 -0
  54. package/dist/server/src/modules/charges-matcher/__tests__/test-helpers.js +143 -0
  55. package/dist/server/src/modules/charges-matcher/__tests__/test-helpers.js.map +1 -0
  56. package/dist/server/src/modules/charges-matcher/__tests__/test-infrastructure.spec.d.ts +1 -0
  57. package/dist/server/src/modules/charges-matcher/__tests__/test-infrastructure.spec.js +137 -0
  58. package/dist/server/src/modules/charges-matcher/__tests__/test-infrastructure.spec.js.map +1 -0
  59. package/dist/server/src/modules/charges-matcher/__tests__/transaction-aggregator.test.d.ts +1 -0
  60. package/dist/server/src/modules/charges-matcher/__tests__/transaction-aggregator.test.js +415 -0
  61. package/dist/server/src/modules/charges-matcher/__tests__/transaction-aggregator.test.js.map +1 -0
  62. package/dist/server/src/modules/charges-matcher/helpers/amount-confidence.helper.d.ts +7 -0
  63. package/dist/server/src/modules/charges-matcher/helpers/amount-confidence.helper.js +70 -0
  64. package/dist/server/src/modules/charges-matcher/helpers/amount-confidence.helper.js.map +1 -0
  65. package/dist/server/src/modules/charges-matcher/helpers/business-confidence.helper.d.ts +7 -0
  66. package/dist/server/src/modules/charges-matcher/helpers/business-confidence.helper.js +19 -0
  67. package/dist/server/src/modules/charges-matcher/helpers/business-confidence.helper.js.map +1 -0
  68. package/dist/server/src/modules/charges-matcher/helpers/candidate-filter.helper.d.ts +24 -0
  69. package/dist/server/src/modules/charges-matcher/helpers/candidate-filter.helper.js +45 -0
  70. package/dist/server/src/modules/charges-matcher/helpers/candidate-filter.helper.js.map +1 -0
  71. package/dist/server/src/modules/charges-matcher/helpers/charge-validator.helper.d.ts +33 -0
  72. package/dist/server/src/modules/charges-matcher/helpers/charge-validator.helper.js +65 -0
  73. package/dist/server/src/modules/charges-matcher/helpers/charge-validator.helper.js.map +1 -0
  74. package/dist/server/src/modules/charges-matcher/helpers/currency-confidence.helper.d.ts +7 -0
  75. package/dist/server/src/modules/charges-matcher/helpers/currency-confidence.helper.js +18 -0
  76. package/dist/server/src/modules/charges-matcher/helpers/currency-confidence.helper.js.map +1 -0
  77. package/dist/server/src/modules/charges-matcher/helpers/date-confidence.helper.d.ts +7 -0
  78. package/dist/server/src/modules/charges-matcher/helpers/date-confidence.helper.js +35 -0
  79. package/dist/server/src/modules/charges-matcher/helpers/date-confidence.helper.js.map +1 -0
  80. package/dist/server/src/modules/charges-matcher/helpers/document-amount.helper.d.ts +49 -0
  81. package/dist/server/src/modules/charges-matcher/helpers/document-amount.helper.js +58 -0
  82. package/dist/server/src/modules/charges-matcher/helpers/document-amount.helper.js.map +1 -0
  83. package/dist/server/src/modules/charges-matcher/helpers/document-business.helper.d.ts +13 -0
  84. package/dist/server/src/modules/charges-matcher/helpers/document-business.helper.js +37 -0
  85. package/dist/server/src/modules/charges-matcher/helpers/document-business.helper.js.map +1 -0
  86. package/dist/server/src/modules/charges-matcher/helpers/overall-confidence.helper.d.ts +42 -0
  87. package/dist/server/src/modules/charges-matcher/helpers/overall-confidence.helper.js +77 -0
  88. package/dist/server/src/modules/charges-matcher/helpers/overall-confidence.helper.js.map +1 -0
  89. package/dist/server/src/modules/charges-matcher/index.d.ts +3 -0
  90. package/dist/server/src/modules/charges-matcher/index.js +15 -0
  91. package/dist/server/src/modules/charges-matcher/index.js.map +1 -0
  92. package/dist/server/src/modules/charges-matcher/providers/auto-match.provider.d.ts +48 -0
  93. package/dist/server/src/modules/charges-matcher/providers/auto-match.provider.js +133 -0
  94. package/dist/server/src/modules/charges-matcher/providers/auto-match.provider.js.map +1 -0
  95. package/dist/server/src/modules/charges-matcher/providers/charges-matcher.provider.d.ts +38 -0
  96. package/dist/server/src/modules/charges-matcher/providers/charges-matcher.provider.js +248 -0
  97. package/dist/server/src/modules/charges-matcher/providers/charges-matcher.provider.js.map +1 -0
  98. package/dist/server/src/modules/charges-matcher/providers/document-aggregator.d.ts +61 -0
  99. package/dist/server/src/modules/charges-matcher/providers/document-aggregator.js +153 -0
  100. package/dist/server/src/modules/charges-matcher/providers/document-aggregator.js.map +1 -0
  101. package/dist/server/src/modules/charges-matcher/providers/match-scorer.provider.d.ts +25 -0
  102. package/dist/server/src/modules/charges-matcher/providers/match-scorer.provider.js +114 -0
  103. package/dist/server/src/modules/charges-matcher/providers/match-scorer.provider.js.map +1 -0
  104. package/dist/server/src/modules/charges-matcher/providers/single-match.provider.d.ts +39 -0
  105. package/dist/server/src/modules/charges-matcher/providers/single-match.provider.js +189 -0
  106. package/dist/server/src/modules/charges-matcher/providers/single-match.provider.js.map +1 -0
  107. package/dist/server/src/modules/charges-matcher/providers/transaction-aggregator.d.ts +54 -0
  108. package/dist/server/src/modules/charges-matcher/providers/transaction-aggregator.js +93 -0
  109. package/dist/server/src/modules/charges-matcher/providers/transaction-aggregator.js.map +1 -0
  110. package/dist/server/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.d.ts +2 -0
  111. package/dist/server/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.js +22 -0
  112. package/dist/server/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.js.map +1 -0
  113. package/dist/server/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.d.ts +2 -0
  114. package/dist/server/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.js +24 -0
  115. package/dist/server/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.js.map +1 -0
  116. package/dist/server/src/modules/charges-matcher/resolvers/index.d.ts +2 -0
  117. package/dist/server/src/modules/charges-matcher/resolvers/index.js +11 -0
  118. package/dist/server/src/modules/charges-matcher/resolvers/index.js.map +1 -0
  119. package/dist/server/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.d.ts +2 -0
  120. package/dist/server/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.js +47 -0
  121. package/dist/server/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.js.map +1 -0
  122. package/dist/server/src/modules/charges-matcher/types.d.ts +179 -0
  123. package/dist/server/src/modules/charges-matcher/types.js +14 -0
  124. package/dist/server/src/modules/charges-matcher/types.js.map +1 -0
  125. package/dist/server/src/modules/documents/resolvers/document-suggestions.resolver.js +2 -2
  126. package/dist/server/src/modules/documents/resolvers/document-suggestions.resolver.js.map +1 -1
  127. package/dist/server/src/modules-app.js +2 -0
  128. package/dist/server/src/modules-app.js.map +1 -1
  129. package/dist/server/src/shared/types/index.d.ts +1 -1
  130. package/package.json +4 -4
  131. package/src/__generated__/types.ts +87 -0
  132. package/src/modules/charges-matcher/README.md +279 -0
  133. package/src/modules/charges-matcher/__generated__/types.ts +71 -0
  134. package/src/modules/charges-matcher/__tests__/amount-confidence.test.ts +260 -0
  135. package/src/modules/charges-matcher/__tests__/auto-match-integration.test.ts +714 -0
  136. package/src/modules/charges-matcher/__tests__/auto-match.test.ts +621 -0
  137. package/src/modules/charges-matcher/__tests__/business-confidence.test.ts +177 -0
  138. package/src/modules/charges-matcher/__tests__/candidate-filter.test.ts +238 -0
  139. package/src/modules/charges-matcher/__tests__/charge-validator.test.ts +374 -0
  140. package/src/modules/charges-matcher/__tests__/currency-confidence.test.ts +164 -0
  141. package/src/modules/charges-matcher/__tests__/date-confidence.test.ts +291 -0
  142. package/src/modules/charges-matcher/__tests__/document-aggregator.test.ts +614 -0
  143. package/src/modules/charges-matcher/__tests__/document-amount.test.ts +352 -0
  144. package/src/modules/charges-matcher/__tests__/document-business.test.ts +192 -0
  145. package/src/modules/charges-matcher/__tests__/match-scorer.test.ts +659 -0
  146. package/src/modules/charges-matcher/__tests__/overall-confidence.test.ts +502 -0
  147. package/src/modules/charges-matcher/__tests__/single-match-integration.test.ts +556 -0
  148. package/src/modules/charges-matcher/__tests__/single-match.test.ts +608 -0
  149. package/src/modules/charges-matcher/__tests__/test-helpers.ts +174 -0
  150. package/src/modules/charges-matcher/__tests__/test-infrastructure.spec.ts +177 -0
  151. package/src/modules/charges-matcher/__tests__/transaction-aggregator.test.ts +547 -0
  152. package/src/modules/charges-matcher/documentation/README.md +331 -0
  153. package/src/modules/charges-matcher/documentation/SPEC.md +1503 -0
  154. package/src/modules/charges-matcher/documentation/TODO.md +799 -0
  155. package/src/modules/charges-matcher/helpers/amount-confidence.helper.ts +88 -0
  156. package/src/modules/charges-matcher/helpers/business-confidence.helper.ts +23 -0
  157. package/src/modules/charges-matcher/helpers/candidate-filter.helper.ts +56 -0
  158. package/src/modules/charges-matcher/helpers/charge-validator.helper.ts +100 -0
  159. package/src/modules/charges-matcher/helpers/currency-confidence.helper.ts +22 -0
  160. package/src/modules/charges-matcher/helpers/date-confidence.helper.ts +41 -0
  161. package/src/modules/charges-matcher/helpers/document-amount.helper.ts +77 -0
  162. package/src/modules/charges-matcher/helpers/document-business.helper.ts +54 -0
  163. package/src/modules/charges-matcher/helpers/overall-confidence.helper.ts +90 -0
  164. package/src/modules/charges-matcher/index.ts +17 -0
  165. package/src/modules/charges-matcher/providers/auto-match.provider.ts +176 -0
  166. package/src/modules/charges-matcher/providers/charges-matcher.provider.ts +322 -0
  167. package/src/modules/charges-matcher/providers/document-aggregator.ts +211 -0
  168. package/src/modules/charges-matcher/providers/match-scorer.provider.ts +154 -0
  169. package/src/modules/charges-matcher/providers/single-match.provider.ts +252 -0
  170. package/src/modules/charges-matcher/providers/transaction-aggregator.ts +131 -0
  171. package/src/modules/charges-matcher/resolvers/auto-match-charges.resolver.ts +23 -0
  172. package/src/modules/charges-matcher/resolvers/find-charge-matches.resolver.ts +25 -0
  173. package/src/modules/charges-matcher/resolvers/index.ts +12 -0
  174. package/src/modules/charges-matcher/typeDefs/charges-matcher.graphql.ts +47 -0
  175. package/src/modules/charges-matcher/types.ts +200 -0
  176. package/src/modules/documents/resolvers/document-suggestions.resolver.ts +2 -2
  177. package/src/modules-app.ts +2 -0
  178. 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
+ }