@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.0

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 (207) hide show
  1. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
  2. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
  3. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
  4. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
  5. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
  6. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
  7. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
  8. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
  9. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
  10. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
  11. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
  12. package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
  13. package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
  14. package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
  15. package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
  16. package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
  17. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
  18. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
  19. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
  20. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
  21. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
  22. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
  23. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
  24. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
  25. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
  26. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
  27. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
  28. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
  29. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
  30. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
  31. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
  32. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
  33. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
  34. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
  35. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
  36. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
  37. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
  38. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
  39. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
  40. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
  41. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
  42. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
  43. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
  44. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
  45. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
  46. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
  47. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
  48. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
  49. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
  50. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
  51. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
  52. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
  53. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
  54. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
  55. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
  56. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
  57. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
  58. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
  59. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
  60. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
  61. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
  62. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
  63. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
  64. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
  65. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
  66. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
  67. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
  68. package/.github/workflows/publish.yml +3 -3
  69. package/.github/workflows/release.yml +4 -0
  70. package/CHANGELOG.md +75 -0
  71. package/NUL +1 -0
  72. package/dist/bundle/index.cjs +65 -42
  73. package/dist/server/errorHandler.d.ts +2 -0
  74. package/dist/server/errorHandler.js +49 -5
  75. package/dist/tools/reconcileAdapter.js +10 -5
  76. package/dist/tools/reconciliation/analyzer.d.ts +4 -2
  77. package/dist/tools/reconciliation/analyzer.js +120 -404
  78. package/dist/tools/reconciliation/csvParser.d.ts +51 -0
  79. package/dist/tools/reconciliation/csvParser.js +413 -0
  80. package/dist/tools/reconciliation/executor.d.ts +8 -0
  81. package/dist/tools/reconciliation/executor.js +204 -58
  82. package/dist/tools/reconciliation/index.d.ts +7 -7
  83. package/dist/tools/reconciliation/index.js +115 -39
  84. package/dist/tools/reconciliation/matcher.d.ts +24 -3
  85. package/dist/tools/reconciliation/matcher.js +175 -133
  86. package/dist/tools/reconciliation/recommendationEngine.js +22 -18
  87. package/dist/tools/reconciliation/reportFormatter.js +9 -8
  88. package/dist/tools/reconciliation/signDetector.d.ts +2 -0
  89. package/dist/tools/reconciliation/signDetector.js +54 -0
  90. package/dist/tools/reconciliation/types.d.ts +20 -34
  91. package/dist/tools/reconciliation/types.js +1 -7
  92. package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
  93. package/dist/tools/reconciliation/ynabAdapter.js +15 -0
  94. package/dist/types/reconciliation.d.ts +24 -0
  95. package/dist/types/reconciliation.js +1 -0
  96. package/docs/guides/ARCHITECTURE.md +12 -129
  97. package/docs/plans/2025-11-21-v014-hardening.md +153 -0
  98. package/docs/plans/reconciliation-v2-redesign.md +1571 -0
  99. package/package.json +6 -1
  100. package/scripts/test-recommendations.ts +1 -1
  101. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
  102. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
  103. package/src/server/errorHandler.ts +52 -5
  104. package/src/tools/reconcileAdapter.ts +10 -5
  105. package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
  106. package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
  107. package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
  108. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +1 -1
  109. package/src/tools/reconciliation/__tests__/executor.test.ts +88 -61
  110. package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
  111. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
  112. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
  113. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
  114. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
  115. package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
  116. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
  117. package/src/tools/reconciliation/analyzer.ts +174 -545
  118. package/src/tools/reconciliation/csvParser.ts +617 -0
  119. package/src/tools/reconciliation/executor.ts +249 -66
  120. package/src/tools/reconciliation/index.ts +141 -48
  121. package/src/tools/reconciliation/matcher.ts +234 -214
  122. package/src/tools/reconciliation/recommendationEngine.ts +23 -19
  123. package/src/tools/reconciliation/reportFormatter.ts +16 -11
  124. package/src/tools/reconciliation/signDetector.ts +117 -0
  125. package/src/tools/reconciliation/types.ts +39 -61
  126. package/src/tools/reconciliation/ynabAdapter.ts +33 -0
  127. package/src/types/reconciliation.ts +49 -0
  128. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
  129. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
  130. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
  131. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
  132. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
  133. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
  134. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
  135. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
  136. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
  137. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
  138. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
  139. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
  140. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
  141. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
  142. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
  143. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
  144. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
  145. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
  146. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
  147. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
  148. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
  149. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
  150. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
  151. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
  152. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
  153. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
  154. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
  155. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
  156. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
  157. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
  158. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
  159. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
  160. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
  161. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
  162. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
  163. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
  164. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
  165. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
  166. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
  167. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
  168. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
  169. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
  170. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
  171. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
  172. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
  173. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
  174. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
  175. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
  176. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
  177. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
  178. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
  179. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
  180. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
  181. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
  182. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
  183. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
  184. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
  185. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
  186. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
  187. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
  188. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
  189. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
  190. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
  191. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
  192. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
  193. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
  194. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
  195. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
  196. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
  197. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
  198. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
  199. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
  200. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
  201. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
  202. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
  203. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
  204. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
  205. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
  206. package/.gemini/settings.json +0 -8
  207. package/WARP.md +0 -245
@@ -1,418 +1,72 @@
1
1
  /**
2
2
  * Analysis phase orchestration for reconciliation
3
3
  * Coordinates CSV parsing, YNAB transaction fetching, and matching
4
+ *
5
+ * V2 UPDATE: Uses new parser and matcher (milliunits based)
4
6
  */
5
7
 
6
- import { randomUUID } from 'crypto';
7
8
  import type * as ynab from 'ynab';
8
- import * as bankParser from '../compareTransactions/parser.js';
9
- import type { CSVFormat as ParserCSVFormat } from '../compareTransactions/types.js';
10
- import { findMatches } from './matcher.js';
11
- import { DEFAULT_MATCHING_CONFIG } from './types.js';
9
+ import { parseCSV, type ParseCSVOptions, type CSVParseResult } from './csvParser.js';
10
+ import { findMatches, normalizeConfig, type MatchingConfig, DEFAULT_CONFIG } from './matcher.js';
11
+ import { normalizeYNABTransactions } from './ynabAdapter.js';
12
+
12
13
  import type {
13
14
  BankTransaction,
14
15
  YNABTransaction,
15
16
  ReconciliationAnalysis,
16
17
  TransactionMatch,
17
- MatchingConfig,
18
18
  BalanceInfo,
19
19
  ReconciliationSummary,
20
20
  ReconciliationInsight,
21
21
  } from './types.js';
22
- import { toMoneyValueFromDecimal } from '../../utils/money.js';
22
+ import type { MatchResult } from './matcher.js'; // Import MatchResult
23
+ import { toMoneyValue } from '../../utils/money.js';
23
24
  import { generateRecommendations } from './recommendationEngine.js';
24
25
 
25
- /**
26
- * Convert YNAB API transaction to simplified format
27
- */
28
- function convertYNABTransaction(apiTxn: ynab.TransactionDetail): YNABTransaction {
29
- return {
30
- id: apiTxn.id,
31
- date: apiTxn.date,
32
- amount: apiTxn.amount,
33
- payee_name: apiTxn.payee_name || null,
34
- category_name: apiTxn.category_name || null,
35
- cleared: apiTxn.cleared,
36
- approved: apiTxn.approved,
37
- memo: apiTxn.memo || null,
38
- };
39
- }
40
-
41
- /**
42
- * Parse CSV bank statement and generate unique IDs for tracking
43
- */
44
- const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
45
- date_column: 'Date',
46
- amount_column: 'Amount',
47
- description_column: 'Description',
48
- date_format: 'MM/DD/YYYY',
49
- has_header: true,
50
- delimiter: ',',
51
- };
52
-
53
- const ENABLE_COMBINATION_MATCHING = true;
54
-
55
- const DAYS_IN_MS = 24 * 60 * 60 * 1000;
56
-
57
- function toDollars(milliunits: number): number {
58
- return milliunits / 1000;
59
- }
60
-
61
- function amountTolerance(config: MatchingConfig): number {
62
- const toleranceCents =
63
- config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
64
- return Math.max(0, toleranceCents) / 100;
65
- }
66
-
67
- function dateTolerance(config: MatchingConfig): number {
68
- return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
69
- }
70
-
71
- function daysBetween(dateA: string, dateB: string): number {
72
- const a = new Date(`${dateA}T00:00:00Z`).getTime();
73
- const b = new Date(`${dateB}T00:00:00Z`).getTime();
74
- if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
75
- return Math.abs(a - b) / DAYS_IN_MS;
76
- }
77
-
78
- function withinDateTolerance(
79
- bankDate: string,
80
- ynabTxns: YNABTransaction[],
81
- toleranceDays: number,
82
- ): boolean {
83
- return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
84
- }
85
-
86
- function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
87
- const bankSign = Math.sign(bankAmount);
88
- const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
89
- return bankSign === sumSign || Math.abs(bankAmount) === 0;
90
- }
91
-
92
- function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
93
- const safeTolerance = tolerance > 0 ? tolerance : 0.01;
94
- const ratio = diff / safeTolerance;
95
- let base = legCount === 2 ? 75 : 70;
96
- if (ratio <= 0.25) {
97
- base += 5;
98
- } else if (ratio <= 0.5) {
99
- base += 3;
100
- } else if (ratio >= 0.9) {
101
- base -= 5;
102
- }
103
- return Math.max(65, Math.min(80, Math.round(base)));
104
- }
105
-
106
- function formatDifference(diff: number): string {
107
- return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
108
- }
109
-
110
- interface CombinationResult {
111
- matches: TransactionMatch[];
112
- insights: ReconciliationInsight[];
113
- }
114
-
115
- function findCombinationMatches(
116
- unmatchedBank: BankTransaction[],
117
- unmatchedYNAB: YNABTransaction[],
118
- config: MatchingConfig,
119
- ): CombinationResult {
120
- if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
121
- return { matches: [], insights: [] };
122
- }
123
-
124
- const tolerance = amountTolerance(config);
125
- const toleranceDays = dateTolerance(config);
126
-
127
- const matches: TransactionMatch[] = [];
128
- const insights: ReconciliationInsight[] = [];
129
- const seenCombinations = new Set<string>();
130
-
131
- for (const bankTxn of unmatchedBank) {
132
- const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
133
- if (viableYnab.length < 2) continue;
134
-
135
- const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
136
-
137
- const addIfValid = (combo: YNABTransaction[]) => {
138
- const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
139
- const diff = Math.abs(sum - bankTxn.amount);
140
- if (diff > tolerance) return;
141
- if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
142
- if (!hasMatchingSign(bankTxn.amount, combo)) return;
143
- evaluated.push({ txns: combo, diff, sum });
144
- };
145
-
146
- const n = viableYnab.length;
147
- for (let i = 0; i < n - 1; i++) {
148
- for (let j = i + 1; j < n; j++) {
149
- addIfValid([viableYnab[i]!, viableYnab[j]!]);
150
- }
151
- }
152
-
153
- if (n >= 3) {
154
- for (let i = 0; i < n - 2; i++) {
155
- for (let j = i + 1; j < n - 1; j++) {
156
- for (let k = j + 1; k < n; k++) {
157
- addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
158
- }
159
- }
160
- }
161
- }
162
-
163
- if (evaluated.length === 0) continue;
164
-
165
- evaluated.sort((a, b) => a.diff - b.diff);
166
- const recordedSizes = new Set<number>();
167
-
168
- for (const combo of evaluated) {
169
- if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
170
- const comboIds = combo.txns.map((txn) => txn.id).sort();
171
- const key = `${bankTxn.id}|${comboIds.join('+')}`;
172
- if (seenCombinations.has(key)) continue;
173
- seenCombinations.add(key);
174
- recordedSizes.add(combo.txns.length);
175
-
176
- const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
177
- const candidateConfidence = Math.max(60, score - 5);
178
- const descriptionTotal = formatCurrency(combo.sum);
179
- const diffLabel = formatDifference(combo.diff);
180
-
181
- matches.push({
182
- bank_transaction: bankTxn,
183
- confidence: 'medium',
184
- confidence_score: score,
185
- match_reason: 'combination_match',
186
- top_confidence: score,
187
- candidates: combo.txns.map((txn) => ({
188
- ynab_transaction: txn,
189
- confidence: candidateConfidence,
190
- match_reason: 'combination_component',
191
- explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
192
- })),
193
- action_hint: 'review_combination',
194
- recommendation:
195
- `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
196
- `${formatCurrency(bankTxn.amount)} on the bank statement.`,
197
- });
198
-
199
- const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
200
- insights.push({
201
- id: insightId,
202
- type: 'combination_match' as unknown as ReconciliationInsight['type'],
203
- severity: 'info',
204
- title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
205
- bankTxn.amount,
206
- )}`,
207
- description:
208
- `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
209
- `${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
210
- evidence: {
211
- bank_transaction_id: bankTxn.id,
212
- bank_amount: bankTxn.amount,
213
- ynab_transaction_ids: comboIds,
214
- ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
215
- combination_size: combo.txns.length,
216
- difference: combo.diff,
217
- },
218
- });
219
- }
220
- }
221
-
222
- return { matches, insights };
223
- }
224
-
225
- type ParserResult =
226
- | {
227
- transactions: unknown[];
228
- format_detected?: string;
229
- delimiter?: string;
230
- total_rows?: number;
231
- valid_rows?: number;
232
- errors?: string[];
233
- }
234
- | unknown[];
235
-
236
- function isParsedCSVData(
237
- result: ParserResult,
238
- ): result is Extract<ParserResult, { transactions: unknown[] }> {
239
- return (
240
- typeof result === 'object' &&
241
- result !== null &&
242
- !Array.isArray(result) &&
243
- 'transactions' in result
244
- );
245
- }
246
-
247
- function normalizeDate(value: unknown): string {
248
- if (value instanceof Date) {
249
- return value.toISOString().split('T')[0]!;
250
- }
251
-
252
- if (typeof value === 'string') {
253
- const trimmed = value.trim();
254
- if (!trimmed) return trimmed;
255
-
256
- const parsed = new Date(trimmed);
257
- if (!Number.isNaN(parsed.getTime())) {
258
- return parsed.toISOString().split('T')[0]!;
259
- }
260
-
261
- return trimmed;
262
- }
263
-
264
- return new Date().toISOString().split('T')[0]!;
265
- }
266
-
267
- function normalizeAmount(record: Record<string, unknown>): number {
268
- const raw = record['amount'];
269
-
270
- if (typeof raw === 'number') {
271
- if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
272
- return Math.round(raw) / 1000;
273
- }
274
- return raw;
275
- }
276
-
277
- if (typeof raw === 'string') {
278
- const cleaned = raw.replace(/[$,\s]/g, '');
279
- const parsed = Number.parseFloat(cleaned);
280
- return Number.isFinite(parsed) ? parsed : 0;
281
- }
282
-
283
- return 0;
284
- }
285
-
286
- function normalizePayee(record: Record<string, unknown>): string {
287
- const candidates = [record['payee'], record['description'], record['memo']];
288
- for (const candidate of candidates) {
289
- if (typeof candidate === 'string' && candidate.trim()) {
290
- return candidate.trim();
291
- }
292
- }
293
- return 'Unknown Payee';
294
- }
295
-
296
- function determineRow(record: Record<string, unknown>, index: number): number {
297
- if (typeof record['original_csv_row'] === 'number') {
298
- return record['original_csv_row'];
299
- }
300
- if (typeof record['row_number'] === 'number') {
301
- return record['row_number'];
302
- }
303
- return index + 1;
304
- }
305
-
306
- function convertParserRecord(record: unknown, index: number): BankTransaction {
307
- const data =
308
- typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
309
-
310
- const dateValue = normalizeDate(data['date']);
311
- const amountValue = normalizeAmount(data);
312
- const payeeValue = normalizePayee(data);
313
- const memoValue =
314
- typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
315
- const originalRow = determineRow(data, index);
316
-
317
- const transaction: BankTransaction = {
318
- id: randomUUID(),
319
- date: dateValue,
320
- amount: amountValue,
321
- payee: payeeValue,
322
- original_csv_row: originalRow,
26
+ // --- Helper Functions ---
27
+
28
+ function mapToTransactionMatch(result: MatchResult): TransactionMatch {
29
+ const candidates = result.candidates.map((c) => ({
30
+ ynab_transaction: c.ynabTransaction,
31
+ confidence: c.scores.combined,
32
+ match_reason: c.matchReasons.join(', '),
33
+ explanation: c.matchReasons.join(', '),
34
+ }));
35
+
36
+ const match: TransactionMatch = {
37
+ bankTransaction: result.bankTransaction,
38
+ candidates,
39
+ confidence: result.confidence,
40
+ confidenceScore: result.confidenceScore,
41
+ matchReason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
42
+ actionHint: result.confidence === 'high' ? 'approve' : 'review',
323
43
  };
324
44
 
325
- if (memoValue !== undefined) {
326
- transaction.memo = memoValue;
45
+ if (result.bestMatch) {
46
+ match.ynabTransaction = result.bestMatch.ynabTransaction;
327
47
  }
328
48
 
329
- return transaction;
330
- }
331
-
332
- function parseBankStatement(csvContent: string, csvFilePath?: string): BankTransaction[] {
333
- const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
334
-
335
- let format: ParserCSVFormat = FALLBACK_CSV_FORMAT;
336
- let autoDetect: ((content: string) => ParserCSVFormat) | undefined;
337
- try {
338
- autoDetect = (bankParser as { autoDetectCSVFormat?: (content: string) => ParserCSVFormat })
339
- .autoDetectCSVFormat;
340
- } catch {
341
- autoDetect = undefined;
49
+ if (result.candidates[0]) {
50
+ match.topConfidence = result.candidates[0].scores.combined;
342
51
  }
343
52
 
344
- if (typeof autoDetect === 'function') {
345
- try {
346
- format = autoDetect(content);
347
- } catch {
348
- format = FALLBACK_CSV_FORMAT;
349
- }
53
+ if (result.confidence === 'none') {
54
+ match.recommendation = 'This bank transaction is not in YNAB. Consider adding it.';
350
55
  }
351
56
 
352
- const rawResult = bankParser.parseBankCSV(content, format) as unknown as ParserResult;
353
- const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
354
-
355
- return records.map(convertParserRecord);
57
+ return match;
356
58
  }
357
59
 
358
- /**
359
- * Categorize matches by confidence level
360
- */
361
- function categorizeMatches(matches: TransactionMatch[]): {
362
- autoMatches: TransactionMatch[];
363
- suggestedMatches: TransactionMatch[];
364
- unmatchedBank: BankTransaction[];
365
- } {
366
- const autoMatches: TransactionMatch[] = [];
367
- const suggestedMatches: TransactionMatch[] = [];
368
- const unmatchedBank: BankTransaction[] = [];
369
-
370
- for (const match of matches) {
371
- if (match.confidence === 'high') {
372
- autoMatches.push(match);
373
- } else if (match.confidence === 'medium') {
374
- suggestedMatches.push(match);
375
- } else {
376
- // low or none confidence
377
- unmatchedBank.push(match.bank_transaction);
378
- }
379
- }
380
-
381
- return { autoMatches, suggestedMatches, unmatchedBank };
382
- }
383
-
384
- /**
385
- * Find unmatched YNAB transactions
386
- * These are transactions in YNAB that don't appear on the bank statement
387
- */
388
- function findUnmatchedYNAB(
389
- ynabTransactions: YNABTransaction[],
390
- matches: TransactionMatch[],
391
- ): YNABTransaction[] {
392
- const matchedIds = new Set<string>();
393
-
394
- for (const match of matches) {
395
- if (match.ynab_transaction) {
396
- matchedIds.add(match.ynab_transaction.id);
397
- }
398
- }
399
-
400
- return ynabTransactions.filter((txn) => !matchedIds.has(txn.id));
401
- }
402
-
403
- /**
404
- * Calculate balance information
405
- */
406
60
  function calculateBalances(
407
61
  ynabTransactions: YNABTransaction[],
408
- statementBalance: number,
62
+ statementBalanceDecimal: number,
409
63
  currency: string,
410
64
  ): BalanceInfo {
411
65
  let clearedBalance = 0;
412
66
  let unclearedBalance = 0;
413
67
 
414
68
  for (const txn of ynabTransactions) {
415
- const amount = txn.amount / 1000; // Convert from milliunits to dollars
69
+ const amount = txn.amount; // Milliunits
416
70
 
417
71
  if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
418
72
  clearedBalance += amount;
@@ -421,22 +75,20 @@ function calculateBalances(
421
75
  }
422
76
  }
423
77
 
78
+ const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
424
79
  const totalBalance = clearedBalance + unclearedBalance;
425
- const discrepancy = clearedBalance - statementBalance;
80
+ const discrepancy = clearedBalance - statementBalanceMilli;
426
81
 
427
82
  return {
428
- current_cleared: toMoneyValueFromDecimal(clearedBalance, currency),
429
- current_uncleared: toMoneyValueFromDecimal(unclearedBalance, currency),
430
- current_total: toMoneyValueFromDecimal(totalBalance, currency),
431
- target_statement: toMoneyValueFromDecimal(statementBalance, currency),
432
- discrepancy: toMoneyValueFromDecimal(discrepancy, currency),
433
- on_track: Math.abs(discrepancy) < 0.01, // Within 1 cent
83
+ current_cleared: toMoneyValue(clearedBalance, currency),
84
+ current_uncleared: toMoneyValue(unclearedBalance, currency),
85
+ current_total: toMoneyValue(totalBalance, currency),
86
+ target_statement: toMoneyValue(statementBalanceMilli, currency),
87
+ discrepancy: toMoneyValue(discrepancy, currency),
88
+ on_track: Math.abs(discrepancy) < 10, // Within 1 cent (10 milliunits)
434
89
  };
435
90
  }
436
91
 
437
- /**
438
- * Generate reconciliation summary
439
- */
440
92
  function generateSummary(
441
93
  bankTransactions: BankTransaction[],
442
94
  ynabTransactions: YNABTransaction[],
@@ -485,9 +137,6 @@ function generateSummary(
485
137
  };
486
138
  }
487
139
 
488
- /**
489
- * Generate next steps for user
490
- */
491
140
  function generateNextSteps(summary: ReconciliationSummary): string[] {
492
141
  const steps: string[] = [];
493
142
 
@@ -516,26 +165,32 @@ function generateNextSteps(summary: ReconciliationSummary): string[] {
516
165
  return steps;
517
166
  }
518
167
 
519
- function formatCurrency(amount: number): string {
168
+ function formatCurrency(amountMilli: number, currency: string = 'USD'): string {
520
169
  const formatter = new Intl.NumberFormat('en-US', {
521
170
  style: 'currency',
522
- currency: 'USD',
171
+ currency: currency,
523
172
  minimumFractionDigits: 2,
524
173
  maximumFractionDigits: 2,
525
174
  });
526
- return formatter.format(amount);
175
+ return formatter.format(amountMilli / 1000);
527
176
  }
528
177
 
529
- function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationInsight[] {
178
+ // --- Insight Generation ---
179
+
180
+ function repeatAmountInsights(
181
+ unmatchedBank: BankTransaction[],
182
+ currency: string = 'USD',
183
+ ): ReconciliationInsight[] {
530
184
  const insights: ReconciliationInsight[] = [];
531
185
  if (unmatchedBank.length === 0) {
532
186
  return insights;
533
187
  }
534
188
 
535
- const frequency = new Map<string, { amount: number; txns: BankTransaction[] }>();
189
+ // Group by milliunits amount
190
+ const frequency = new Map<number, { amount: number; txns: BankTransaction[] }>();
536
191
 
537
192
  for (const txn of unmatchedBank) {
538
- const key = txn.amount.toFixed(2);
193
+ const key = txn.amount;
539
194
  const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
540
195
  entry.txns.push(txn);
541
196
  frequency.set(key, entry);
@@ -551,88 +206,34 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
551
206
 
552
207
  const top = repeated[0]!;
553
208
  insights.push({
554
- id: `repeat-${top.amount.toFixed(2)}`,
209
+ id: `repeat-${top.amount}`,
555
210
  type: 'repeat_amount',
556
211
  severity: top.txns.length >= 4 ? 'critical' : 'warning',
557
- title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount)}`,
212
+ title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
558
213
  description:
559
- `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount)}. ` +
214
+ `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. ` +
560
215
  'Repeated amounts are usually the quickest wins — reconcile these first.',
561
216
  evidence: {
562
- amount: top.amount,
217
+ amount: top.amount, // Milliunits
563
218
  occurrences: top.txns.length,
564
219
  dates: top.txns.map((txn) => txn.date),
565
- csv_rows: top.txns.map((txn) => txn.original_csv_row),
220
+ csv_rows: top.txns.map((txn) => txn.sourceRow),
566
221
  },
567
222
  });
568
223
 
569
224
  return insights;
570
225
  }
571
226
 
572
- function nearMatchInsights(
573
- matches: TransactionMatch[],
574
- config: MatchingConfig,
575
- ): ReconciliationInsight[] {
227
+ function anomalyInsights(balances: BalanceInfo): ReconciliationInsight[] {
576
228
  const insights: ReconciliationInsight[] = [];
229
+ const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
577
230
 
578
- for (const match of matches) {
579
- if (!match.candidates || match.candidates.length === 0) continue;
580
- if (match.confidence === 'high') continue;
581
-
582
- const topCandidate = match.candidates[0]!;
583
- const score = topCandidate.confidence;
584
- const highSignal =
585
- (match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
586
- (match.confidence === 'low' && score >= config.suggestionThreshold) ||
587
- (match.confidence === 'none' && score >= config.suggestionThreshold);
588
-
589
- if (!highSignal) continue;
590
-
591
- const bankTxn = match.bank_transaction;
592
- const ynabTxn = topCandidate.ynab_transaction;
593
-
594
- insights.push({
595
- id: `near-${bankTxn.id}`,
596
- type: 'near_match',
597
- severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
598
- title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
599
- description:
600
- `Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
601
- `${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
602
- evidence: {
603
- bank_transaction: {
604
- id: bankTxn.id,
605
- date: bankTxn.date,
606
- amount: bankTxn.amount,
607
- payee: bankTxn.payee,
608
- },
609
- candidate: {
610
- id: ynabTxn.id,
611
- date: ynabTxn.date,
612
- amount_milliunits: ynabTxn.amount,
613
- payee_name: ynabTxn.payee_name,
614
- confidence: score,
615
- reasons: topCandidate.match_reason,
616
- },
617
- },
618
- });
619
- }
620
-
621
- return insights.slice(0, 3);
622
- }
623
-
624
- function anomalyInsights(
625
- summary: ReconciliationSummary,
626
- balances: BalanceInfo,
627
- ): ReconciliationInsight[] {
628
- const insights: ReconciliationInsight[] = [];
629
- const discrepancyAbs = Math.abs(balances.discrepancy.value);
630
-
631
- if (discrepancyAbs >= 1) {
231
+ if (discrepancyAbs >= 1000) {
232
+ // 1 dollar
632
233
  insights.push({
633
234
  id: 'balance-gap',
634
235
  type: 'anomaly',
635
- severity: discrepancyAbs >= 100 ? 'critical' : 'warning',
236
+ severity: discrepancyAbs >= 100000 ? 'critical' : 'warning', // 100 dollars
636
237
  title: `Cleared balance off by ${balances.discrepancy.value_display}`,
637
238
  description:
638
239
  `YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
@@ -645,30 +246,16 @@ function anomalyInsights(
645
246
  });
646
247
  }
647
248
 
648
- if (summary.unmatched_bank >= 5) {
649
- insights.push({
650
- id: 'bulk-missing-bank',
651
- type: 'anomaly',
652
- severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
653
- title: `${summary.unmatched_bank} bank transactions still unmatched`,
654
- description:
655
- `There are ${summary.unmatched_bank} bank transactions without a match. ` +
656
- 'Consider bulk importing or reviewing by date sequence.',
657
- evidence: {
658
- unmatched_bank: summary.unmatched_bank,
659
- },
660
- });
661
- }
662
-
663
249
  return insights;
664
250
  }
665
251
 
666
252
  function detectInsights(
667
- matches: TransactionMatch[],
668
253
  unmatchedBank: BankTransaction[],
669
- summary: ReconciliationSummary,
254
+ _summary: ReconciliationSummary,
670
255
  balances: BalanceInfo,
671
- config: MatchingConfig,
256
+ currency: string,
257
+ csvErrors: { row: number; field: string; message: string }[] = [],
258
+ csvWarnings: { row: number; message: string }[] = [],
672
259
  ): ReconciliationInsight[] {
673
260
  const insights: ReconciliationInsight[] = [];
674
261
  const seen = new Set<string>();
@@ -679,45 +266,61 @@ function detectInsights(
679
266
  insights.push(insight);
680
267
  };
681
268
 
682
- for (const insight of repeatAmountInsights(unmatchedBank)) {
683
- addUnique(insight);
269
+ // Surface CSV parsing errors
270
+ if (csvErrors.length > 0) {
271
+ addUnique({
272
+ id: 'csv-parse-errors',
273
+ type: 'anomaly',
274
+ severity: csvErrors.length >= 5 ? 'critical' : 'warning',
275
+ title: `${csvErrors.length} CSV parsing error(s)`,
276
+ description:
277
+ csvErrors
278
+ .slice(0, 3)
279
+ .map((e) => `Row ${e.row}: ${e.message}`)
280
+ .join('; ') + (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ''),
281
+ evidence: {
282
+ error_count: csvErrors.length,
283
+ errors: csvErrors.slice(0, 5),
284
+ },
285
+ });
286
+ }
287
+
288
+ // Surface CSV parsing warnings
289
+ if (csvWarnings.length > 0) {
290
+ addUnique({
291
+ id: 'csv-parse-warnings',
292
+ type: 'anomaly',
293
+ severity: 'info',
294
+ title: `${csvWarnings.length} CSV parsing warning(s)`,
295
+ description:
296
+ csvWarnings
297
+ .slice(0, 3)
298
+ .map((w) => `Row ${w.row}: ${w.message}`)
299
+ .join('; ') + (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ''),
300
+ evidence: {
301
+ warning_count: csvWarnings.length,
302
+ warnings: csvWarnings.slice(0, 5),
303
+ },
304
+ });
684
305
  }
685
306
 
686
- for (const insight of nearMatchInsights(matches, config)) {
307
+ for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
687
308
  addUnique(insight);
688
309
  }
689
310
 
690
- for (const insight of anomalyInsights(summary, balances)) {
311
+ for (const insight of anomalyInsights(balances)) {
691
312
  addUnique(insight);
692
313
  }
693
314
 
694
315
  return insights.slice(0, 5);
695
316
  }
696
317
 
697
- function mergeInsights(
698
- base: ReconciliationInsight[],
699
- additional: ReconciliationInsight[],
700
- ): ReconciliationInsight[] {
701
- if (additional.length === 0) {
702
- return base;
703
- }
704
-
705
- const seen = new Set(base.map((insight) => insight.id));
706
- const merged = [...base];
707
-
708
- for (const insight of additional) {
709
- if (seen.has(insight.id)) continue;
710
- seen.add(insight.id);
711
- merged.push(insight);
712
- }
713
-
714
- return merged.slice(0, 5);
715
- }
318
+ // --- Main Analysis Function ---
716
319
 
717
320
  /**
718
321
  * Perform reconciliation analysis
719
322
  *
720
- * @param csvContent - CSV file content or file path
323
+ * @param csvContentOrParsed - CSV file content or pre-parsed result
721
324
  * @param csvFilePath - Optional file path (if csvContent is a path)
722
325
  * @param ynabTransactions - YNAB transactions from API
723
326
  * @param statementBalance - Expected cleared balance from statement
@@ -726,63 +329,83 @@ function mergeInsights(
726
329
  * @param accountId - Account ID for recommendation context
727
330
  * @param budgetId - Budget ID for recommendation context
728
331
  * @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
332
+ * @param csvOptions - Optional CSV parsing options (manual overrides)
729
333
  */
730
334
  export function analyzeReconciliation(
731
- csvContent: string,
732
- csvFilePath: string | undefined,
335
+ csvContentOrParsed: string | CSVParseResult,
336
+ _csvFilePath: string | undefined,
733
337
  ynabTransactions: ynab.TransactionDetail[],
734
338
  statementBalance: number,
735
- config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
339
+ config: MatchingConfig = DEFAULT_CONFIG,
736
340
  currency: string = 'USD',
737
341
  accountId?: string,
738
342
  budgetId?: string,
739
343
  invertBankAmounts: boolean = false,
344
+ csvOptions?: ParseCSVOptions,
740
345
  ): ReconciliationAnalysis {
741
- // Step 1: Parse bank CSV
742
- let bankTransactions = parseBankStatement(csvContent, csvFilePath);
743
-
744
- // Step 1b: Optionally invert bank transaction amounts
745
- // Some banks show charges as positive (need inversion to match YNAB's negative convention)
746
- // Other banks (e.g., Wealthsimple) show charges as negative already (no inversion needed)
747
- if (invertBankAmounts) {
748
- bankTransactions = bankTransactions.map((txn) => ({
749
- ...txn,
750
- amount: -txn.amount,
751
- }));
346
+ // Step 1: Parse bank CSV using new Parser (or use provided result)
347
+ let parseResult: CSVParseResult;
348
+
349
+ if (typeof csvContentOrParsed === 'string') {
350
+ parseResult = parseCSV(csvContentOrParsed, {
351
+ ...csvOptions,
352
+ invertAmounts: invertBankAmounts,
353
+ });
354
+ } else {
355
+ parseResult = csvContentOrParsed;
752
356
  }
753
357
 
754
- // Step 2: Convert YNAB transactions
755
- const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
358
+ const newBankTransactions = parseResult.transactions;
359
+ const csvParseErrors = parseResult.errors;
360
+ const csvParseWarnings = parseResult.warnings;
756
361
 
757
- // Step 3: Run matching algorithm
758
- const matches = findMatches(bankTransactions, convertedYNABTxns, config);
362
+ // Step 2: Normalize YNAB transactions
363
+ const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
759
364
 
760
- // Step 4: Categorize matches
761
- const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
365
+ // Step 3: Run new matching algorithm
366
+ // Use normalizeConfig to convert legacy config to V2 format with defaults
367
+ const normalizedConfig = normalizeConfig(config);
762
368
 
763
- // Step 5: Find unmatched YNAB transactions
764
- const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
369
+ const newMatches = findMatches(newBankTransactions, newYNABTransactions, normalizedConfig);
370
+ const matches: TransactionMatch[] = newMatches.map(mapToTransactionMatch);
765
371
 
766
- let combinationMatches: TransactionMatch[] = [];
767
- let combinationInsights: ReconciliationInsight[] = [];
372
+ // Categorize
373
+ const autoMatches = matches.filter((m) => m.confidence === 'high');
768
374
 
769
- if (ENABLE_COMBINATION_MATCHING) {
770
- const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
771
- combinationMatches = combinationResult.matches;
772
- combinationInsights = combinationResult.insights;
773
- }
375
+ // Build set of YNAB transaction IDs that are already auto-matched
376
+ const autoMatchedYnabIds = new Set<string>();
377
+ autoMatches.forEach((m) => {
378
+ if (m.ynabTransaction) autoMatchedYnabIds.add(m.ynabTransaction.id);
379
+ });
380
+
381
+ // Only suggest matches for YNAB transactions NOT already auto-matched
382
+ const suggestedMatches = matches.filter(
383
+ (m) =>
384
+ m.confidence === 'medium' &&
385
+ (!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)),
386
+ );
387
+
388
+ const unmatchedBankMatches = matches.filter(
389
+ (m) => m.confidence === 'low' || m.confidence === 'none',
390
+ );
391
+ const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
774
392
 
775
- const enrichedSuggestedMatches = [...suggestedMatches, ...combinationMatches];
393
+ // Find unmatched YNAB
394
+ const matchedYnabIds = new Set<string>();
395
+ matches.forEach((m) => {
396
+ if (m.ynabTransaction) matchedYnabIds.add(m.ynabTransaction.id);
397
+ });
398
+ const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
776
399
 
777
400
  // Step 6: Calculate balances
778
- const balances = calculateBalances(convertedYNABTxns, statementBalance, currency);
401
+ const balances = calculateBalances(newYNABTransactions, statementBalance, currency);
779
402
 
780
403
  // Step 7: Generate summary
781
404
  const summary = generateSummary(
782
- bankTransactions,
783
- convertedYNABTxns,
405
+ matches.map((m) => m.bankTransaction),
406
+ newYNABTransactions,
784
407
  autoMatches,
785
- enrichedSuggestedMatches,
408
+ suggestedMatches,
786
409
  unmatchedBank,
787
410
  unmatchedYNAB,
788
411
  balances,
@@ -791,9 +414,15 @@ export function analyzeReconciliation(
791
414
  // Step 8: Generate next steps
792
415
  const nextSteps = generateNextSteps(summary);
793
416
 
794
- // Step 9: Detect insights and patterns
795
- const baseInsights = detectInsights(matches, unmatchedBank, summary, balances, config);
796
- const insights = mergeInsights(baseInsights, combinationInsights);
417
+ // Step 9: Detect insights (including any CSV parsing issues)
418
+ const insights = detectInsights(
419
+ unmatchedBank,
420
+ summary,
421
+ balances,
422
+ currency,
423
+ csvParseErrors,
424
+ csvParseWarnings,
425
+ );
797
426
 
798
427
  // Step 10: Build the analysis result
799
428
  const analysis: ReconciliationAnalysis = {
@@ -801,7 +430,7 @@ export function analyzeReconciliation(
801
430
  phase: 'analysis',
802
431
  summary,
803
432
  auto_matches: autoMatches,
804
- suggested_matches: enrichedSuggestedMatches,
433
+ suggested_matches: suggestedMatches,
805
434
  unmatched_bank: unmatchedBank,
806
435
  unmatched_ynab: unmatchedYNAB,
807
436
  balance_info: balances,
@@ -809,13 +438,13 @@ export function analyzeReconciliation(
809
438
  insights,
810
439
  };
811
440
 
812
- // Step 11: Generate recommendations (if account and budget IDs are provided)
441
+ // Step 11: Generate recommendations
813
442
  if (accountId && budgetId) {
814
443
  const recommendations = generateRecommendations({
815
444
  account_id: accountId,
816
445
  budget_id: budgetId,
817
446
  analysis,
818
- matching_config: config,
447
+ matching_config: normalizedConfig,
819
448
  });
820
449
  analysis.recommendations = recommendations;
821
450
  }