@dizzlkheinz/ynab-mcpb 0.12.2 → 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 (262) 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/ci-tests.yml +6 -2
  69. package/.github/workflows/publish.yml +3 -3
  70. package/.github/workflows/release.yml +4 -0
  71. package/CHANGELOG.md +89 -1
  72. package/NUL +1 -1
  73. package/README.md +36 -10
  74. package/dist/bundle/index.cjs +65 -42
  75. package/dist/index.js +9 -20
  76. package/dist/server/YNABMCPServer.d.ts +2 -1
  77. package/dist/server/YNABMCPServer.js +61 -27
  78. package/dist/server/cacheKeys.d.ts +8 -0
  79. package/dist/server/cacheKeys.js +8 -0
  80. package/dist/server/config.d.ts +22 -3
  81. package/dist/server/config.js +16 -17
  82. package/dist/server/errorHandler.d.ts +2 -0
  83. package/dist/server/errorHandler.js +49 -5
  84. package/dist/server/securityMiddleware.js +3 -6
  85. package/dist/server/toolRegistry.js +8 -10
  86. package/dist/tools/accountTools.js +4 -3
  87. package/dist/tools/categoryTools.js +8 -7
  88. package/dist/tools/monthTools.js +2 -1
  89. package/dist/tools/payeeTools.js +2 -1
  90. package/dist/tools/reconcileAdapter.js +10 -5
  91. package/dist/tools/reconciliation/analyzer.d.ts +4 -2
  92. package/dist/tools/reconciliation/analyzer.js +120 -404
  93. package/dist/tools/reconciliation/csvParser.d.ts +51 -0
  94. package/dist/tools/reconciliation/csvParser.js +413 -0
  95. package/dist/tools/reconciliation/executor.d.ts +8 -0
  96. package/dist/tools/reconciliation/executor.js +277 -50
  97. package/dist/tools/reconciliation/index.d.ts +7 -7
  98. package/dist/tools/reconciliation/index.js +115 -39
  99. package/dist/tools/reconciliation/matcher.d.ts +24 -3
  100. package/dist/tools/reconciliation/matcher.js +175 -133
  101. package/dist/tools/reconciliation/recommendationEngine.js +22 -18
  102. package/dist/tools/reconciliation/reportFormatter.js +9 -8
  103. package/dist/tools/reconciliation/signDetector.d.ts +2 -0
  104. package/dist/tools/reconciliation/signDetector.js +54 -0
  105. package/dist/tools/reconciliation/types.d.ts +20 -34
  106. package/dist/tools/reconciliation/types.js +1 -7
  107. package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
  108. package/dist/tools/reconciliation/ynabAdapter.js +15 -0
  109. package/dist/tools/transactionTools.d.ts +3 -17
  110. package/dist/tools/transactionTools.js +5 -17
  111. package/dist/types/reconciliation.d.ts +24 -0
  112. package/dist/types/reconciliation.js +1 -0
  113. package/dist/utils/baseError.d.ts +3 -0
  114. package/dist/utils/baseError.js +7 -0
  115. package/dist/utils/errors.d.ts +13 -0
  116. package/dist/utils/errors.js +15 -0
  117. package/dist/utils/validationError.d.ts +3 -0
  118. package/dist/utils/validationError.js +3 -0
  119. package/docs/guides/ARCHITECTURE.md +12 -129
  120. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
  121. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
  122. package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
  123. package/docs/plans/2025-11-21-v014-hardening.md +153 -0
  124. package/docs/plans/reconciliation-v2-redesign.md +1571 -0
  125. package/package.json +8 -2
  126. package/scripts/run-throttled-integration-tests.js +9 -3
  127. package/scripts/test-recommendations.ts +1 -1
  128. package/src/__tests__/performance.test.ts +12 -5
  129. package/src/__tests__/testUtils.ts +62 -5
  130. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
  131. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
  132. package/src/__tests__/workflows.e2e.test.ts +33 -0
  133. package/src/index.ts +8 -31
  134. package/src/server/YNABMCPServer.ts +81 -42
  135. package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
  136. package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
  137. package/src/server/__tests__/config.test.ts +76 -152
  138. package/src/server/__tests__/server-startup.integration.test.ts +42 -14
  139. package/src/server/__tests__/toolRegistry.test.ts +1 -1
  140. package/src/server/cacheKeys.ts +8 -0
  141. package/src/server/config.ts +20 -38
  142. package/src/server/errorHandler.ts +52 -5
  143. package/src/server/securityMiddleware.ts +3 -7
  144. package/src/server/toolRegistry.ts +14 -10
  145. package/src/tools/__tests__/categoryTools.test.ts +37 -19
  146. package/src/tools/__tests__/transactionTools.test.ts +58 -2
  147. package/src/tools/accountTools.ts +8 -3
  148. package/src/tools/categoryTools.ts +12 -7
  149. package/src/tools/monthTools.ts +7 -1
  150. package/src/tools/payeeTools.ts +7 -1
  151. package/src/tools/reconcileAdapter.ts +10 -5
  152. package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
  153. package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
  154. package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
  155. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +26 -6
  156. package/src/tools/reconciliation/__tests__/executor.test.ts +133 -60
  157. package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
  158. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
  159. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
  160. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
  161. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
  162. package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
  163. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
  164. package/src/tools/reconciliation/analyzer.ts +174 -545
  165. package/src/tools/reconciliation/csvParser.ts +617 -0
  166. package/src/tools/reconciliation/executor.ts +344 -58
  167. package/src/tools/reconciliation/index.ts +141 -48
  168. package/src/tools/reconciliation/matcher.ts +234 -214
  169. package/src/tools/reconciliation/recommendationEngine.ts +23 -19
  170. package/src/tools/reconciliation/reportFormatter.ts +16 -11
  171. package/src/tools/reconciliation/signDetector.ts +117 -0
  172. package/src/tools/reconciliation/types.ts +39 -61
  173. package/src/tools/reconciliation/ynabAdapter.ts +33 -0
  174. package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
  175. package/src/tools/transactionTools.ts +7 -18
  176. package/src/types/reconciliation.ts +49 -0
  177. package/src/utils/baseError.ts +7 -0
  178. package/src/utils/errors.ts +21 -0
  179. package/src/utils/validationError.ts +3 -0
  180. package/temp-recon.ts +126 -0
  181. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
  182. package/test_mcp_tools.mjs +75 -0
  183. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
  184. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
  185. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
  186. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
  187. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
  188. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
  189. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
  190. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
  191. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
  192. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
  193. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
  194. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
  195. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
  196. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
  197. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
  198. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
  199. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
  200. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
  201. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
  202. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
  203. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
  204. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
  205. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
  206. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
  207. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
  208. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
  209. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
  210. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
  211. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
  212. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
  213. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
  214. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
  215. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
  216. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
  217. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
  218. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
  219. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
  220. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
  221. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
  222. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
  223. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
  224. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
  225. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
  226. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
  227. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
  228. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
  229. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
  230. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
  231. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
  232. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
  233. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
  234. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
  235. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
  236. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
  237. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
  238. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
  239. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
  240. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
  241. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
  242. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
  243. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
  244. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
  245. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
  246. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
  247. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
  248. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
  249. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
  250. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
  251. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
  252. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
  253. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
  254. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
  255. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
  256. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
  257. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
  258. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
  259. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
  260. package/.gemini/settings.json +0 -8
  261. package/ADOS-2-Module-1-Complete-Manual.md +0 -757
  262. package/WARP.md +0 -245
@@ -0,0 +1,1271 @@
1
+ # Reconciliation Tool v2 - Complete Redesign
2
+
3
+ ## Executive Summary
4
+
5
+ The current reconciliation tool has fundamental architectural problems that prevent it from working reliably. This document outlines a complete redesign based on:
6
+ 1. Analysis of the existing codebase and its failure modes
7
+ 2. Research into best-in-class libraries for CSV parsing, fuzzy matching, and date handling
8
+ 3. Learnings from production reconciliation engines (Midday.ai's open-source implementation)
9
+
10
+ **Target Outcome:** A reconciliation tool that achieves 90%+ auto-match accuracy for Canadian bank statements against YNAB transactions.
11
+
12
+ ---
13
+
14
+ ## Part 1: Critical Problems in Current Implementation
15
+
16
+ ### Problem 1: Two Incompatible `BankTransaction` Types
17
+
18
+ There are two completely different interfaces with the same name:
19
+
20
+ **`src/tools/compareTransactions/types.ts`:**
21
+ ```typescript
22
+ interface BankTransaction {
23
+ date: Date; // JavaScript Date object
24
+ amount: number; // In MILLIUNITS (1000 = $1.00)
25
+ description: string;
26
+ raw_amount: string;
27
+ raw_date: string;
28
+ row_number: number;
29
+ }
30
+ ```
31
+
32
+ **`src/tools/reconciliation/types.ts`:**
33
+ ```typescript
34
+ interface BankTransaction {
35
+ id: string;
36
+ date: string; // YYYY-MM-DD string
37
+ amount: number; // In DOLLARS (1.00 = $1.00)
38
+ payee: string;
39
+ memo?: string;
40
+ original_csv_row: number;
41
+ }
42
+ ```
43
+
44
+ The analyzer imports from the parser (compareTransactions) but expects reconciliation types. A fragile `normalizeAmount()` function tries to detect which format based on whether `date instanceof Date` - this is the root cause of most matching failures.
45
+
46
+ ### Problem 2: Tests Mock the Parser with Wrong Types
47
+
48
+ ```typescript
49
+ // In analyzer.test.ts - THESE ARE WRONG
50
+ vi.mocked(parser.parseBankCSV).mockReturnValue({
51
+ transactions: [
52
+ { date: '2025-10-15', amount: -45.23, payee: 'Shell Gas' } // String date, dollars
53
+ ]
54
+ });
55
+
56
+ // But real parser returns:
57
+ { date: new Date('2025-10-15'), amount: -45230, description: 'Shell Gas' } // Date object, milliunits
58
+ ```
59
+
60
+ Tests pass but real code path fails.
61
+
62
+ ### Problem 3: Rigid Confidence Scoring
63
+
64
+ Current weights:
65
+ - Amount match: 40% (REQUIRED - 0 if no match)
66
+ - Date match: 40% (within 2 days default)
67
+ - Payee match: 20%
68
+
69
+ Problems:
70
+ 1. **2-day date tolerance too tight** - banks post 3-7 days after transaction
71
+ 2. **Losing 40% for date mismatch is catastrophic** - a perfect amount + payee match with date outside tolerance only scores 60%
72
+ 3. **Payee only worth 20%** - can't compensate for date issues
73
+ 4. **Auto-match threshold 90%** - nearly impossible to reach without all three matching
74
+
75
+ ### Problem 4: Primitive Fuzzy Matching
76
+
77
+ Current payee matching uses hand-rolled Levenshtein distance. This fails on real-world bank data:
78
+ - "AMZN MKTP CA*123456" vs "Amazon"  Low score
79
+ - "SQ *COFFEE SHOP TORONTO" vs "Square Coffee"  Low score
80
+ - "PAYPAL *NETFLIX" vs "Netflix"  Low score
81
+
82
+ ### Problem 5: CSV Parser Fragility
83
+
84
+ The `autoDetectCSVFormat()` function:
85
+ - Only looks at first 3 rows
86
+ - Has limited date format support
87
+ - Fails on European number formats (1.234,56)
88
+ - No presets for known Canadian bank formats
89
+
90
+ ---
91
+
92
+ ## Part 2: Recommended Libraries
93
+
94
+ ### CSV Parsing: PapaParse
95
+
96
+ **Why:** Auto-detects delimiters, handles malformed CSVs gracefully, dynamic typing, excellent edge case handling.
97
+
98
+ ```bash
99
+ npm install papaparse @types/papaparse
100
+ ```
101
+
102
+ **Key Features:**
103
+ - `delimiter: ""`  auto-detect
104
+ - `dynamicTyping: true`  auto-convert numbers
105
+ - `skipEmptyLines: true`
106
+ - `transformHeader`  normalize column names
107
+ - Detailed error reporting per row
108
+
109
+ **Usage:**
110
+ ```typescript
111
+ import Papa from 'papaparse';
112
+
113
+ const result = Papa.parse(csvContent, {
114
+ header: true,
115
+ dynamicTyping: true,
116
+ skipEmptyLines: true,
117
+ transformHeader: (h) => h.toLowerCase().trim(),
118
+ });
119
+
120
+ // result.data = parsed rows
121
+ // result.errors = array of {row, message} for each parsing error
122
+ // result.meta = {delimiter, fields, truncated}
123
+ ```
124
+
125
+ ### Fuzzy String Matching: fuzzball
126
+
127
+ **Why:** Port of Python's TheFuzz (fuzzywuzzy), battle-tested algorithms for transaction matching, includes token-based matching crucial for merchant names.
128
+
129
+ ```bash
130
+ npm install fuzzball
131
+ ```
132
+
133
+ **Key Algorithms:**
134
+ ```typescript
135
+ import fuzz from 'fuzzball';
136
+
137
+ // Basic ratio - simple Levenshtein
138
+ fuzz.ratio("Shell Gas", "SHELL GAS STATION"); // 73
139
+
140
+ // Token Set Ratio - handles word order, duplicates
141
+ fuzz.token_set_ratio("AMZN MKTP CA*123", "Amazon Marketplace"); // 90+
142
+
143
+ // Token Sort Ratio - alphabetizes then compares
144
+ fuzz.token_sort_ratio("fuzzy wuzzy", "wuzzy fuzzy"); // 100
145
+
146
+ // Partial Ratio - best substring match
147
+ fuzz.partial_ratio("Netflix", "PAYPAL *NETFLIX INC"); // 100
148
+
149
+ // WRatio - weighted combination of above
150
+ fuzz.WRatio("SQ *COFFEE SHOP", "Square Coffee Shop"); // ~90
151
+ ```
152
+
153
+ **Recommendation:** Use `token_set_ratio` for payee matching as it handles the common case of bank merchant names having extra tokens (location codes, transaction IDs, etc.)
154
+
155
+ ### Date Parsing: chrono-node
156
+
157
+ **Why:** Parses natural language dates, handles many formats automatically, battle-tested.
158
+
159
+ ```bash
160
+ npm install chrono-node
161
+ ```
162
+
163
+ **Usage:**
164
+ ```typescript
165
+ import * as chrono from 'chrono-node';
166
+
167
+ chrono.parseDate("Sep 18, 2025"); // Date object
168
+ chrono.parseDate("18/09/2025"); // Date object
169
+ chrono.parseDate("2025-09-18"); // Date object
170
+ chrono.parseDate("September 18th"); // Date object (relative to today)
171
+ ```
172
+
173
+ **Fallback:** Use with dayjs for formatting:
174
+ ```bash
175
+ npm install dayjs
176
+ ```
177
+
178
+ ### Optional: Vector Embeddings for Semantic Matching
179
+
180
+ For future enhancement, consider pgvector with OpenAI/Google embeddings for semantic merchant matching. The Midday.ai approach uses 768-dimensional vectors but this is overkill for v2 - fuzzball's token_set_ratio should get us to 90%+ accuracy.
181
+
182
+ ---
183
+
184
+ ## Part 3: Architectural Redesign
185
+
186
+ ### 3.1 Unified Transaction Types
187
+
188
+ **File:** `src/types/reconciliation.ts`
189
+
190
+ > **Design Note:** These types are intentionally decoupled from the YNAB SDK. The `NormalizedYNABTransaction` interface is SDK-agnostic; a thin adapter in `src/tools/reconciliation/ynabAdapter.ts` handles the conversion from `ynab.TransactionDetail`.
191
+
192
+ > **Critical Decision: All amounts are in MILLIUNITS (integers).**
193
+ > - 1000 milliunits = $1.00
194
+ > - This matches YNAB's native format
195
+ > - Eliminates floating-point precision issues entirely
196
+ > - Enables exact integer comparison: `a === b`
197
+ > - Conversion from CSV floats happens ONCE at the parser boundary
198
+
199
+ ```typescript
200
+ /**
201
+ * Canonical bank transaction type used throughout reconciliation.
202
+ *
203
+ * AMOUNTS ARE IN MILLIUNITS (integers, 1000 = $1.00).
204
+ * This matches YNAB's native format and allows exact integer comparison.
205
+ */
206
+ export interface BankTransaction {
207
+ /** UUID generated for tracking */
208
+ id: string;
209
+ /** ISO date string YYYY-MM-DD */
210
+ date: string;
211
+ /** Amount in MILLIUNITS (negative = outflow, positive = inflow) */
212
+ amount: number;
213
+ /** Merchant/payee name from CSV */
214
+ payee: string;
215
+ /** Optional memo/description */
216
+ memo?: string;
217
+ /** Original CSV row number (1-indexed, after header) */
218
+ sourceRow: number;
219
+ /** Raw values from CSV for debugging */
220
+ raw: {
221
+ date: string;
222
+ amount: string;
223
+ description: string;
224
+ };
225
+ /** Parser warnings (e.g., ambiguous debit/credit) */
226
+ warnings?: string[];
227
+ }
228
+
229
+ /**
230
+ * YNAB transaction normalized for comparison.
231
+ *
232
+ * This interface is intentionally SDK-agnostic. Use the adapter
233
+ * function in ynabAdapter.ts to convert from ynab.TransactionDetail.
234
+ *
235
+ * AMOUNTS ARE IN MILLIUNITS - same as YNAB API native format.
236
+ * No conversion needed from the SDK.
237
+ */
238
+ export interface NormalizedYNABTransaction {
239
+ id: string;
240
+ date: string; // YYYY-MM-DD
241
+ /** Amount in MILLIUNITS (same as YNAB API) */
242
+ amount: number;
243
+ payee: string | null;
244
+ memo: string | null;
245
+ categoryName: string | null;
246
+ cleared: 'cleared' | 'uncleared' | 'reconciled';
247
+ approved: boolean;
248
+ }
249
+ ```
250
+
251
+ **File:** `src/tools/reconciliation/ynabAdapter.ts`
252
+
253
+ ```typescript
254
+ import type * as ynab from 'ynab';
255
+ import type { NormalizedYNABTransaction } from '../../types/reconciliation.js';
256
+
257
+ /**
258
+ * Convert YNAB SDK transaction to normalized format for matching.
259
+ *
260
+ * This adapter keeps the YNAB SDK dependency isolated from the
261
+ * reconciliation core logic.
262
+ *
263
+ * NOTE: Amount stays in milliunits - no conversion needed since
264
+ * YNAB API already uses milliunits natively.
265
+ */
266
+ export function normalizeYNABTransaction(
267
+ txn: ynab.TransactionDetail
268
+ ): NormalizedYNABTransaction {
269
+ return {
270
+ id: txn.id,
271
+ date: txn.date,
272
+ amount: txn.amount, // Already in milliunits - no conversion!
273
+ payee: txn.payee_name ?? null,
274
+ memo: txn.memo ?? null,
275
+ categoryName: txn.category_name ?? null,
276
+ cleared: txn.cleared,
277
+ approved: txn.approved,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Batch convert YNAB transactions.
283
+ */
284
+ export function normalizeYNABTransactions(
285
+ txns: ynab.TransactionDetail[]
286
+ ): NormalizedYNABTransaction[] {
287
+ return txns.map(normalizeYNABTransaction);
288
+ }
289
+ ```
290
+
291
+ ### 3.2 New CSV Parser Module
292
+
293
+ **File:** `src/tools/reconciliation/csvParser.ts`
294
+
295
+ ```typescript
296
+ import Papa from 'papaparse';
297
+ import * as chrono from 'chrono-node';
298
+ import { randomUUID } from 'crypto';
299
+ import type { BankTransaction } from '../../types/reconciliation.js';
300
+
301
+ export interface CSVParseResult {
302
+ transactions: BankTransaction[];
303
+ errors: ParseError[];
304
+ warnings: ParseWarning[];
305
+ meta: {
306
+ detectedDelimiter: string;
307
+ detectedColumns: string[];
308
+ totalRows: number;
309
+ validRows: number;
310
+ skippedRows: number;
311
+ };
312
+ }
313
+
314
+ export interface ParseError {
315
+ row: number;
316
+ field: string;
317
+ message: string;
318
+ rawValue: string;
319
+ }
320
+
321
+ export interface ParseWarning {
322
+ row: number;
323
+ message: string;
324
+ }
325
+
326
+ export interface BankPreset {
327
+ name: string;
328
+ dateColumn: string | string[];
329
+ amountColumn?: string | string[];
330
+ debitColumn?: string;
331
+ creditColumn?: string;
332
+ descriptionColumn: string | string[];
333
+ amountMultiplier?: number;
334
+ /** Expected date format hint: 'YMD', 'MDY', 'DMY' */
335
+ dateFormat?: 'YMD' | 'MDY' | 'DMY';
336
+ }
337
+
338
+ // Presets for Canadian banks
339
+ export const BANK_PRESETS: Record<string, BankPreset> = {
340
+ 'td': {
341
+ name: 'TD Canada Trust',
342
+ dateColumn: ['Date', 'Transaction Date', 'Posted Date'],
343
+ amountColumn: ['Amount', 'CAD$'],
344
+ descriptionColumn: ['Description', 'Transaction Description', 'Merchant'],
345
+ dateFormat: 'MDY', // TD typically uses MM/DD/YYYY
346
+ },
347
+ 'rbc': {
348
+ name: 'RBC Royal Bank',
349
+ dateColumn: ['Transaction Date', 'Date'],
350
+ debitColumn: 'Debit',
351
+ creditColumn: 'Credit',
352
+ descriptionColumn: ['Description 1', 'Description', 'Transaction'],
353
+ dateFormat: 'YMD', // RBC typically uses YYYY-MM-DD
354
+ },
355
+ 'scotiabank': {
356
+ name: 'Scotiabank',
357
+ dateColumn: ['Date', 'Transaction Date'],
358
+ amountColumn: ['Amount'],
359
+ descriptionColumn: ['Description', 'Transaction Details'],
360
+ dateFormat: 'DMY', // Scotiabank often uses DD/MM/YYYY
361
+ },
362
+ 'wealthsimple': {
363
+ name: 'Wealthsimple',
364
+ dateColumn: ['Date'],
365
+ amountColumn: ['Amount'],
366
+ descriptionColumn: ['Description', 'Payee'],
367
+ amountMultiplier: 1,
368
+ dateFormat: 'YMD',
369
+ },
370
+ 'tangerine': {
371
+ name: 'Tangerine',
372
+ dateColumn: ['Date', 'Transaction date'],
373
+ amountColumn: ['Amount'],
374
+ descriptionColumn: ['Name', 'Transaction name', 'Memo'],
375
+ dateFormat: 'MDY',
376
+ },
377
+ };
378
+
379
+ export interface ParseCSVOptions {
380
+ /** Bank preset key (e.g., 'td', 'rbc') */
381
+ preset?: string;
382
+ /** Multiply all amounts by -1 */
383
+ invertAmounts?: boolean;
384
+ }
385
+
386
+ /**
387
+ * Parse a bank CSV file into BankTransaction objects.
388
+ *
389
+ * IMPORTANT: Amounts are converted to MILLIUNITS (integers) at this boundary.
390
+ * This is the ONLY place where float-to-milliunit conversion happens.
391
+ */
392
+ export function parseCSV(
393
+ content: string,
394
+ options: ParseCSVOptions = {}
395
+ ): CSVParseResult {
396
+ const errors: ParseError[] = [];
397
+ const warnings: ParseWarning[] = [];
398
+
399
+ // Parse with PapaParse
400
+ const parsed = Papa.parse(content, {
401
+ header: true,
402
+ dynamicTyping: false, // We'll handle type conversion ourselves
403
+ skipEmptyLines: true,
404
+ transformHeader: (h) => h.trim(),
405
+ });
406
+
407
+ if (parsed.errors.length > 0) {
408
+ for (const err of parsed.errors) {
409
+ errors.push({
410
+ row: err.row ?? 0,
411
+ field: 'csv',
412
+ message: err.message,
413
+ rawValue: '',
414
+ });
415
+ }
416
+ }
417
+
418
+ const columns = parsed.meta.fields ?? [];
419
+ const preset = options.preset ? BANK_PRESETS[options.preset] : detectPreset(columns);
420
+
421
+ // Find actual column names
422
+ const dateCol = findColumn(columns, preset?.dateColumn ?? ['Date', 'Transaction Date', 'Posted Date']);
423
+ const descCol = findColumn(columns, preset?.descriptionColumn ?? ['Description', 'Payee', 'Merchant', 'Name']);
424
+
425
+ let amountCol: string | null = null;
426
+ let debitCol: string | null = null;
427
+ let creditCol: string | null = null;
428
+
429
+ if (preset?.debitColumn && preset?.creditColumn) {
430
+ debitCol = findColumn(columns, [preset.debitColumn]);
431
+ creditCol = findColumn(columns, [preset.creditColumn]);
432
+ } else {
433
+ amountCol = findColumn(columns, preset?.amountColumn ?? ['Amount', 'CAD$', 'Value']);
434
+ }
435
+
436
+ if (!dateCol) {
437
+ errors.push({ row: 0, field: 'date', message: 'Could not identify date column', rawValue: columns.join(', ') });
438
+ }
439
+ if (!amountCol && !debitCol) {
440
+ errors.push({ row: 0, field: 'amount', message: 'Could not identify amount column', rawValue: columns.join(', ') });
441
+ }
442
+
443
+ const transactions: BankTransaction[] = [];
444
+ const rows = parsed.data as Record<string, string>[];
445
+
446
+ for (let i = 0; i < rows.length; i++) {
447
+ const row = rows[i];
448
+ const rowNum = i + 2; // 1-indexed, after header
449
+ const rowWarnings: string[] = [];
450
+
451
+ // Parse date with priority: explicit format > ISO > chrono-node fallback
452
+ const rawDate = dateCol ? row[dateCol]?.trim() ?? '' : '';
453
+ const parsedDate = parseDate(rawDate, preset?.dateFormat);
454
+ if (!parsedDate) {
455
+ errors.push({ row: rowNum, field: 'date', message: `Could not parse date: "${rawDate}"`, rawValue: rawDate });
456
+ continue;
457
+ }
458
+ // Use LOCAL date components to avoid timezone shifts
459
+ const dateStr = formatLocalDate(parsedDate);
460
+
461
+ // Parse amount - convert to MILLIUNITS immediately
462
+ let amountMilliunits: number;
463
+ let rawAmount: string;
464
+
465
+ if (amountCol) {
466
+ rawAmount = row[amountCol]?.trim() ?? '';
467
+ amountMilliunits = dollarStringToMilliunits(rawAmount);
468
+ } else if (debitCol && creditCol) {
469
+ const debit = row[debitCol]?.trim() ?? '';
470
+ const credit = row[creditCol]?.trim() ?? '';
471
+ rawAmount = debit || credit;
472
+
473
+ const debitMilliunits = dollarStringToMilliunits(debit);
474
+ const creditMilliunits = dollarStringToMilliunits(credit);
475
+
476
+ // Warn if both debit and credit have values (ambiguous)
477
+ if (Math.abs(debitMilliunits) > 0 && Math.abs(creditMilliunits) > 0) {
478
+ rowWarnings.push(`Both Debit (${debit}) and Credit (${credit}) have values - using Debit`);
479
+ warnings.push({ row: rowNum, message: rowWarnings[rowWarnings.length - 1] });
480
+ }
481
+
482
+ if (Math.abs(debitMilliunits) > 0) {
483
+ amountMilliunits = -Math.abs(debitMilliunits); // Debits are outflows (negative)
484
+ } else if (Math.abs(creditMilliunits) > 0) {
485
+ amountMilliunits = Math.abs(creditMilliunits); // Credits are inflows (positive)
486
+ } else {
487
+ amountMilliunits = 0;
488
+ }
489
+
490
+ // Warn if debit column contains negative value (unusual)
491
+ if (debitMilliunits < 0) {
492
+ rowWarnings.push(`Debit column contains negative value (${debit}) - treating as positive debit`);
493
+ warnings.push({ row: rowNum, message: rowWarnings[rowWarnings.length - 1] });
494
+ }
495
+ } else {
496
+ continue; // Skip row if no amount columns
497
+ }
498
+
499
+ if (!Number.isFinite(amountMilliunits)) {
500
+ errors.push({ row: rowNum, field: 'amount', message: `Invalid amount: "${rawAmount}"`, rawValue: rawAmount });
501
+ continue;
502
+ }
503
+
504
+ // Apply amount inversion if needed
505
+ const multiplier = options.invertAmounts ? -1 : (preset?.amountMultiplier ?? 1);
506
+ amountMilliunits *= multiplier;
507
+
508
+ // Parse description
509
+ const rawDesc = descCol ? row[descCol]?.trim() ?? '' : '';
510
+
511
+ transactions.push({
512
+ id: randomUUID(),
513
+ date: dateStr,
514
+ amount: amountMilliunits,
515
+ payee: rawDesc || 'Unknown',
516
+ sourceRow: rowNum,
517
+ raw: {
518
+ date: rawDate,
519
+ amount: rawAmount,
520
+ description: rawDesc,
521
+ },
522
+ warnings: rowWarnings.length > 0 ? rowWarnings : undefined,
523
+ });
524
+ }
525
+
526
+ return {
527
+ transactions,
528
+ errors,
529
+ warnings,
530
+ meta: {
531
+ detectedDelimiter: parsed.meta.delimiter,
532
+ detectedColumns: columns,
533
+ totalRows: rows.length,
534
+ validRows: transactions.length,
535
+ skippedRows: rows.length - transactions.length,
536
+ },
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Parse date with priority:
542
+ * 1. ISO format (YYYY-MM-DD) - unambiguous
543
+ * 2. Explicit format hint from preset
544
+ * 3. chrono-node fallback (may be ambiguous for dates like 02/03/2025)
545
+ */
546
+ function parseDate(raw: string, formatHint?: 'YMD' | 'MDY' | 'DMY'): Date | null {
547
+ if (!raw) return null;
548
+
549
+ // 1. Try ISO format first (unambiguous)
550
+ const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
551
+ if (isoMatch) {
552
+ const [, year, month, day] = isoMatch;
553
+ return new Date(parseInt(year!), parseInt(month!) - 1, parseInt(day!));
554
+ }
555
+
556
+ // 2. Try explicit format hint for ambiguous numeric dates
557
+ const numericMatch = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$/);
558
+ if (numericMatch && formatHint) {
559
+ const [, a, b, c] = numericMatch;
560
+ let year = parseInt(c!);
561
+ if (year < 100) year += 2000; // 25 -> 2025
562
+
563
+ let month: number, day: number;
564
+ switch (formatHint) {
565
+ case 'YMD':
566
+ month = parseInt(a!);
567
+ day = parseInt(b!);
568
+ break;
569
+ case 'MDY': // US format: MM/DD/YYYY
570
+ month = parseInt(a!);
571
+ day = parseInt(b!);
572
+ break;
573
+ case 'DMY': // European/UK format: DD/MM/YYYY
574
+ day = parseInt(a!);
575
+ month = parseInt(b!);
576
+ break;
577
+ }
578
+
579
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
580
+ return new Date(year, month - 1, day);
581
+ }
582
+ }
583
+
584
+ // 3. Fallback to chrono-node (handles natural language, many formats)
585
+ // Note: chrono defaults to US (MDY) for ambiguous dates like 02/03/2025
586
+ return chrono.parseDate(raw);
587
+ }
588
+
589
+ /**
590
+ * Format Date to YYYY-MM-DD using LOCAL date components.
591
+ *
592
+ * IMPORTANT: Do NOT use toISOString() as it converts to UTC,
593
+ * which can shift the date if the local time is before midnight UTC.
594
+ */
595
+ function formatLocalDate(date: Date): string {
596
+ const year = date.getFullYear();
597
+ const month = String(date.getMonth() + 1).padStart(2, '0');
598
+ const day = String(date.getDate()).padStart(2, '0');
599
+ return `${year}-${month}-${day}`;
600
+ }
601
+
602
+ function findColumn(available: string[], candidates: string | string[]): string | null {
603
+ const candidateList = Array.isArray(candidates) ? candidates : [candidates];
604
+
605
+ for (const candidate of candidateList) {
606
+ const lower = candidate.toLowerCase();
607
+ const found = available.find(col => col.toLowerCase() === lower);
608
+ if (found) return found;
609
+ }
610
+
611
+ // Try partial match
612
+ for (const candidate of candidateList) {
613
+ const lower = candidate.toLowerCase();
614
+ const found = available.find(col => col.toLowerCase().includes(lower));
615
+ if (found) return found;
616
+ }
617
+
618
+ return null;
619
+ }
620
+
621
+ function detectPreset(columns: string[]): BankPreset | undefined {
622
+ const colSet = new Set(columns.map(c => c.toLowerCase()));
623
+
624
+ if (colSet.has('description 1') || colSet.has('account type')) {
625
+ return BANK_PRESETS['rbc'];
626
+ }
627
+ if (columns.some(c => c.toLowerCase().includes('cad$'))) {
628
+ return BANK_PRESETS['td'];
629
+ }
630
+
631
+ return undefined;
632
+ }
633
+
634
+ /**
635
+ * Supported currency symbols:
636
+ * $ (dollar - USD, CAD, AUD, etc.)
637
+ * ? (euro)
638
+ * � (pound)
639
+ * � (yen/yuan)
640
+ *
641
+ * Also strips: CAD, USD, EUR, GBP (case-insensitive)
642
+ *
643
+ * Number formats supported:
644
+ * - Standard: 1234.56 or 1,234.56
645
+ * - European: 1.234,56 (detected by pattern)
646
+ * - Negative: -123.45 or (123.45)
647
+ *
648
+ * Returns: Amount in MILLIUNITS (integer, 1000 = $1.00)
649
+ */
650
+ const CURRENCY_SYMBOLS = /[$?��]/g;
651
+ const CURRENCY_CODES = /\b(CAD|USD|EUR|GBP)\b/gi;
652
+
653
+ function dollarStringToMilliunits(str: string): number {
654
+ if (!str) return 0;
655
+
656
+ let cleaned = str
657
+ .replace(CURRENCY_SYMBOLS, '')
658
+ .replace(CURRENCY_CODES, '')
659
+ .trim();
660
+
661
+ // Handle parentheses as negative: (123.45)  -123.45
662
+ if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
663
+ cleaned = '-' + cleaned.slice(1, -1);
664
+ }
665
+
666
+ // Detect European format: 1.234,56  1234.56
667
+ if (/^-?\d{1,3}(\.\d{3})+,\d{2}$/.test(cleaned)) {
668
+ cleaned = cleaned.replace(/\./g, '').replace(',', '.');
669
+ }
670
+
671
+ // Handle thousands separator: 1,234.56  1234.56
672
+ if (cleaned.includes('.')) {
673
+ cleaned = cleaned.replace(/,/g, '');
674
+ }
675
+
676
+ const dollars = parseFloat(cleaned);
677
+ if (!Number.isFinite(dollars)) return 0;
678
+
679
+ // Convert to milliunits: $1.00  1000
680
+ return Math.round(dollars * 1000);
681
+ }
682
+ ```
683
+
684
+ ### 3.3 New Matching Algorithm
685
+
686
+ **File:** `src/tools/reconciliation/matcher.ts`
687
+
688
+ ```typescript
689
+ import fuzz from 'fuzzball';
690
+ import type { BankTransaction, NormalizedYNABTransaction } from '../../types/reconciliation.js';
691
+
692
+ export interface MatchCandidate {
693
+ ynabTransaction: NormalizedYNABTransaction;
694
+ scores: {
695
+ amount: number; // 0-100
696
+ date: number; // 0-100
697
+ payee: number; // 0-100
698
+ combined: number; // Weighted combination
699
+ };
700
+ matchReasons: string[];
701
+ }
702
+
703
+ export interface MatchResult {
704
+ bankTransaction: BankTransaction;
705
+ bestMatch: MatchCandidate | null;
706
+ candidates: MatchCandidate[]; // Top 3
707
+ confidence: 'high' | 'medium' | 'low' | 'none';
708
+ confidenceScore: number;
709
+ }
710
+
711
+ export interface MatchingConfig {
712
+ weights: {
713
+ amount: number; // Recommended: 0.50
714
+ date: number; // Recommended: 0.15
715
+ payee: number; // Recommended: 0.35
716
+ };
717
+
718
+ // Tolerances (in MILLIUNITS for amount)
719
+ amountToleranceMilliunits: number; // Default: 50 (5 cents)
720
+ dateToleranceDays: number; // Default: 7
721
+
722
+ // Thresholds
723
+ autoMatchThreshold: number; // Default: 85
724
+ suggestedMatchThreshold: number; // Default: 60
725
+ minimumCandidateScore: number; // Default: 40
726
+
727
+ // Bonuses for perfect matches
728
+ exactAmountBonus: number; // Default: 10
729
+ exactDateBonus: number; // Default: 5
730
+ exactPayeeBonus: number; // Default: 10
731
+ }
732
+
733
+ export const DEFAULT_CONFIG: MatchingConfig = {
734
+ weights: {
735
+ amount: 0.50,
736
+ date: 0.15,
737
+ payee: 0.35,
738
+ },
739
+ amountToleranceMilliunits: 50, // 5 cents
740
+ dateToleranceDays: 7,
741
+ autoMatchThreshold: 85,
742
+ suggestedMatchThreshold: 60,
743
+ minimumCandidateScore: 40,
744
+ exactAmountBonus: 10,
745
+ exactDateBonus: 5,
746
+ exactPayeeBonus: 10,
747
+ };
748
+
749
+ export function findMatches(
750
+ bankTransactions: BankTransaction[],
751
+ ynabTransactions: NormalizedYNABTransaction[],
752
+ config: MatchingConfig = DEFAULT_CONFIG
753
+ ): MatchResult[] {
754
+ const results: MatchResult[] = [];
755
+ const usedYnabIds = new Set<string>();
756
+
757
+ for (const bankTxn of bankTransactions) {
758
+ const candidates = findCandidates(bankTxn, ynabTransactions, usedYnabIds, config);
759
+
760
+ const bestMatch = candidates.length > 0 ? candidates[0] : null;
761
+ const confidenceScore = bestMatch?.scores.combined ?? 0;
762
+
763
+ let confidence: MatchResult['confidence'];
764
+ if (confidenceScore >= config.autoMatchThreshold) {
765
+ confidence = 'high';
766
+ if (bestMatch) usedYnabIds.add(bestMatch.ynabTransaction.id);
767
+ } else if (confidenceScore >= config.suggestedMatchThreshold) {
768
+ confidence = 'medium';
769
+ } else if (confidenceScore >= config.minimumCandidateScore) {
770
+ confidence = 'low';
771
+ } else {
772
+ confidence = 'none';
773
+ }
774
+
775
+ results.push({
776
+ bankTransaction: bankTxn,
777
+ bestMatch,
778
+ candidates: candidates.slice(0, 3),
779
+ confidence,
780
+ confidenceScore,
781
+ });
782
+ }
783
+
784
+ return results;
785
+ }
786
+
787
+ function findCandidates(
788
+ bankTxn: BankTransaction,
789
+ ynabTransactions: NormalizedYNABTransaction[],
790
+ usedIds: Set<string>,
791
+ config: MatchingConfig
792
+ ): MatchCandidate[] {
793
+ const candidates: MatchCandidate[] = [];
794
+
795
+ for (const ynabTxn of ynabTransactions) {
796
+ if (usedIds.has(ynabTxn.id)) continue;
797
+
798
+ // Sign check - both must be same sign (or both zero)
799
+ const bankSign = Math.sign(bankTxn.amount);
800
+ const ynabSign = Math.sign(ynabTxn.amount);
801
+ if (bankSign !== ynabSign && bankSign !== 0 && ynabSign !== 0) {
802
+ continue;
803
+ }
804
+
805
+ const scores = calculateScores(bankTxn, ynabTxn, config);
806
+
807
+ if (scores.combined >= config.minimumCandidateScore) {
808
+ candidates.push({
809
+ ynabTransaction: ynabTxn,
810
+ scores,
811
+ matchReasons: buildMatchReasons(scores, config),
812
+ });
813
+ }
814
+ }
815
+
816
+ candidates.sort((a, b) => b.scores.combined - a.scores.combined);
817
+ return candidates;
818
+ }
819
+
820
+ function calculateScores(
821
+ bankTxn: BankTransaction,
822
+ ynabTxn: NormalizedYNABTransaction,
823
+ config: MatchingConfig
824
+ ): MatchCandidate['scores'] {
825
+ // Amount score - now using INTEGER comparison (milliunits)
826
+ const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
827
+ let amountScore: number;
828
+
829
+ if (amountDiff === 0) {
830
+ // Exact integer match - no floating point issues!
831
+ amountScore = 100;
832
+ } else if (amountDiff <= config.amountToleranceMilliunits) {
833
+ amountScore = 95;
834
+ } else if (amountDiff <= 1000) { // Within $1
835
+ amountScore = 80 - (amountDiff / 1000 * 20);
836
+ } else {
837
+ amountScore = Math.max(0, 60 - (amountDiff / 1000 * 5));
838
+ }
839
+
840
+ // Date score
841
+ const bankDate = new Date(bankTxn.date);
842
+ const ynabDate = new Date(ynabTxn.date);
843
+ const daysDiff = Math.abs(bankDate.getTime() - ynabDate.getTime()) / (1000 * 60 * 60 * 24);
844
+ let dateScore: number;
845
+
846
+ if (daysDiff < 0.5) {
847
+ dateScore = 100;
848
+ } else if (daysDiff <= 1) {
849
+ dateScore = 95;
850
+ } else if (daysDiff <= config.dateToleranceDays) {
851
+ dateScore = 90 - ((daysDiff - 1) * (40 / config.dateToleranceDays));
852
+ } else {
853
+ dateScore = Math.max(0, 50 - ((daysDiff - config.dateToleranceDays) * 5));
854
+ }
855
+
856
+ // Payee score using fuzzball
857
+ const payeeScore = calculatePayeeScore(bankTxn.payee, ynabTxn.payee);
858
+
859
+ // Combined score with weights
860
+ let combined =
861
+ (amountScore * config.weights.amount) +
862
+ (dateScore * config.weights.date) +
863
+ (payeeScore * config.weights.payee);
864
+
865
+ // Apply bonuses
866
+ if (amountScore === 100) combined += config.exactAmountBonus;
867
+ if (dateScore === 100) combined += config.exactDateBonus;
868
+ if (payeeScore >= 95) combined += config.exactPayeeBonus;
869
+
870
+ combined = Math.min(100, combined);
871
+
872
+ return {
873
+ amount: Math.round(amountScore),
874
+ date: Math.round(dateScore),
875
+ payee: Math.round(payeeScore),
876
+ combined: Math.round(combined),
877
+ };
878
+ }
879
+
880
+ function calculatePayeeScore(bankPayee: string, ynabPayee: string | null): number {
881
+ if (!ynabPayee) return 30;
882
+
883
+ const scores = [
884
+ fuzz.token_set_ratio(bankPayee, ynabPayee),
885
+ fuzz.token_sort_ratio(bankPayee, ynabPayee),
886
+ fuzz.partial_ratio(bankPayee, ynabPayee),
887
+ fuzz.WRatio(bankPayee, ynabPayee),
888
+ ];
889
+
890
+ return Math.max(...scores);
891
+ }
892
+
893
+ function buildMatchReasons(scores: MatchCandidate['scores'], config: MatchingConfig): string[] {
894
+ const reasons: string[] = [];
895
+
896
+ if (scores.amount === 100) {
897
+ reasons.push('Exact amount match');
898
+ } else if (scores.amount >= 95) {
899
+ reasons.push('Amount within tolerance');
900
+ }
901
+
902
+ if (scores.date === 100) {
903
+ reasons.push('Same date');
904
+ } else if (scores.date >= 90) {
905
+ reasons.push('Date within 1-2 days');
906
+ } else if (scores.date >= 50) {
907
+ reasons.push(`Date within ${config.dateToleranceDays} days`);
908
+ }
909
+
910
+ if (scores.payee >= 95) {
911
+ reasons.push('Payee exact match');
912
+ } else if (scores.payee >= 80) {
913
+ reasons.push('Payee highly similar');
914
+ } else if (scores.payee >= 60) {
915
+ reasons.push('Payee somewhat similar');
916
+ }
917
+
918
+ return reasons;
919
+ }
920
+ ```
921
+
922
+ ### 3.4 Integration Tests with Real CSV Data
923
+
924
+ **File:** `src/__tests__/tools/reconciliation/csvParser.integration.test.ts`
925
+
926
+ > **Note:** Tests follow repo convention: `src/__tests__/` with fixtures in `test-exports/csv/`
927
+
928
+ ```typescript
929
+ import { describe, it, expect } from 'vitest';
930
+ import { parseCSV } from '../../../tools/reconciliation/csvParser.js';
931
+ import { findMatches, DEFAULT_CONFIG } from '../../../tools/reconciliation/matcher.js';
932
+ import { normalizeYNABTransaction } from '../../../tools/reconciliation/ynabAdapter.js';
933
+
934
+ describe('CSV Parser Integration Tests', () => {
935
+ describe('TD Bank CSV', () => {
936
+ const tdCSV = `Date,Description,Amount
937
+ 09/15/2025,SHELL STATION 1234 TORONTO ON,-45.23
938
+ 09/16/2025,AMZN MKTP CA*1A2B3C4,-127.99
939
+ 09/17/2025,PAYROLL DEPOSIT ABC CORP,2500.00`;
940
+
941
+ it('should parse TD CSV correctly', () => {
942
+ const result = parseCSV(tdCSV, { preset: 'td' });
943
+
944
+ expect(result.errors).toHaveLength(0);
945
+ expect(result.transactions).toHaveLength(3);
946
+ expect(result.transactions[0].amount).toBe(-45230); // Milliunits!
947
+ expect(result.transactions[0].payee).toBe('SHELL STATION 1234 TORONTO ON');
948
+ expect(result.transactions[0].date).toBe('2025-09-15');
949
+ });
950
+ });
951
+
952
+ describe('RBC Debit/Credit CSV', () => {
953
+ const rbcCSV = `Transaction Date,Description 1,Debit,Credit
954
+ 2025-09-15,SHELL GAS,45.23,
955
+ 2025-09-16,TRANSFER FROM SAVINGS,,500.00`;
956
+
957
+ it('should parse RBC CSV with debit/credit columns', () => {
958
+ const result = parseCSV(rbcCSV, { preset: 'rbc' });
959
+
960
+ expect(result.errors).toHaveLength(0);
961
+ expect(result.transactions).toHaveLength(2);
962
+ expect(result.transactions[0].amount).toBe(-45230); // Debit = negative milliunits
963
+ expect(result.transactions[1].amount).toBe(500000); // Credit = positive milliunits
964
+ });
965
+ });
966
+
967
+ describe('Ambiguous Debit/Credit Warning', () => {
968
+ const ambiguousCSV = `Transaction Date,Description,Debit,Credit
969
+ 2025-09-15,WEIRD TXN,50.00,25.00`;
970
+
971
+ it('should warn when both debit and credit have values', () => {
972
+ const result = parseCSV(ambiguousCSV, { preset: 'rbc' });
973
+
974
+ expect(result.warnings).toHaveLength(1);
975
+ expect(result.warnings[0].message).toContain('Both Debit');
976
+ expect(result.transactions[0].amount).toBe(-50000); // Uses debit
977
+ });
978
+ });
979
+
980
+ describe('European Number Format', () => {
981
+ const euroCSV = `Date,Amount,Description
982
+ 15/09/2025,"1.234,56",Big Purchase`;
983
+
984
+ it('should handle European number format', () => {
985
+ const result = parseCSV(euroCSV);
986
+
987
+ expect(result.transactions[0].amount).toBe(1234560); // 1234.56 in milliunits
988
+ });
989
+ });
990
+ });
991
+
992
+ describe('Matcher Integration Tests', () => {
993
+ const mockYNABTransactions = [
994
+ { id: 'y1', date: '2025-09-15', amount: -45230, payee_name: 'Shell', category_name: 'Gas', cleared: 'uncleared', approved: true },
995
+ { id: 'y2', date: '2025-09-17', amount: -127990, payee_name: 'Amazon', category_name: 'Shopping', cleared: 'uncleared', approved: true },
996
+ ].map(t => normalizeYNABTransaction(t as any));
997
+
998
+ it('should achieve high confidence matches with exact integer comparison', () => {
999
+ const bankCSV = `Date,Description,Amount
1000
+ 09/15/2025,SHELL STATION 1234,-45.23
1001
+ 09/16/2025,AMZN MKTP CA*ABC123,-127.99`;
1002
+
1003
+ const parsed = parseCSV(bankCSV);
1004
+ const matches = findMatches(parsed.transactions, mockYNABTransactions);
1005
+
1006
+ // Shell: exact amount match (both -45230 milliunits)
1007
+ expect(matches[0].confidence).toBe('high');
1008
+ expect(matches[0].bestMatch?.scores.amount).toBe(100);
1009
+
1010
+ // Amazon: exact amount match (both -127990 milliunits)
1011
+ expect(matches[1].confidence).toBe('high');
1012
+ expect(matches[1].bestMatch?.scores.amount).toBe(100);
1013
+ });
1014
+
1015
+ it('should use exact integer comparison (no float precision issues)', () => {
1016
+ // Both are now integers - no floating point comparison needed!
1017
+ const bankTxn = {
1018
+ id: 'b1',
1019
+ date: '2025-09-15',
1020
+ amount: -45230, // Integer milliunits
1021
+ payee: 'Shell',
1022
+ sourceRow: 2,
1023
+ raw: { date: '09/15/2025', amount: '-45.23', description: 'Shell' }
1024
+ };
1025
+
1026
+ const ynabTxn = {
1027
+ id: 'y1',
1028
+ date: '2025-09-15',
1029
+ amount: -45230, // Integer milliunits - direct from YNAB API
1030
+ payee: 'Shell',
1031
+ memo: null,
1032
+ categoryName: 'Gas',
1033
+ cleared: 'uncleared' as const,
1034
+ approved: true,
1035
+ };
1036
+
1037
+ const matches = findMatches([bankTxn], [ynabTxn]);
1038
+ // Exact match because integers compare exactly: -45230 === -45230
1039
+ expect(matches[0].bestMatch?.scores.amount).toBe(100);
1040
+ });
1041
+ });
1042
+ ```
1043
+
1044
+ ---
1045
+
1046
+ ## Part 4: Diagnostic/Debug Mode
1047
+
1048
+ Add diagnostic output to help debug matching issues. **Diagnostics should be returned even on failure/partial match.**
1049
+
1050
+ ```typescript
1051
+ export interface MatchDiagnostics {
1052
+ csvParsing: {
1053
+ detectedDelimiter: string;
1054
+ detectedColumns: string[];
1055
+ totalRows: number;
1056
+ validRows: number;
1057
+ errors: ParseError[];
1058
+ warnings: ParseWarning[];
1059
+ };
1060
+ matchingDetails: Array<{
1061
+ bankTxn: { date: string; amount: number; payee: string };
1062
+ bestMatch: {
1063
+ ynabTxn: { date: string; amount: number; payee: string | null };
1064
+ scores: { amount: number; date: number; payee: number; combined: number };
1065
+ } | null;
1066
+ allCandidates: Array<{
1067
+ ynabId: string;
1068
+ scores: { amount: number; date: number; payee: number; combined: number };
1069
+ rejectedBecause?: string;
1070
+ }>;
1071
+ confidence: 'high' | 'medium' | 'low' | 'none';
1072
+ }>;
1073
+ timing: {
1074
+ parseMs: number;
1075
+ matchMs: number;
1076
+ };
1077
+ }
1078
+
1079
+ // In reconcile_account schema:
1080
+ {
1081
+ // ... existing params
1082
+ include_diagnostics: z.boolean().optional().default(false),
1083
+ }
1084
+
1085
+ // ALWAYS include diagnostics on error or low match rate
1086
+ const shouldIncludeDiagnostics =
1087
+ params.include_diagnostics ||
1088
+ parseResult.errors.length > 0 ||
1089
+ matches.filter(m => m.confidence === 'none').length > matches.length * 0.5;
1090
+ ```
1091
+
1092
+ ---
1093
+
1094
+ ## Part 5: Migration Path
1095
+
1096
+ ### Phase 1: Foundation (Week 1)
1097
+ 1. Install new dependencies: `papaparse`, `fuzzball`, `chrono-node`, `dayjs`
1098
+ 2. Create unified types in `src/types/reconciliation.ts`
1099
+ 3. Create YNAB adapter in `src/tools/reconciliation/ynabAdapter.ts`
1100
+ 4. Create new CSV parser module
1101
+ 5. Create new matcher module
1102
+ 6. Add integration tests in `src/__tests__/tools/reconciliation/`
1103
+
1104
+ ### Phase 2: Integration (Week 2)
1105
+ 1. Update `analyzeReconciliation()` to use new parser and matcher
1106
+ 2. Update reconcile adapter for new response format
1107
+ 3. Add diagnostic mode (always on for errors)
1108
+ 4. Update existing tests to not mock the parser
1109
+
1110
+ ### Phase 3: Validation (Week 3)
1111
+ 1. Test against saved CSV exports from TD, RBC, Scotiabank, Wealthsimple
1112
+ 2. Compare match quality against current implementation
1113
+ 3. Tune thresholds based on real-world data
1114
+ 4. Document bank-specific quirks
1115
+
1116
+ ### Phase 4: Cleanup (Week 4)
1117
+ 1. Remove old `compareTransactions/parser.ts` if no longer needed
1118
+ 2. Remove duplicate BankTransaction type
1119
+ 3. Update all remaining references
1120
+ 4. Final documentation
1121
+
1122
+ ---
1123
+
1124
+ ## Part 6: Configuration Recommendations
1125
+
1126
+ Based on research and the Midday.ai approach:
1127
+
1128
+ | Parameter | Current | Recommended | Rationale |
1129
+ |-----------|---------|-------------|-----------|
1130
+ | `dateToleranceDays` | 2 | 7 | Banks often post 3-7 days late |
1131
+ | `amountToleranceMilliunits` | 10 | 50 | 5 cents for rounding |
1132
+ | `autoMatchThreshold` | 90 | 85 | More lenient with better algorithm |
1133
+ | `suggestedMatchThreshold` | 60 | 60 | Keep same |
1134
+ | Amount weight | 40% | 50% | Amount is most reliable signal |
1135
+ | Date weight | 40% | 15% | Dates are unreliable |
1136
+ | Payee weight | 20% | 35% | With fuzzball, payee matching is much better |
1137
+
1138
+ ---
1139
+
1140
+ ## Part 7: Future Enhancements
1141
+
1142
+ 1. **Merchant Learning:** Cache successful payee mappings ("AMZN MKTP"  "Amazon") per user/budget
1143
+ 2. **Adaptive Thresholds:** Learn from user confirmations/rejections like Midday.ai
1144
+ 3. **Vector Embeddings:** For truly semantic matching (requires OpenAI/embedding API)
1145
+ 4. **Split Transaction Detection:** Detect when one bank transaction = multiple YNAB transactions
1146
+ 5. **Recurring Transaction Patterns:** Use historical patterns to boost confidence
1147
+
1148
+ ---
1149
+
1150
+ ## Part 8: Design Decisions & Rationale
1151
+
1152
+ ### Why Milliunits (Integers) Instead of Dollars (Floats)?
1153
+
1154
+ **This is the most important architectural decision in the redesign.**
1155
+
1156
+ The original plan used dollars (floats), requiring tolerance-based comparison everywhere:
1157
+ ```typescript
1158
+ // Old approach (floats) - error-prone
1159
+ if (Math.abs(bankTxn.amount - ynabTxn.amount) < 0.001) { ... }
1160
+ ```
1161
+
1162
+ The new approach uses milliunits (integers), enabling exact comparison:
1163
+ ```typescript
1164
+ // New approach (integers) - bulletproof
1165
+ if (bankTxn.amount === ynabTxn.amount) { ... }
1166
+ ```
1167
+
1168
+ Benefits:
1169
+ - **Eliminates floating-point precision bugs** - No more `45.23 !== 45.230000000001`
1170
+ - **Matches YNAB's native format** - YNAB API uses milliunits, so no conversion needed for YNAB transactions
1171
+ - **Single conversion point** - Only the CSV parser converts dollarsmilliunits
1172
+ - **Simpler matcher logic** - `===` instead of `Math.abs(...) < epsilon`
1173
+
1174
+ ### Why Date Format Hints in Bank Presets?
1175
+
1176
+ chrono-node is powerful but can misparse ambiguous dates like `02/03/2025`:
1177
+ - US interpretation: February 3rd
1178
+ - European interpretation: March 2nd
1179
+
1180
+ Bank presets include a `dateFormat` hint ('MDY', 'DMY', 'YMD') that we try BEFORE falling back to chrono-node.
1181
+
1182
+ Priority:
1183
+ 1. ISO format `YYYY-MM-DD` (unambiguous)
1184
+ 2. Preset's format hint (for ambiguous numeric dates)
1185
+ 3. chrono-node fallback (for natural language, weird formats)
1186
+
1187
+ ### Why Timezone-Safe Date Formatting?
1188
+
1189
+ `toISOString()` converts to UTC, which can shift the date:
1190
+ ```typescript
1191
+ // WRONG - can shift date by timezone
1192
+ const dateStr = parsedDate.toISOString().split('T')[0];
1193
+
1194
+ // RIGHT - uses local date components
1195
+ const dateStr = `${date.getFullYear()}-${...}`;
1196
+ ```
1197
+
1198
+ ### Why Decouple YNAB Types?
1199
+
1200
+ The `NormalizedYNABTransaction` interface in `src/types/reconciliation.ts` intentionally does NOT import from the YNAB SDK. This:
1201
+ - Keeps the reconciliation core testable without SDK mocks
1202
+ - Allows the types file to be shared without pulling in SDK dependencies
1203
+ - Makes it easier to swap adapters if YNAB API changes
1204
+
1205
+ The adapter in `src/tools/reconciliation/ynabAdapter.ts` is the single point of contact with the SDK.
1206
+
1207
+ ### Test File Location Convention
1208
+
1209
+ Tests live in `src/__tests__/` mirroring the source structure:
1210
+ ```
1211
+ src/tools/reconciliation/csvParser.ts
1212
+  src/__tests__/tools/reconciliation/csvParser.test.ts
1213
+  src/__tests__/tools/reconciliation/csvParser.integration.test.ts
1214
+ ```
1215
+
1216
+ CSV fixtures go in `test-exports/csv/` following existing repo conventions.
1217
+
1218
+ ---
1219
+
1220
+ ## Appendix A: Library Comparison Summary
1221
+
1222
+ | Library | Purpose | Size | Key Feature |
1223
+ |---------|---------|------|-------------|
1224
+ | **PapaParse** | CSV parsing | 260 kB | Auto-detect delimiters, malformed CSV handling |
1225
+ | **fuzzball** | Fuzzy matching | 15 kB | token_set_ratio for merchant names |
1226
+ | **chrono-node** | Date parsing | 20 kB | Natural language dates, many formats |
1227
+ | **dayjs** | Date formatting | 2 kB | Lightweight date manipulation |
1228
+
1229
+ ## Appendix B: Test CSV Fixtures
1230
+
1231
+ Create `test-exports/csv/` with sample exports from:
1232
+ - TD Canada Trust
1233
+ - RBC Royal Bank
1234
+ - Scotiabank
1235
+ - Wealthsimple Cash
1236
+ - Tangerine
1237
+ - CIBC
1238
+ - BMO
1239
+
1240
+ Each fixture should include edge cases:
1241
+ - Transactions with commas in description
1242
+ - European date formats (DD/MM/YYYY)
1243
+ - Negative amounts in parentheses
1244
+ - Multi-line descriptions
1245
+ - Currency symbols ($, ?, �, CAD, USD)
1246
+ - Missing fields
1247
+ - Ambiguous dates (02/03/2025)
1248
+ - Both debit and credit columns populated
1249
+
1250
+ ## Appendix C: Reference Materials
1251
+
1252
+ ### Research Sources
1253
+ 1. **Midday.ai Reconciliation Engine** - https://midday.ai/updates/automatic-reconciliation-engine/
1254
+ - Open source: https://github.com/midday-ai/midday
1255
+ - Uses vector embeddings + multi-dimensional scoring
1256
+ - Key insight: 50% amount, 35% semantic, 10% currency, 5% date
1257
+
1258
+ 2. **CSV Parser Comparison** - https://www.oneschema.co/blog/top-5-javascript-csv-parsers
1259
+ - PapaParse: Best for malformed CSVs, auto-detect
1260
+ - csv-parser: Fastest for large files
1261
+ - fast-csv: Smallest footprint
1262
+
1263
+ 3. **Fuzzball (TheFuzz port)** - https://www.npmjs.com/package/fuzzball
1264
+ - token_set_ratio: Best for merchant name matching
1265
+ - Handles word order variations
1266
+ - Built-in normalization
1267
+
1268
+ 4. **chrono-node** - https://www.npmjs.com/package/chrono-node
1269
+ - Parses virtually any date format
1270
+ - Natural language support
1271
+