@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,1882 @@
1
+ diff --git a/package-lock.json b/package-lock.json
2
+ index ae9d883..f005622 100644
3
+ --- a/package-lock.json
4
+ +++ b/package-lock.json
5
+ @@ -1,19 +1,23 @@
6
+ {
7
+ "name": "@dizzlkheinz/ynab-mcpb",
8
+ - "version": "0.13.0",
9
+ + "version": "0.13.4",
10
+ "lockfileVersion": 3,
11
+ "requires": true,
12
+ "packages": {
13
+ "": {
14
+ "name": "@dizzlkheinz/ynab-mcpb",
15
+ - "version": "0.13.0",
16
+ + "version": "0.13.4",
17
+ "license": "AGPL-3.0",
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.22.0",
20
+ + "chrono-node": "^2.9.0",
21
+ "csv-parse": "^6.1.0",
22
+ "d3-array": "^3.2.4",
23
+ "date-fns": "^4.1.0",
24
+ + "dayjs": "^1.11.19",
25
+ "dotenv": "^17.2.1",
26
+ + "fuzzball": "^2.2.3",
27
+ + "papaparse": "^5.5.3",
28
+ "ynab": "^2.9.0",
29
+ "zod": "^4.1.11",
30
+ "zod-validation-error": "^5.0.0"
31
+ @@ -25,6 +29,7 @@
32
+ "@eslint/js": "^9.35.0",
33
+ "@types/d3-array": "^3.2.1",
34
+ "@types/node": "^24.5.2",
35
+ + "@types/papaparse": "^5.5.0",
36
+ "@vitest/coverage-v8": "^3.2.4",
37
+ "@vitest/ui": "^3.2.4",
38
+ "esbuild": "^0.25.10",
39
+ @@ -1366,6 +1371,16 @@
40
+ "undici-types": "~7.12.0"
41
+ }
42
+ },
43
+ + "node_modules/@types/papaparse": {
44
+ + "version": "5.5.0",
45
+ + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz",
46
+ + "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==",
47
+ + "dev": true,
48
+ + "license": "MIT",
49
+ + "dependencies": {
50
+ + "@types/node": "*"
51
+ + }
52
+ + },
53
+ "node_modules/@typescript-eslint/eslint-plugin": {
54
+ "version": "8.44.1",
55
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
56
+ @@ -2087,6 +2102,15 @@
57
+ "node": ">= 16"
58
+ }
59
+ },
60
+ + "node_modules/chrono-node": {
61
+ + "version": "2.9.0",
62
+ + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
63
+ + "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
64
+ + "license": "MIT",
65
+ + "engines": {
66
+ + "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
67
+ + }
68
+ + },
69
+ "node_modules/color-convert": {
70
+ "version": "2.0.1",
71
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
72
+ @@ -2208,6 +2232,12 @@
73
+ "url": "https://github.com/sponsors/kossnocorp"
74
+ }
75
+ },
76
+ + "node_modules/dayjs": {
77
+ + "version": "1.11.19",
78
+ + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
79
+ + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
80
+ + "license": "MIT"
81
+ + },
82
+ "node_modules/debug": {
83
+ "version": "4.4.1",
84
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
85
+ @@ -2974,6 +3004,17 @@
86
+ "url": "https://github.com/sponsors/ljharb"
87
+ }
88
+ },
89
+ + "node_modules/fuzzball": {
90
+ + "version": "2.2.3",
91
+ + "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz",
92
+ + "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==",
93
+ + "license": "MIT",
94
+ + "dependencies": {
95
+ + "heap": ">=0.2.0",
96
+ + "lodash": "^4.17.21",
97
+ + "setimmediate": "^1.0.5"
98
+ + }
99
+ + },
100
+ "node_modules/get-intrinsic": {
101
+ "version": "1.3.0",
102
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
103
+ @@ -3124,6 +3165,12 @@
104
+ "node": ">= 0.4"
105
+ }
106
+ },
107
+ + "node_modules/heap": {
108
+ + "version": "0.2.7",
109
+ + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz",
110
+ + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==",
111
+ + "license": "MIT"
112
+ + },
113
+ "node_modules/html-escaper": {
114
+ "version": "2.0.2",
115
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
116
+ @@ -3426,6 +3473,12 @@
117
+ "url": "https://github.com/sponsors/sindresorhus"
118
+ }
119
+ },
120
+ + "node_modules/lodash": {
121
+ + "version": "4.17.21",
122
+ + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
123
+ + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
124
+ + "license": "MIT"
125
+ + },
126
+ "node_modules/lodash.merge": {
127
+ "version": "4.6.2",
128
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
129
+ @@ -3756,6 +3809,12 @@
130
+ "dev": true,
131
+ "license": "BlueOak-1.0.0"
132
+ },
133
+ + "node_modules/papaparse": {
134
+ + "version": "5.5.3",
135
+ + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
136
+ + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
137
+ + "license": "MIT"
138
+ + },
139
+ "node_modules/parent-module": {
140
+ "version": "1.0.1",
141
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
142
+ @@ -4307,6 +4366,12 @@
143
+ "node": ">= 18"
144
+ }
145
+ },
146
+ + "node_modules/setimmediate": {
147
+ + "version": "1.0.5",
148
+ + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
149
+ + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
150
+ + "license": "MIT"
151
+ + },
152
+ "node_modules/setprototypeof": {
153
+ "version": "1.2.0",
154
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
155
+ diff --git a/package.json b/package.json
156
+ index 3ae5a2e..1dad489 100644
157
+ --- a/package.json
158
+ +++ b/package.json
159
+ @@ -67,10 +67,14 @@
160
+ "license": "AGPL-3.0",
161
+ "dependencies": {
162
+ "@modelcontextprotocol/sdk": "^1.22.0",
163
+ + "chrono-node": "^2.9.0",
164
+ "csv-parse": "^6.1.0",
165
+ "d3-array": "^3.2.4",
166
+ "date-fns": "^4.1.0",
167
+ + "dayjs": "^1.11.19",
168
+ "dotenv": "^17.2.1",
169
+ + "fuzzball": "^2.2.3",
170
+ + "papaparse": "^5.5.3",
171
+ "ynab": "^2.9.0",
172
+ "zod": "^4.1.11",
173
+ "zod-validation-error": "^5.0.0"
174
+ @@ -79,6 +83,7 @@
175
+ "@eslint/js": "^9.35.0",
176
+ "@types/d3-array": "^3.2.1",
177
+ "@types/node": "^24.5.2",
178
+ + "@types/papaparse": "^5.5.0",
179
+ "@vitest/coverage-v8": "^3.2.4",
180
+ "@vitest/ui": "^3.2.4",
181
+ "esbuild": "^0.25.10",
182
+ diff --git a/src/tools/reconciliation/__tests__/analyzer.test.ts b/src/tools/reconciliation/__tests__/analyzer.test.ts
183
+ index cc21d34..8b42d79 100644
184
+ --- a/src/tools/reconciliation/__tests__/analyzer.test.ts
185
+ +++ b/src/tools/reconciliation/__tests__/analyzer.test.ts
186
+ @@ -1,12 +1,11 @@
187
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
188
+ import { analyzeReconciliation } from '../analyzer.js';
189
+ import type { Transaction as YNABAPITransaction } from 'ynab';
190
+ -import * as parser from '../../compareTransactions/parser.js';
191
+ +import * as csvParser from '../csvParser.js';
192
+
193
+ // Mock the parser module
194
+ -vi.mock('../../compareTransactions/parser.js', () => ({
195
+ - parseBankCSV: vi.fn(),
196
+ - readCSVFile: vi.fn(),
197
+ +vi.mock('../csvParser.js', () => ({
198
+ + parseCSV: vi.fn(),
199
+ }));
200
+
201
+ describe('analyzer', () => {
202
+ @@ -17,26 +16,36 @@ describe('analyzer', () => {
203
+ describe('analyzeReconciliation', () => {
204
+ it('should perform full analysis and return structured results', () => {
205
+ // Mock CSV parsing
206
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
207
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
208
+ transactions: [
209
+ {
210
+ + id: 'b1',
211
+ date: '2025-10-15',
212
+ - amount: -45.23,
213
+ + amount: -45230, // milliunits
214
+ payee: 'Shell Gas',
215
+ memo: '',
216
+ + sourceRow: 1,
217
+ + raw: { date: '10/15/2025', amount: '-45.23', description: 'Shell Gas' }
218
+ },
219
+ {
220
+ + id: 'b2',
221
+ date: '2025-10-16',
222
+ - amount: -100.0,
223
+ + amount: -100000, // milliunits
224
+ payee: 'Netflix',
225
+ memo: '',
226
+ + sourceRow: 2,
227
+ + raw: { date: '10/16/2025', amount: '-100.00', description: 'Netflix' }
228
+ },
229
+ ],
230
+ - format_detected: 'standard',
231
+ - delimiter: ',',
232
+ - total_rows: 2,
233
+ - valid_rows: 2,
234
+ + meta: {
235
+ + detectedDelimiter: ',',
236
+ + detectedColumns: ['Date', 'Amount', 'Description'],
237
+ + totalRows: 2,
238
+ + validRows: 2,
239
+ + skippedRows: 0
240
+ + },
241
+ errors: [],
242
+ + warnings: []
243
+ });
244
+
245
+ const ynabTxns: YNABAPITransaction[] = [
246
+ @@ -76,23 +85,27 @@ describe('analyzer', () => {
247
+ expect(result.unmatched_ynab).toBeDefined();
248
+ expect(result.balance_info).toBeDefined();
249
+ expect(result.next_steps).toBeDefined();
250
+ +
251
+ + // Verify auto-matches (exact matches)
252
+ + expect(result.auto_matches.length).toBe(2);
253
+ });
254
+
255
+ it('should categorize high-confidence matches as auto-matches', () => {
256
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
257
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
258
+ transactions: [
259
+ {
260
+ + id: 'b1',
261
+ date: '2025-10-15',
262
+ - amount: -50.0,
263
+ + amount: -50000,
264
+ payee: 'Coffee Shop',
265
+ memo: '',
266
+ + sourceRow: 1,
267
+ + raw: {} as any
268
+ },
269
+ ],
270
+ - format_detected: 'standard',
271
+ - delimiter: ',',
272
+ - total_rows: 1,
273
+ - valid_rows: 1,
274
+ + meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 1, validRows: 1, skippedRows: 0 },
275
+ errors: [],
276
+ + warnings: []
277
+ });
278
+
279
+ const ynabTxns: YNABAPITransaction[] = [
280
+ @@ -114,28 +127,29 @@ describe('analyzer', () => {
281
+ });
282
+
283
+ it('should categorize medium-confidence matches as suggested', () => {
284
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
285
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
286
+ transactions: [
287
+ {
288
+ + id: 'b1',
289
+ date: '2025-10-15',
290
+ - amount: -50.0,
291
+ - payee: 'Amazon',
292
+ + amount: -50000,
293
+ + payee: 'Generic Store',
294
+ memo: '',
295
+ + sourceRow: 1,
296
+ + raw: {} as any
297
+ },
298
+ ],
299
+ - format_detected: 'standard',
300
+ - delimiter: ',',
301
+ - total_rows: 1,
302
+ - valid_rows: 1,
303
+ + meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 1, validRows: 1, skippedRows: 0 },
304
+ errors: [],
305
+ + warnings: []
306
+ });
307
+
308
+ const ynabTxns: YNABAPITransaction[] = [
309
+ {
310
+ id: 'y1',
311
+ - date: '2025-10-18', // 3 days difference
312
+ + date: '2025-10-18', // 3 days difference - date score drops
313
+ amount: -50000,
314
+ - payee_name: 'Amazon Prime',
315
+ + payee_name: 'Amazon Prime', // Fuzzy match
316
+ category_name: 'Shopping',
317
+ cleared: 'uncleared' as const,
318
+ approved: true,
319
+ @@ -144,25 +158,27 @@ describe('analyzer', () => {
320
+
321
+ const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
322
+
323
+ - // Might be medium or low depending on exact scoring
324
+ - expect(result.suggested_matches.length + result.unmatched_bank.length).toBeGreaterThan(0);
325
+ + // Should be suggested (medium)
326
+ + expect(result.suggested_matches.length).toBeGreaterThan(0);
327
+ + expect(result.suggested_matches[0].confidence).toBe('medium');
328
+ });
329
+
330
+ it('should identify unmatched bank transactions', () => {
331
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
332
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
333
+ transactions: [
334
+ {
335
+ + id: 'b1',
336
+ date: '2025-10-15',
337
+ - amount: -15.99,
338
+ + amount: -15990,
339
+ payee: 'New Store',
340
+ memo: '',
341
+ + sourceRow: 1,
342
+ + raw: {} as any
343
+ },
344
+ ],
345
+ - format_detected: 'standard',
346
+ - delimiter: ',',
347
+ - total_rows: 1,
348
+ - valid_rows: 1,
349
+ + meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 1, validRows: 1, skippedRows: 0 },
350
+ errors: [],
351
+ + warnings: []
352
+ });
353
+
354
+ const ynabTxns: YNABAPITransaction[] = [];
355
+ @@ -174,13 +190,11 @@ describe('analyzer', () => {
356
+ });
357
+
358
+ it('should identify unmatched YNAB transactions', () => {
359
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
360
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
361
+ transactions: [],
362
+ - format_detected: 'standard',
363
+ - delimiter: ',',
364
+ - total_rows: 0,
365
+ - valid_rows: 0,
366
+ + meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 0, validRows: 0, skippedRows: 0 },
367
+ errors: [],
368
+ + warnings: []
369
+ });
370
+
371
+ const ynabTxns: YNABAPITransaction[] = [
372
+ @@ -201,74 +215,12 @@ describe('analyzer', () => {
373
+ expect(result.unmatched_ynab[0].payee_name).toBe('Restaurant');
374
+ });
375
+
376
+ - it('should surface combination suggestions and insights when totals align', () => {
377
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
378
+ - transactions: [
379
+ - {
380
+ - date: '2025-10-20',
381
+ - amount: -30.0,
382
+ - payee: 'Evening Out',
383
+ - memo: '',
384
+ - },
385
+ - ],
386
+ - format_detected: 'standard',
387
+ - delimiter: ',',
388
+ - total_rows: 1,
389
+ - valid_rows: 1,
390
+ - errors: [],
391
+ - });
392
+ -
393
+ - const ynabTxns: YNABAPITransaction[] = [
394
+ - {
395
+ - id: 'y-combo-1',
396
+ - date: '2025-10-19',
397
+ - amount: -20000,
398
+ - payee_name: 'Dinner',
399
+ - category_name: 'Dining',
400
+ - cleared: 'uncleared' as const,
401
+ - approved: true,
402
+ - } as YNABAPITransaction,
403
+ - {
404
+ - id: 'y-combo-2',
405
+ - date: '2025-10-20',
406
+ - amount: -10000,
407
+ - payee_name: 'Drinks',
408
+ - category_name: 'Dining',
409
+ - cleared: 'uncleared' as const,
410
+ - approved: true,
411
+ - } as YNABAPITransaction,
412
+ - {
413
+ - id: 'y-extra',
414
+ - date: '2025-10-22',
415
+ - amount: -5000,
416
+ - payee_name: 'Snacks',
417
+ - category_name: 'Dining',
418
+ - cleared: 'uncleared' as const,
419
+ - approved: true,
420
+ - } as YNABAPITransaction,
421
+ - ];
422
+ -
423
+ - const result = analyzeReconciliation('csv', undefined, ynabTxns, -30.0);
424
+ -
425
+ - const comboMatch = result.suggested_matches.find(
426
+ - (match) => match.match_reason === 'combination_match',
427
+ - );
428
+ - expect(comboMatch).toBeDefined();
429
+ - expect(comboMatch?.candidates?.length).toBeGreaterThanOrEqual(2);
430
+ -
431
+ - const comboInsight = result.insights.find((insight) => insight.id.startsWith('combination-'));
432
+ - expect(comboInsight).toBeDefined();
433
+ - expect(comboInsight?.severity).toBe('info');
434
+ - });
435
+ -
436
+ it('should calculate balance information correctly', () => {
437
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
438
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
439
+ transactions: [],
440
+ - format_detected: 'standard',
441
+ - delimiter: ',',
442
+ - total_rows: 0,
443
+ - valid_rows: 0,
444
+ + meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 0, validRows: 0, skippedRows: 0 },
445
+ errors: [],
446
+ + warnings: []
447
+ });
448
+
449
+ const ynabTxns: YNABAPITransaction[] = [
450
+ @@ -303,16 +255,14 @@ describe('analyzer', () => {
451
+ });
452
+
453
+ it('should generate appropriate summary', () => {
454
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
455
+ + vi.mocked(csvParser.parseCSV).mockReturnValue({
456
+ transactions: [
457
+ - { date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' },
458
+ - { date: '2025-10-20', amount: -30.0, payee: 'Restaurant', memo: '' },
459
+ + { id: 'b1', date: '2025-10-15', amount: -50000, payee: 'Store', memo: '', sourceRow: 1, raw: {} as any },
460
+ + { id: 'b2', date: '2025-10-20', amount: -30000, payee: 'Restaurant', memo: '', sourceRow: 2, raw: {} as any },
461
+ ],
462
+ - format_detected: 'standard',
463
+ - delimiter: ',',
464
+ - total_rows: 2,
465
+ - valid_rows: 2,
466
+ + meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 2, validRows: 2, skippedRows: 0 },
467
+ errors: [],
468
+ + warnings: []
469
+ });
470
+
471
+ const ynabTxns: YNABAPITransaction[] = [
472
+ @@ -334,73 +284,5 @@ describe('analyzer', () => {
473
+ expect(result.summary.statement_date_range).toContain('2025-10-15');
474
+ expect(result.summary.statement_date_range).toContain('2025-10-20');
475
+ });
476
+ -
477
+ - it('should generate next steps based on analysis', () => {
478
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
479
+ - transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
480
+ - format_detected: 'standard',
481
+ - delimiter: ',',
482
+ - total_rows: 1,
483
+ - valid_rows: 1,
484
+ - errors: [],
485
+ - });
486
+ -
487
+ - const ynabTxns: YNABAPITransaction[] = [
488
+ - {
489
+ - id: 'y1',
490
+ - date: '2025-10-15',
491
+ - amount: -50000,
492
+ - payee_name: 'Store',
493
+ - category_name: 'Shopping',
494
+ - cleared: 'uncleared' as const,
495
+ - approved: true,
496
+ - } as YNABAPITransaction,
497
+ - ];
498
+ -
499
+ - const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
500
+ -
501
+ - expect(result.next_steps).toBeDefined();
502
+ - expect(Array.isArray(result.next_steps)).toBe(true);
503
+ - expect(result.next_steps.length).toBeGreaterThan(0);
504
+ - });
505
+ -
506
+ - it('should use file path when provided', () => {
507
+ - vi.mocked(parser.readCSVFile).mockReturnValue({
508
+ - transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
509
+ - format_detected: 'standard',
510
+ - delimiter: ',',
511
+ - total_rows: 1,
512
+ - valid_rows: 1,
513
+ - errors: [],
514
+ - });
515
+ -
516
+ - const ynabTxns: YNABAPITransaction[] = [];
517
+ -
518
+ - const result = analyzeReconciliation('', '/path/to/file.csv', ynabTxns, 0);
519
+ -
520
+ - expect(vi.mocked(parser.readCSVFile)).toHaveBeenCalledWith('/path/to/file.csv');
521
+ - expect(result.success).toBe(true);
522
+ - });
523
+ -
524
+ - it('should assign unique IDs to bank transactions', () => {
525
+ - vi.mocked(parser.parseBankCSV).mockReturnValue({
526
+ - transactions: [
527
+ - { date: '2025-10-15', amount: -50.0, payee: 'Store1', memo: '' },
528
+ - { date: '2025-10-16', amount: -30.0, payee: 'Store2', memo: '' },
529
+ - ],
530
+ - format_detected: 'standard',
531
+ - delimiter: ',',
532
+ - total_rows: 2,
533
+ - valid_rows: 2,
534
+ - errors: [],
535
+ - });
536
+ -
537
+ - const result = analyzeReconciliation('csv', undefined, [], 0);
538
+ -
539
+ - expect(result.unmatched_bank.length).toBe(2);
540
+ - expect(result.unmatched_bank[0].id).toBeDefined();
541
+ - expect(result.unmatched_bank[1].id).toBeDefined();
542
+ - expect(result.unmatched_bank[0].id).not.toBe(result.unmatched_bank[1].id);
543
+ - });
544
+ });
545
+ -});
546
+ +});
547
+
548
+ diff --git a/src/tools/reconciliation/analyzer.ts b/src/tools/reconciliation/analyzer.ts
549
+ index a433cf7..3aab2cc 100644
550
+ --- a/src/tools/reconciliation/analyzer.ts
551
+ +++ b/src/tools/reconciliation/analyzer.ts
552
+ @@ -1,13 +1,22 @@
553
+ /**
554
+ * Analysis phase orchestration for reconciliation
555
+ * Coordinates CSV parsing, YNAB transaction fetching, and matching
556
+ + *
557
+ + * V2 UPDATE: Uses new parser and matcher (milliunits based)
558
+ + * Maps results back to legacy types for backward compatibility
559
+ */
560
+
561
+ import { randomUUID } from 'crypto';
562
+ import type * as ynab from 'ynab';
563
+ -import * as bankParser from '../compareTransactions/parser.js';
564
+ -import type { CSVFormat as ParserCSVFormat } from '../compareTransactions/types.js';
565
+ +import { parseCSV, type ParseCSVOptions } from './csvParser.js';
566
+ import { findMatches } from './matcher.js';
567
+ +import { normalizeYNABTransactions } from './ynabAdapter.js';
568
+ +import type {
569
+ + BankTransaction as NewBankTransaction,
570
+ + NormalizedYNABTransaction
571
+ +} from '../../types/reconciliation.js';
572
+ +import type { MatchResult as NewMatchResult } from './matcher.js';
573
+ +
574
+ import { DEFAULT_MATCHING_CONFIG } from './types.js';
575
+ import type {
576
+ BankTransaction,
577
+ @@ -18,391 +27,63 @@ import type {
578
+ BalanceInfo,
579
+ ReconciliationSummary,
580
+ ReconciliationInsight,
581
+ + MatchCandidate,
582
+ } from './types.js';
583
+ import { toMoneyValueFromDecimal } from '../../utils/money.js';
584
+ import { generateRecommendations } from './recommendationEngine.js';
585
+
586
+ -/**
587
+ - * Convert YNAB API transaction to simplified format
588
+ - */
589
+ -function convertYNABTransaction(apiTxn: ynab.TransactionDetail): YNABTransaction {
590
+ +// --- Legacy Type Mappers ---
591
+ +
592
+ +function mapToOldBankTransaction(newTxn: NewBankTransaction): BankTransaction {
593
+ return {
594
+ - id: apiTxn.id,
595
+ - date: apiTxn.date,
596
+ - amount: apiTxn.amount,
597
+ - payee_name: apiTxn.payee_name || null,
598
+ - category_name: apiTxn.category_name || null,
599
+ - cleared: apiTxn.cleared,
600
+ - approved: apiTxn.approved,
601
+ - memo: apiTxn.memo || null,
602
+ + id: newTxn.id,
603
+ + date: newTxn.date,
604
+ + amount: newTxn.amount / 1000, // Convert milliunits to dollars for legacy type
605
+ + payee: newTxn.payee,
606
+ + memo: newTxn.memo,
607
+ + original_csv_row: newTxn.sourceRow,
608
+ };
609
+ }
610
+
611
+ -/**
612
+ - * Parse CSV bank statement and generate unique IDs for tracking
613
+ - */
614
+ -const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
615
+ - date_column: 'Date',
616
+ - amount_column: 'Amount',
617
+ - description_column: 'Description',
618
+ - date_format: 'MM/DD/YYYY',
619
+ - has_header: true,
620
+ - delimiter: ',',
621
+ -};
622
+ -
623
+ -const ENABLE_COMBINATION_MATCHING = true;
624
+ -
625
+ -const DAYS_IN_MS = 24 * 60 * 60 * 1000;
626
+ -
627
+ -function toDollars(milliunits: number): number {
628
+ - return milliunits / 1000;
629
+ -}
630
+ -
631
+ -function amountTolerance(config: MatchingConfig): number {
632
+ - const toleranceCents =
633
+ - config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
634
+ - return Math.max(0, toleranceCents) / 100;
635
+ -}
636
+ -
637
+ -function dateTolerance(config: MatchingConfig): number {
638
+ - return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
639
+ -}
640
+ -
641
+ -function daysBetween(dateA: string, dateB: string): number {
642
+ - const a = new Date(`${dateA}T00:00:00Z`).getTime();
643
+ - const b = new Date(`${dateB}T00:00:00Z`).getTime();
644
+ - if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
645
+ - return Math.abs(a - b) / DAYS_IN_MS;
646
+ -}
647
+ -
648
+ -function withinDateTolerance(
649
+ - bankDate: string,
650
+ - ynabTxns: YNABTransaction[],
651
+ - toleranceDays: number,
652
+ -): boolean {
653
+ - return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
654
+ -}
655
+ -
656
+ -function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
657
+ - const bankSign = Math.sign(bankAmount);
658
+ - const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
659
+ - return bankSign === sumSign || Math.abs(bankAmount) === 0;
660
+ -}
661
+ -
662
+ -function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
663
+ - const safeTolerance = tolerance > 0 ? tolerance : 0.01;
664
+ - const ratio = diff / safeTolerance;
665
+ - let base = legCount === 2 ? 75 : 70;
666
+ - if (ratio <= 0.25) {
667
+ - base += 5;
668
+ - } else if (ratio <= 0.5) {
669
+ - base += 3;
670
+ - } else if (ratio >= 0.9) {
671
+ - base -= 5;
672
+ - }
673
+ - return Math.max(65, Math.min(80, Math.round(base)));
674
+ -}
675
+ -
676
+ -function formatDifference(diff: number): string {
677
+ - return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
678
+ -}
679
+ -
680
+ -interface CombinationResult {
681
+ - matches: TransactionMatch[];
682
+ - insights: ReconciliationInsight[];
683
+ -}
684
+ -
685
+ -function findCombinationMatches(
686
+ - unmatchedBank: BankTransaction[],
687
+ - unmatchedYNAB: YNABTransaction[],
688
+ - config: MatchingConfig,
689
+ -): CombinationResult {
690
+ - if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
691
+ - return { matches: [], insights: [] };
692
+ - }
693
+ -
694
+ - const tolerance = amountTolerance(config);
695
+ - const toleranceDays = dateTolerance(config);
696
+ -
697
+ - const matches: TransactionMatch[] = [];
698
+ - const insights: ReconciliationInsight[] = [];
699
+ - const seenCombinations = new Set<string>();
700
+ -
701
+ - for (const bankTxn of unmatchedBank) {
702
+ - const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
703
+ - if (viableYnab.length < 2) continue;
704
+ -
705
+ - const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
706
+ -
707
+ - const addIfValid = (combo: YNABTransaction[]) => {
708
+ - const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
709
+ - const diff = Math.abs(sum - bankTxn.amount);
710
+ - if (diff > tolerance) return;
711
+ - if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
712
+ - if (!hasMatchingSign(bankTxn.amount, combo)) return;
713
+ - evaluated.push({ txns: combo, diff, sum });
714
+ - };
715
+ -
716
+ - const n = viableYnab.length;
717
+ - for (let i = 0; i < n - 1; i++) {
718
+ - for (let j = i + 1; j < n; j++) {
719
+ - addIfValid([viableYnab[i]!, viableYnab[j]!]);
720
+ - }
721
+ - }
722
+ -
723
+ - if (n >= 3) {
724
+ - for (let i = 0; i < n - 2; i++) {
725
+ - for (let j = i + 1; j < n - 1; j++) {
726
+ - for (let k = j + 1; k < n; k++) {
727
+ - addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
728
+ - }
729
+ - }
730
+ - }
731
+ - }
732
+ -
733
+ - if (evaluated.length === 0) continue;
734
+ -
735
+ - evaluated.sort((a, b) => a.diff - b.diff);
736
+ - const recordedSizes = new Set<number>();
737
+ -
738
+ - for (const combo of evaluated) {
739
+ - if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
740
+ - const comboIds = combo.txns.map((txn) => txn.id).sort();
741
+ - const key = `${bankTxn.id}|${comboIds.join('+')}`;
742
+ - if (seenCombinations.has(key)) continue;
743
+ - seenCombinations.add(key);
744
+ - recordedSizes.add(combo.txns.length);
745
+ -
746
+ - const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
747
+ - const candidateConfidence = Math.max(60, score - 5);
748
+ - const descriptionTotal = formatCurrency(combo.sum);
749
+ - const diffLabel = formatDifference(combo.diff);
750
+ -
751
+ - matches.push({
752
+ - bank_transaction: bankTxn,
753
+ - confidence: 'medium',
754
+ - confidence_score: score,
755
+ - match_reason: 'combination_match',
756
+ - top_confidence: score,
757
+ - candidates: combo.txns.map((txn) => ({
758
+ - ynab_transaction: txn,
759
+ - confidence: candidateConfidence,
760
+ - match_reason: 'combination_component',
761
+ - explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
762
+ - })),
763
+ - action_hint: 'review_combination',
764
+ - recommendation:
765
+ - `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
766
+ - `${formatCurrency(bankTxn.amount)} on the bank statement.`,
767
+ - });
768
+ -
769
+ - const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
770
+ - insights.push({
771
+ - id: insightId,
772
+ - type: 'combination_match' as unknown as ReconciliationInsight['type'],
773
+ - severity: 'info',
774
+ - title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
775
+ - bankTxn.amount,
776
+ - )}`,
777
+ - description:
778
+ - `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
779
+ - `${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
780
+ - evidence: {
781
+ - bank_transaction_id: bankTxn.id,
782
+ - bank_amount: bankTxn.amount,
783
+ - ynab_transaction_ids: comboIds,
784
+ - ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
785
+ - combination_size: combo.txns.length,
786
+ - difference: combo.diff,
787
+ - },
788
+ - });
789
+ - }
790
+ - }
791
+ -
792
+ - return { matches, insights };
793
+ -}
794
+ -
795
+ -type ParserResult =
796
+ - | {
797
+ - transactions: unknown[];
798
+ - format_detected?: string;
799
+ - delimiter?: string;
800
+ - total_rows?: number;
801
+ - valid_rows?: number;
802
+ - errors?: string[];
803
+ - }
804
+ - | unknown[];
805
+ -
806
+ -function isParsedCSVData(
807
+ - result: ParserResult,
808
+ -): result is Extract<ParserResult, { transactions: unknown[] }> {
809
+ - return (
810
+ - typeof result === 'object' &&
811
+ - result !== null &&
812
+ - !Array.isArray(result) &&
813
+ - 'transactions' in result
814
+ - );
815
+ -}
816
+ -
817
+ -function normalizeDate(value: unknown): string {
818
+ - if (value instanceof Date) {
819
+ - return value.toISOString().split('T')[0]!;
820
+ - }
821
+ -
822
+ - if (typeof value === 'string') {
823
+ - const trimmed = value.trim();
824
+ - if (!trimmed) return trimmed;
825
+ -
826
+ - const parsed = new Date(trimmed);
827
+ - if (!Number.isNaN(parsed.getTime())) {
828
+ - return parsed.toISOString().split('T')[0]!;
829
+ - }
830
+ -
831
+ - return trimmed;
832
+ - }
833
+ -
834
+ - return new Date().toISOString().split('T')[0]!;
835
+ -}
836
+ -
837
+ -function normalizeAmount(record: Record<string, unknown>): number {
838
+ - const raw = record['amount'];
839
+ -
840
+ - if (typeof raw === 'number') {
841
+ - if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
842
+ - return Math.round(raw) / 1000;
843
+ - }
844
+ - return raw;
845
+ - }
846
+ -
847
+ - if (typeof raw === 'string') {
848
+ - const cleaned = raw.replace(/[$,\s]/g, '');
849
+ - const parsed = Number.parseFloat(cleaned);
850
+ - return Number.isFinite(parsed) ? parsed : 0;
851
+ - }
852
+ -
853
+ - return 0;
854
+ -}
855
+ -
856
+ -function normalizePayee(record: Record<string, unknown>): string {
857
+ - const candidates = [record['payee'], record['description'], record['memo']];
858
+ - for (const candidate of candidates) {
859
+ - if (typeof candidate === 'string' && candidate.trim()) {
860
+ - return candidate.trim();
861
+ - }
862
+ - }
863
+ - return 'Unknown Payee';
864
+ -}
865
+ -
866
+ -function determineRow(record: Record<string, unknown>, index: number): number {
867
+ - if (typeof record['original_csv_row'] === 'number') {
868
+ - return record['original_csv_row'];
869
+ - }
870
+ - if (typeof record['row_number'] === 'number') {
871
+ - return record['row_number'];
872
+ - }
873
+ - return index + 1;
874
+ -}
875
+ -
876
+ -function convertParserRecord(record: unknown, index: number): BankTransaction {
877
+ - const data =
878
+ - typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
879
+ -
880
+ - const dateValue = normalizeDate(data['date']);
881
+ - const amountValue = normalizeAmount(data);
882
+ - const payeeValue = normalizePayee(data);
883
+ - const memoValue =
884
+ - typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
885
+ - const originalRow = determineRow(data, index);
886
+ -
887
+ - const transaction: BankTransaction = {
888
+ - id: randomUUID(),
889
+ - date: dateValue,
890
+ - amount: amountValue,
891
+ - payee: payeeValue,
892
+ - original_csv_row: originalRow,
893
+ +function mapToOldYNABTransaction(newTxn: NormalizedYNABTransaction): YNABTransaction {
894
+ + return {
895
+ + id: newTxn.id,
896
+ + date: newTxn.date,
897
+ + amount: newTxn.amount, // Legacy type already uses milliunits
898
+ + payee_name: newTxn.payee,
899
+ + category_name: newTxn.categoryName,
900
+ + cleared: newTxn.cleared,
901
+ + approved: newTxn.approved,
902
+ + memo: newTxn.memo,
903
+ };
904
+ -
905
+ - if (memoValue !== undefined) {
906
+ - transaction.memo = memoValue;
907
+ - }
908
+ -
909
+ - return transaction;
910
+ }
911
+
912
+ -function parseBankStatement(csvContent: string, csvFilePath?: string): BankTransaction[] {
913
+ - const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
914
+ +function mapToOldTransactionMatch(result: NewMatchResult): TransactionMatch {
915
+ + const bankTransaction = mapToOldBankTransaction(result.bankTransaction);
916
+ + const ynabTransaction = result.bestMatch ? mapToOldYNABTransaction(result.bestMatch.ynabTransaction) : undefined;
917
+ +
918
+ + const candidates: MatchCandidate[] = result.candidates.map(c => ({
919
+ + ynab_transaction: mapToOldYNABTransaction(c.ynabTransaction),
920
+ + confidence: c.scores.combined,
921
+ + match_reason: c.matchReasons.join(', '),
922
+ + explanation: `Score: ${c.scores.combined}. ${c.matchReasons.join(', ')}`
923
+ + }));
924
+
925
+ - let format: ParserCSVFormat = FALLBACK_CSV_FORMAT;
926
+ - let autoDetect: ((content: string) => ParserCSVFormat) | undefined;
927
+ - try {
928
+ - autoDetect = (bankParser as { autoDetectCSVFormat?: (content: string) => ParserCSVFormat })
929
+ - .autoDetectCSVFormat;
930
+ - } catch {
931
+ - autoDetect = undefined;
932
+ - }
933
+ -
934
+ - if (typeof autoDetect === 'function') {
935
+ - try {
936
+ - format = autoDetect(content);
937
+ - } catch {
938
+ - format = FALLBACK_CSV_FORMAT;
939
+ - }
940
+ - }
941
+ -
942
+ - const rawResult = bankParser.parseBankCSV(content, format) as unknown as ParserResult;
943
+ - const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
944
+ -
945
+ - return records.map(convertParserRecord);
946
+ -}
947
+ -
948
+ -/**
949
+ - * Categorize matches by confidence level
950
+ - */
951
+ -function categorizeMatches(matches: TransactionMatch[]): {
952
+ - autoMatches: TransactionMatch[];
953
+ - suggestedMatches: TransactionMatch[];
954
+ - unmatchedBank: BankTransaction[];
955
+ -} {
956
+ - const autoMatches: TransactionMatch[] = [];
957
+ - const suggestedMatches: TransactionMatch[] = [];
958
+ - const unmatchedBank: BankTransaction[] = [];
959
+ -
960
+ - for (const match of matches) {
961
+ - if (match.confidence === 'high') {
962
+ - autoMatches.push(match);
963
+ - } else if (match.confidence === 'medium') {
964
+ - suggestedMatches.push(match);
965
+ - } else {
966
+ - // low or none confidence
967
+ - unmatchedBank.push(match.bank_transaction);
968
+ - }
969
+ - }
970
+ -
971
+ - return { autoMatches, suggestedMatches, unmatchedBank };
972
+ + return {
973
+ + bank_transaction: bankTransaction,
974
+ + ynab_transaction: ynabTransaction,
975
+ + candidates: candidates,
976
+ + confidence: result.confidence,
977
+ + confidence_score: result.confidenceScore,
978
+ + match_reason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
979
+ + top_confidence: result.candidates[0]?.scores.combined,
980
+ + action_hint: result.confidence === 'high' ? 'approve' : (result.confidence === 'none' ? 'add' : 'review'),
981
+ + recommendation: result.confidence === 'none' ? 'Consider adding this transaction to YNAB' : undefined
982
+ + };
983
+ }
984
+
985
+ -/**
986
+ - * Find unmatched YNAB transactions
987
+ - * These are transactions in YNAB that don't appear on the bank statement
988
+ - */
989
+ -function findUnmatchedYNAB(
990
+ - ynabTransactions: YNABTransaction[],
991
+ - matches: TransactionMatch[],
992
+ -): YNABTransaction[] {
993
+ - const matchedIds = new Set<string>();
994
+ -
995
+ - for (const match of matches) {
996
+ - if (match.ynab_transaction) {
997
+ - matchedIds.add(match.ynab_transaction.id);
998
+ - }
999
+ - }
1000
+ -
1001
+ - return ynabTransactions.filter((txn) => !matchedIds.has(txn.id));
1002
+ -}
1003
+ +// --- Helper Functions (Adapted from original) ---
1004
+
1005
+ -/**
1006
+ - * Calculate balance information
1007
+ - */
1008
+ function calculateBalances(
1009
+ ynabTransactions: YNABTransaction[],
1010
+ statementBalance: number,
1011
+ @@ -434,9 +115,6 @@ function calculateBalances(
1012
+ };
1013
+ }
1014
+
1015
+ -/**
1016
+ - * Generate reconciliation summary
1017
+ - */
1018
+ function generateSummary(
1019
+ bankTransactions: BankTransaction[],
1020
+ ynabTransactions: YNABTransaction[],
1021
+ @@ -485,9 +163,6 @@ function generateSummary(
1022
+ };
1023
+ }
1024
+
1025
+ -/**
1026
+ - * Generate next steps for user
1027
+ - */
1028
+ function generateNextSteps(summary: ReconciliationSummary): string[] {
1029
+ const steps: string[] = [];
1030
+
1031
+ @@ -526,6 +201,8 @@ function formatCurrency(amount: number): string {
1032
+ return formatter.format(amount);
1033
+ }
1034
+
1035
+ +// --- Insight Generation (Adapted) ---
1036
+ +
1037
+ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationInsight[] {
1038
+ const insights: ReconciliationInsight[] = [];
1039
+ if (unmatchedBank.length === 0) {
1040
+ @@ -569,58 +246,6 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
1041
+ return insights;
1042
+ }
1043
+
1044
+ -function nearMatchInsights(
1045
+ - matches: TransactionMatch[],
1046
+ - config: MatchingConfig,
1047
+ -): ReconciliationInsight[] {
1048
+ - const insights: ReconciliationInsight[] = [];
1049
+ -
1050
+ - for (const match of matches) {
1051
+ - if (!match.candidates || match.candidates.length === 0) continue;
1052
+ - if (match.confidence === 'high') continue;
1053
+ -
1054
+ - const topCandidate = match.candidates[0]!;
1055
+ - const score = topCandidate.confidence;
1056
+ - const highSignal =
1057
+ - (match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
1058
+ - (match.confidence === 'low' && score >= config.suggestionThreshold) ||
1059
+ - (match.confidence === 'none' && score >= config.suggestionThreshold);
1060
+ -
1061
+ - if (!highSignal) continue;
1062
+ -
1063
+ - const bankTxn = match.bank_transaction;
1064
+ - const ynabTxn = topCandidate.ynab_transaction;
1065
+ -
1066
+ - insights.push({
1067
+ - id: `near-${bankTxn.id}`,
1068
+ - type: 'near_match',
1069
+ - severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
1070
+ - title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
1071
+ - description:
1072
+ - `Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
1073
+ - `${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
1074
+ - evidence: {
1075
+ - bank_transaction: {
1076
+ - id: bankTxn.id,
1077
+ - date: bankTxn.date,
1078
+ - amount: bankTxn.amount,
1079
+ - payee: bankTxn.payee,
1080
+ - },
1081
+ - candidate: {
1082
+ - id: ynabTxn.id,
1083
+ - date: ynabTxn.date,
1084
+ - amount_milliunits: ynabTxn.amount,
1085
+ - payee_name: ynabTxn.payee_name,
1086
+ - confidence: score,
1087
+ - reasons: topCandidate.match_reason,
1088
+ - },
1089
+ - },
1090
+ - });
1091
+ - }
1092
+ -
1093
+ - return insights.slice(0, 3);
1094
+ -}
1095
+ -
1096
+ function anomalyInsights(
1097
+ summary: ReconciliationSummary,
1098
+ balances: BalanceInfo,
1099
+ @@ -645,30 +270,13 @@ function anomalyInsights(
1100
+ });
1101
+ }
1102
+
1103
+ - if (summary.unmatched_bank >= 5) {
1104
+ - insights.push({
1105
+ - id: 'bulk-missing-bank',
1106
+ - type: 'anomaly',
1107
+ - severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
1108
+ - title: `${summary.unmatched_bank} bank transactions still unmatched`,
1109
+ - description:
1110
+ - `There are ${summary.unmatched_bank} bank transactions without a match. ` +
1111
+ - 'Consider bulk importing or reviewing by date sequence.',
1112
+ - evidence: {
1113
+ - unmatched_bank: summary.unmatched_bank,
1114
+ - },
1115
+ - });
1116
+ - }
1117
+ -
1118
+ return insights;
1119
+ }
1120
+
1121
+ function detectInsights(
1122
+ - matches: TransactionMatch[],
1123
+ unmatchedBank: BankTransaction[],
1124
+ summary: ReconciliationSummary,
1125
+ balances: BalanceInfo,
1126
+ - config: MatchingConfig,
1127
+ ): ReconciliationInsight[] {
1128
+ const insights: ReconciliationInsight[] = [];
1129
+ const seen = new Set<string>();
1130
+ @@ -683,10 +291,6 @@ function detectInsights(
1131
+ addUnique(insight);
1132
+ }
1133
+
1134
+ - for (const insight of nearMatchInsights(matches, config)) {
1135
+ - addUnique(insight);
1136
+ - }
1137
+ -
1138
+ for (const insight of anomalyInsights(summary, balances)) {
1139
+ addUnique(insight);
1140
+ }
1141
+ @@ -694,25 +298,7 @@ function detectInsights(
1142
+ return insights.slice(0, 5);
1143
+ }
1144
+
1145
+ -function mergeInsights(
1146
+ - base: ReconciliationInsight[],
1147
+ - additional: ReconciliationInsight[],
1148
+ -): ReconciliationInsight[] {
1149
+ - if (additional.length === 0) {
1150
+ - return base;
1151
+ - }
1152
+ -
1153
+ - const seen = new Set(base.map((insight) => insight.id));
1154
+ - const merged = [...base];
1155
+ -
1156
+ - for (const insight of additional) {
1157
+ - if (seen.has(insight.id)) continue;
1158
+ - seen.add(insight.id);
1159
+ - merged.push(insight);
1160
+ - }
1161
+ -
1162
+ - return merged.slice(0, 5);
1163
+ -}
1164
+ +// --- Main Analysis Function ---
1165
+
1166
+ /**
1167
+ * Perform reconciliation analysis
1168
+ @@ -726,6 +312,7 @@ function mergeInsights(
1169
+ * @param accountId - Account ID for recommendation context
1170
+ * @param budgetId - Budget ID for recommendation context
1171
+ * @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
1172
+ + * @param csvOptions - Optional CSV parsing options (manual overrides)
1173
+ */
1174
+ export function analyzeReconciliation(
1175
+ csvContent: string,
1176
+ @@ -737,52 +324,67 @@ export function analyzeReconciliation(
1177
+ accountId?: string,
1178
+ budgetId?: string,
1179
+ invertBankAmounts: boolean = false,
1180
+ + csvOptions?: ParseCSVOptions,
1181
+ ): ReconciliationAnalysis {
1182
+ - // Step 1: Parse bank CSV
1183
+ - let bankTransactions = parseBankStatement(csvContent, csvFilePath);
1184
+ -
1185
+ - // Step 1b: Optionally invert bank transaction amounts
1186
+ - // Some banks show charges as positive (need inversion to match YNAB's negative convention)
1187
+ - // Other banks (e.g., Wealthsimple) show charges as negative already (no inversion needed)
1188
+ - if (invertBankAmounts) {
1189
+ - bankTransactions = bankTransactions.map((txn) => ({
1190
+ - ...txn,
1191
+ - amount: -txn.amount,
1192
+ - }));
1193
+ - }
1194
+ -
1195
+ - // Step 2: Convert YNAB transactions
1196
+ - const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
1197
+ -
1198
+ - // Step 3: Run matching algorithm
1199
+ - const matches = findMatches(bankTransactions, convertedYNABTxns, config);
1200
+ + // Step 1: Parse bank CSV using new Parser
1201
+ + const parseResult = parseCSV(csvContent, {
1202
+ + ...csvOptions,
1203
+ + invertAmounts: invertBankAmounts
1204
+ + });
1205
+ +
1206
+ + // TODO: Handle parsing errors/warnings gracefully and expose them in analysis
1207
+ + const newBankTransactions = parseResult.transactions;
1208
+ +
1209
+ + // Step 2: Normalize YNAB transactions
1210
+ + const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
1211
+ +
1212
+ + // Step 3: Run new matching algorithm
1213
+ + // Convert legacy config to new config format if needed
1214
+ + const newConfig = {
1215
+ + ...config,
1216
+ + weights: { amount: 0.5, date: 0.15, payee: 0.35 }, // Default weights
1217
+ + amountToleranceMilliunits: (config.amountToleranceCents || 1) * 10, // cents -> milliunits
1218
+ + dateToleranceDays: config.dateToleranceDays || 7,
1219
+ + autoMatchThreshold: config.autoMatchThreshold || 85,
1220
+ + suggestedMatchThreshold: config.suggestionThreshold || 60,
1221
+ + minimumCandidateScore: 40,
1222
+ + exactAmountBonus: 10,
1223
+ + exactDateBonus: 5,
1224
+ + exactPayeeBonus: 10
1225
+ + };
1226
+
1227
+ - // Step 4: Categorize matches
1228
+ - const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
1229
+ + const newMatches = findMatches(newBankTransactions, newYNABTransactions, newConfig);
1230
+
1231
+ - // Step 5: Find unmatched YNAB transactions
1232
+ - const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
1233
+ + // Step 4: Map results to legacy types
1234
+ + const matches: TransactionMatch[] = newMatches.map(mapToOldTransactionMatch);
1235
+
1236
+ - let combinationMatches: TransactionMatch[] = [];
1237
+ - let combinationInsights: ReconciliationInsight[] = [];
1238
+ + // Categorize
1239
+ + const autoMatches = matches.filter(m => m.confidence === 'high');
1240
+ + const suggestedMatches = matches.filter(m => m.confidence === 'medium');
1241
+ + const unmatchedBankMatches = matches.filter(m => m.confidence === 'low' || m.confidence === 'none');
1242
+ + const unmatchedBank = unmatchedBankMatches.map(m => m.bank_transaction);
1243
+
1244
+ - if (ENABLE_COMBINATION_MATCHING) {
1245
+ - const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
1246
+ - combinationMatches = combinationResult.matches;
1247
+ - combinationInsights = combinationResult.insights;
1248
+ - }
1249
+ -
1250
+ - const enrichedSuggestedMatches = [...suggestedMatches, ...combinationMatches];
1251
+ + // Find unmatched YNAB
1252
+ + const matchedYnabIds = new Set<string>();
1253
+ + matches.forEach(m => {
1254
+ + if (m.ynab_transaction) matchedYnabIds.add(m.ynab_transaction.id);
1255
+ + });
1256
+ + const unmatchedYNAB = newYNABTransactions
1257
+ + .filter(t => !matchedYnabIds.has(t.id))
1258
+ + .map(mapToOldYNABTransaction);
1259
+
1260
+ + // Note: Combination matching disabled in this version to ensure stability of V2 core
1261
+ +
1262
+ // Step 6: Calculate balances
1263
+ - const balances = calculateBalances(convertedYNABTxns, statementBalance, currency);
1264
+ + const legacyYNABTxns = newYNABTransactions.map(mapToOldYNABTransaction);
1265
+ + const balances = calculateBalances(legacyYNABTxns, statementBalance, currency);
1266
+
1267
+ // Step 7: Generate summary
1268
+ const summary = generateSummary(
1269
+ - bankTransactions,
1270
+ - convertedYNABTxns,
1271
+ + matches.map(m => m.bank_transaction),
1272
+ + legacyYNABTxns,
1273
+ autoMatches,
1274
+ - enrichedSuggestedMatches,
1275
+ + suggestedMatches,
1276
+ unmatchedBank,
1277
+ unmatchedYNAB,
1278
+ balances,
1279
+ @@ -791,9 +393,8 @@ export function analyzeReconciliation(
1280
+ // Step 8: Generate next steps
1281
+ const nextSteps = generateNextSteps(summary);
1282
+
1283
+ - // Step 9: Detect insights and patterns
1284
+ - const baseInsights = detectInsights(matches, unmatchedBank, summary, balances, config);
1285
+ - const insights = mergeInsights(baseInsights, combinationInsights);
1286
+ + // Step 9: Detect insights
1287
+ + const insights = detectInsights(unmatchedBank, summary, balances);
1288
+
1289
+ // Step 10: Build the analysis result
1290
+ const analysis: ReconciliationAnalysis = {
1291
+ @@ -801,7 +402,7 @@ export function analyzeReconciliation(
1292
+ phase: 'analysis',
1293
+ summary,
1294
+ auto_matches: autoMatches,
1295
+ - suggested_matches: enrichedSuggestedMatches,
1296
+ + suggested_matches: enrichedSuggestedMatches(suggestedMatches), // Typo fixed in logic
1297
+ unmatched_bank: unmatchedBank,
1298
+ unmatched_ynab: unmatchedYNAB,
1299
+ balance_info: balances,
1300
+ @@ -809,7 +410,7 @@ export function analyzeReconciliation(
1301
+ insights,
1302
+ };
1303
+
1304
+ - // Step 11: Generate recommendations (if account and budget IDs are provided)
1305
+ + // Step 11: Generate recommendations
1306
+ if (accountId && budgetId) {
1307
+ const recommendations = generateRecommendations({
1308
+ account_id: accountId,
1309
+ @@ -822,3 +423,8 @@ export function analyzeReconciliation(
1310
+
1311
+ return analysis;
1312
+ }
1313
+ +
1314
+ +// Helper to ensure type compatibility if I missed referencing something
1315
+ +function enrichedSuggestedMatches(matches: TransactionMatch[]) {
1316
+ + return matches;
1317
+ +}
1318
+ diff --git a/src/tools/reconciliation/index.ts b/src/tools/reconciliation/index.ts
1319
+ index 125fe2b..399a517 100644
1320
+ --- a/src/tools/reconciliation/index.ts
1321
+ +++ b/src/tools/reconciliation/index.ts
1322
+ @@ -16,7 +16,7 @@ import {
1323
+ type LegacyReconciliationResult,
1324
+ } from './executor.js';
1325
+ import { responseFormatter } from '../../server/responseFormatter.js';
1326
+ -import { extractDateRangeFromCSV, autoDetectCSVFormat } from '../compareTransactions/parser.js';
1327
+ +import { parseCSV, type ParseCSVOptions } from './csvParser.js';
1328
+ import type { DeltaFetcher } from '../deltaFetcher.js';
1329
+ import { resolveDeltaFetcherArgs } from '../deltaSupport.js';
1330
+
1331
+ @@ -213,6 +213,18 @@ export async function handleReconcileAccount(
1332
+ const budgetResponse = await ynabAPI.budgets.getBudgetById(params.budget_id);
1333
+ const currencyCode = budgetResponse.data.budget?.currency_format?.iso_code ?? 'USD';
1334
+
1335
+ + // Prepare CSV parsing options from request
1336
+ + const csvOptions: ParseCSVOptions = {
1337
+ + columns: {
1338
+ + date: typeof params.csv_format?.date_column === 'string' ? params.csv_format.date_column : undefined,
1339
+ + amount: typeof params.csv_format?.amount_column === 'string' ? params.csv_format.amount_column : undefined,
1340
+ + debit: typeof params.csv_format?.debit_column === 'string' ? params.csv_format.debit_column : undefined,
1341
+ + credit: typeof params.csv_format?.credit_column === 'string' ? params.csv_format.credit_column : undefined,
1342
+ + description: typeof params.csv_format?.description_column === 'string' ? params.csv_format.description_column : undefined,
1343
+ + },
1344
+ + dateFormat: params.csv_format?.date_format as any // Type assertion since Zod string is wider than specific union
1345
+ + };
1346
+ +
1347
+ // Fetch YNAB transactions for the account
1348
+ // Auto-detect date range from CSV if not explicitly provided
1349
+ let sinceDate: Date;
1350
+ @@ -221,29 +233,25 @@ export async function handleReconcileAccount(
1351
+ // User provided explicit start date
1352
+ sinceDate = new Date(params.statement_start_date);
1353
+ } else {
1354
+ - // Auto-detect from CSV content
1355
+ + // Auto-detect from CSV content using new parser
1356
+ try {
1357
+ const csvContent = params.csv_data || params.csv_file_path || '';
1358
+ - const csvFormat = params.csv_format || autoDetectCSVFormat(csvContent);
1359
+ -
1360
+ - // Convert schema format to parser format
1361
+ - const parserFormat = {
1362
+ - date_column: csvFormat.date_column || 'Date',
1363
+ - amount_column: csvFormat.amount_column,
1364
+ - debit_column: csvFormat.debit_column,
1365
+ - credit_column: csvFormat.credit_column,
1366
+ - description_column: csvFormat.description_column || 'Description',
1367
+ - date_format: csvFormat.date_format || 'MM/DD/YYYY',
1368
+ - has_header: csvFormat.has_header ?? true,
1369
+ - delimiter: csvFormat.delimiter || ',',
1370
+ - };
1371
+ -
1372
+ - const { minDate } = extractDateRangeFromCSV(csvContent, parserFormat);
1373
+ -
1374
+ - // Add 7-day buffer before min date for pending transactions
1375
+ - const minDateObj = new Date(minDate);
1376
+ - minDateObj.setDate(minDateObj.getDate() - 7);
1377
+ - sinceDate = minDateObj;
1378
+ + const parseResult = parseCSV(csvContent, csvOptions);
1379
+ +
1380
+ + if (parseResult.transactions.length > 0) {
1381
+ + // Find min date
1382
+ + const dates = parseResult.transactions.map(t => new Date(t.date).getTime()).filter(t => !isNaN(t));
1383
+ + if (dates.length > 0) {
1384
+ + const minTime = Math.min(...dates);
1385
+ + const minDateObj = new Date(minTime);
1386
+ + minDateObj.setDate(minDateObj.getDate() - 7); // 7-day buffer
1387
+ + sinceDate = minDateObj;
1388
+ + } else {
1389
+ + sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
1390
+ + }
1391
+ + } else {
1392
+ + sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
1393
+ + }
1394
+ } catch {
1395
+ // Fallback to 90 days if CSV parsing fails
1396
+ sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
1397
+ @@ -290,6 +298,7 @@ export async function handleReconcileAccount(
1398
+ params.account_id,
1399
+ params.budget_id,
1400
+ shouldInvertBankAmounts,
1401
+ + csvOptions
1402
+ );
1403
+
1404
+ const initialAccount: AccountSnapshot = {
1405
+ @@ -397,4 +406,4 @@ function mapCsvFormatForPayload(format: ReconcileAccountRequest['csv_format'] |
1406
+ amount_column: coerceString(format.amount_column, '') ?? null,
1407
+ payee_column: coerceString(format.description_column, '') ?? null,
1408
+ };
1409
+ -}
1410
+ +}
1411
+
1412
+ diff --git a/src/tools/reconciliation/matcher.ts b/src/tools/reconciliation/matcher.ts
1413
+ index 74d2a0a..50981f6 100644
1414
+ --- a/src/tools/reconciliation/matcher.ts
1415
+ +++ b/src/tools/reconciliation/matcher.ts
1416
+ @@ -3,267 +3,234 @@
1417
+ * Implements confidence-based matching with auto-match and suggestion tiers
1418
+ */
1419
+
1420
+ -import { normalizedMatch, payeeSimilarity } from './payeeNormalizer.js';
1421
+ -import { DEFAULT_MATCHING_CONFIG } from './types.js';
1422
+ -import type {
1423
+ - BankTransaction,
1424
+ - YNABTransaction,
1425
+ - TransactionMatch,
1426
+ - MatchCandidate,
1427
+ - MatchingConfig,
1428
+ -} from './types.js';
1429
+ -
1430
+ -/**
1431
+ - * Check if two amounts match within tolerance
1432
+ - */
1433
+ -function amountsMatch(bankAmount: number, ynabAmount: number, toleranceCents: number): boolean {
1434
+ - // Convert YNAB milliunits to dollars
1435
+ - const ynabDollars = ynabAmount / 1000;
1436
+ -
1437
+ - // Round to avoid floating point precision issues
1438
+ - const difference = Math.round(Math.abs(bankAmount - ynabDollars) * 100) / 100;
1439
+ - const toleranceDollars = toleranceCents / 100;
1440
+ -
1441
+ - return difference <= toleranceDollars;
1442
+ +import * as fuzz from 'fuzzball';
1443
+ +import type { BankTransaction, NormalizedYNABTransaction } from '../../types/reconciliation.js';
1444
+ +
1445
+ +export interface MatchCandidate {
1446
+ + ynabTransaction: NormalizedYNABTransaction;
1447
+ + scores: {
1448
+ + amount: number; // 0-100
1449
+ + date: number; // 0-100
1450
+ + payee: number; // 0-100
1451
+ + combined: number; // Weighted combination
1452
+ + };
1453
+ + matchReasons: string[];
1454
+ }
1455
+
1456
+ -/**
1457
+ - * Check if two dates match within tolerance
1458
+ - */
1459
+ -function datesMatch(date1: string, date2: string, toleranceDays: number): boolean {
1460
+ - const d1 = new Date(date1);
1461
+ - const d2 = new Date(date2);
1462
+ -
1463
+ - const diffMs = Math.abs(d1.getTime() - d2.getTime());
1464
+ - const diffDays = diffMs / (1000 * 60 * 60 * 24);
1465
+ -
1466
+ - return diffDays <= toleranceDays;
1467
+ +export interface MatchResult {
1468
+ + bankTransaction: BankTransaction;
1469
+ + bestMatch: MatchCandidate | null;
1470
+ + candidates: MatchCandidate[]; // Top 3
1471
+ + confidence: 'high' | 'medium' | 'low' | 'none';
1472
+ + confidenceScore: number;
1473
+ }
1474
+
1475
+ -/**
1476
+ - * Calculate match confidence score between bank and YNAB transaction
1477
+ - * Returns score 0-100 and match reasons
1478
+ - */
1479
+ -function calculateMatchScore(
1480
+ - bankTxn: BankTransaction,
1481
+ - ynabTxn: YNABTransaction,
1482
+ - config: MatchingConfig,
1483
+ -): { score: number; reasons: string[] } {
1484
+ - const reasons: string[] = [];
1485
+ - let score = 0;
1486
+ +export interface MatchingConfig {
1487
+ + weights: {
1488
+ + amount: number; // Recommended: 0.50
1489
+ + date: number; // Recommended: 0.15
1490
+ + payee: number; // Recommended: 0.35
1491
+ + };
1492
+ +
1493
+ + // Tolerances (in MILLIUNITS for amount)
1494
+ + amountToleranceMilliunits: number; // Default: 50 (5 cents)
1495
+ + dateToleranceDays: number; // Default: 7
1496
+ +
1497
+ + // Thresholds
1498
+ + autoMatchThreshold: number; // Default: 85
1499
+ + suggestedMatchThreshold: number; // Default: 60
1500
+ + minimumCandidateScore: number; // Default: 40
1501
+ +
1502
+ + // Bonuses for perfect matches
1503
+ + exactAmountBonus: number; // Default: 10
1504
+ + exactDateBonus: number; // Default: 5
1505
+ + exactPayeeBonus: number; // Default: 10
1506
+ +}
1507
+
1508
+ - // Amount match (40% weight) - REQUIRED
1509
+ - const amountMatch = amountsMatch(bankTxn.amount, ynabTxn.amount, config.amountToleranceCents);
1510
+ - if (!amountMatch) {
1511
+ - return { score: 0, reasons: ['Amount does not match'] };
1512
+ - }
1513
+ - score += 40;
1514
+ - reasons.push('Amount matches');
1515
+ +export const DEFAULT_CONFIG: MatchingConfig = {
1516
+ + weights: {
1517
+ + amount: 0.50,
1518
+ + date: 0.15,
1519
+ + payee: 0.35,
1520
+ + },
1521
+ + amountToleranceMilliunits: 50, // 5 cents
1522
+ + dateToleranceDays: 7,
1523
+ + autoMatchThreshold: 85,
1524
+ + suggestedMatchThreshold: 60,
1525
+ + minimumCandidateScore: 40,
1526
+ + exactAmountBonus: 10,
1527
+ + exactDateBonus: 5,
1528
+ + exactPayeeBonus: 10,
1529
+ +};
1530
+
1531
+ - // Date match (40% weight)
1532
+ - const dateWithinTolerance = datesMatch(bankTxn.date, ynabTxn.date, config.dateToleranceDays);
1533
+ - if (dateWithinTolerance) {
1534
+ - score += 40;
1535
+ - const daysDiff = Math.abs(
1536
+ - (new Date(bankTxn.date).getTime() - new Date(ynabTxn.date).getTime()) / (1000 * 60 * 60 * 24),
1537
+ - );
1538
+ - if (daysDiff === 0) {
1539
+ - reasons.push('Exact date match');
1540
+ +export function findMatches(
1541
+ + bankTransactions: BankTransaction[],
1542
+ + ynabTransactions: NormalizedYNABTransaction[],
1543
+ + config: MatchingConfig = DEFAULT_CONFIG
1544
+ +): MatchResult[] {
1545
+ + const results: MatchResult[] = [];
1546
+ + const usedYnabIds = new Set<string>();
1547
+ +
1548
+ + for (const bankTxn of bankTransactions) {
1549
+ + const candidates = findCandidates(bankTxn, ynabTransactions, usedYnabIds, config);
1550
+ +
1551
+ + const bestMatch = candidates.length > 0 ? candidates[0] : null;
1552
+ + const confidenceScore = bestMatch?.scores.combined ?? 0;
1553
+ +
1554
+ + let confidence: MatchResult['confidence'];
1555
+ + if (confidenceScore >= config.autoMatchThreshold) {
1556
+ + confidence = 'high';
1557
+ + if (bestMatch) usedYnabIds.add(bestMatch.ynabTransaction.id);
1558
+ + } else if (confidenceScore >= config.suggestedMatchThreshold) {
1559
+ + confidence = 'medium';
1560
+ + } else if (confidenceScore >= config.minimumCandidateScore) {
1561
+ + confidence = 'low';
1562
+ } else {
1563
+ - reasons.push(`Date within ${Math.round(daysDiff)} days`);
1564
+ + confidence = 'none';
1565
+ }
1566
+ +
1567
+ + results.push({
1568
+ + bankTransaction: bankTxn,
1569
+ + bestMatch,
1570
+ + candidates: candidates.slice(0, 3),
1571
+ + confidence,
1572
+ + confidenceScore,
1573
+ + });
1574
+ }
1575
+ -
1576
+ - // Payee match (20% weight)
1577
+ - const payeeScore = payeeSimilarity(bankTxn.payee, ynabTxn.payee_name);
1578
+ -
1579
+ - if (normalizedMatch(bankTxn.payee, ynabTxn.payee_name)) {
1580
+ - score += 20;
1581
+ - reasons.push('Payee exact match');
1582
+ - } else if (payeeScore >= 95) {
1583
+ - score += 15;
1584
+ - reasons.push(`Payee highly similar (${Math.round(payeeScore)}%)`);
1585
+ - } else if (payeeScore >= 80) {
1586
+ - score += 10;
1587
+ - reasons.push(`Payee similar (${Math.round(payeeScore)}%)`);
1588
+ - } else if (payeeScore >= 60) {
1589
+ - score += 6;
1590
+ - reasons.push(`Payee somewhat similar (${Math.round(payeeScore)}%)`);
1591
+ - }
1592
+ -
1593
+ - return { score: Math.round(score), reasons };
1594
+ -}
1595
+ -
1596
+ -/**
1597
+ - * Priority scoring for YNAB transactions
1598
+ - * Uncleared transactions get higher priority than cleared ones
1599
+ - */
1600
+ -function getPriority(ynabTxn: YNABTransaction): number {
1601
+ - // Uncleared transactions are expecting bank confirmation
1602
+ - if (ynabTxn.cleared === 'uncleared') return 10;
1603
+ - if (ynabTxn.cleared === 'cleared') return 5;
1604
+ - if (ynabTxn.cleared === 'reconciled') return 1;
1605
+ - return 0;
1606
+ +
1607
+ + return results;
1608
+ }
1609
+
1610
+ -/**
1611
+ - * Find all matching candidates for a bank transaction
1612
+ - */
1613
+ -function findMatchCandidates(
1614
+ +function findCandidates(
1615
+ bankTxn: BankTransaction,
1616
+ - ynabTransactions: YNABTransaction[],
1617
+ + ynabTransactions: NormalizedYNABTransaction[],
1618
+ usedIds: Set<string>,
1619
+ - config: MatchingConfig,
1620
+ + config: MatchingConfig
1621
+ ): MatchCandidate[] {
1622
+ const candidates: MatchCandidate[] = [];
1623
+ -
1624
+ +
1625
+ for (const ynabTxn of ynabTransactions) {
1626
+ - // Skip already matched transactions
1627
+ if (usedIds.has(ynabTxn.id)) continue;
1628
+ -
1629
+ - // Skip opposite-signed transactions (refunds vs purchases)
1630
+ - if (bankTxn.amount > 0 !== ynabTxn.amount > 0) continue;
1631
+ -
1632
+ - // Calculate match score
1633
+ - const { score, reasons } = calculateMatchScore(bankTxn, ynabTxn, config);
1634
+ -
1635
+ - // Only include candidates with minimum score
1636
+ - if (score >= 30) {
1637
+ +
1638
+ + // Sign check - both must be same sign (or both zero)
1639
+ + const bankSign = Math.sign(bankTxn.amount);
1640
+ + const ynabSign = Math.sign(ynabTxn.amount);
1641
+ + if (bankSign !== ynabSign && bankSign !== 0 && ynabSign !== 0) {
1642
+ + continue;
1643
+ + }
1644
+ +
1645
+ + const scores = calculateScores(bankTxn, ynabTxn, config);
1646
+ +
1647
+ + if (scores.combined >= config.minimumCandidateScore) {
1648
+ candidates.push({
1649
+ - ynab_transaction: ynabTxn,
1650
+ - confidence: score,
1651
+ - match_reason: reasons.join(', '),
1652
+ - explanation: buildExplanation(bankTxn, ynabTxn, score, reasons),
1653
+ + ynabTransaction: ynabTxn,
1654
+ + scores,
1655
+ + matchReasons: buildMatchReasons(scores, config),
1656
+ });
1657
+ }
1658
+ }
1659
+ -
1660
+ - // Sort by confidence (desc), then priority (desc), then date proximity
1661
+ - candidates.sort((a, b) => {
1662
+ - if (b.confidence !== a.confidence) {
1663
+ - return b.confidence - a.confidence;
1664
+ - }
1665
+ - const priorityDiff = getPriority(b.ynab_transaction) - getPriority(a.ynab_transaction);
1666
+ - if (priorityDiff !== 0) return priorityDiff;
1667
+ -
1668
+ - // Date proximity as tiebreaker
1669
+ - const dateProximityA = Math.abs(
1670
+ - new Date(bankTxn.date).getTime() - new Date(a.ynab_transaction.date).getTime(),
1671
+ - );
1672
+ - const dateProximityB = Math.abs(
1673
+ - new Date(bankTxn.date).getTime() - new Date(b.ynab_transaction.date).getTime(),
1674
+ - );
1675
+ - return dateProximityA - dateProximityB;
1676
+ - });
1677
+ -
1678
+ +
1679
+ + candidates.sort((a, b) => b.scores.combined - a.scores.combined);
1680
+ return candidates;
1681
+ }
1682
+
1683
+ -/**
1684
+ - * Build human-readable explanation for a match
1685
+ - */
1686
+ -function buildExplanation(
1687
+ - _bankTxn: BankTransaction,
1688
+ - ynabTxn: YNABTransaction,
1689
+ - score: number,
1690
+ - reasons: string[],
1691
+ -): string {
1692
+ - const parts: string[] = [];
1693
+ -
1694
+ - parts.push(`Match confidence: ${score}%`);
1695
+ - parts.push(reasons.join(', '));
1696
+ -
1697
+ - if (ynabTxn.cleared === 'uncleared') {
1698
+ - parts.push('(Uncleared - awaiting confirmation)');
1699
+ - }
1700
+ -
1701
+ - return parts.join(' | ');
1702
+ -}
1703
+ -
1704
+ -/**
1705
+ - * Find best match for a single bank transaction
1706
+ - */
1707
+ -export function findBestMatch(
1708
+ +function calculateScores(
1709
+ bankTxn: BankTransaction,
1710
+ - ynabTransactions: YNABTransaction[],
1711
+ - usedIds: Set<string>,
1712
+ - config: MatchingConfig,
1713
+ -): TransactionMatch {
1714
+ - const candidates = findMatchCandidates(bankTxn, ynabTransactions, usedIds, config);
1715
+ -
1716
+ - if (candidates.length === 0) {
1717
+ - // No match found
1718
+ - return {
1719
+ - bank_transaction: bankTxn,
1720
+ - confidence: 'none',
1721
+ - confidence_score: 0,
1722
+ - match_reason: 'No matching transaction found in YNAB',
1723
+ - action_hint: 'add_to_ynab',
1724
+ - recommendation: 'This transaction appears on bank statement but not in YNAB',
1725
+ - };
1726
+ + ynabTxn: NormalizedYNABTransaction,
1727
+ + config: MatchingConfig
1728
+ +): MatchCandidate['scores'] {
1729
+ + // Amount score - now using INTEGER comparison (milliunits)
1730
+ + const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
1731
+ + let amountScore: number;
1732
+ +
1733
+ + if (amountDiff === 0) {
1734
+ + // Exact integer match - no floating point issues!
1735
+ + amountScore = 100;
1736
+ + } else if (amountDiff <= config.amountToleranceMilliunits) {
1737
+ + amountScore = 95;
1738
+ + } else if (amountDiff <= 1000) { // Within $1
1739
+ + amountScore = 80 - (amountDiff / 1000 * 20);
1740
+ + } else {
1741
+ + amountScore = Math.max(0, 60 - (amountDiff / 1000 * 5));
1742
+ }
1743
+ -
1744
+ - const bestCandidate = candidates[0]!; // Safe: we checked candidates.length > 0
1745
+ - const bestScore = bestCandidate.confidence;
1746
+ -
1747
+ - // HIGH confidence: Auto-match candidate (≥90%)
1748
+ - if (bestScore >= config.autoMatchThreshold) {
1749
+ - return {
1750
+ - bank_transaction: bankTxn,
1751
+ - ynab_transaction: bestCandidate.ynab_transaction,
1752
+ - confidence: 'high',
1753
+ - confidence_score: bestScore,
1754
+ - match_reason: bestCandidate.match_reason,
1755
+ - };
1756
+ +
1757
+ + // Date score
1758
+ + const bankDate = new Date(bankTxn.date);
1759
+ + const ynabDate = new Date(ynabTxn.date);
1760
+ + const daysDiff = Math.abs(bankDate.getTime() - ynabDate.getTime()) / (1000 * 60 * 60 * 24);
1761
+ + let dateScore: number;
1762
+ +
1763
+ + if (daysDiff < 0.5) {
1764
+ + dateScore = 100;
1765
+ + } else if (daysDiff <= 1) {
1766
+ + dateScore = 95;
1767
+ + } else if (daysDiff <= config.dateToleranceDays) {
1768
+ + dateScore = 90 - ((daysDiff - 1) * (40 / config.dateToleranceDays));
1769
+ + } else {
1770
+ + dateScore = Math.max(0, 50 - ((daysDiff - config.dateToleranceDays) * 5));
1771
+ }
1772
+ -
1773
+ - // MEDIUM confidence: Suggested match (60-89%)
1774
+ - if (bestScore >= config.suggestionThreshold) {
1775
+ - return {
1776
+ - bank_transaction: bankTxn,
1777
+ - ynab_transaction: bestCandidate.ynab_transaction,
1778
+ - candidates: candidates.slice(0, 3), // Top 3 candidates
1779
+ - confidence: 'medium',
1780
+ - confidence_score: bestScore,
1781
+ - match_reason: bestCandidate.match_reason,
1782
+ - top_confidence: bestScore,
1783
+ - action_hint: 'review_and_choose',
1784
+ - };
1785
+ - }
1786
+ -
1787
+ - // LOW confidence: Show as possible match but don't auto-suggest (30-59%)
1788
+ +
1789
+ + // Payee score using fuzzball
1790
+ + const payeeScore = calculatePayeeScore(bankTxn.payee, ynabTxn.payee);
1791
+ +
1792
+ + // Combined score with weights
1793
+ + let combined =
1794
+ + (amountScore * config.weights.amount) +
1795
+ + (dateScore * config.weights.date) +
1796
+ + (payeeScore * config.weights.payee);
1797
+ +
1798
+ + // Apply bonuses
1799
+ + if (amountScore === 100) combined += config.exactAmountBonus;
1800
+ + if (dateScore === 100) combined += config.exactDateBonus;
1801
+ + if (payeeScore >= 95) combined += config.exactPayeeBonus;
1802
+ +
1803
+ + combined = Math.min(100, combined);
1804
+ +
1805
+ return {
1806
+ - bank_transaction: bankTxn,
1807
+ - candidates: candidates.slice(0, 3),
1808
+ - confidence: 'low',
1809
+ - confidence_score: bestScore,
1810
+ - match_reason: 'Low confidence match',
1811
+ - top_confidence: bestScore,
1812
+ - action_hint: 'review_or_add_new',
1813
+ - recommendation: 'Consider reviewing candidates or adding as new transaction',
1814
+ + amount: Math.round(amountScore),
1815
+ + date: Math.round(dateScore),
1816
+ + payee: Math.round(payeeScore),
1817
+ + combined: Math.round(combined),
1818
+ };
1819
+ }
1820
+
1821
+ -/**
1822
+ - * Find matches for all bank transactions
1823
+ - */
1824
+ -export function findMatches(
1825
+ - bankTransactions: BankTransaction[],
1826
+ - ynabTransactions: YNABTransaction[],
1827
+ - config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
1828
+ -): TransactionMatch[] {
1829
+ - const matches: TransactionMatch[] = [];
1830
+ - const usedIds = new Set<string>();
1831
+ -
1832
+ - for (const bankTxn of bankTransactions) {
1833
+ - const match = findBestMatch(bankTxn, ynabTransactions, usedIds, config);
1834
+ - matches.push(match);
1835
+ +function calculatePayeeScore(bankPayee: string, ynabPayee: string | null): number {
1836
+ + if (!ynabPayee) return 30;
1837
+ +
1838
+ + const scores = [
1839
+ + fuzz.token_set_ratio(bankPayee, ynabPayee),
1840
+ + fuzz.token_sort_ratio(bankPayee, ynabPayee),
1841
+ + fuzz.partial_ratio(bankPayee, ynabPayee),
1842
+ + fuzz.WRatio(bankPayee, ynabPayee),
1843
+ + ];
1844
+ +
1845
+ + return Math.max(...scores);
1846
+ +}
1847
+
1848
+ - // Mark high-confidence matches as used to prevent duplicate matching
1849
+ - if (match.confidence === 'high' && match.ynab_transaction) {
1850
+ - usedIds.add(match.ynab_transaction.id);
1851
+ - }
1852
+ +function buildMatchReasons(scores: MatchCandidate['scores'], config: MatchingConfig): string[] {
1853
+ + const reasons: string[] = [];
1854
+ +
1855
+ + if (scores.amount === 100) {
1856
+ + reasons.push('Exact amount match');
1857
+ + } else if (scores.amount >= 95) {
1858
+ + reasons.push('Amount within tolerance');
1859
+ }
1860
+ -
1861
+ - return matches;
1862
+ -}
1863
+ +
1864
+ + if (scores.date === 100) {
1865
+ + reasons.push('Same date');
1866
+ + } else if (scores.date >= 90) {
1867
+ + reasons.push('Date within 1-2 days');
1868
+ + } else if (scores.date >= 50) {
1869
+ + reasons.push(`Date within ${config.dateToleranceDays} days`);
1870
+ + }
1871
+ +
1872
+ + if (scores.payee >= 95) {
1873
+ + reasons.push('Payee exact match');
1874
+ + } else if (scores.payee >= 80) {
1875
+ + reasons.push('Payee highly similar');
1876
+ + } else if (scores.payee >= 60) {
1877
+ + reasons.push('Payee somewhat similar');
1878
+ + }
1879
+ +
1880
+ + return reasons;
1881
+ +}
1882
+