@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.1

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