@dizzlkheinz/ynab-mcpb 0.12.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 (435) hide show
  1. package/.chunkhound.json +11 -0
  2. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +8 -0
  3. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +3 -0
  4. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +3 -0
  5. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +1 -0
  6. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +12 -0
  7. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +1 -0
  8. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +1 -0
  9. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +5 -0
  10. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +1453 -0
  11. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +8 -0
  12. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +9 -0
  13. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +1 -0
  14. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +5 -0
  15. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +81 -0
  16. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +610 -0
  17. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +1 -0
  18. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +1 -0
  19. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +1 -0
  20. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +3 -0
  21. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +8 -0
  22. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +5 -0
  23. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +1 -0
  24. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +9 -0
  25. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +1 -0
  26. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +13 -0
  27. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +14 -0
  28. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +5 -0
  29. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +5 -0
  30. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +5 -0
  31. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +3 -0
  32. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +1 -0
  33. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +1 -0
  34. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +5 -0
  35. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +5 -0
  36. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +8 -0
  37. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +8 -0
  38. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +5 -0
  39. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +1 -0
  40. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +1 -0
  41. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +1 -0
  42. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +1 -0
  43. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +1 -0
  44. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +5 -0
  45. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +1 -0
  46. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +1 -0
  47. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +1 -0
  48. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +3 -0
  49. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +8 -0
  50. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +1 -0
  51. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +1 -0
  52. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +8 -0
  53. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +3 -0
  54. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +5 -0
  55. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +1 -0
  56. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +1 -0
  57. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +5 -0
  58. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +1 -0
  59. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +14 -0
  60. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +1 -0
  61. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +5 -0
  62. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +5 -0
  63. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +11 -0
  64. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +1 -0
  65. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +8 -0
  66. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +1 -0
  67. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +3 -0
  68. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +3 -0
  69. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +8 -0
  70. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +1 -0
  71. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +5 -0
  72. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +1 -0
  73. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +1 -0
  74. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +1 -0
  75. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +1 -0
  76. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +3 -0
  77. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +8 -0
  78. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +1 -0
  79. package/.dxtignore +57 -0
  80. package/.env.example +44 -0
  81. package/.gemini/settings.json +8 -0
  82. package/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
  83. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  84. package/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
  85. package/.github/ISSUE_TEMPLATE/release_checklist.md +31 -0
  86. package/.github/pull_request_template.md +41 -0
  87. package/.github/workflows/ci-tests.yml +41 -0
  88. package/.github/workflows/claude-code-review.yml +57 -0
  89. package/.github/workflows/claude.yml +50 -0
  90. package/.github/workflows/full-integration.yml +22 -0
  91. package/.github/workflows/pr-description-check.yml +88 -0
  92. package/.github/workflows/publish.yml +33 -0
  93. package/.github/workflows/release.yml +89 -0
  94. package/.mcpbignore +58 -0
  95. package/.prettierignore +10 -0
  96. package/.prettierrc.json +10 -0
  97. package/ADOS-2-Module-1-Complete-Manual.md +757 -0
  98. package/AGENTS.md +36 -0
  99. package/CHANGELOG.md +187 -0
  100. package/CLAUDE.md +414 -0
  101. package/CODEREVIEW_RESPONSE.md +128 -0
  102. package/LICENSE +17 -0
  103. package/NUL +1 -0
  104. package/README.md +222 -0
  105. package/SCHEMA_IMPROVEMENT_SUMMARY.md +120 -0
  106. package/TESTING_NOTES.md +217 -0
  107. package/WARP.md +245 -0
  108. package/accountactivity-merged.csv +149 -0
  109. package/bin/ynab-mcp-server.cjs +4 -0
  110. package/bin/ynab-mcp-server.js +8 -0
  111. package/bundle-analysis.html +13110 -0
  112. package/dist/bundle/index.cjs +124 -0
  113. package/dist/index.d.ts +2 -0
  114. package/dist/index.js +85 -0
  115. package/dist/server/YNABMCPServer.d.ts +264 -0
  116. package/dist/server/YNABMCPServer.js +845 -0
  117. package/dist/server/budgetResolver.d.ts +15 -0
  118. package/dist/server/budgetResolver.js +99 -0
  119. package/dist/server/cacheManager.d.ts +74 -0
  120. package/dist/server/cacheManager.js +306 -0
  121. package/dist/server/config.d.ts +3 -0
  122. package/dist/server/config.js +19 -0
  123. package/dist/server/deltaCache.d.ts +61 -0
  124. package/dist/server/deltaCache.js +206 -0
  125. package/dist/server/deltaCache.merge.d.ts +9 -0
  126. package/dist/server/deltaCache.merge.js +111 -0
  127. package/dist/server/diagnostics.d.ts +90 -0
  128. package/dist/server/diagnostics.js +163 -0
  129. package/dist/server/errorHandler.d.ts +69 -0
  130. package/dist/server/errorHandler.js +524 -0
  131. package/dist/server/prompts.d.ts +31 -0
  132. package/dist/server/prompts.js +205 -0
  133. package/dist/server/rateLimiter.d.ts +27 -0
  134. package/dist/server/rateLimiter.js +82 -0
  135. package/dist/server/requestLogger.d.ts +62 -0
  136. package/dist/server/requestLogger.js +190 -0
  137. package/dist/server/resources.d.ts +39 -0
  138. package/dist/server/resources.js +85 -0
  139. package/dist/server/responseFormatter.d.ts +14 -0
  140. package/dist/server/responseFormatter.js +42 -0
  141. package/dist/server/securityMiddleware.d.ts +87 -0
  142. package/dist/server/securityMiddleware.js +117 -0
  143. package/dist/server/serverKnowledgeStore.d.ts +11 -0
  144. package/dist/server/serverKnowledgeStore.js +42 -0
  145. package/dist/server/toolRegistry.d.ts +85 -0
  146. package/dist/server/toolRegistry.js +272 -0
  147. package/dist/tools/__tests__/deltaTestUtils.d.ts +18 -0
  148. package/dist/tools/__tests__/deltaTestUtils.js +26 -0
  149. package/dist/tools/accountTools.d.ts +37 -0
  150. package/dist/tools/accountTools.js +175 -0
  151. package/dist/tools/budgetTools.d.ts +10 -0
  152. package/dist/tools/budgetTools.js +68 -0
  153. package/dist/tools/categoryTools.d.ts +27 -0
  154. package/dist/tools/categoryTools.js +232 -0
  155. package/dist/tools/compareTransactions/formatter.d.ts +71 -0
  156. package/dist/tools/compareTransactions/formatter.js +97 -0
  157. package/dist/tools/compareTransactions/index.d.ts +30 -0
  158. package/dist/tools/compareTransactions/index.js +160 -0
  159. package/dist/tools/compareTransactions/matcher.d.ts +12 -0
  160. package/dist/tools/compareTransactions/matcher.js +140 -0
  161. package/dist/tools/compareTransactions/parser.d.ts +14 -0
  162. package/dist/tools/compareTransactions/parser.js +430 -0
  163. package/dist/tools/compareTransactions/types.d.ts +27 -0
  164. package/dist/tools/compareTransactions/types.js +1 -0
  165. package/dist/tools/compareTransactions.d.ts +1 -0
  166. package/dist/tools/compareTransactions.js +1 -0
  167. package/dist/tools/deltaFetcher.d.ts +22 -0
  168. package/dist/tools/deltaFetcher.js +137 -0
  169. package/dist/tools/deltaSupport.d.ts +20 -0
  170. package/dist/tools/deltaSupport.js +176 -0
  171. package/dist/tools/exportTransactions.d.ts +17 -0
  172. package/dist/tools/exportTransactions.js +191 -0
  173. package/dist/tools/monthTools.d.ts +16 -0
  174. package/dist/tools/monthTools.js +107 -0
  175. package/dist/tools/payeeTools.d.ts +17 -0
  176. package/dist/tools/payeeTools.js +82 -0
  177. package/dist/tools/reconcileAdapter.d.ts +25 -0
  178. package/dist/tools/reconcileAdapter.js +167 -0
  179. package/dist/tools/reconciliation/analyzer.d.ts +3 -0
  180. package/dist/tools/reconciliation/analyzer.js +567 -0
  181. package/dist/tools/reconciliation/executor.d.ts +94 -0
  182. package/dist/tools/reconciliation/executor.js +611 -0
  183. package/dist/tools/reconciliation/index.d.ts +54 -0
  184. package/dist/tools/reconciliation/index.js +249 -0
  185. package/dist/tools/reconciliation/matcher.d.ts +3 -0
  186. package/dist/tools/reconciliation/matcher.js +160 -0
  187. package/dist/tools/reconciliation/payeeNormalizer.d.ts +6 -0
  188. package/dist/tools/reconciliation/payeeNormalizer.js +77 -0
  189. package/dist/tools/reconciliation/recommendationEngine.d.ts +2 -0
  190. package/dist/tools/reconciliation/recommendationEngine.js +273 -0
  191. package/dist/tools/reconciliation/reportFormatter.d.ts +13 -0
  192. package/dist/tools/reconciliation/reportFormatter.js +214 -0
  193. package/dist/tools/reconciliation/types.d.ts +172 -0
  194. package/dist/tools/reconciliation/types.js +7 -0
  195. package/dist/tools/schemas/outputs/accountOutputs.d.ts +58 -0
  196. package/dist/tools/schemas/outputs/accountOutputs.js +24 -0
  197. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +48 -0
  198. package/dist/tools/schemas/outputs/budgetOutputs.js +15 -0
  199. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +93 -0
  200. package/dist/tools/schemas/outputs/categoryOutputs.js +37 -0
  201. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +269 -0
  202. package/dist/tools/schemas/outputs/comparisonOutputs.js +181 -0
  203. package/dist/tools/schemas/outputs/index.d.ts +14 -0
  204. package/dist/tools/schemas/outputs/index.js +14 -0
  205. package/dist/tools/schemas/outputs/monthOutputs.d.ts +122 -0
  206. package/dist/tools/schemas/outputs/monthOutputs.js +51 -0
  207. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +34 -0
  208. package/dist/tools/schemas/outputs/payeeOutputs.js +16 -0
  209. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +1275 -0
  210. package/dist/tools/schemas/outputs/reconciliationOutputs.js +377 -0
  211. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +717 -0
  212. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +260 -0
  213. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +98 -0
  214. package/dist/tools/schemas/outputs/transactionOutputs.js +49 -0
  215. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +219 -0
  216. package/dist/tools/schemas/outputs/utilityOutputs.js +120 -0
  217. package/dist/tools/schemas/shared/commonOutputs.d.ts +24 -0
  218. package/dist/tools/schemas/shared/commonOutputs.js +27 -0
  219. package/dist/tools/toolCategories.d.ts +32 -0
  220. package/dist/tools/toolCategories.js +32 -0
  221. package/dist/tools/transactionTools.d.ts +315 -0
  222. package/dist/tools/transactionTools.js +1722 -0
  223. package/dist/tools/utilityTools.d.ts +10 -0
  224. package/dist/tools/utilityTools.js +56 -0
  225. package/dist/types/index.d.ts +20 -0
  226. package/dist/types/index.js +16 -0
  227. package/dist/types/toolAnnotations.d.ts +7 -0
  228. package/dist/types/toolAnnotations.js +1 -0
  229. package/dist/utils/amountUtils.d.ts +3 -0
  230. package/dist/utils/amountUtils.js +10 -0
  231. package/dist/utils/dateUtils.d.ts +9 -0
  232. package/dist/utils/dateUtils.js +43 -0
  233. package/dist/utils/money.d.ts +21 -0
  234. package/dist/utils/money.js +51 -0
  235. package/docs/README.md +72 -0
  236. package/docs/assets/examples/reconciliation-with-recommendations.json +68 -0
  237. package/docs/assets/schemas/reconciliation-v2.json +338 -0
  238. package/docs/getting-started/CONFIGURATION.md +175 -0
  239. package/docs/getting-started/INSTALLATION.md +333 -0
  240. package/docs/getting-started/QUICKSTART.md +282 -0
  241. package/docs/guides/ARCHITECTURE.md +650 -0
  242. package/docs/guides/DEPLOYMENT.md +189 -0
  243. package/docs/guides/INTEGRATION_TESTING.md +730 -0
  244. package/docs/guides/TESTING.md +591 -0
  245. package/docs/reconciliation-flow.md +83 -0
  246. package/docs/reference/API.md +1450 -0
  247. package/docs/reference/EXAMPLES.md +946 -0
  248. package/docs/reference/TOOLS.md +348 -0
  249. package/docs/reference/TROUBLESHOOTING.md +481 -0
  250. package/esbuild.config.mjs +68 -0
  251. package/eslint.config.js +49 -0
  252. package/fix-types.sh +17 -0
  253. package/meta.json +12550 -0
  254. package/package.json +105 -0
  255. package/package.json.tmp +105 -0
  256. package/scripts/analyze-bundle.mjs +41 -0
  257. package/scripts/create-pr-description.js +203 -0
  258. package/scripts/generate-mcpb.ps1 +96 -0
  259. package/scripts/run-domain-integration-tests.js +33 -0
  260. package/scripts/run-generate-mcpb.js +29 -0
  261. package/scripts/run-throttled-integration-tests.js +116 -0
  262. package/scripts/test-delta-params.mjs +140 -0
  263. package/scripts/test-recommendations.ts +53 -0
  264. package/scripts/tmpTransaction.ts +48 -0
  265. package/scripts/validate-env.js +122 -0
  266. package/scripts/verify-build.js +105 -0
  267. package/scripts/watch-and-restart.ps1 +50 -0
  268. package/src/__tests__/comprehensive.integration.test.ts +1196 -0
  269. package/src/__tests__/delta.performance.test.ts +80 -0
  270. package/src/__tests__/performance.test.ts +725 -0
  271. package/src/__tests__/setup.ts +449 -0
  272. package/src/__tests__/testRunner.ts +444 -0
  273. package/src/__tests__/testUtils.ts +563 -0
  274. package/src/__tests__/workflows.e2e.test.ts +1675 -0
  275. package/src/index.ts +124 -0
  276. package/src/server/.gitkeep +1 -0
  277. package/src/server/YNABMCPServer.ts +1188 -0
  278. package/src/server/__tests__/YNABMCPServer.integration.test.ts +903 -0
  279. package/src/server/__tests__/YNABMCPServer.test.ts +894 -0
  280. package/src/server/__tests__/budgetResolver.test.ts +425 -0
  281. package/src/server/__tests__/cacheManager.test.ts +880 -0
  282. package/src/server/__tests__/config.test.ts +166 -0
  283. package/src/server/__tests__/deltaCache.merge.test.ts +724 -0
  284. package/src/server/__tests__/deltaCache.swr.test.ts +168 -0
  285. package/src/server/__tests__/deltaCache.test.ts +774 -0
  286. package/src/server/__tests__/diagnostics.test.ts +823 -0
  287. package/src/server/__tests__/errorHandler.integration.test.ts +466 -0
  288. package/src/server/__tests__/errorHandler.test.ts +416 -0
  289. package/src/server/__tests__/prompts.test.ts +354 -0
  290. package/src/server/__tests__/rateLimiter.test.ts +314 -0
  291. package/src/server/__tests__/requestLogger.test.ts +408 -0
  292. package/src/server/__tests__/resources.test.ts +299 -0
  293. package/src/server/__tests__/security.integration.test.ts +426 -0
  294. package/src/server/__tests__/securityMiddleware.test.ts +449 -0
  295. package/src/server/__tests__/server-startup.integration.test.ts +477 -0
  296. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -0
  297. package/src/server/__tests__/toolRegistry.test.ts +855 -0
  298. package/src/server/budgetResolver.ts +235 -0
  299. package/src/server/cacheManager.ts +503 -0
  300. package/src/server/config.ts +41 -0
  301. package/src/server/deltaCache.merge.ts +149 -0
  302. package/src/server/deltaCache.ts +341 -0
  303. package/src/server/diagnostics.ts +338 -0
  304. package/src/server/errorHandler.ts +756 -0
  305. package/src/server/prompts.ts +291 -0
  306. package/src/server/rateLimiter.ts +156 -0
  307. package/src/server/requestLogger.ts +344 -0
  308. package/src/server/resources.ts +168 -0
  309. package/src/server/responseFormatter.ts +51 -0
  310. package/src/server/securityMiddleware.ts +236 -0
  311. package/src/server/serverKnowledgeStore.ts +91 -0
  312. package/src/server/toolRegistry.ts +489 -0
  313. package/src/tools/.gitkeep +1 -0
  314. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -0
  315. package/src/tools/__tests__/accountTools.integration.test.ts +117 -0
  316. package/src/tools/__tests__/accountTools.test.ts +653 -0
  317. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +90 -0
  318. package/src/tools/__tests__/budgetTools.integration.test.ts +134 -0
  319. package/src/tools/__tests__/budgetTools.test.ts +423 -0
  320. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +80 -0
  321. package/src/tools/__tests__/categoryTools.integration.test.ts +295 -0
  322. package/src/tools/__tests__/categoryTools.test.ts +622 -0
  323. package/src/tools/__tests__/compareTransactions/formatter.test.ts +486 -0
  324. package/src/tools/__tests__/compareTransactions/index.test.ts +383 -0
  325. package/src/tools/__tests__/compareTransactions/matcher.test.ts +410 -0
  326. package/src/tools/__tests__/compareTransactions/parser.test.ts +764 -0
  327. package/src/tools/__tests__/compareTransactions.test.ts +342 -0
  328. package/src/tools/__tests__/compareTransactions.window.test.ts +147 -0
  329. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +76 -0
  330. package/src/tools/__tests__/deltaFetcher.test.ts +270 -0
  331. package/src/tools/__tests__/deltaSupport.test.ts +188 -0
  332. package/src/tools/__tests__/deltaTestUtils.ts +46 -0
  333. package/src/tools/__tests__/exportTransactions.test.ts +213 -0
  334. package/src/tools/__tests__/monthTools.delta.integration.test.ts +80 -0
  335. package/src/tools/__tests__/monthTools.integration.test.ts +174 -0
  336. package/src/tools/__tests__/monthTools.test.ts +523 -0
  337. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +80 -0
  338. package/src/tools/__tests__/payeeTools.integration.test.ts +150 -0
  339. package/src/tools/__tests__/payeeTools.test.ts +445 -0
  340. package/src/tools/__tests__/transactionTools.integration.test.ts +762 -0
  341. package/src/tools/__tests__/transactionTools.test.ts +3521 -0
  342. package/src/tools/__tests__/utilityTools.integration.test.ts +128 -0
  343. package/src/tools/__tests__/utilityTools.test.ts +205 -0
  344. package/src/tools/accountTools.ts +283 -0
  345. package/src/tools/budgetTools.ts +112 -0
  346. package/src/tools/categoryTools.ts +366 -0
  347. package/src/tools/compareTransactions/formatter.ts +163 -0
  348. package/src/tools/compareTransactions/index.ts +228 -0
  349. package/src/tools/compareTransactions/matcher.ts +240 -0
  350. package/src/tools/compareTransactions/parser.ts +557 -0
  351. package/src/tools/compareTransactions/types.ts +60 -0
  352. package/src/tools/compareTransactions.ts +3 -0
  353. package/src/tools/deltaFetcher.ts +278 -0
  354. package/src/tools/deltaSupport.ts +293 -0
  355. package/src/tools/exportTransactions.ts +273 -0
  356. package/src/tools/monthTools.ts +164 -0
  357. package/src/tools/payeeTools.ts +140 -0
  358. package/src/tools/reconcileAdapter.ts +312 -0
  359. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +122 -0
  360. package/src/tools/reconciliation/__tests__/adapter.test.ts +234 -0
  361. package/src/tools/reconciliation/__tests__/analyzer.test.ts +406 -0
  362. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +366 -0
  363. package/src/tools/reconciliation/__tests__/executor.test.ts +779 -0
  364. package/src/tools/reconciliation/__tests__/matcher.test.ts +650 -0
  365. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +278 -0
  366. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +658 -0
  367. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1000 -0
  368. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +151 -0
  369. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +573 -0
  370. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +78 -0
  371. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +47 -0
  372. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +61 -0
  373. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +49 -0
  374. package/src/tools/reconciliation/analyzer.ts +824 -0
  375. package/src/tools/reconciliation/executor.ts +880 -0
  376. package/src/tools/reconciliation/index.ts +400 -0
  377. package/src/tools/reconciliation/matcher.ts +269 -0
  378. package/src/tools/reconciliation/payeeNormalizer.ts +167 -0
  379. package/src/tools/reconciliation/recommendationEngine.ts +506 -0
  380. package/src/tools/reconciliation/reportFormatter.ts +363 -0
  381. package/src/tools/reconciliation/types.ts +314 -0
  382. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +424 -0
  383. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +310 -0
  384. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +448 -0
  385. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +519 -0
  386. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +155 -0
  387. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +288 -0
  388. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +478 -0
  389. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +370 -0
  390. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +401 -0
  391. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +213 -0
  392. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +474 -0
  393. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +333 -0
  394. package/src/tools/schemas/outputs/accountOutputs.ts +137 -0
  395. package/src/tools/schemas/outputs/budgetOutputs.ts +86 -0
  396. package/src/tools/schemas/outputs/categoryOutputs.ts +194 -0
  397. package/src/tools/schemas/outputs/comparisonOutputs.ts +600 -0
  398. package/src/tools/schemas/outputs/index.ts +270 -0
  399. package/src/tools/schemas/outputs/monthOutputs.ts +243 -0
  400. package/src/tools/schemas/outputs/payeeOutputs.ts +105 -0
  401. package/src/tools/schemas/outputs/reconciliationOutputs.ts +796 -0
  402. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +758 -0
  403. package/src/tools/schemas/outputs/transactionOutputs.ts +243 -0
  404. package/src/tools/schemas/outputs/utilityOutputs.ts +411 -0
  405. package/src/tools/schemas/shared/commonOutputs.ts +140 -0
  406. package/src/tools/toolCategories.ts +140 -0
  407. package/src/tools/transactionTools.ts +2509 -0
  408. package/src/tools/utilityTools.ts +90 -0
  409. package/src/types/.gitkeep +1 -0
  410. package/src/types/__tests__/index.test.ts +52 -0
  411. package/src/types/index.ts +67 -0
  412. package/src/types/integration-tests.d.ts +35 -0
  413. package/src/types/toolAnnotations.ts +44 -0
  414. package/src/utils/__tests__/dateUtils.test.ts +170 -0
  415. package/src/utils/__tests__/money.test.ts +189 -0
  416. package/src/utils/amountUtils.ts +32 -0
  417. package/src/utils/dateUtils.ts +108 -0
  418. package/src/utils/money.ts +123 -0
  419. package/test-csv-sample.csv +28 -0
  420. package/test-exports/sample_bank_statement.csv +7 -0
  421. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +23 -0
  422. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +23 -0
  423. package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +44 -0
  424. package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +58 -0
  425. package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +115 -0
  426. package/test-reconcile-autodetect.js +40 -0
  427. package/test-reconcile-tool.js +152 -0
  428. package/test-reconcile-with-csv.cjs +89 -0
  429. package/test-statement.csv +8 -0
  430. package/test_debug.js +47 -0
  431. package/test_simple.mjs +16 -0
  432. package/tsconfig.json +31 -0
  433. package/tsconfig.prod.json +18 -0
  434. package/vitest-reporters/split-json-reporter.ts +211 -0
  435. package/vitest.config.ts +96 -0
@@ -0,0 +1,824 @@
1
+ /**
2
+ * Analysis phase orchestration for reconciliation
3
+ * Coordinates CSV parsing, YNAB transaction fetching, and matching
4
+ */
5
+
6
+ import { randomUUID } from 'crypto';
7
+ 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';
12
+ import type {
13
+ BankTransaction,
14
+ YNABTransaction,
15
+ ReconciliationAnalysis,
16
+ TransactionMatch,
17
+ MatchingConfig,
18
+ BalanceInfo,
19
+ ReconciliationSummary,
20
+ ReconciliationInsight,
21
+ } from './types.js';
22
+ import { toMoneyValueFromDecimal } from '../../utils/money.js';
23
+ import { generateRecommendations } from './recommendationEngine.js';
24
+
25
+ /**
26
+ * Convert YNAB API transaction to simplified format
27
+ */
28
+ function convertYNABTransaction(apiTxn: ynab.TransactionDetail): YNABTransaction {
29
+ return {
30
+ id: apiTxn.id,
31
+ date: apiTxn.date,
32
+ amount: apiTxn.amount,
33
+ payee_name: apiTxn.payee_name || null,
34
+ category_name: apiTxn.category_name || null,
35
+ cleared: apiTxn.cleared,
36
+ approved: apiTxn.approved,
37
+ memo: apiTxn.memo || null,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Parse CSV bank statement and generate unique IDs for tracking
43
+ */
44
+ const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
45
+ date_column: 'Date',
46
+ amount_column: 'Amount',
47
+ description_column: 'Description',
48
+ date_format: 'MM/DD/YYYY',
49
+ has_header: true,
50
+ delimiter: ',',
51
+ };
52
+
53
+ const ENABLE_COMBINATION_MATCHING = true;
54
+
55
+ const DAYS_IN_MS = 24 * 60 * 60 * 1000;
56
+
57
+ function toDollars(milliunits: number): number {
58
+ return milliunits / 1000;
59
+ }
60
+
61
+ function amountTolerance(config: MatchingConfig): number {
62
+ const toleranceCents =
63
+ config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
64
+ return Math.max(0, toleranceCents) / 100;
65
+ }
66
+
67
+ function dateTolerance(config: MatchingConfig): number {
68
+ return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
69
+ }
70
+
71
+ function daysBetween(dateA: string, dateB: string): number {
72
+ const a = new Date(`${dateA}T00:00:00Z`).getTime();
73
+ const b = new Date(`${dateB}T00:00:00Z`).getTime();
74
+ if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
75
+ return Math.abs(a - b) / DAYS_IN_MS;
76
+ }
77
+
78
+ function withinDateTolerance(
79
+ bankDate: string,
80
+ ynabTxns: YNABTransaction[],
81
+ toleranceDays: number,
82
+ ): boolean {
83
+ return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
84
+ }
85
+
86
+ function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
87
+ const bankSign = Math.sign(bankAmount);
88
+ const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
89
+ return bankSign === sumSign || Math.abs(bankAmount) === 0;
90
+ }
91
+
92
+ function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
93
+ const safeTolerance = tolerance > 0 ? tolerance : 0.01;
94
+ const ratio = diff / safeTolerance;
95
+ let base = legCount === 2 ? 75 : 70;
96
+ if (ratio <= 0.25) {
97
+ base += 5;
98
+ } else if (ratio <= 0.5) {
99
+ base += 3;
100
+ } else if (ratio >= 0.9) {
101
+ base -= 5;
102
+ }
103
+ return Math.max(65, Math.min(80, Math.round(base)));
104
+ }
105
+
106
+ function formatDifference(diff: number): string {
107
+ return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
108
+ }
109
+
110
+ interface CombinationResult {
111
+ matches: TransactionMatch[];
112
+ insights: ReconciliationInsight[];
113
+ }
114
+
115
+ function findCombinationMatches(
116
+ unmatchedBank: BankTransaction[],
117
+ unmatchedYNAB: YNABTransaction[],
118
+ config: MatchingConfig,
119
+ ): CombinationResult {
120
+ if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
121
+ return { matches: [], insights: [] };
122
+ }
123
+
124
+ const tolerance = amountTolerance(config);
125
+ const toleranceDays = dateTolerance(config);
126
+
127
+ const matches: TransactionMatch[] = [];
128
+ const insights: ReconciliationInsight[] = [];
129
+ const seenCombinations = new Set<string>();
130
+
131
+ for (const bankTxn of unmatchedBank) {
132
+ const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
133
+ if (viableYnab.length < 2) continue;
134
+
135
+ const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
136
+
137
+ const addIfValid = (combo: YNABTransaction[]) => {
138
+ const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
139
+ const diff = Math.abs(sum - bankTxn.amount);
140
+ if (diff > tolerance) return;
141
+ if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
142
+ if (!hasMatchingSign(bankTxn.amount, combo)) return;
143
+ evaluated.push({ txns: combo, diff, sum });
144
+ };
145
+
146
+ const n = viableYnab.length;
147
+ for (let i = 0; i < n - 1; i++) {
148
+ for (let j = i + 1; j < n; j++) {
149
+ addIfValid([viableYnab[i]!, viableYnab[j]!]);
150
+ }
151
+ }
152
+
153
+ if (n >= 3) {
154
+ for (let i = 0; i < n - 2; i++) {
155
+ for (let j = i + 1; j < n - 1; j++) {
156
+ for (let k = j + 1; k < n; k++) {
157
+ addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ if (evaluated.length === 0) continue;
164
+
165
+ evaluated.sort((a, b) => a.diff - b.diff);
166
+ const recordedSizes = new Set<number>();
167
+
168
+ for (const combo of evaluated) {
169
+ if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
170
+ const comboIds = combo.txns.map((txn) => txn.id).sort();
171
+ const key = `${bankTxn.id}|${comboIds.join('+')}`;
172
+ if (seenCombinations.has(key)) continue;
173
+ seenCombinations.add(key);
174
+ recordedSizes.add(combo.txns.length);
175
+
176
+ const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
177
+ const candidateConfidence = Math.max(60, score - 5);
178
+ const descriptionTotal = formatCurrency(combo.sum);
179
+ const diffLabel = formatDifference(combo.diff);
180
+
181
+ matches.push({
182
+ bank_transaction: bankTxn,
183
+ confidence: 'medium',
184
+ confidence_score: score,
185
+ match_reason: 'combination_match',
186
+ top_confidence: score,
187
+ candidates: combo.txns.map((txn) => ({
188
+ ynab_transaction: txn,
189
+ confidence: candidateConfidence,
190
+ match_reason: 'combination_component',
191
+ explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
192
+ })),
193
+ action_hint: 'review_combination',
194
+ recommendation:
195
+ `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
196
+ `${formatCurrency(bankTxn.amount)} on the bank statement.`,
197
+ });
198
+
199
+ const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
200
+ insights.push({
201
+ id: insightId,
202
+ type: 'combination_match' as unknown as ReconciliationInsight['type'],
203
+ severity: 'info',
204
+ title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
205
+ bankTxn.amount,
206
+ )}`,
207
+ description:
208
+ `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
209
+ `${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
210
+ evidence: {
211
+ bank_transaction_id: bankTxn.id,
212
+ bank_amount: bankTxn.amount,
213
+ ynab_transaction_ids: comboIds,
214
+ ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
215
+ combination_size: combo.txns.length,
216
+ difference: combo.diff,
217
+ },
218
+ });
219
+ }
220
+ }
221
+
222
+ return { matches, insights };
223
+ }
224
+
225
+ type ParserResult =
226
+ | {
227
+ transactions: unknown[];
228
+ format_detected?: string;
229
+ delimiter?: string;
230
+ total_rows?: number;
231
+ valid_rows?: number;
232
+ errors?: string[];
233
+ }
234
+ | unknown[];
235
+
236
+ function isParsedCSVData(
237
+ result: ParserResult,
238
+ ): result is Extract<ParserResult, { transactions: unknown[] }> {
239
+ return (
240
+ typeof result === 'object' &&
241
+ result !== null &&
242
+ !Array.isArray(result) &&
243
+ 'transactions' in result
244
+ );
245
+ }
246
+
247
+ function normalizeDate(value: unknown): string {
248
+ if (value instanceof Date) {
249
+ return value.toISOString().split('T')[0]!;
250
+ }
251
+
252
+ if (typeof value === 'string') {
253
+ const trimmed = value.trim();
254
+ if (!trimmed) return trimmed;
255
+
256
+ const parsed = new Date(trimmed);
257
+ if (!Number.isNaN(parsed.getTime())) {
258
+ return parsed.toISOString().split('T')[0]!;
259
+ }
260
+
261
+ return trimmed;
262
+ }
263
+
264
+ return new Date().toISOString().split('T')[0]!;
265
+ }
266
+
267
+ function normalizeAmount(record: Record<string, unknown>): number {
268
+ const raw = record['amount'];
269
+
270
+ if (typeof raw === 'number') {
271
+ if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
272
+ return Math.round(raw) / 1000;
273
+ }
274
+ return raw;
275
+ }
276
+
277
+ if (typeof raw === 'string') {
278
+ const cleaned = raw.replace(/[$,\s]/g, '');
279
+ const parsed = Number.parseFloat(cleaned);
280
+ return Number.isFinite(parsed) ? parsed : 0;
281
+ }
282
+
283
+ return 0;
284
+ }
285
+
286
+ function normalizePayee(record: Record<string, unknown>): string {
287
+ const candidates = [record['payee'], record['description'], record['memo']];
288
+ for (const candidate of candidates) {
289
+ if (typeof candidate === 'string' && candidate.trim()) {
290
+ return candidate.trim();
291
+ }
292
+ }
293
+ return 'Unknown Payee';
294
+ }
295
+
296
+ function determineRow(record: Record<string, unknown>, index: number): number {
297
+ if (typeof record['original_csv_row'] === 'number') {
298
+ return record['original_csv_row'];
299
+ }
300
+ if (typeof record['row_number'] === 'number') {
301
+ return record['row_number'];
302
+ }
303
+ return index + 1;
304
+ }
305
+
306
+ function convertParserRecord(record: unknown, index: number): BankTransaction {
307
+ const data =
308
+ typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
309
+
310
+ const dateValue = normalizeDate(data['date']);
311
+ const amountValue = normalizeAmount(data);
312
+ const payeeValue = normalizePayee(data);
313
+ const memoValue =
314
+ typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
315
+ const originalRow = determineRow(data, index);
316
+
317
+ const transaction: BankTransaction = {
318
+ id: randomUUID(),
319
+ date: dateValue,
320
+ amount: amountValue,
321
+ payee: payeeValue,
322
+ original_csv_row: originalRow,
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
+ function calculateBalances(
407
+ ynabTransactions: YNABTransaction[],
408
+ statementBalance: number,
409
+ currency: string,
410
+ ): BalanceInfo {
411
+ let clearedBalance = 0;
412
+ let unclearedBalance = 0;
413
+
414
+ for (const txn of ynabTransactions) {
415
+ const amount = txn.amount / 1000; // Convert from milliunits to dollars
416
+
417
+ if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
418
+ clearedBalance += amount;
419
+ } else {
420
+ unclearedBalance += amount;
421
+ }
422
+ }
423
+
424
+ const totalBalance = clearedBalance + unclearedBalance;
425
+ const discrepancy = clearedBalance - statementBalance;
426
+
427
+ 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
434
+ };
435
+ }
436
+
437
+ /**
438
+ * Generate reconciliation summary
439
+ */
440
+ function generateSummary(
441
+ bankTransactions: BankTransaction[],
442
+ ynabTransactions: YNABTransaction[],
443
+ autoMatches: TransactionMatch[],
444
+ suggestedMatches: TransactionMatch[],
445
+ unmatchedBank: BankTransaction[],
446
+ unmatchedYNAB: YNABTransaction[],
447
+ balances: BalanceInfo,
448
+ ): ReconciliationSummary {
449
+ // Determine date range from bank transactions
450
+ const dates = bankTransactions.map((t) => t.date).sort();
451
+ const dateRange = dates.length > 0 ? `${dates[0]} to ${dates[dates.length - 1]}` : 'Unknown';
452
+
453
+ // Build discrepancy explanation
454
+ let discrepancyExplanation = '';
455
+ if (balances.on_track) {
456
+ discrepancyExplanation = 'Cleared balance matches statement';
457
+ } else {
458
+ const actionsNeeded: string[] = [];
459
+ if (autoMatches.length > 0) {
460
+ actionsNeeded.push(`clear ${autoMatches.length} transactions`);
461
+ }
462
+ if (unmatchedBank.length > 0) {
463
+ actionsNeeded.push(`add ${unmatchedBank.length} missing`);
464
+ }
465
+ if (unmatchedYNAB.length > 0) {
466
+ actionsNeeded.push(`review ${unmatchedYNAB.length} unmatched YNAB`);
467
+ }
468
+
469
+ discrepancyExplanation =
470
+ actionsNeeded.length > 0 ? `Need to ${actionsNeeded.join(', ')}` : 'Manual review required';
471
+ }
472
+
473
+ return {
474
+ statement_date_range: dateRange,
475
+ bank_transactions_count: bankTransactions.length,
476
+ ynab_transactions_count: ynabTransactions.length,
477
+ auto_matched: autoMatches.length,
478
+ suggested_matches: suggestedMatches.length,
479
+ unmatched_bank: unmatchedBank.length,
480
+ unmatched_ynab: unmatchedYNAB.length,
481
+ current_cleared_balance: balances.current_cleared,
482
+ target_statement_balance: balances.target_statement,
483
+ discrepancy: balances.discrepancy,
484
+ discrepancy_explanation: discrepancyExplanation,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Generate next steps for user
490
+ */
491
+ function generateNextSteps(summary: ReconciliationSummary): string[] {
492
+ const steps: string[] = [];
493
+
494
+ if (summary.auto_matched > 0) {
495
+ steps.push(`Review ${summary.auto_matched} auto-matched transactions for approval`);
496
+ }
497
+
498
+ if (summary.suggested_matches > 0) {
499
+ steps.push(`Review ${summary.suggested_matches} suggested matches and choose best match`);
500
+ }
501
+
502
+ if (summary.unmatched_bank > 0) {
503
+ steps.push(`Decide whether to add ${summary.unmatched_bank} missing bank transactions to YNAB`);
504
+ }
505
+
506
+ if (summary.unmatched_ynab > 0) {
507
+ steps.push(
508
+ `Decide what to do with ${summary.unmatched_ynab} unmatched YNAB transactions (unclear/delete/ignore)`,
509
+ );
510
+ }
511
+
512
+ if (steps.length === 0) {
513
+ steps.push('All transactions matched! Review and approve to complete reconciliation');
514
+ }
515
+
516
+ return steps;
517
+ }
518
+
519
+ function formatCurrency(amount: number): string {
520
+ const formatter = new Intl.NumberFormat('en-US', {
521
+ style: 'currency',
522
+ currency: 'USD',
523
+ minimumFractionDigits: 2,
524
+ maximumFractionDigits: 2,
525
+ });
526
+ return formatter.format(amount);
527
+ }
528
+
529
+ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationInsight[] {
530
+ const insights: ReconciliationInsight[] = [];
531
+ if (unmatchedBank.length === 0) {
532
+ return insights;
533
+ }
534
+
535
+ const frequency = new Map<string, { amount: number; txns: BankTransaction[] }>();
536
+
537
+ for (const txn of unmatchedBank) {
538
+ const key = txn.amount.toFixed(2);
539
+ const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
540
+ entry.txns.push(txn);
541
+ frequency.set(key, entry);
542
+ }
543
+
544
+ const repeated = Array.from(frequency.values())
545
+ .filter((entry) => entry.txns.length >= 2)
546
+ .sort((a, b) => b.txns.length - a.txns.length);
547
+
548
+ if (repeated.length === 0) {
549
+ return insights;
550
+ }
551
+
552
+ const top = repeated[0]!;
553
+ insights.push({
554
+ id: `repeat-${top.amount.toFixed(2)}`,
555
+ type: 'repeat_amount',
556
+ severity: top.txns.length >= 4 ? 'critical' : 'warning',
557
+ title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount)}`,
558
+ description:
559
+ `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount)}. ` +
560
+ 'Repeated amounts are usually the quickest wins — reconcile these first.',
561
+ evidence: {
562
+ amount: top.amount,
563
+ occurrences: top.txns.length,
564
+ dates: top.txns.map((txn) => txn.date),
565
+ csv_rows: top.txns.map((txn) => txn.original_csv_row),
566
+ },
567
+ });
568
+
569
+ return insights;
570
+ }
571
+
572
+ function nearMatchInsights(
573
+ matches: TransactionMatch[],
574
+ config: MatchingConfig,
575
+ ): ReconciliationInsight[] {
576
+ const insights: ReconciliationInsight[] = [];
577
+
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) {
632
+ insights.push({
633
+ id: 'balance-gap',
634
+ type: 'anomaly',
635
+ severity: discrepancyAbs >= 100 ? 'critical' : 'warning',
636
+ title: `Cleared balance off by ${balances.discrepancy.value_display}`,
637
+ description:
638
+ `YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
639
+ `${balances.target_statement.value_display}. Focus on closing this gap.`,
640
+ evidence: {
641
+ cleared_balance: balances.current_cleared,
642
+ statement_balance: balances.target_statement,
643
+ discrepancy: balances.discrepancy,
644
+ },
645
+ });
646
+ }
647
+
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
+ return insights;
664
+ }
665
+
666
+ function detectInsights(
667
+ matches: TransactionMatch[],
668
+ unmatchedBank: BankTransaction[],
669
+ summary: ReconciliationSummary,
670
+ balances: BalanceInfo,
671
+ config: MatchingConfig,
672
+ ): ReconciliationInsight[] {
673
+ const insights: ReconciliationInsight[] = [];
674
+ const seen = new Set<string>();
675
+
676
+ const addUnique = (insight: ReconciliationInsight) => {
677
+ if (seen.has(insight.id)) return;
678
+ seen.add(insight.id);
679
+ insights.push(insight);
680
+ };
681
+
682
+ for (const insight of repeatAmountInsights(unmatchedBank)) {
683
+ addUnique(insight);
684
+ }
685
+
686
+ for (const insight of nearMatchInsights(matches, config)) {
687
+ addUnique(insight);
688
+ }
689
+
690
+ for (const insight of anomalyInsights(summary, balances)) {
691
+ addUnique(insight);
692
+ }
693
+
694
+ return insights.slice(0, 5);
695
+ }
696
+
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
+ }
716
+
717
+ /**
718
+ * Perform reconciliation analysis
719
+ *
720
+ * @param csvContent - CSV file content or file path
721
+ * @param csvFilePath - Optional file path (if csvContent is a path)
722
+ * @param ynabTransactions - YNAB transactions from API
723
+ * @param statementBalance - Expected cleared balance from statement
724
+ * @param config - Matching configuration
725
+ * @param currency - Currency code (default: USD)
726
+ * @param accountId - Account ID for recommendation context
727
+ * @param budgetId - Budget ID for recommendation context
728
+ * @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
729
+ */
730
+ export function analyzeReconciliation(
731
+ csvContent: string,
732
+ csvFilePath: string | undefined,
733
+ ynabTransactions: ynab.TransactionDetail[],
734
+ statementBalance: number,
735
+ config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
736
+ currency: string = 'USD',
737
+ accountId?: string,
738
+ budgetId?: string,
739
+ invertBankAmounts: boolean = false,
740
+ ): 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
+ }));
752
+ }
753
+
754
+ // Step 2: Convert YNAB transactions
755
+ const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
756
+
757
+ // Step 3: Run matching algorithm
758
+ const matches = findMatches(bankTransactions, convertedYNABTxns, config);
759
+
760
+ // Step 4: Categorize matches
761
+ const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
762
+
763
+ // Step 5: Find unmatched YNAB transactions
764
+ const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
765
+
766
+ let combinationMatches: TransactionMatch[] = [];
767
+ let combinationInsights: ReconciliationInsight[] = [];
768
+
769
+ if (ENABLE_COMBINATION_MATCHING) {
770
+ const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
771
+ combinationMatches = combinationResult.matches;
772
+ combinationInsights = combinationResult.insights;
773
+ }
774
+
775
+ const enrichedSuggestedMatches = [...suggestedMatches, ...combinationMatches];
776
+
777
+ // Step 6: Calculate balances
778
+ const balances = calculateBalances(convertedYNABTxns, statementBalance, currency);
779
+
780
+ // Step 7: Generate summary
781
+ const summary = generateSummary(
782
+ bankTransactions,
783
+ convertedYNABTxns,
784
+ autoMatches,
785
+ enrichedSuggestedMatches,
786
+ unmatchedBank,
787
+ unmatchedYNAB,
788
+ balances,
789
+ );
790
+
791
+ // Step 8: Generate next steps
792
+ const nextSteps = generateNextSteps(summary);
793
+
794
+ // Step 9: Detect insights and patterns
795
+ const baseInsights = detectInsights(matches, unmatchedBank, summary, balances, config);
796
+ const insights = mergeInsights(baseInsights, combinationInsights);
797
+
798
+ // Step 10: Build the analysis result
799
+ const analysis: ReconciliationAnalysis = {
800
+ success: true,
801
+ phase: 'analysis',
802
+ summary,
803
+ auto_matches: autoMatches,
804
+ suggested_matches: enrichedSuggestedMatches,
805
+ unmatched_bank: unmatchedBank,
806
+ unmatched_ynab: unmatchedYNAB,
807
+ balance_info: balances,
808
+ next_steps: nextSteps,
809
+ insights,
810
+ };
811
+
812
+ // Step 11: Generate recommendations (if account and budget IDs are provided)
813
+ if (accountId && budgetId) {
814
+ const recommendations = generateRecommendations({
815
+ account_id: accountId,
816
+ budget_id: budgetId,
817
+ analysis,
818
+ matching_config: config,
819
+ });
820
+ analysis.recommendations = recommendations;
821
+ }
822
+
823
+ return analysis;
824
+ }