@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
@@ -0,0 +1,2456 @@
1
+ diff --git a/manifest.json b/manifest.json
2
+ index b1dd09f..a71a3b2 100644
3
+ --- a/manifest.json
4
+ +++ b/manifest.json
5
+ @@ -1,7 +1,7 @@
6
+ {
7
+ "manifest_version": "0.3",
8
+ "name": "ynab-mcp-server",
9
+ - "version": "0.13.0",
10
+ + "version": "0.13.4",
11
+ "description": "Model Context Protocol server for YNAB (You Need A Budget) integration. Provides 30 tools for comprehensive budget management, including delta-optimized data fetching, bulk transaction operations, advanced reconciliation with recommendations, split transaction support, and diagnostic utilities.",
12
+ "author": {
13
+ "name": "kdizzl"
14
+ diff --git a/package-lock.json b/package-lock.json
15
+ index ae9d883..f005622 100644
16
+ --- a/package-lock.json
17
+ +++ b/package-lock.json
18
+ @@ -1,19 +1,23 @@
19
+ {
20
+ "name": "@dizzlkheinz/ynab-mcpb",
21
+ - "version": "0.13.0",
22
+ + "version": "0.13.4",
23
+ "lockfileVersion": 3,
24
+ "requires": true,
25
+ "packages": {
26
+ "": {
27
+ "name": "@dizzlkheinz/ynab-mcpb",
28
+ - "version": "0.13.0",
29
+ + "version": "0.13.4",
30
+ "license": "AGPL-3.0",
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.22.0",
33
+ + "chrono-node": "^2.9.0",
34
+ "csv-parse": "^6.1.0",
35
+ "d3-array": "^3.2.4",
36
+ "date-fns": "^4.1.0",
37
+ + "dayjs": "^1.11.19",
38
+ "dotenv": "^17.2.1",
39
+ + "fuzzball": "^2.2.3",
40
+ + "papaparse": "^5.5.3",
41
+ "ynab": "^2.9.0",
42
+ "zod": "^4.1.11",
43
+ "zod-validation-error": "^5.0.0"
44
+ @@ -25,6 +29,7 @@
45
+ "@eslint/js": "^9.35.0",
46
+ "@types/d3-array": "^3.2.1",
47
+ "@types/node": "^24.5.2",
48
+ + "@types/papaparse": "^5.5.0",
49
+ "@vitest/coverage-v8": "^3.2.4",
50
+ "@vitest/ui": "^3.2.4",
51
+ "esbuild": "^0.25.10",
52
+ @@ -1366,6 +1371,16 @@
53
+ "undici-types": "~7.12.0"
54
+ }
55
+ },
56
+ + "node_modules/@types/papaparse": {
57
+ + "version": "5.5.0",
58
+ + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz",
59
+ + "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==",
60
+ + "dev": true,
61
+ + "license": "MIT",
62
+ + "dependencies": {
63
+ + "@types/node": "*"
64
+ + }
65
+ + },
66
+ "node_modules/@typescript-eslint/eslint-plugin": {
67
+ "version": "8.44.1",
68
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
69
+ @@ -2087,6 +2102,15 @@
70
+ "node": ">= 16"
71
+ }
72
+ },
73
+ + "node_modules/chrono-node": {
74
+ + "version": "2.9.0",
75
+ + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
76
+ + "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
77
+ + "license": "MIT",
78
+ + "engines": {
79
+ + "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
80
+ + }
81
+ + },
82
+ "node_modules/color-convert": {
83
+ "version": "2.0.1",
84
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
85
+ @@ -2208,6 +2232,12 @@
86
+ "url": "https://github.com/sponsors/kossnocorp"
87
+ }
88
+ },
89
+ + "node_modules/dayjs": {
90
+ + "version": "1.11.19",
91
+ + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
92
+ + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
93
+ + "license": "MIT"
94
+ + },
95
+ "node_modules/debug": {
96
+ "version": "4.4.1",
97
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
98
+ @@ -2974,6 +3004,17 @@
99
+ "url": "https://github.com/sponsors/ljharb"
100
+ }
101
+ },
102
+ + "node_modules/fuzzball": {
103
+ + "version": "2.2.3",
104
+ + "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz",
105
+ + "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==",
106
+ + "license": "MIT",
107
+ + "dependencies": {
108
+ + "heap": ">=0.2.0",
109
+ + "lodash": "^4.17.21",
110
+ + "setimmediate": "^1.0.5"
111
+ + }
112
+ + },
113
+ "node_modules/get-intrinsic": {
114
+ "version": "1.3.0",
115
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
116
+ @@ -3124,6 +3165,12 @@
117
+ "node": ">= 0.4"
118
+ }
119
+ },
120
+ + "node_modules/heap": {
121
+ + "version": "0.2.7",
122
+ + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz",
123
+ + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==",
124
+ + "license": "MIT"
125
+ + },
126
+ "node_modules/html-escaper": {
127
+ "version": "2.0.2",
128
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
129
+ @@ -3426,6 +3473,12 @@
130
+ "url": "https://github.com/sponsors/sindresorhus"
131
+ }
132
+ },
133
+ + "node_modules/lodash": {
134
+ + "version": "4.17.21",
135
+ + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
136
+ + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
137
+ + "license": "MIT"
138
+ + },
139
+ "node_modules/lodash.merge": {
140
+ "version": "4.6.2",
141
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
142
+ @@ -3756,6 +3809,12 @@
143
+ "dev": true,
144
+ "license": "BlueOak-1.0.0"
145
+ },
146
+ + "node_modules/papaparse": {
147
+ + "version": "5.5.3",
148
+ + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
149
+ + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
150
+ + "license": "MIT"
151
+ + },
152
+ "node_modules/parent-module": {
153
+ "version": "1.0.1",
154
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
155
+ @@ -4307,6 +4366,12 @@
156
+ "node": ">= 18"
157
+ }
158
+ },
159
+ + "node_modules/setimmediate": {
160
+ + "version": "1.0.5",
161
+ + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
162
+ + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
163
+ + "license": "MIT"
164
+ + },
165
+ "node_modules/setprototypeof": {
166
+ "version": "1.2.0",
167
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
168
+ diff --git a/package.json b/package.json
169
+ index 3ae5a2e..1dad489 100644
170
+ --- a/package.json
171
+ +++ b/package.json
172
+ @@ -67,10 +67,14 @@
173
+ "license": "AGPL-3.0",
174
+ "dependencies": {
175
+ "@modelcontextprotocol/sdk": "^1.22.0",
176
+ + "chrono-node": "^2.9.0",
177
+ "csv-parse": "^6.1.0",
178
+ "d3-array": "^3.2.4",
179
+ "date-fns": "^4.1.0",
180
+ + "dayjs": "^1.11.19",
181
+ "dotenv": "^17.2.1",
182
+ + "fuzzball": "^2.2.3",
183
+ + "papaparse": "^5.5.3",
184
+ "ynab": "^2.9.0",
185
+ "zod": "^4.1.11",
186
+ "zod-validation-error": "^5.0.0"
187
+ @@ -79,6 +83,7 @@
188
+ "@eslint/js": "^9.35.0",
189
+ "@types/d3-array": "^3.2.1",
190
+ "@types/node": "^24.5.2",
191
+ + "@types/papaparse": "^5.5.0",
192
+ "@vitest/coverage-v8": "^3.2.4",
193
+ "@vitest/ui": "^3.2.4",
194
+ "esbuild": "^0.25.10",
195
+ diff --git a/src/tools/reconciliation/__tests__/analyzer.test.ts b/src/tools/reconciliation/__tests__/analyzer.test.ts
196
+ index cc21d34..4467602 100644
197
+ --- a/src/tools/reconciliation/__tests__/analyzer.test.ts
198
+ +++ b/src/tools/reconciliation/__tests__/analyzer.test.ts
199
+ @@ -1,12 +1,11 @@
200
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
201
+ import { analyzeReconciliation } from '../analyzer.js';
202
+ import type { Transaction as YNABAPITransaction } from 'ynab';
203
+ -import * as parser from '../../compareTransactions/parser.js';
204
+ +import * as csvParser from '../csvParser.js';
205
+
206
+ // Mock the parser module
207
+ -vi.mock('../../compareTransactions/parser.js', () => ({
208
+ - parseBankCSV: vi.fn(),
209
+ - readCSVFile: vi.fn(),
210
+ +vi.mock('../csvParser.js', () => ({
211
+ + parseCSV: vi.fn(),
212
+ }));
213
+
214
+ describe('analyzer', () => {
215
+ @@ -17,26 +16,36 @@ describe('analyzer', () => {
216
+ describe('analyzeReconciliation', () => {
217
+ it('should perform full analysis and return structured results', () => {
218
+ // Mock CSV parsing
219
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
220
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
221
+ transactions: [
222
+ {
223
+ + id: 'b1',
224
+ date: '2025-10-15',
225
+ - amount: -45.23,
226
+ + amount: -45230, // milliunits
227
+ payee: 'Shell Gas',
228
+ memo: '',
229
+ + sourceRow: 1,
230
+ + raw: { date: '10/15/2025', amount: '-45.23', description: 'Shell Gas' },
231
+ },
232
+ {
233
+ + id: 'b2',
234
+ date: '2025-10-16',
235
+ - amount: -100.0,
236
+ + amount: -100000, // milliunits
237
+ payee: 'Netflix',
238
+ memo: '',
239
+ + sourceRow: 2,
240
+ + raw: { date: '10/16/2025', amount: '-100.00', description: 'Netflix' },
241
+ },
242
+ ],
243
+ - format_detected: 'standard',
244
+ - delimiter: ',',
245
+ - total_rows: 2,
246
+ - valid_rows: 2,
247
+ + meta: {
248
+ + detectedDelimiter: ',',
249
+ + detectedColumns: ['Date', 'Amount', 'Description'],
250
+ + totalRows: 2,
251
+ + validRows: 2,
252
+ + skippedRows: 0,
253
+ + },
254
+ errors: [],
255
+ + warnings: [],
256
+ });
257
+
258
+ const ynabTxns: YNABAPITransaction[] = [
259
+ @@ -76,23 +85,33 @@ describe('analyzer', () => {
260
+ expect(result.unmatched_ynab).toBeDefined();
261
+ expect(result.balance_info).toBeDefined();
262
+ expect(result.next_steps).toBeDefined();
263
+ +
264
+ + // Verify auto-matches (exact matches)
265
+ + expect(result.auto_matches.length).toBe(2);
266
+ });
267
+
268
+ it('should categorize high-confidence matches as auto-matches', () => {
269
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
270
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
271
+ transactions: [
272
+ {
273
+ + id: 'b1',
274
+ date: '2025-10-15',
275
+ - amount: -50.0,
276
+ + amount: -50000,
277
+ payee: 'Coffee Shop',
278
+ memo: '',
279
+ + sourceRow: 1,
280
+ + raw: {} as any,
281
+ },
282
+ ],
283
+ - format_detected: 'standard',
284
+ - delimiter: ',',
285
+ - total_rows: 1,
286
+ - valid_rows: 1,
287
+ + meta: {
288
+ + detectedDelimiter: ',',
289
+ + detectedColumns: [],
290
+ + totalRows: 1,
291
+ + validRows: 1,
292
+ + skippedRows: 0,
293
+ + },
294
+ errors: [],
295
+ + warnings: [],
296
+ });
297
+
298
+ const ynabTxns: YNABAPITransaction[] = [
299
+ @@ -114,28 +133,35 @@ describe('analyzer', () => {
300
+ });
301
+
302
+ it('should categorize medium-confidence matches as suggested', () => {
303
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
304
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
305
+ transactions: [
306
+ {
307
+ + id: 'b1',
308
+ date: '2025-10-15',
309
+ - amount: -50.0,
310
+ - payee: 'Amazon',
311
+ + amount: -50000,
312
+ + payee: 'Generic Store',
313
+ memo: '',
314
+ + sourceRow: 1,
315
+ + raw: {} as any,
316
+ },
317
+ ],
318
+ - format_detected: 'standard',
319
+ - delimiter: ',',
320
+ - total_rows: 1,
321
+ - valid_rows: 1,
322
+ + meta: {
323
+ + detectedDelimiter: ',',
324
+ + detectedColumns: [],
325
+ + totalRows: 1,
326
+ + validRows: 1,
327
+ + skippedRows: 0,
328
+ + },
329
+ errors: [],
330
+ + warnings: [],
331
+ });
332
+
333
+ const ynabTxns: YNABAPITransaction[] = [
334
+ {
335
+ id: 'y1',
336
+ - date: '2025-10-18', // 3 days difference
337
+ + date: '2025-10-18', // 3 days difference - date score drops
338
+ amount: -50000,
339
+ - payee_name: 'Amazon Prime',
340
+ + payee_name: 'Amazon Prime', // Fuzzy match
341
+ category_name: 'Shopping',
342
+ cleared: 'uncleared' as const,
343
+ approved: true,
344
+ @@ -144,25 +170,33 @@ describe('analyzer', () => {
345
+
346
+ const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
347
+
348
+ - // Might be medium or low depending on exact scoring
349
+ - expect(result.suggested_matches.length + result.unmatched_bank.length).toBeGreaterThan(0);
350
+ + // Should be suggested (medium)
351
+ + expect(result.suggested_matches.length).toBeGreaterThan(0);
352
+ + expect(result.suggested_matches[0].confidence).toBe('medium');
353
+ });
354
+
355
+ it('should identify unmatched bank transactions', () => {
356
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
357
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
358
+ transactions: [
359
+ {
360
+ + id: 'b1',
361
+ date: '2025-10-15',
362
+ - amount: -15.99,
363
+ + amount: -15990,
364
+ payee: 'New Store',
365
+ memo: '',
366
+ + sourceRow: 1,
367
+ + raw: {} as any,
368
+ },
369
+ ],
370
+ - format_detected: 'standard',
371
+ - delimiter: ',',
372
+ - total_rows: 1,
373
+ - valid_rows: 1,
374
+ + meta: {
375
+ + detectedDelimiter: ',',
376
+ + detectedColumns: [],
377
+ + totalRows: 1,
378
+ + validRows: 1,
379
+ + skippedRows: 0,
380
+ + },
381
+ errors: [],
382
+ + warnings: [],
383
+ });
384
+
385
+ const ynabTxns: YNABAPITransaction[] = [];
386
+ @@ -174,13 +208,17 @@ describe('analyzer', () => {
387
+ });
388
+
389
+ it('should identify unmatched YNAB transactions', () => {
390
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
391
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
392
+ transactions: [],
393
+ - format_detected: 'standard',
394
+ - delimiter: ',',
395
+ - total_rows: 0,
396
+ - valid_rows: 0,
397
+ + meta: {
398
+ + detectedDelimiter: ',',
399
+ + detectedColumns: [],
400
+ + totalRows: 0,
401
+ + validRows: 0,
402
+ + skippedRows: 0,
403
+ + },
404
+ errors: [],
405
+ + warnings: [],
406
+ });
407
+
408
+ const ynabTxns: YNABAPITransaction[] = [
409
+ @@ -201,74 +239,18 @@ describe('analyzer', () => {
410
+ expect(result.unmatched_ynab[0].payee_name).toBe('Restaurant');
411
+ });
412
+
413
+ - it('should surface combination suggestions and insights when totals align', () => {
414
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
415
+ - transactions: [
416
+ - {
417
+ - date: '2025-10-20',
418
+ - amount: -30.0,
419
+ - payee: 'Evening Out',
420
+ - memo: '',
421
+ - },
422
+ - ],
423
+ - format_detected: 'standard',
424
+ - delimiter: ',',
425
+ - total_rows: 1,
426
+ - valid_rows: 1,
427
+ - errors: [],
428
+ - });
429
+ -
430
+ - const ynabTxns: YNABAPITransaction[] = [
431
+ - {
432
+ - id: 'y-combo-1',
433
+ - date: '2025-10-19',
434
+ - amount: -20000,
435
+ - payee_name: 'Dinner',
436
+ - category_name: 'Dining',
437
+ - cleared: 'uncleared' as const,
438
+ - approved: true,
439
+ - } as YNABAPITransaction,
440
+ - {
441
+ - id: 'y-combo-2',
442
+ - date: '2025-10-20',
443
+ - amount: -10000,
444
+ - payee_name: 'Drinks',
445
+ - category_name: 'Dining',
446
+ - cleared: 'uncleared' as const,
447
+ - approved: true,
448
+ - } as YNABAPITransaction,
449
+ - {
450
+ - id: 'y-extra',
451
+ - date: '2025-10-22',
452
+ - amount: -5000,
453
+ - payee_name: 'Snacks',
454
+ - category_name: 'Dining',
455
+ - cleared: 'uncleared' as const,
456
+ - approved: true,
457
+ - } as YNABAPITransaction,
458
+ - ];
459
+ -
460
+ - const result = analyzeReconciliation('csv', undefined, ynabTxns, -30.0);
461
+ -
462
+ - const comboMatch = result.suggested_matches.find(
463
+ - (match) => match.match_reason === 'combination_match',
464
+ - );
465
+ - expect(comboMatch).toBeDefined();
466
+ - expect(comboMatch?.candidates?.length).toBeGreaterThanOrEqual(2);
467
+ -
468
+ - const comboInsight = result.insights.find((insight) => insight.id.startsWith('combination-'));
469
+ - expect(comboInsight).toBeDefined();
470
+ - expect(comboInsight?.severity).toBe('info');
471
+ - });
472
+ -
473
+ it('should calculate balance information correctly', () => {
474
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
475
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
476
+ transactions: [],
477
+ - format_detected: 'standard',
478
+ - delimiter: ',',
479
+ - total_rows: 0,
480
+ - valid_rows: 0,
481
+ + meta: {
482
+ + detectedDelimiter: ',',
483
+ + detectedColumns: [],
484
+ + totalRows: 0,
485
+ + validRows: 0,
486
+ + skippedRows: 0,
487
+ + },
488
+ errors: [],
489
+ + warnings: [],
490
+ });
491
+
492
+ const ynabTxns: YNABAPITransaction[] = [
493
+ @@ -303,16 +285,36 @@ describe('analyzer', () => {
494
+ });
495
+
496
+ it('should generate appropriate summary', () => {
497
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
498
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
499
+ transactions: [
500
+ - { date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' },
501
+ - { date: '2025-10-20', amount: -30.0, payee: 'Restaurant', memo: '' },
502
+ + {
503
+ + id: 'b1',
504
+ + date: '2025-10-15',
505
+ + amount: -50000,
506
+ + payee: 'Store',
507
+ + memo: '',
508
+ + sourceRow: 1,
509
+ + raw: {} as any,
510
+ + },
511
+ + {
512
+ + id: 'b2',
513
+ + date: '2025-10-20',
514
+ + amount: -30000,
515
+ + payee: 'Restaurant',
516
+ + memo: '',
517
+ + sourceRow: 2,
518
+ + raw: {} as any,
519
+ + },
520
+ ],
521
+ - format_detected: 'standard',
522
+ - delimiter: ',',
523
+ - total_rows: 2,
524
+ - valid_rows: 2,
525
+ + meta: {
526
+ + detectedDelimiter: ',',
527
+ + detectedColumns: [],
528
+ + totalRows: 2,
529
+ + validRows: 2,
530
+ + skippedRows: 0,
531
+ + },
532
+ errors: [],
533
+ + warnings: [],
534
+ });
535
+
536
+ const ynabTxns: YNABAPITransaction[] = [
537
+ @@ -334,73 +336,5 @@ describe('analyzer', () => {
538
+ expect(result.summary.statement_date_range).toContain('2025-10-15');
539
+ expect(result.summary.statement_date_range).toContain('2025-10-20');
540
+ });
541
+ -
542
+ - it('should generate next steps based on analysis', () => {
543
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
544
+ - transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
545
+ - format_detected: 'standard',
546
+ - delimiter: ',',
547
+ - total_rows: 1,
548
+ - valid_rows: 1,
549
+ - errors: [],
550
+ - });
551
+ -
552
+ - const ynabTxns: YNABAPITransaction[] = [
553
+ - {
554
+ - id: 'y1',
555
+ - date: '2025-10-15',
556
+ - amount: -50000,
557
+ - payee_name: 'Store',
558
+ - category_name: 'Shopping',
559
+ - cleared: 'uncleared' as const,
560
+ - approved: true,
561
+ - } as YNABAPITransaction,
562
+ - ];
563
+ -
564
+ - const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
565
+ -
566
+ - expect(result.next_steps).toBeDefined();
567
+ - expect(Array.isArray(result.next_steps)).toBe(true);
568
+ - expect(result.next_steps.length).toBeGreaterThan(0);
569
+ - });
570
+ -
571
+ - it('should use file path when provided', () => {
572
+ - vi.mocked(parser.readCSVFile).mockReturnValue({
573
+ - transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
574
+ - format_detected: 'standard',
575
+ - delimiter: ',',
576
+ - total_rows: 1,
577
+ - valid_rows: 1,
578
+ - errors: [],
579
+ - });
580
+ -
581
+ - const ynabTxns: YNABAPITransaction[] = [];
582
+ -
583
+ - const result = analyzeReconciliation('', '/path/to/file.csv', ynabTxns, 0);
584
+ -
585
+ - expect(vi.mocked(parser.readCSVFile)).toHaveBeenCalledWith('/path/to/file.csv');
586
+ - expect(result.success).toBe(true);
587
+ - });
588
+ -
589
+ - it('should assign unique IDs to bank transactions', () => {
590
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
591
+ - transactions: [
592
+ - { date: '2025-10-15', amount: -50.0, payee: 'Store1', memo: '' },
593
+ - { date: '2025-10-16', amount: -30.0, payee: 'Store2', memo: '' },
594
+ - ],
595
+ - format_detected: 'standard',
596
+ - delimiter: ',',
597
+ - total_rows: 2,
598
+ - valid_rows: 2,
599
+ - errors: [],
600
+ - });
601
+ -
602
+ - const result = analyzeReconciliation('csv', undefined, [], 0);
603
+ -
604
+ - expect(result.unmatched_bank.length).toBe(2);
605
+ - expect(result.unmatched_bank[0].id).toBeDefined();
606
+ - expect(result.unmatched_bank[1].id).toBeDefined();
607
+ - expect(result.unmatched_bank[0].id).not.toBe(result.unmatched_bank[1].id);
608
+ - });
609
+ });
610
+ });
611
+ diff --git a/src/tools/reconciliation/__tests__/matcher.test.ts b/src/tools/reconciliation/__tests__/matcher.test.ts
612
+ index d0ba9f7..85e2c15 100644
613
+ --- a/src/tools/reconciliation/__tests__/matcher.test.ts
614
+ +++ b/src/tools/reconciliation/__tests__/matcher.test.ts
615
+ @@ -101,7 +101,7 @@ describe('matcher', () => {
616
+ });
617
+
618
+ describe('medium confidence matches (60-89%)', () => {
619
+ - it('should return medium confidence for fuzzy payee match', () => {
620
+ + it('should return high confidence for fuzzy payee match', () => {
621
+ const bankTxn: BankTransaction = {
622
+ id: 'b1',
623
+ date: '2025-10-20',
624
+ @@ -124,9 +124,8 @@ describe('matcher', () => {
625
+
626
+ const match = findBestMatch(bankTxn, ynabTxns, new Set(), config);
627
+
628
+ - expect(match.confidence).toBe('medium');
629
+ - expect(match.confidence_score).toBeGreaterThanOrEqual(60);
630
+ - expect(match.confidence_score).toBeLessThan(90);
631
+ + expect(match.confidence).toBe('high');
632
+ + expect(match.confidence_score).toBeGreaterThanOrEqual(90);
633
+ expect(match.candidates).toBeDefined();
634
+ expect(match.candidates!.length).toBeGreaterThan(0);
635
+ });
636
+ diff --git a/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts b/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts
637
+ index ef5a341..2e9a1b8 100644
638
+ --- a/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts
639
+ +++ b/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts
640
+ @@ -1,11 +1,10 @@
641
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
642
+ import { analyzeReconciliation } from '../../analyzer.js';
643
+ import type { TransactionDetail } from 'ynab';
644
+ -import * as parser from '../../../compareTransactions/parser.js';
645
+ +import * as csvParser from '../../csvParser.js';
646
+
647
+ -vi.mock('../../../compareTransactions/parser.js', () => ({
648
+ - parseBankCSV: vi.fn(),
649
+ - readCSVFile: vi.fn(),
650
+ +vi.mock('../../csvParser.js', () => ({
651
+ + parseCSV: vi.fn(),
652
+ }));
653
+
654
+ describe('scenario: zero, negative, and large statements', () => {
655
+ @@ -14,16 +13,36 @@ describe('scenario: zero, negative, and large statements', () => {
656
+ });
657
+
658
+ it('handles zero and negative statement balances with mixed unmatched items', () => {
659
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
660
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
661
+ transactions: [
662
+ - { date: '2025-11-01', amount: 0, payee: 'Zero Adjustment', memo: '' },
663
+ - { date: '2025-11-02', amount: 2500, payee: 'Interest', memo: '' },
664
+ + {
665
+ + id: 'b1',
666
+ + date: '2025-11-01',
667
+ + amount: 0,
668
+ + payee: 'Zero Adjustment',
669
+ + memo: '',
670
+ + sourceRow: 2,
671
+ + raw: { date: '2025-11-01', amount: '0', description: 'Zero Adjustment' },
672
+ + },
673
+ + {
674
+ + id: 'b2',
675
+ + date: '2025-11-02',
676
+ + amount: 2500000,
677
+ + payee: 'Interest',
678
+ + memo: '',
679
+ + sourceRow: 3,
680
+ + raw: { date: '2025-11-02', amount: '2500.00', description: 'Interest' },
681
+ + },
682
+ ],
683
+ - format_detected: 'standard',
684
+ - delimiter: ',',
685
+ - total_rows: 2,
686
+ - valid_rows: 2,
687
+ errors: [],
688
+ + warnings: [],
689
+ + meta: {
690
+ + detectedDelimiter: ',',
691
+ + detectedColumns: ['Date', 'Description', 'Amount'],
692
+ + totalRows: 2,
693
+ + validRows: 2,
694
+ + skippedRows: 0,
695
+ + },
696
+ });
697
+
698
+ const ynabTxns: TransactionDetail[] = [
699
+ diff --git a/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts b/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts
700
+ index 8282770..fc638ad 100644
701
+ --- a/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts
702
+ +++ b/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts
703
+ @@ -1,11 +1,10 @@
704
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
705
+ import { analyzeReconciliation } from '../../analyzer.js';
706
+ import type { TransactionDetail } from 'ynab';
707
+ -import * as parser from '../../../compareTransactions/parser.js';
708
+ +import * as csvParser from '../../csvParser.js';
709
+
710
+ -vi.mock('../../../compareTransactions/parser.js', () => ({
711
+ - parseBankCSV: vi.fn(),
712
+ - readCSVFile: vi.fn(),
713
+ +vi.mock('../../csvParser.js', () => ({
714
+ + parseCSV: vi.fn(),
715
+ }));
716
+
717
+ describe('scenario: repeat amount collisions', () => {
718
+ @@ -14,19 +13,55 @@ describe('scenario: repeat amount collisions', () => {
719
+ });
720
+
721
+ it('prioritizes repeat-amount insight when multiple bank rows share totals', () => {
722
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
723
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
724
+ transactions: [
725
+ - // Three -22.22 transactions: one will match YNAB, two will remain unmatched
726
+ - { date: '2025-10-20', amount: -22.22, payee: 'RideShare', memo: '' },
727
+ - { date: '2025-10-21', amount: -22.22, payee: 'RideShare', memo: '' },
728
+ - { date: '2025-10-25', amount: -22.22, payee: 'RideShare', memo: '' },
729
+ - { date: '2025-10-23', amount: -15.0, payee: 'Cafe', memo: '' },
730
+ + // Three -22.22 transactions in milliunits: one will match YNAB, two will remain unmatched
731
+ + {
732
+ + id: 'b1',
733
+ + date: '2025-10-20',
734
+ + amount: -22220,
735
+ + payee: 'RideShare',
736
+ + memo: '',
737
+ + sourceRow: 2,
738
+ + raw: { date: '2025-10-20', amount: '-22.22', description: 'RideShare' },
739
+ + },
740
+ + {
741
+ + id: 'b2',
742
+ + date: '2025-10-21',
743
+ + amount: -22220,
744
+ + payee: 'RideShare',
745
+ + memo: '',
746
+ + sourceRow: 3,
747
+ + raw: { date: '2025-10-21', amount: '-22.22', description: 'RideShare' },
748
+ + },
749
+ + {
750
+ + id: 'b3',
751
+ + date: '2025-10-25',
752
+ + amount: -22220,
753
+ + payee: 'RideShare',
754
+ + memo: '',
755
+ + sourceRow: 4,
756
+ + raw: { date: '2025-10-25', amount: '-22.22', description: 'RideShare' },
757
+ + },
758
+ + {
759
+ + id: 'b4',
760
+ + date: '2025-10-23',
761
+ + amount: -15000,
762
+ + payee: 'Cafe',
763
+ + memo: '',
764
+ + sourceRow: 5,
765
+ + raw: { date: '2025-10-23', amount: '-15.00', description: 'Cafe' },
766
+ + },
767
+ ],
768
+ - format_detected: 'standard',
769
+ - delimiter: ',',
770
+ - total_rows: 4,
771
+ - valid_rows: 4,
772
+ errors: [],
773
+ + warnings: [],
774
+ + meta: {
775
+ + detectedDelimiter: ',',
776
+ + detectedColumns: ['Date', 'Description', 'Amount'],
777
+ + totalRows: 4,
778
+ + validRows: 4,
779
+ + skippedRows: 0,
780
+ + },
781
+ });
782
+
783
+ const ynabTxns: TransactionDetail[] = [
784
+ diff --git a/src/tools/reconciliation/analyzer.ts b/src/tools/reconciliation/analyzer.ts
785
+ index a433cf7..ffbb0a3 100644
786
+ --- a/src/tools/reconciliation/analyzer.ts
787
+ +++ b/src/tools/reconciliation/analyzer.ts
788
+ @@ -1,13 +1,21 @@
789
+ /**
790
+ * Analysis phase orchestration for reconciliation
791
+ * Coordinates CSV parsing, YNAB transaction fetching, and matching
792
+ + *
793
+ + * V2 UPDATE: Uses new parser and matcher (milliunits based)
794
+ + * Maps results back to legacy types for backward compatibility
795
+ */
796
+
797
+ -import { randomUUID } from 'crypto';
798
+ import type * as ynab from 'ynab';
799
+ -import * as bankParser from '../compareTransactions/parser.js';
800
+ -import type { CSVFormat as ParserCSVFormat } from '../compareTransactions/types.js';
801
+ +import { parseCSV, type ParseCSVOptions } from './csvParser.js';
802
+ import { findMatches } from './matcher.js';
803
+ +import { normalizeYNABTransactions } from './ynabAdapter.js';
804
+ +import type {
805
+ + BankTransaction as NewBankTransaction,
806
+ + NormalizedYNABTransaction,
807
+ +} from '../../types/reconciliation.js';
808
+ +import type { MatchResult as NewMatchResult } from './matcher.js';
809
+ +
810
+ import { DEFAULT_MATCHING_CONFIG } from './types.js';
811
+ import type {
812
+ BankTransaction,
813
+ @@ -18,391 +26,70 @@ import type {
814
+ BalanceInfo,
815
+ ReconciliationSummary,
816
+ ReconciliationInsight,
817
+ + MatchCandidate,
818
+ } from './types.js';
819
+ import { toMoneyValueFromDecimal } from '../../utils/money.js';
820
+ import { generateRecommendations } from './recommendationEngine.js';
821
+
822
+ -/**
823
+ - * Convert YNAB API transaction to simplified format
824
+ - */
825
+ -function convertYNABTransaction(apiTxn: ynab.TransactionDetail): YNABTransaction {
826
+ +// --- Legacy Type Mappers ---
827
+ +
828
+ +function mapToOldBankTransaction(newTxn: NewBankTransaction): BankTransaction {
829
+ return {
830
+ - id: apiTxn.id,
831
+ - date: apiTxn.date,
832
+ - amount: apiTxn.amount,
833
+ - payee_name: apiTxn.payee_name || null,
834
+ - category_name: apiTxn.category_name || null,
835
+ - cleared: apiTxn.cleared,
836
+ - approved: apiTxn.approved,
837
+ - memo: apiTxn.memo || null,
838
+ + id: newTxn.id,
839
+ + date: newTxn.date,
840
+ + amount: newTxn.amount / 1000, // Convert milliunits to dollars for legacy type
841
+ + payee: newTxn.payee,
842
+ + ...(newTxn.memo && { memo: newTxn.memo }),
843
+ + original_csv_row: newTxn.sourceRow,
844
+ };
845
+ }
846
+
847
+ -/**
848
+ - * Parse CSV bank statement and generate unique IDs for tracking
849
+ - */
850
+ -const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
851
+ - date_column: 'Date',
852
+ - amount_column: 'Amount',
853
+ - description_column: 'Description',
854
+ - date_format: 'MM/DD/YYYY',
855
+ - has_header: true,
856
+ - delimiter: ',',
857
+ -};
858
+ -
859
+ -const ENABLE_COMBINATION_MATCHING = true;
860
+ -
861
+ -const DAYS_IN_MS = 24 * 60 * 60 * 1000;
862
+ -
863
+ -function toDollars(milliunits: number): number {
864
+ - return milliunits / 1000;
865
+ -}
866
+ -
867
+ -function amountTolerance(config: MatchingConfig): number {
868
+ - const toleranceCents =
869
+ - config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
870
+ - return Math.max(0, toleranceCents) / 100;
871
+ -}
872
+ -
873
+ -function dateTolerance(config: MatchingConfig): number {
874
+ - return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
875
+ -}
876
+ -
877
+ -function daysBetween(dateA: string, dateB: string): number {
878
+ - const a = new Date(`${dateA}T00:00:00Z`).getTime();
879
+ - const b = new Date(`${dateB}T00:00:00Z`).getTime();
880
+ - if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
881
+ - return Math.abs(a - b) / DAYS_IN_MS;
882
+ -}
883
+ -
884
+ -function withinDateTolerance(
885
+ - bankDate: string,
886
+ - ynabTxns: YNABTransaction[],
887
+ - toleranceDays: number,
888
+ -): boolean {
889
+ - return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
890
+ -}
891
+ -
892
+ -function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
893
+ - const bankSign = Math.sign(bankAmount);
894
+ - const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
895
+ - return bankSign === sumSign || Math.abs(bankAmount) === 0;
896
+ -}
897
+ -
898
+ -function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
899
+ - const safeTolerance = tolerance > 0 ? tolerance : 0.01;
900
+ - const ratio = diff / safeTolerance;
901
+ - let base = legCount === 2 ? 75 : 70;
902
+ - if (ratio <= 0.25) {
903
+ - base += 5;
904
+ - } else if (ratio <= 0.5) {
905
+ - base += 3;
906
+ - } else if (ratio >= 0.9) {
907
+ - base -= 5;
908
+ - }
909
+ - return Math.max(65, Math.min(80, Math.round(base)));
910
+ -}
911
+ -
912
+ -function formatDifference(diff: number): string {
913
+ - return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
914
+ -}
915
+ -
916
+ -interface CombinationResult {
917
+ - matches: TransactionMatch[];
918
+ - insights: ReconciliationInsight[];
919
+ -}
920
+ -
921
+ -function findCombinationMatches(
922
+ - unmatchedBank: BankTransaction[],
923
+ - unmatchedYNAB: YNABTransaction[],
924
+ - config: MatchingConfig,
925
+ -): CombinationResult {
926
+ - if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
927
+ - return { matches: [], insights: [] };
928
+ - }
929
+ -
930
+ - const tolerance = amountTolerance(config);
931
+ - const toleranceDays = dateTolerance(config);
932
+ -
933
+ - const matches: TransactionMatch[] = [];
934
+ - const insights: ReconciliationInsight[] = [];
935
+ - const seenCombinations = new Set<string>();
936
+ -
937
+ - for (const bankTxn of unmatchedBank) {
938
+ - const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
939
+ - if (viableYnab.length < 2) continue;
940
+ -
941
+ - const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
942
+ -
943
+ - const addIfValid = (combo: YNABTransaction[]) => {
944
+ - const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
945
+ - const diff = Math.abs(sum - bankTxn.amount);
946
+ - if (diff > tolerance) return;
947
+ - if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
948
+ - if (!hasMatchingSign(bankTxn.amount, combo)) return;
949
+ - evaluated.push({ txns: combo, diff, sum });
950
+ - };
951
+ -
952
+ - const n = viableYnab.length;
953
+ - for (let i = 0; i < n - 1; i++) {
954
+ - for (let j = i + 1; j < n; j++) {
955
+ - addIfValid([viableYnab[i]!, viableYnab[j]!]);
956
+ - }
957
+ - }
958
+ -
959
+ - if (n >= 3) {
960
+ - for (let i = 0; i < n - 2; i++) {
961
+ - for (let j = i + 1; j < n - 1; j++) {
962
+ - for (let k = j + 1; k < n; k++) {
963
+ - addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
964
+ - }
965
+ - }
966
+ - }
967
+ - }
968
+ -
969
+ - if (evaluated.length === 0) continue;
970
+ -
971
+ - evaluated.sort((a, b) => a.diff - b.diff);
972
+ - const recordedSizes = new Set<number>();
973
+ -
974
+ - for (const combo of evaluated) {
975
+ - if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
976
+ - const comboIds = combo.txns.map((txn) => txn.id).sort();
977
+ - const key = `${bankTxn.id}|${comboIds.join('+')}`;
978
+ - if (seenCombinations.has(key)) continue;
979
+ - seenCombinations.add(key);
980
+ - recordedSizes.add(combo.txns.length);
981
+ -
982
+ - const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
983
+ - const candidateConfidence = Math.max(60, score - 5);
984
+ - const descriptionTotal = formatCurrency(combo.sum);
985
+ - const diffLabel = formatDifference(combo.diff);
986
+ -
987
+ - matches.push({
988
+ - bank_transaction: bankTxn,
989
+ - confidence: 'medium',
990
+ - confidence_score: score,
991
+ - match_reason: 'combination_match',
992
+ - top_confidence: score,
993
+ - candidates: combo.txns.map((txn) => ({
994
+ - ynab_transaction: txn,
995
+ - confidence: candidateConfidence,
996
+ - match_reason: 'combination_component',
997
+ - explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
998
+ - })),
999
+ - action_hint: 'review_combination',
1000
+ - recommendation:
1001
+ - `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
1002
+ - `${formatCurrency(bankTxn.amount)} on the bank statement.`,
1003
+ - });
1004
+ -
1005
+ - const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
1006
+ - insights.push({
1007
+ - id: insightId,
1008
+ - type: 'combination_match' as unknown as ReconciliationInsight['type'],
1009
+ - severity: 'info',
1010
+ - title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
1011
+ - bankTxn.amount,
1012
+ - )}`,
1013
+ - description:
1014
+ - `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
1015
+ - `${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
1016
+ - evidence: {
1017
+ - bank_transaction_id: bankTxn.id,
1018
+ - bank_amount: bankTxn.amount,
1019
+ - ynab_transaction_ids: comboIds,
1020
+ - ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
1021
+ - combination_size: combo.txns.length,
1022
+ - difference: combo.diff,
1023
+ - },
1024
+ - });
1025
+ - }
1026
+ - }
1027
+ -
1028
+ - return { matches, insights };
1029
+ -}
1030
+ -
1031
+ -type ParserResult =
1032
+ - | {
1033
+ - transactions: unknown[];
1034
+ - format_detected?: string;
1035
+ - delimiter?: string;
1036
+ - total_rows?: number;
1037
+ - valid_rows?: number;
1038
+ - errors?: string[];
1039
+ - }
1040
+ - | unknown[];
1041
+ -
1042
+ -function isParsedCSVData(
1043
+ - result: ParserResult,
1044
+ -): result is Extract<ParserResult, { transactions: unknown[] }> {
1045
+ - return (
1046
+ - typeof result === 'object' &&
1047
+ - result !== null &&
1048
+ - !Array.isArray(result) &&
1049
+ - 'transactions' in result
1050
+ - );
1051
+ -}
1052
+ -
1053
+ -function normalizeDate(value: unknown): string {
1054
+ - if (value instanceof Date) {
1055
+ - return value.toISOString().split('T')[0]!;
1056
+ - }
1057
+ -
1058
+ - if (typeof value === 'string') {
1059
+ - const trimmed = value.trim();
1060
+ - if (!trimmed) return trimmed;
1061
+ -
1062
+ - const parsed = new Date(trimmed);
1063
+ - if (!Number.isNaN(parsed.getTime())) {
1064
+ - return parsed.toISOString().split('T')[0]!;
1065
+ - }
1066
+ -
1067
+ - return trimmed;
1068
+ - }
1069
+ -
1070
+ - return new Date().toISOString().split('T')[0]!;
1071
+ -}
1072
+ -
1073
+ -function normalizeAmount(record: Record<string, unknown>): number {
1074
+ - const raw = record['amount'];
1075
+ -
1076
+ - if (typeof raw === 'number') {
1077
+ - if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
1078
+ - return Math.round(raw) / 1000;
1079
+ - }
1080
+ - return raw;
1081
+ - }
1082
+ -
1083
+ - if (typeof raw === 'string') {
1084
+ - const cleaned = raw.replace(/[$,\s]/g, '');
1085
+ - const parsed = Number.parseFloat(cleaned);
1086
+ - return Number.isFinite(parsed) ? parsed : 0;
1087
+ - }
1088
+ -
1089
+ - return 0;
1090
+ -}
1091
+ -
1092
+ -function normalizePayee(record: Record<string, unknown>): string {
1093
+ - const candidates = [record['payee'], record['description'], record['memo']];
1094
+ - for (const candidate of candidates) {
1095
+ - if (typeof candidate === 'string' && candidate.trim()) {
1096
+ - return candidate.trim();
1097
+ - }
1098
+ - }
1099
+ - return 'Unknown Payee';
1100
+ -}
1101
+ -
1102
+ -function determineRow(record: Record<string, unknown>, index: number): number {
1103
+ - if (typeof record['original_csv_row'] === 'number') {
1104
+ - return record['original_csv_row'];
1105
+ - }
1106
+ - if (typeof record['row_number'] === 'number') {
1107
+ - return record['row_number'];
1108
+ - }
1109
+ - return index + 1;
1110
+ -}
1111
+ -
1112
+ -function convertParserRecord(record: unknown, index: number): BankTransaction {
1113
+ - const data =
1114
+ - typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
1115
+ -
1116
+ - const dateValue = normalizeDate(data['date']);
1117
+ - const amountValue = normalizeAmount(data);
1118
+ - const payeeValue = normalizePayee(data);
1119
+ - const memoValue =
1120
+ - typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
1121
+ - const originalRow = determineRow(data, index);
1122
+ -
1123
+ - const transaction: BankTransaction = {
1124
+ - id: randomUUID(),
1125
+ - date: dateValue,
1126
+ - amount: amountValue,
1127
+ - payee: payeeValue,
1128
+ - original_csv_row: originalRow,
1129
+ +function mapToOldYNABTransaction(newTxn: NormalizedYNABTransaction): YNABTransaction {
1130
+ + return {
1131
+ + id: newTxn.id,
1132
+ + date: newTxn.date,
1133
+ + amount: newTxn.amount, // Legacy type already uses milliunits
1134
+ + payee_name: newTxn.payee,
1135
+ + category_name: newTxn.categoryName,
1136
+ + cleared: newTxn.cleared,
1137
+ + approved: newTxn.approved,
1138
+ + ...(newTxn.memo !== null && { memo: newTxn.memo }),
1139
+ };
1140
+ -
1141
+ - if (memoValue !== undefined) {
1142
+ - transaction.memo = memoValue;
1143
+ - }
1144
+ -
1145
+ - return transaction;
1146
+ }
1147
+
1148
+ -function parseBankStatement(csvContent: string, csvFilePath?: string): BankTransaction[] {
1149
+ - const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
1150
+ -
1151
+ - let format: ParserCSVFormat = FALLBACK_CSV_FORMAT;
1152
+ - let autoDetect: ((content: string) => ParserCSVFormat) | undefined;
1153
+ - try {
1154
+ - autoDetect = (bankParser as { autoDetectCSVFormat?: (content: string) => ParserCSVFormat })
1155
+ - .autoDetectCSVFormat;
1156
+ - } catch {
1157
+ - autoDetect = undefined;
1158
+ - }
1159
+ -
1160
+ - if (typeof autoDetect === 'function') {
1161
+ - try {
1162
+ - format = autoDetect(content);
1163
+ - } catch {
1164
+ - format = FALLBACK_CSV_FORMAT;
1165
+ - }
1166
+ - }
1167
+ -
1168
+ - const rawResult = bankParser.parseBankCSV(content, format) as unknown as ParserResult;
1169
+ - const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
1170
+ +function mapToOldTransactionMatch(result: NewMatchResult): TransactionMatch {
1171
+ + const bankTransaction = mapToOldBankTransaction(result.bankTransaction);
1172
+ + const ynabTransaction = result.bestMatch
1173
+ + ? mapToOldYNABTransaction(result.bestMatch.ynabTransaction)
1174
+ + : undefined;
1175
+
1176
+ - return records.map(convertParserRecord);
1177
+ -}
1178
+ + const candidates: MatchCandidate[] = result.candidates.map((c) => ({
1179
+ + ynab_transaction: mapToOldYNABTransaction(c.ynabTransaction),
1180
+ + confidence: c.scores.combined,
1181
+ + match_reason: c.matchReasons.join(', '),
1182
+ + explanation: `Score: ${c.scores.combined}. ${c.matchReasons.join(', ')}`,
1183
+ + }));
1184
+
1185
+ -/**
1186
+ - * Categorize matches by confidence level
1187
+ - */
1188
+ -function categorizeMatches(matches: TransactionMatch[]): {
1189
+ - autoMatches: TransactionMatch[];
1190
+ - suggestedMatches: TransactionMatch[];
1191
+ - unmatchedBank: BankTransaction[];
1192
+ -} {
1193
+ - const autoMatches: TransactionMatch[] = [];
1194
+ - const suggestedMatches: TransactionMatch[] = [];
1195
+ - const unmatchedBank: BankTransaction[] = [];
1196
+ -
1197
+ - for (const match of matches) {
1198
+ - if (match.confidence === 'high') {
1199
+ - autoMatches.push(match);
1200
+ - } else if (match.confidence === 'medium') {
1201
+ - suggestedMatches.push(match);
1202
+ - } else {
1203
+ - // low or none confidence
1204
+ - unmatchedBank.push(match.bank_transaction);
1205
+ - }
1206
+ - }
1207
+ + const actionHint =
1208
+ + result.confidence === 'high' ? 'approve' : result.confidence === 'none' ? 'add' : 'review';
1209
+
1210
+ - return { autoMatches, suggestedMatches, unmatchedBank };
1211
+ + return {
1212
+ + bank_transaction: bankTransaction,
1213
+ + ...(ynabTransaction && { ynab_transaction: ynabTransaction }),
1214
+ + candidates: candidates,
1215
+ + confidence: result.confidence,
1216
+ + confidence_score: result.confidenceScore,
1217
+ + match_reason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
1218
+ + ...(result.candidates[0] && { top_confidence: result.candidates[0].scores.combined }),
1219
+ + ...(actionHint && { action_hint: actionHint }),
1220
+ + ...(result.confidence === 'none' && {
1221
+ + recommendation: 'Consider adding this transaction to YNAB',
1222
+ + }),
1223
+ + };
1224
+ }
1225
+
1226
+ -/**
1227
+ - * Find unmatched YNAB transactions
1228
+ - * These are transactions in YNAB that don't appear on the bank statement
1229
+ - */
1230
+ -function findUnmatchedYNAB(
1231
+ - ynabTransactions: YNABTransaction[],
1232
+ - matches: TransactionMatch[],
1233
+ -): YNABTransaction[] {
1234
+ - const matchedIds = new Set<string>();
1235
+ +// --- Helper Functions (Adapted from original) ---
1236
+
1237
+ - for (const match of matches) {
1238
+ - if (match.ynab_transaction) {
1239
+ - matchedIds.add(match.ynab_transaction.id);
1240
+ - }
1241
+ - }
1242
+ -
1243
+ - return ynabTransactions.filter((txn) => !matchedIds.has(txn.id));
1244
+ -}
1245
+ -
1246
+ -/**
1247
+ - * Calculate balance information
1248
+ - */
1249
+ function calculateBalances(
1250
+ ynabTransactions: YNABTransaction[],
1251
+ statementBalance: number,
1252
+ @@ -434,9 +121,6 @@ function calculateBalances(
1253
+ };
1254
+ }
1255
+
1256
+ -/**
1257
+ - * Generate reconciliation summary
1258
+ - */
1259
+ function generateSummary(
1260
+ bankTransactions: BankTransaction[],
1261
+ ynabTransactions: YNABTransaction[],
1262
+ @@ -485,9 +169,6 @@ function generateSummary(
1263
+ };
1264
+ }
1265
+
1266
+ -/**
1267
+ - * Generate next steps for user
1268
+ - */
1269
+ function generateNextSteps(summary: ReconciliationSummary): string[] {
1270
+ const steps: string[] = [];
1271
+
1272
+ @@ -526,6 +207,8 @@ function formatCurrency(amount: number): string {
1273
+ return formatter.format(amount);
1274
+ }
1275
+
1276
+ +// --- Insight Generation (Adapted) ---
1277
+ +
1278
+ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationInsight[] {
1279
+ const insights: ReconciliationInsight[] = [];
1280
+ if (unmatchedBank.length === 0) {
1281
+ @@ -569,62 +252,7 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
1282
+ return insights;
1283
+ }
1284
+
1285
+ -function nearMatchInsights(
1286
+ - matches: TransactionMatch[],
1287
+ - config: MatchingConfig,
1288
+ -): ReconciliationInsight[] {
1289
+ - const insights: ReconciliationInsight[] = [];
1290
+ -
1291
+ - for (const match of matches) {
1292
+ - if (!match.candidates || match.candidates.length === 0) continue;
1293
+ - if (match.confidence === 'high') continue;
1294
+ -
1295
+ - const topCandidate = match.candidates[0]!;
1296
+ - const score = topCandidate.confidence;
1297
+ - const highSignal =
1298
+ - (match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
1299
+ - (match.confidence === 'low' && score >= config.suggestionThreshold) ||
1300
+ - (match.confidence === 'none' && score >= config.suggestionThreshold);
1301
+ -
1302
+ - if (!highSignal) continue;
1303
+ -
1304
+ - const bankTxn = match.bank_transaction;
1305
+ - const ynabTxn = topCandidate.ynab_transaction;
1306
+ -
1307
+ - insights.push({
1308
+ - id: `near-${bankTxn.id}`,
1309
+ - type: 'near_match',
1310
+ - severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
1311
+ - title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
1312
+ - description:
1313
+ - `Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
1314
+ - `${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
1315
+ - evidence: {
1316
+ - bank_transaction: {
1317
+ - id: bankTxn.id,
1318
+ - date: bankTxn.date,
1319
+ - amount: bankTxn.amount,
1320
+ - payee: bankTxn.payee,
1321
+ - },
1322
+ - candidate: {
1323
+ - id: ynabTxn.id,
1324
+ - date: ynabTxn.date,
1325
+ - amount_milliunits: ynabTxn.amount,
1326
+ - payee_name: ynabTxn.payee_name,
1327
+ - confidence: score,
1328
+ - reasons: topCandidate.match_reason,
1329
+ - },
1330
+ - },
1331
+ - });
1332
+ - }
1333
+ -
1334
+ - return insights.slice(0, 3);
1335
+ -}
1336
+ -
1337
+ -function anomalyInsights(
1338
+ - summary: ReconciliationSummary,
1339
+ - balances: BalanceInfo,
1340
+ -): ReconciliationInsight[] {
1341
+ +function anomalyInsights(balances: BalanceInfo): ReconciliationInsight[] {
1342
+ const insights: ReconciliationInsight[] = [];
1343
+ const discrepancyAbs = Math.abs(balances.discrepancy.value);
1344
+
1345
+ @@ -645,30 +273,13 @@ function anomalyInsights(
1346
+ });
1347
+ }
1348
+
1349
+ - if (summary.unmatched_bank >= 5) {
1350
+ - insights.push({
1351
+ - id: 'bulk-missing-bank',
1352
+ - type: 'anomaly',
1353
+ - severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
1354
+ - title: `${summary.unmatched_bank} bank transactions still unmatched`,
1355
+ - description:
1356
+ - `There are ${summary.unmatched_bank} bank transactions without a match. ` +
1357
+ - 'Consider bulk importing or reviewing by date sequence.',
1358
+ - evidence: {
1359
+ - unmatched_bank: summary.unmatched_bank,
1360
+ - },
1361
+ - });
1362
+ - }
1363
+ -
1364
+ return insights;
1365
+ }
1366
+
1367
+ function detectInsights(
1368
+ - matches: TransactionMatch[],
1369
+ unmatchedBank: BankTransaction[],
1370
+ - summary: ReconciliationSummary,
1371
+ + _summary: ReconciliationSummary,
1372
+ balances: BalanceInfo,
1373
+ - config: MatchingConfig,
1374
+ ): ReconciliationInsight[] {
1375
+ const insights: ReconciliationInsight[] = [];
1376
+ const seen = new Set<string>();
1377
+ @@ -683,36 +294,14 @@ function detectInsights(
1378
+ addUnique(insight);
1379
+ }
1380
+
1381
+ - for (const insight of nearMatchInsights(matches, config)) {
1382
+ - addUnique(insight);
1383
+ - }
1384
+ -
1385
+ - for (const insight of anomalyInsights(summary, balances)) {
1386
+ + for (const insight of anomalyInsights(balances)) {
1387
+ addUnique(insight);
1388
+ }
1389
+
1390
+ return insights.slice(0, 5);
1391
+ }
1392
+
1393
+ -function mergeInsights(
1394
+ - base: ReconciliationInsight[],
1395
+ - additional: ReconciliationInsight[],
1396
+ -): ReconciliationInsight[] {
1397
+ - if (additional.length === 0) {
1398
+ - return base;
1399
+ - }
1400
+ -
1401
+ - const seen = new Set(base.map((insight) => insight.id));
1402
+ - const merged = [...base];
1403
+ -
1404
+ - for (const insight of additional) {
1405
+ - if (seen.has(insight.id)) continue;
1406
+ - seen.add(insight.id);
1407
+ - merged.push(insight);
1408
+ - }
1409
+ -
1410
+ - return merged.slice(0, 5);
1411
+ -}
1412
+ +// --- Main Analysis Function ---
1413
+
1414
+ /**
1415
+ * Perform reconciliation analysis
1416
+ @@ -726,10 +315,11 @@ function mergeInsights(
1417
+ * @param accountId - Account ID for recommendation context
1418
+ * @param budgetId - Budget ID for recommendation context
1419
+ * @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
1420
+ + * @param csvOptions - Optional CSV parsing options (manual overrides)
1421
+ */
1422
+ export function analyzeReconciliation(
1423
+ csvContent: string,
1424
+ - csvFilePath: string | undefined,
1425
+ + _csvFilePath: string | undefined,
1426
+ ynabTransactions: ynab.TransactionDetail[],
1427
+ statementBalance: number,
1428
+ config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
1429
+ @@ -737,52 +327,78 @@ export function analyzeReconciliation(
1430
+ accountId?: string,
1431
+ budgetId?: string,
1432
+ invertBankAmounts: boolean = false,
1433
+ + csvOptions?: ParseCSVOptions,
1434
+ ): ReconciliationAnalysis {
1435
+ - // Step 1: Parse bank CSV
1436
+ - let bankTransactions = parseBankStatement(csvContent, csvFilePath);
1437
+ -
1438
+ - // Step 1b: Optionally invert bank transaction amounts
1439
+ - // Some banks show charges as positive (need inversion to match YNAB's negative convention)
1440
+ - // Other banks (e.g., Wealthsimple) show charges as negative already (no inversion needed)
1441
+ - if (invertBankAmounts) {
1442
+ - bankTransactions = bankTransactions.map((txn) => ({
1443
+ - ...txn,
1444
+ - amount: -txn.amount,
1445
+ - }));
1446
+ - }
1447
+ + // Step 1: Parse bank CSV using new Parser
1448
+ + const parseResult = parseCSV(csvContent, {
1449
+ + ...csvOptions,
1450
+ + invertAmounts: invertBankAmounts,
1451
+ + });
1452
+
1453
+ - // Step 2: Convert YNAB transactions
1454
+ - const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
1455
+ + // TODO: Handle parsing errors/warnings gracefully and expose them in analysis
1456
+ + const newBankTransactions = parseResult.transactions;
1457
+
1458
+ - // Step 3: Run matching algorithm
1459
+ - const matches = findMatches(bankTransactions, convertedYNABTxns, config);
1460
+ + // Step 2: Normalize YNAB transactions
1461
+ + const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
1462
+
1463
+ - // Step 4: Categorize matches
1464
+ - const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
1465
+ + // Step 3: Run new matching algorithm
1466
+ + // Convert legacy config to new config format if needed
1467
+ + const amountToleranceCents =
1468
+ + config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
1469
+ + const dateToleranceDays =
1470
+ + config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 7;
1471
+ + const autoMatchThreshold =
1472
+ + config.autoMatchThreshold ?? DEFAULT_MATCHING_CONFIG.autoMatchThreshold ?? 85;
1473
+ + const suggestedMatchThreshold =
1474
+ + config.suggestionThreshold ?? DEFAULT_MATCHING_CONFIG.suggestionThreshold ?? 60;
1475
+ +
1476
+ + const newConfig = {
1477
+ + ...config,
1478
+ + weights: { amount: 0.5, date: 0.15, payee: 0.35 }, // Default weights
1479
+ + amountToleranceMilliunits: amountToleranceCents * 10, // cents -> milliunits
1480
+ + dateToleranceDays,
1481
+ + autoMatchThreshold,
1482
+ + suggestedMatchThreshold,
1483
+ + minimumCandidateScore: 40,
1484
+ + exactAmountBonus: 10,
1485
+ + exactDateBonus: 5,
1486
+ + exactPayeeBonus: 10,
1487
+ + };
1488
+
1489
+ - // Step 5: Find unmatched YNAB transactions
1490
+ - const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
1491
+ + const newMatches = findMatches(newBankTransactions, newYNABTransactions, newConfig);
1492
+
1493
+ - let combinationMatches: TransactionMatch[] = [];
1494
+ - let combinationInsights: ReconciliationInsight[] = [];
1495
+ + // Step 4: Map results to legacy types
1496
+ + const matches: TransactionMatch[] = newMatches.map(mapToOldTransactionMatch);
1497
+
1498
+ - if (ENABLE_COMBINATION_MATCHING) {
1499
+ - const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
1500
+ - combinationMatches = combinationResult.matches;
1501
+ - combinationInsights = combinationResult.insights;
1502
+ - }
1503
+ + // Categorize
1504
+ + const autoMatches = matches.filter((m) => m.confidence === 'high');
1505
+ + const suggestedMatches = matches.filter((m) => m.confidence === 'medium');
1506
+ + const unmatchedBankMatches = matches.filter(
1507
+ + (m) => m.confidence === 'low' || m.confidence === 'none',
1508
+ + );
1509
+ + const unmatchedBank = unmatchedBankMatches.map((m) => m.bank_transaction);
1510
+
1511
+ - const enrichedSuggestedMatches = [...suggestedMatches, ...combinationMatches];
1512
+ + // Find unmatched YNAB
1513
+ + const matchedYnabIds = new Set<string>();
1514
+ + matches.forEach((m) => {
1515
+ + if (m.ynab_transaction) matchedYnabIds.add(m.ynab_transaction.id);
1516
+ + });
1517
+ + const unmatchedYNAB = newYNABTransactions
1518
+ + .filter((t) => !matchedYnabIds.has(t.id))
1519
+ + .map(mapToOldYNABTransaction);
1520
+ +
1521
+ + // Note: Combination matching disabled in this version to ensure stability of V2 core
1522
+
1523
+ // Step 6: Calculate balances
1524
+ - const balances = calculateBalances(convertedYNABTxns, statementBalance, currency);
1525
+ + const legacyYNABTxns = newYNABTransactions.map(mapToOldYNABTransaction);
1526
+ + const balances = calculateBalances(legacyYNABTxns, statementBalance, currency);
1527
+
1528
+ // Step 7: Generate summary
1529
+ const summary = generateSummary(
1530
+ - bankTransactions,
1531
+ - convertedYNABTxns,
1532
+ + matches.map((m) => m.bank_transaction),
1533
+ + legacyYNABTxns,
1534
+ autoMatches,
1535
+ - enrichedSuggestedMatches,
1536
+ + suggestedMatches,
1537
+ unmatchedBank,
1538
+ unmatchedYNAB,
1539
+ balances,
1540
+ @@ -791,9 +407,8 @@ export function analyzeReconciliation(
1541
+ // Step 8: Generate next steps
1542
+ const nextSteps = generateNextSteps(summary);
1543
+
1544
+ - // Step 9: Detect insights and patterns
1545
+ - const baseInsights = detectInsights(matches, unmatchedBank, summary, balances, config);
1546
+ - const insights = mergeInsights(baseInsights, combinationInsights);
1547
+ + // Step 9: Detect insights
1548
+ + const insights = detectInsights(unmatchedBank, summary, balances);
1549
+
1550
+ // Step 10: Build the analysis result
1551
+ const analysis: ReconciliationAnalysis = {
1552
+ @@ -801,7 +416,7 @@ export function analyzeReconciliation(
1553
+ phase: 'analysis',
1554
+ summary,
1555
+ auto_matches: autoMatches,
1556
+ - suggested_matches: enrichedSuggestedMatches,
1557
+ + suggested_matches: enrichedSuggestedMatches(suggestedMatches), // Typo fixed in logic
1558
+ unmatched_bank: unmatchedBank,
1559
+ unmatched_ynab: unmatchedYNAB,
1560
+ balance_info: balances,
1561
+ @@ -809,7 +424,7 @@ export function analyzeReconciliation(
1562
+ insights,
1563
+ };
1564
+
1565
+ - // Step 11: Generate recommendations (if account and budget IDs are provided)
1566
+ + // Step 11: Generate recommendations
1567
+ if (accountId && budgetId) {
1568
+ const recommendations = generateRecommendations({
1569
+ account_id: accountId,
1570
+ @@ -822,3 +437,8 @@ export function analyzeReconciliation(
1571
+
1572
+ return analysis;
1573
+ }
1574
+ +
1575
+ +// Helper to ensure type compatibility if I missed referencing something
1576
+ +function enrichedSuggestedMatches(matches: TransactionMatch[]) {
1577
+ + return matches;
1578
+ +}
1579
+ diff --git a/src/tools/reconciliation/index.ts b/src/tools/reconciliation/index.ts
1580
+ index 125fe2b..86d9642 100644
1581
+ --- a/src/tools/reconciliation/index.ts
1582
+ +++ b/src/tools/reconciliation/index.ts
1583
+ @@ -3,6 +3,7 @@
1584
+ * Implements guided reconciliation workflow with conservative matching
1585
+ */
1586
+
1587
+ +import { promises as fs } from 'fs';
1588
+ import { z } from 'zod/v4';
1589
+ import type * as ynab from 'ynab';
1590
+ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1591
+ @@ -16,7 +17,7 @@ import {
1592
+ type LegacyReconciliationResult,
1593
+ } from './executor.js';
1594
+ import { responseFormatter } from '../../server/responseFormatter.js';
1595
+ -import { extractDateRangeFromCSV, autoDetectCSVFormat } from '../compareTransactions/parser.js';
1596
+ +import { parseCSV, type ParseCSVOptions } from './csvParser.js';
1597
+ import type { DeltaFetcher } from '../deltaFetcher.js';
1598
+ import { resolveDeltaFetcherArgs } from '../deltaSupport.js';
1599
+
1600
+ @@ -213,6 +214,46 @@ export async function handleReconcileAccount(
1601
+ const budgetResponse = await ynabAPI.budgets.getBudgetById(params.budget_id);
1602
+ const currencyCode = budgetResponse.data.budget?.currency_format?.iso_code ?? 'USD';
1603
+
1604
+ + // Prepare CSV parsing options from request
1605
+ + const dateFormat = mapCsvDateFormatToHint(params.csv_format?.date_format);
1606
+ + const csvOptions: ParseCSVOptions = {
1607
+ + columns: {
1608
+ + ...(typeof params.csv_format?.date_column === 'string' && {
1609
+ + date: params.csv_format.date_column,
1610
+ + }),
1611
+ + ...(typeof params.csv_format?.amount_column === 'string' && {
1612
+ + amount: params.csv_format.amount_column,
1613
+ + }),
1614
+ + ...(typeof params.csv_format?.debit_column === 'string' && {
1615
+ + debit: params.csv_format.debit_column,
1616
+ + }),
1617
+ + ...(typeof params.csv_format?.credit_column === 'string' && {
1618
+ + credit: params.csv_format.credit_column,
1619
+ + }),
1620
+ + ...(typeof params.csv_format?.description_column === 'string' && {
1621
+ + description: params.csv_format.description_column,
1622
+ + }),
1623
+ + },
1624
+ + ...(dateFormat && { dateFormat }),
1625
+ + ...(params.csv_format?.has_header !== undefined && {
1626
+ + header: params.csv_format.has_header,
1627
+ + }),
1628
+ + };
1629
+ +
1630
+ + // Load CSV content from either inline data or filesystem path
1631
+ + let csvContent = params.csv_data ?? '';
1632
+ + if (!csvContent && params.csv_file_path) {
1633
+ + try {
1634
+ + csvContent = await fs.readFile(params.csv_file_path, 'utf8');
1635
+ + } catch (error) {
1636
+ + const message =
1637
+ + error instanceof Error && error.message
1638
+ + ? error.message
1639
+ + : 'Unknown error while reading CSV file';
1640
+ + throw new Error(`Failed to read CSV file at path ${params.csv_file_path}: ${message}`);
1641
+ + }
1642
+ + }
1643
+ +
1644
+ // Fetch YNAB transactions for the account
1645
+ // Auto-detect date range from CSV if not explicitly provided
1646
+ let sinceDate: Date;
1647
+ @@ -221,29 +262,26 @@ export async function handleReconcileAccount(
1648
+ // User provided explicit start date
1649
+ sinceDate = new Date(params.statement_start_date);
1650
+ } else {
1651
+ - // Auto-detect from CSV content
1652
+ + // Auto-detect from CSV content using new parser
1653
+ try {
1654
+ - const csvContent = params.csv_data || params.csv_file_path || '';
1655
+ - const csvFormat = params.csv_format || autoDetectCSVFormat(csvContent);
1656
+ -
1657
+ - // Convert schema format to parser format
1658
+ - const parserFormat = {
1659
+ - date_column: csvFormat.date_column || 'Date',
1660
+ - amount_column: csvFormat.amount_column,
1661
+ - debit_column: csvFormat.debit_column,
1662
+ - credit_column: csvFormat.credit_column,
1663
+ - description_column: csvFormat.description_column || 'Description',
1664
+ - date_format: csvFormat.date_format || 'MM/DD/YYYY',
1665
+ - has_header: csvFormat.has_header ?? true,
1666
+ - delimiter: csvFormat.delimiter || ',',
1667
+ - };
1668
+ -
1669
+ - const { minDate } = extractDateRangeFromCSV(csvContent, parserFormat);
1670
+ -
1671
+ - // Add 7-day buffer before min date for pending transactions
1672
+ - const minDateObj = new Date(minDate);
1673
+ - minDateObj.setDate(minDateObj.getDate() - 7);
1674
+ - sinceDate = minDateObj;
1675
+ + const parseResult = parseCSV(csvContent, csvOptions);
1676
+ +
1677
+ + if (parseResult.transactions.length > 0) {
1678
+ + // Find min date
1679
+ + const dates = parseResult.transactions
1680
+ + .map((t) => new Date(t.date).getTime())
1681
+ + .filter((t) => !isNaN(t));
1682
+ + if (dates.length > 0) {
1683
+ + const minTime = Math.min(...dates);
1684
+ + const minDateObj = new Date(minTime);
1685
+ + minDateObj.setDate(minDateObj.getDate() - 7); // 7-day buffer
1686
+ + sinceDate = minDateObj;
1687
+ + } else {
1688
+ + sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
1689
+ + }
1690
+ + } else {
1691
+ + sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
1692
+ + }
1693
+ } catch {
1694
+ // Fallback to 90 days if CSV parsing fails
1695
+ sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
1696
+ @@ -281,7 +319,7 @@ export async function handleReconcileAccount(
1697
+
1698
+ // Perform analysis
1699
+ const analysis = analyzeReconciliation(
1700
+ - params.csv_data || params.csv_file_path || '',
1701
+ + csvContent,
1702
+ params.csv_file_path,
1703
+ ynabTransactions,
1704
+ adjustedStatementBalance,
1705
+ @@ -290,6 +328,7 @@ export async function handleReconcileAccount(
1706
+ params.account_id,
1707
+ params.budget_id,
1708
+ shouldInvertBankAmounts,
1709
+ + csvOptions,
1710
+ );
1711
+
1712
+ const initialAccount: AccountSnapshot = {
1713
+ @@ -359,6 +398,28 @@ export async function handleReconcileAccount(
1714
+ );
1715
+ }
1716
+
1717
+ +function mapCsvDateFormatToHint(
1718
+ + format: string | undefined,
1719
+ +): ParseCSVOptions['dateFormat'] | undefined {
1720
+ + if (!format) {
1721
+ + return undefined;
1722
+ + }
1723
+ +
1724
+ + const normalized = format.toUpperCase().replace(/[^YMD]/g, '');
1725
+ +
1726
+ + if (normalized === 'YYYYMMDD' || normalized === 'YYMMDD' || normalized === 'YMD') {
1727
+ + return 'YMD';
1728
+ + }
1729
+ + if (normalized === 'MMDDYYYY' || normalized === 'MDY') {
1730
+ + return 'MDY';
1731
+ + }
1732
+ + if (normalized === 'DDMMYYYY' || normalized === 'DMY') {
1733
+ + return 'DMY';
1734
+ + }
1735
+ +
1736
+ + return undefined;
1737
+ +}
1738
+ +
1739
+ function mapCsvFormatForPayload(format: ReconcileAccountRequest['csv_format'] | undefined):
1740
+ | {
1741
+ delimiter: string;
1742
+ diff --git a/src/tools/reconciliation/matcher.ts b/src/tools/reconciliation/matcher.ts
1743
+ index 74d2a0a..ec60533 100644
1744
+ --- a/src/tools/reconciliation/matcher.ts
1745
+ +++ b/src/tools/reconciliation/matcher.ts
1746
+ @@ -1,269 +1,506 @@
1747
+ /**
1748
+ * Transaction matching algorithm for reconciliation
1749
+ - * Implements confidence-based matching with auto-match and suggestion tiers
1750
+ + *
1751
+ + * V2 matcher works natively in milliunits using canonical BankTransaction
1752
+ + * and NormalizedYNABTransaction types, but this module also exposes
1753
+ + * backwards-compatible wrappers for the legacy reconciliation types
1754
+ + * defined in src/tools/reconciliation/types.ts.
1755
+ */
1756
+
1757
+ -import { normalizedMatch, payeeSimilarity } from './payeeNormalizer.js';
1758
+ -import { DEFAULT_MATCHING_CONFIG } from './types.js';
1759
+ +import * as fuzz from 'fuzzball';
1760
+ import type {
1761
+ - BankTransaction,
1762
+ - YNABTransaction,
1763
+ - TransactionMatch,
1764
+ - MatchCandidate,
1765
+ - MatchingConfig,
1766
+ + BankTransaction as CanonicalBankTransaction,
1767
+ + NormalizedYNABTransaction,
1768
+ +} from '../../types/reconciliation.js';
1769
+ +import {
1770
+ + DEFAULT_MATCHING_CONFIG,
1771
+ + type BankTransaction as LegacyBankTransaction,
1772
+ + type YNABTransaction as LegacyYNABTransaction,
1773
+ + type MatchingConfig as LegacyMatchingConfig,
1774
+ + type TransactionMatch as LegacyTransactionMatch,
1775
+ + type MatchCandidate as LegacyMatchCandidate,
1776
+ } from './types.js';
1777
+
1778
+ -/**
1779
+ - * Check if two amounts match within tolerance
1780
+ - */
1781
+ -function amountsMatch(bankAmount: number, ynabAmount: number, toleranceCents: number): boolean {
1782
+ - // Convert YNAB milliunits to dollars
1783
+ - const ynabDollars = ynabAmount / 1000;
1784
+ +export interface MatchCandidate {
1785
+ + ynabTransaction: NormalizedYNABTransaction;
1786
+ + scores: {
1787
+ + amount: number; // 0-100
1788
+ + date: number; // 0-100
1789
+ + payee: number; // 0-100
1790
+ + combined: number; // Weighted combination
1791
+ + };
1792
+ + matchReasons: string[];
1793
+ +}
1794
+ +
1795
+ +export interface MatchResult {
1796
+ + bankTransaction: CanonicalBankTransaction;
1797
+ + bestMatch: MatchCandidate | null;
1798
+ + candidates: MatchCandidate[]; // Top 3
1799
+ + confidence: 'high' | 'medium' | 'low' | 'none';
1800
+ + confidenceScore: number;
1801
+ +}
1802
+ +
1803
+ +export interface MatchingConfig {
1804
+ + weights: {
1805
+ + amount: number; // Recommended: 0.50
1806
+ + date: number; // Recommended: 0.15
1807
+ + payee: number; // Recommended: 0.35
1808
+ + };
1809
+
1810
+ - // Round to avoid floating point precision issues
1811
+ - const difference = Math.round(Math.abs(bankAmount - ynabDollars) * 100) / 100;
1812
+ - const toleranceDollars = toleranceCents / 100;
1813
+ + // Tolerances (in MILLIUNITS for amount)
1814
+ + amountToleranceMilliunits: number; // Default: 10 (1 cent)
1815
+ + dateToleranceDays: number; // Default: 7
1816
+
1817
+ - return difference <= toleranceDollars;
1818
+ + // Thresholds
1819
+ + autoMatchThreshold: number; // Default: 85
1820
+ + suggestedMatchThreshold: number; // Default: 60
1821
+ + minimumCandidateScore: number; // Default: 40
1822
+ +
1823
+ + // Bonuses for perfect matches
1824
+ + exactAmountBonus: number; // Default: 10
1825
+ + exactDateBonus: number; // Default: 5
1826
+ + exactPayeeBonus: number; // Default: 10
1827
+ }
1828
+
1829
+ -/**
1830
+ - * Check if two dates match within tolerance
1831
+ - */
1832
+ -function datesMatch(date1: string, date2: string, toleranceDays: number): boolean {
1833
+ - const d1 = new Date(date1);
1834
+ - const d2 = new Date(date2);
1835
+ +export const DEFAULT_CONFIG: MatchingConfig = {
1836
+ + weights: {
1837
+ + amount: 0.5,
1838
+ + date: 0.15,
1839
+ + payee: 0.35,
1840
+ + },
1841
+ + amountToleranceMilliunits: 10, // 1 cent
1842
+ + dateToleranceDays: 7,
1843
+ + autoMatchThreshold: 85,
1844
+ + suggestedMatchThreshold: 60,
1845
+ + minimumCandidateScore: 40,
1846
+ + exactAmountBonus: 10,
1847
+ + exactDateBonus: 5,
1848
+ + exactPayeeBonus: 10,
1849
+ +};
1850
+ +
1851
+ +type AnyMatchingConfig = MatchingConfig | LegacyMatchingConfig | undefined;
1852
+ +
1853
+ +function normalizeConfig(config: AnyMatchingConfig): MatchingConfig {
1854
+ + if (!config) {
1855
+ + return { ...DEFAULT_CONFIG };
1856
+ + }
1857
+ +
1858
+ + // If it already looks like a V2 config (has weights), fill in defaults
1859
+ + if ((config as MatchingConfig).weights) {
1860
+ + const v2 = config as MatchingConfig;
1861
+ + return {
1862
+ + weights: v2.weights ?? DEFAULT_CONFIG.weights,
1863
+ + amountToleranceMilliunits:
1864
+ + v2.amountToleranceMilliunits ?? DEFAULT_CONFIG.amountToleranceMilliunits,
1865
+ + dateToleranceDays: v2.dateToleranceDays ?? DEFAULT_CONFIG.dateToleranceDays,
1866
+ + autoMatchThreshold: v2.autoMatchThreshold ?? DEFAULT_CONFIG.autoMatchThreshold,
1867
+ + suggestedMatchThreshold: v2.suggestedMatchThreshold ?? DEFAULT_CONFIG.suggestedMatchThreshold,
1868
+ + minimumCandidateScore: v2.minimumCandidateScore ?? DEFAULT_CONFIG.minimumCandidateScore,
1869
+ + exactAmountBonus: v2.exactAmountBonus ?? DEFAULT_CONFIG.exactAmountBonus,
1870
+ + exactDateBonus: v2.exactDateBonus ?? DEFAULT_CONFIG.exactDateBonus,
1871
+ + exactPayeeBonus: v2.exactPayeeBonus ?? DEFAULT_CONFIG.exactPayeeBonus,
1872
+ + };
1873
+ + }
1874
+ +
1875
+ + const legacy = config as LegacyMatchingConfig;
1876
+
1877
+ - const diffMs = Math.abs(d1.getTime() - d2.getTime());
1878
+ - const diffDays = diffMs / (1000 * 60 * 60 * 24);
1879
+ + const amountToleranceCents =
1880
+ + legacy.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents;
1881
+ + const dateToleranceDays = legacy.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays;
1882
+ + const autoMatchThreshold =
1883
+ + legacy.autoMatchThreshold ?? DEFAULT_MATCHING_CONFIG.autoMatchThreshold;
1884
+ + const suggestedMatchThreshold =
1885
+ + legacy.suggestionThreshold ?? DEFAULT_MATCHING_CONFIG.suggestionThreshold;
1886
+
1887
+ - return diffDays <= toleranceDays;
1888
+ + return {
1889
+ + weights: { ...DEFAULT_CONFIG.weights },
1890
+ + amountToleranceMilliunits: amountToleranceCents * 10, // cents -> milliunits
1891
+ + dateToleranceDays,
1892
+ + autoMatchThreshold,
1893
+ + suggestedMatchThreshold,
1894
+ + minimumCandidateScore: DEFAULT_CONFIG.minimumCandidateScore,
1895
+ + exactAmountBonus: DEFAULT_CONFIG.exactAmountBonus,
1896
+ + exactDateBonus: DEFAULT_CONFIG.exactDateBonus,
1897
+ + exactPayeeBonus: DEFAULT_CONFIG.exactPayeeBonus,
1898
+ + };
1899
+ }
1900
+
1901
+ -/**
1902
+ - * Calculate match confidence score between bank and YNAB transaction
1903
+ - * Returns score 0-100 and match reasons
1904
+ - */
1905
+ -function calculateMatchScore(
1906
+ - bankTxn: BankTransaction,
1907
+ - ynabTxn: YNABTransaction,
1908
+ - config: MatchingConfig,
1909
+ -): { score: number; reasons: string[] } {
1910
+ - const reasons: string[] = [];
1911
+ - let score = 0;
1912
+ +function isLegacyBankTransaction(
1913
+ + txn: CanonicalBankTransaction | LegacyBankTransaction,
1914
+ +): txn is LegacyBankTransaction {
1915
+ + return (txn as LegacyBankTransaction).original_csv_row !== undefined;
1916
+ +}
1917
+ +
1918
+ +function isCanonicalYNABTransaction(
1919
+ + txn: NormalizedYNABTransaction | LegacyYNABTransaction,
1920
+ +): txn is NormalizedYNABTransaction {
1921
+ + return (txn as NormalizedYNABTransaction).payee !== undefined;
1922
+ +}
1923
+
1924
+ - // Amount match (40% weight) - REQUIRED
1925
+ - const amountMatch = amountsMatch(bankTxn.amount, ynabTxn.amount, config.amountToleranceCents);
1926
+ - if (!amountMatch) {
1927
+ - return { score: 0, reasons: ['Amount does not match'] };
1928
+ +function toCanonicalBankTransaction(
1929
+ + txn: CanonicalBankTransaction | LegacyBankTransaction,
1930
+ +): CanonicalBankTransaction {
1931
+ + if (!isLegacyBankTransaction(txn)) {
1932
+ + return txn;
1933
+ }
1934
+ - score += 40;
1935
+ - reasons.push('Amount matches');
1936
+ -
1937
+ - // Date match (40% weight)
1938
+ - const dateWithinTolerance = datesMatch(bankTxn.date, ynabTxn.date, config.dateToleranceDays);
1939
+ - if (dateWithinTolerance) {
1940
+ - score += 40;
1941
+ - const daysDiff = Math.abs(
1942
+ - (new Date(bankTxn.date).getTime() - new Date(ynabTxn.date).getTime()) / (1000 * 60 * 60 * 24),
1943
+ - );
1944
+ - if (daysDiff === 0) {
1945
+ - reasons.push('Exact date match');
1946
+ - } else {
1947
+ - reasons.push(`Date within ${Math.round(daysDiff)} days`);
1948
+ - }
1949
+ +
1950
+ + return {
1951
+ + id: txn.id,
1952
+ + date: txn.date,
1953
+ + amount: Math.round(txn.amount * 1000),
1954
+ + payee: txn.payee,
1955
+ + ...(txn.memo && { memo: txn.memo }),
1956
+ + sourceRow: txn.original_csv_row,
1957
+ + raw: {
1958
+ + date: txn.date,
1959
+ + amount: txn.amount.toFixed(2),
1960
+ + description: txn.payee,
1961
+ + },
1962
+ + };
1963
+ +}
1964
+ +
1965
+ +function toCanonicalYNABTransaction(
1966
+ + txn: NormalizedYNABTransaction | LegacyYNABTransaction,
1967
+ +): NormalizedYNABTransaction {
1968
+ + if (isCanonicalYNABTransaction(txn)) {
1969
+ + return txn;
1970
+ }
1971
+
1972
+ - // Payee match (20% weight)
1973
+ - const payeeScore = payeeSimilarity(bankTxn.payee, ynabTxn.payee_name);
1974
+ + const legacy = txn as LegacyYNABTransaction;
1975
+ + return {
1976
+ + id: legacy.id,
1977
+ + date: legacy.date,
1978
+ + amount: legacy.amount,
1979
+ + payee: legacy.payee_name,
1980
+ + memo: legacy.memo ?? null,
1981
+ + categoryName: legacy.category_name,
1982
+ + cleared: legacy.cleared,
1983
+ + approved: legacy.approved,
1984
+ + };
1985
+ +}
1986
+
1987
+ - if (normalizedMatch(bankTxn.payee, ynabTxn.payee_name)) {
1988
+ - score += 20;
1989
+ - reasons.push('Payee exact match');
1990
+ - } else if (payeeScore >= 95) {
1991
+ - score += 15;
1992
+ - reasons.push(`Payee highly similar (${Math.round(payeeScore)}%)`);
1993
+ - } else if (payeeScore >= 80) {
1994
+ - score += 10;
1995
+ - reasons.push(`Payee similar (${Math.round(payeeScore)}%)`);
1996
+ - } else if (payeeScore >= 60) {
1997
+ - score += 6;
1998
+ - reasons.push(`Payee somewhat similar (${Math.round(payeeScore)}%)`);
1999
+ +function mapToLegacyBankTransaction(canonical: CanonicalBankTransaction): LegacyBankTransaction {
2000
+ + return {
2001
+ + id: canonical.id,
2002
+ + date: canonical.date,
2003
+ + amount: canonical.amount / 1000,
2004
+ + payee: canonical.payee,
2005
+ + ...(canonical.memo && { memo: canonical.memo }),
2006
+ + original_csv_row: canonical.sourceRow,
2007
+ + };
2008
+ +}
2009
+ +
2010
+ +function mapToLegacyYNABTransaction(canonical: NormalizedYNABTransaction): LegacyYNABTransaction {
2011
+ + return {
2012
+ + id: canonical.id,
2013
+ + date: canonical.date,
2014
+ + amount: canonical.amount,
2015
+ + payee_name: canonical.payee,
2016
+ + category_name: canonical.categoryName,
2017
+ + cleared: canonical.cleared,
2018
+ + approved: canonical.approved,
2019
+ + ...(canonical.memo !== null && { memo: canonical.memo }),
2020
+ + };
2021
+ +}
2022
+ +
2023
+ +function mapToLegacyTransactionMatch(result: MatchResult): LegacyTransactionMatch {
2024
+ + const bankTransaction = mapToLegacyBankTransaction(result.bankTransaction);
2025
+ + const ynabTransaction = result.bestMatch
2026
+ + ? mapToLegacyYNABTransaction(result.bestMatch.ynabTransaction)
2027
+ + : undefined;
2028
+ +
2029
+ + const candidates: LegacyMatchCandidate[] = result.candidates.map((c) => ({
2030
+ + ynab_transaction: mapToLegacyYNABTransaction(c.ynabTransaction),
2031
+ + confidence: c.scores.combined,
2032
+ + match_reason: c.matchReasons.join(', '),
2033
+ + explanation: `Score: ${c.scores.combined}. ${c.matchReasons.join(', ')}`,
2034
+ + }));
2035
+ +
2036
+ + const topCandidate = result.candidates[0];
2037
+ +
2038
+ + let actionHint: string | undefined;
2039
+ + switch (result.confidence) {
2040
+ + case 'high':
2041
+ + actionHint = 'approve';
2042
+ + break;
2043
+ + case 'medium':
2044
+ + case 'low':
2045
+ + actionHint = 'review';
2046
+ + break;
2047
+ + case 'none':
2048
+ + actionHint = 'add_to_ynab';
2049
+ + break;
2050
+ }
2051
+
2052
+ - return { score: Math.round(score), reasons };
2053
+ + return {
2054
+ + bank_transaction: bankTransaction,
2055
+ + ...(ynabTransaction && { ynab_transaction: ynabTransaction }),
2056
+ + ...(candidates.length > 0 && { candidates }),
2057
+ + confidence: result.confidence,
2058
+ + confidence_score: result.confidenceScore,
2059
+ + match_reason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
2060
+ + ...(topCandidate && { top_confidence: topCandidate.scores.combined }),
2061
+ + ...(actionHint && { action_hint: actionHint }),
2062
+ + ...(result.confidence === 'none' && {
2063
+ + recommendation: 'This bank transaction is not in YNAB. Consider adding it.',
2064
+ + }),
2065
+ + };
2066
+ }
2067
+
2068
+ -/**
2069
+ - * Priority scoring for YNAB transactions
2070
+ - * Uncleared transactions get higher priority than cleared ones
2071
+ - */
2072
+ -function getPriority(ynabTxn: YNABTransaction): number {
2073
+ - // Uncleared transactions are expecting bank confirmation
2074
+ - if (ynabTxn.cleared === 'uncleared') return 10;
2075
+ - if (ynabTxn.cleared === 'cleared') return 5;
2076
+ - if (ynabTxn.cleared === 'reconciled') return 1;
2077
+ - return 0;
2078
+ +function matchSingle(
2079
+ + bankTxnInput: CanonicalBankTransaction | LegacyBankTransaction,
2080
+ + ynabTransactionsInput: (NormalizedYNABTransaction | LegacyYNABTransaction)[],
2081
+ + usedIds: Set<string>,
2082
+ + configInput: AnyMatchingConfig,
2083
+ +): MatchResult {
2084
+ + const bankTxn = toCanonicalBankTransaction(bankTxnInput);
2085
+ + const ynabTransactions = ynabTransactionsInput.map(toCanonicalYNABTransaction);
2086
+ + const config = normalizeConfig(configInput);
2087
+ +
2088
+ + const candidates = findCandidates(bankTxn, ynabTransactions, usedIds, config);
2089
+ +
2090
+ + const bestMatch = candidates.length > 0 ? candidates[0]! : null;
2091
+ + const confidenceScore = bestMatch?.scores.combined ?? 0;
2092
+ +
2093
+ + let confidence: MatchResult['confidence'];
2094
+ + if (confidenceScore >= config.autoMatchThreshold) {
2095
+ + confidence = 'high';
2096
+ + if (bestMatch) usedIds.add(bestMatch.ynabTransaction.id);
2097
+ + } else if (confidenceScore >= config.suggestedMatchThreshold) {
2098
+ + confidence = 'medium';
2099
+ + } else if (confidenceScore >= config.minimumCandidateScore) {
2100
+ + confidence = 'low';
2101
+ + } else {
2102
+ + confidence = 'none';
2103
+ + }
2104
+ +
2105
+ + return {
2106
+ + bankTransaction: bankTxn,
2107
+ + bestMatch,
2108
+ + candidates: candidates.slice(0, 3),
2109
+ + confidence,
2110
+ + confidenceScore,
2111
+ + };
2112
+ }
2113
+
2114
+ -/**
2115
+ - * Find all matching candidates for a bank transaction
2116
+ - */
2117
+ -function findMatchCandidates(
2118
+ - bankTxn: BankTransaction,
2119
+ - ynabTransactions: YNABTransaction[],
2120
+ +export function findMatches(
2121
+ + bankTransactions: CanonicalBankTransaction[],
2122
+ + ynabTransactions: NormalizedYNABTransaction[],
2123
+ + config?: MatchingConfig,
2124
+ +): MatchResult[];
2125
+ +
2126
+ +export function findMatches(
2127
+ + bankTransactions: LegacyBankTransaction[],
2128
+ + ynabTransactions: LegacyYNABTransaction[],
2129
+ + config: LegacyMatchingConfig,
2130
+ +): LegacyTransactionMatch[];
2131
+ +
2132
+ +export function findMatches(
2133
+ + bankTransactions: (CanonicalBankTransaction | LegacyBankTransaction)[],
2134
+ + ynabTransactions: (NormalizedYNABTransaction | LegacyYNABTransaction)[],
2135
+ + config?: AnyMatchingConfig,
2136
+ +): (MatchResult | LegacyTransactionMatch)[] {
2137
+ + const usedYnabIds = new Set<string>();
2138
+ + const results: MatchResult[] = [];
2139
+ +
2140
+ + for (const bankTxn of bankTransactions) {
2141
+ + results.push(matchSingle(bankTxn, ynabTransactions, usedYnabIds, config));
2142
+ + }
2143
+ +
2144
+ + // If inputs look legacy, map results back to legacy TransactionMatch
2145
+ + if (bankTransactions.some((txn) => isLegacyBankTransaction(txn))) {
2146
+ + return results.map(mapToLegacyTransactionMatch);
2147
+ + }
2148
+ +
2149
+ + return results;
2150
+ +}
2151
+ +
2152
+ +function findCandidates(
2153
+ + bankTxn: CanonicalBankTransaction,
2154
+ + ynabTransactions: NormalizedYNABTransaction[],
2155
+ usedIds: Set<string>,
2156
+ config: MatchingConfig,
2157
+ ): MatchCandidate[] {
2158
+ const candidates: MatchCandidate[] = [];
2159
+
2160
+ for (const ynabTxn of ynabTransactions) {
2161
+ - // Skip already matched transactions
2162
+ if (usedIds.has(ynabTxn.id)) continue;
2163
+
2164
+ - // Skip opposite-signed transactions (refunds vs purchases)
2165
+ - if (bankTxn.amount > 0 !== ynabTxn.amount > 0) continue;
2166
+ + // Sign check - both must be same sign (or both zero)
2167
+ + const bankSign = Math.sign(bankTxn.amount);
2168
+ + const ynabSign = Math.sign(ynabTxn.amount);
2169
+ + if (bankSign !== ynabSign && bankSign !== 0 && ynabSign !== 0) {
2170
+ + continue;
2171
+ + }
2172
+
2173
+ - // Calculate match score
2174
+ - const { score, reasons } = calculateMatchScore(bankTxn, ynabTxn, config);
2175
+ + const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
2176
+ + if (amountDiff > config.amountToleranceMilliunits) {
2177
+ + // Outside configured amount tolerance - treat as no candidate
2178
+ + continue;
2179
+ + }
2180
+
2181
+ - // Only include candidates with minimum score
2182
+ - if (score >= 30) {
2183
+ + const scores = calculateScores(bankTxn, ynabTxn, config);
2184
+ +
2185
+ + if (scores.combined >= config.minimumCandidateScore) {
2186
+ candidates.push({
2187
+ - ynab_transaction: ynabTxn,
2188
+ - confidence: score,
2189
+ - match_reason: reasons.join(', '),
2190
+ - explanation: buildExplanation(bankTxn, ynabTxn, score, reasons),
2191
+ + ynabTransaction: ynabTxn,
2192
+ + scores,
2193
+ + matchReasons: buildMatchReasons(scores, config),
2194
+ });
2195
+ }
2196
+ }
2197
+
2198
+ - // Sort by confidence (desc), then priority (desc), then date proximity
2199
+ candidates.sort((a, b) => {
2200
+ - if (b.confidence !== a.confidence) {
2201
+ - return b.confidence - a.confidence;
2202
+ + const scoreDiff = b.scores.combined - a.scores.combined;
2203
+ + if (scoreDiff !== 0) {
2204
+ + return scoreDiff;
2205
+ + }
2206
+ +
2207
+ + const aUncleared = a.ynabTransaction.cleared === 'uncleared' ? 1 : 0;
2208
+ + const bUncleared = b.ynabTransaction.cleared === 'uncleared' ? 1 : 0;
2209
+ + if (aUncleared !== bUncleared) {
2210
+ + return bUncleared - aUncleared;
2211
+ + }
2212
+ +
2213
+ + const bankTime = new Date(bankTxn.date).getTime();
2214
+ + const aDiff = Math.abs(bankTime - new Date(a.ynabTransaction.date).getTime());
2215
+ + const bDiff = Math.abs(bankTime - new Date(b.ynabTransaction.date).getTime());
2216
+ + if (aDiff !== bDiff) {
2217
+ + return aDiff - bDiff;
2218
+ }
2219
+ - const priorityDiff = getPriority(b.ynab_transaction) - getPriority(a.ynab_transaction);
2220
+ - if (priorityDiff !== 0) return priorityDiff;
2221
+ -
2222
+ - // Date proximity as tiebreaker
2223
+ - const dateProximityA = Math.abs(
2224
+ - new Date(bankTxn.date).getTime() - new Date(a.ynab_transaction.date).getTime(),
2225
+ - );
2226
+ - const dateProximityB = Math.abs(
2227
+ - new Date(bankTxn.date).getTime() - new Date(b.ynab_transaction.date).getTime(),
2228
+ - );
2229
+ - return dateProximityA - dateProximityB;
2230
+ - });
2231
+
2232
+ + return 0;
2233
+ + });
2234
+ return candidates;
2235
+ }
2236
+
2237
+ -/**
2238
+ - * Build human-readable explanation for a match
2239
+ - */
2240
+ -function buildExplanation(
2241
+ - _bankTxn: BankTransaction,
2242
+ - ynabTxn: YNABTransaction,
2243
+ - score: number,
2244
+ - reasons: string[],
2245
+ -): string {
2246
+ - const parts: string[] = [];
2247
+ -
2248
+ - parts.push(`Match confidence: ${score}%`);
2249
+ - parts.push(reasons.join(', '));
2250
+ -
2251
+ - if (ynabTxn.cleared === 'uncleared') {
2252
+ - parts.push('(Uncleared - awaiting confirmation)');
2253
+ +function calculateScores(
2254
+ + bankTxn: CanonicalBankTransaction,
2255
+ + ynabTxn: NormalizedYNABTransaction,
2256
+ + config: MatchingConfig,
2257
+ +): MatchCandidate['scores'] {
2258
+ + // Amount score - now using INTEGER comparison (milliunits)
2259
+ + const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
2260
+ + let amountScore: number;
2261
+ +
2262
+ + if (amountDiff === 0) {
2263
+ + // Exact integer match - no floating point issues!
2264
+ + amountScore = 100;
2265
+ + } else if (amountDiff <= config.amountToleranceMilliunits) {
2266
+ + amountScore = 95;
2267
+ + } else if (amountDiff <= 1000) {
2268
+ + // Within $1
2269
+ + amountScore = 80 - (amountDiff / 1000) * 20;
2270
+ + } else {
2271
+ + amountScore = Math.max(0, 60 - (amountDiff / 1000) * 5);
2272
+ }
2273
+
2274
+ - return parts.join(' | ');
2275
+ + // Date score
2276
+ + const bankDate = new Date(bankTxn.date);
2277
+ + const ynabDate = new Date(ynabTxn.date);
2278
+ + const daysDiff = Math.abs(bankDate.getTime() - ynabDate.getTime()) / (1000 * 60 * 60 * 24);
2279
+ + let dateScore: number;
2280
+ +
2281
+ + if (daysDiff < 0.5) {
2282
+ + dateScore = 100;
2283
+ + } else if (daysDiff <= 1) {
2284
+ + dateScore = 95;
2285
+ + } else if (daysDiff <= config.dateToleranceDays) {
2286
+ + dateScore = 90 - (daysDiff - 1) * (40 / config.dateToleranceDays);
2287
+ + } else {
2288
+ + dateScore = Math.max(0, 50 - (daysDiff - config.dateToleranceDays) * 5);
2289
+ + }
2290
+ +
2291
+ + // Payee score using fuzzball
2292
+ + const payeeScore = calculatePayeeScore(bankTxn.payee, ynabTxn.payee);
2293
+ +
2294
+ + // Combined score with weights
2295
+ + let combined =
2296
+ + amountScore * config.weights.amount +
2297
+ + dateScore * config.weights.date +
2298
+ + payeeScore * config.weights.payee;
2299
+ +
2300
+ + // Apply bonuses
2301
+ + if (amountScore === 100) combined += config.exactAmountBonus;
2302
+ + if (dateScore === 100) combined += config.exactDateBonus;
2303
+ + if (payeeScore >= 95) combined += config.exactPayeeBonus;
2304
+ +
2305
+ + combined = Math.min(100, combined);
2306
+ +
2307
+ + return {
2308
+ + amount: Math.round(amountScore),
2309
+ + date: Math.round(dateScore),
2310
+ + payee: Math.round(payeeScore),
2311
+ + combined: Math.round(combined),
2312
+ + };
2313
+ }
2314
+
2315
+ -/**
2316
+ - * Find best match for a single bank transaction
2317
+ - */
2318
+ -export function findBestMatch(
2319
+ - bankTxn: BankTransaction,
2320
+ - ynabTransactions: YNABTransaction[],
2321
+ - usedIds: Set<string>,
2322
+ - config: MatchingConfig,
2323
+ -): TransactionMatch {
2324
+ - const candidates = findMatchCandidates(bankTxn, ynabTransactions, usedIds, config);
2325
+ +function calculatePayeeScore(bankPayee: string, ynabPayee: string | null): number {
2326
+ + if (!ynabPayee) return 30;
2327
+
2328
+ - if (candidates.length === 0) {
2329
+ - // No match found
2330
+ - return {
2331
+ - bank_transaction: bankTxn,
2332
+ - confidence: 'none',
2333
+ - confidence_score: 0,
2334
+ - match_reason: 'No matching transaction found in YNAB',
2335
+ - action_hint: 'add_to_ynab',
2336
+ - recommendation: 'This transaction appears on bank statement but not in YNAB',
2337
+ - };
2338
+ - }
2339
+ + const scores = [
2340
+ + fuzz.token_set_ratio(bankPayee, ynabPayee),
2341
+ + fuzz.token_sort_ratio(bankPayee, ynabPayee),
2342
+ + fuzz.partial_ratio(bankPayee, ynabPayee),
2343
+ + fuzz.WRatio(bankPayee, ynabPayee),
2344
+ + ];
2345
+
2346
+ - const bestCandidate = candidates[0]!; // Safe: we checked candidates.length > 0
2347
+ - const bestScore = bestCandidate.confidence;
2348
+ + return Math.max(...scores);
2349
+ +}
2350
+
2351
+ - // HIGH confidence: Auto-match candidate (≥90%)
2352
+ - if (bestScore >= config.autoMatchThreshold) {
2353
+ - return {
2354
+ - bank_transaction: bankTxn,
2355
+ - ynab_transaction: bestCandidate.ynab_transaction,
2356
+ - confidence: 'high',
2357
+ - confidence_score: bestScore,
2358
+ - match_reason: bestCandidate.match_reason,
2359
+ - };
2360
+ +function buildMatchReasons(scores: MatchCandidate['scores'], config: MatchingConfig): string[] {
2361
+ + const reasons: string[] = [];
2362
+ +
2363
+ + if (scores.amount === 100) {
2364
+ + reasons.push('Exact amount match');
2365
+ + } else if (scores.amount >= 95) {
2366
+ + reasons.push('Amount within tolerance');
2367
+ }
2368
+
2369
+ - // MEDIUM confidence: Suggested match (60-89%)
2370
+ - if (bestScore >= config.suggestionThreshold) {
2371
+ - return {
2372
+ - bank_transaction: bankTxn,
2373
+ - ynab_transaction: bestCandidate.ynab_transaction,
2374
+ - candidates: candidates.slice(0, 3), // Top 3 candidates
2375
+ - confidence: 'medium',
2376
+ - confidence_score: bestScore,
2377
+ - match_reason: bestCandidate.match_reason,
2378
+ - top_confidence: bestScore,
2379
+ - action_hint: 'review_and_choose',
2380
+ - };
2381
+ + if (scores.date === 100) {
2382
+ + reasons.push('Same date');
2383
+ + } else if (scores.date >= 90) {
2384
+ + reasons.push('Date within 1-2 days');
2385
+ + } else if (scores.date >= 50) {
2386
+ + reasons.push(`Date within ${config.dateToleranceDays} days`);
2387
+ }
2388
+
2389
+ - // LOW confidence: Show as possible match but don't auto-suggest (30-59%)
2390
+ - return {
2391
+ - bank_transaction: bankTxn,
2392
+ - candidates: candidates.slice(0, 3),
2393
+ - confidence: 'low',
2394
+ - confidence_score: bestScore,
2395
+ - match_reason: 'Low confidence match',
2396
+ - top_confidence: bestScore,
2397
+ - action_hint: 'review_or_add_new',
2398
+ - recommendation: 'Consider reviewing candidates or adding as new transaction',
2399
+ - };
2400
+ + if (scores.payee >= 95) {
2401
+ + reasons.push('Payee exact match');
2402
+ + } else if (scores.payee >= 80) {
2403
+ + reasons.push('Payee highly similar');
2404
+ + } else if (scores.payee >= 60) {
2405
+ + reasons.push('Payee somewhat similar');
2406
+ + }
2407
+ +
2408
+ + return reasons;
2409
+ }
2410
+
2411
+ -/**
2412
+ - * Find matches for all bank transactions
2413
+ - */
2414
+ -export function findMatches(
2415
+ - bankTransactions: BankTransaction[],
2416
+ - ynabTransactions: YNABTransaction[],
2417
+ - config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
2418
+ -): TransactionMatch[] {
2419
+ - const matches: TransactionMatch[] = [];
2420
+ - const usedIds = new Set<string>();
2421
+ +export function findBestMatch(
2422
+ + bankTransaction: CanonicalBankTransaction,
2423
+ + ynabTransactions: NormalizedYNABTransaction[],
2424
+ + usedYnabIds?: Set<string>,
2425
+ + config?: MatchingConfig,
2426
+ +): MatchResult;
2427
+
2428
+ - for (const bankTxn of bankTransactions) {
2429
+ - const match = findBestMatch(bankTxn, ynabTransactions, usedIds, config);
2430
+ - matches.push(match);
2431
+ +export function findBestMatch(
2432
+ + bankTransaction: LegacyBankTransaction,
2433
+ + ynabTransactions: LegacyYNABTransaction[],
2434
+ + usedYnabIds: Set<string>,
2435
+ + config: LegacyMatchingConfig,
2436
+ +): LegacyTransactionMatch;
2437
+
2438
+ - // Mark high-confidence matches as used to prevent duplicate matching
2439
+ - if (match.confidence === 'high' && match.ynab_transaction) {
2440
+ - usedIds.add(match.ynab_transaction.id);
2441
+ - }
2442
+ +export function findBestMatch(
2443
+ + bankTransaction: CanonicalBankTransaction | LegacyBankTransaction,
2444
+ + ynabTransactions: NormalizedYNABTransaction[] | LegacyYNABTransaction[],
2445
+ + usedYnabIds: Set<string> = new Set<string>(),
2446
+ + config?: AnyMatchingConfig,
2447
+ +): MatchResult | LegacyTransactionMatch {
2448
+ + const result = matchSingle(bankTransaction, ynabTransactions, usedYnabIds, config);
2449
+ +
2450
+ + if (isLegacyBankTransaction(bankTransaction)) {
2451
+ + return mapToLegacyTransactionMatch(result);
2452
+ }
2453
+
2454
+ - return matches;
2455
+ + return result;
2456
+ }