@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,880 @@
1
+ import { createHash } from 'crypto';
2
+ import type * as ynab from 'ynab';
3
+ import type { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
4
+ import { toMilli, toMoneyValue, toMoneyValueFromDecimal, addMilli } from '../../utils/money.js';
5
+ import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from './types.js';
6
+ import type { ReconcileAccountRequest } from './index.js';
7
+ import {
8
+ generateCorrelationKey,
9
+ correlateResults,
10
+ toCorrelationPayload,
11
+ } from '../transactionTools.js';
12
+
13
+ export interface AccountSnapshot {
14
+ balance: number; // milliunits
15
+ cleared_balance: number; // milliunits
16
+ uncleared_balance: number; // milliunits
17
+ }
18
+
19
+ export interface ExecutionOptions {
20
+ ynabAPI: ynab.API;
21
+ analysis: ReconciliationAnalysis;
22
+ params: ReconcileAccountRequest;
23
+ budgetId: string;
24
+ accountId: string;
25
+ initialAccount: AccountSnapshot;
26
+ currencyCode: string;
27
+ }
28
+
29
+ export interface ExecutionActionRecord {
30
+ type: string;
31
+ transaction: Record<string, unknown> | null;
32
+ reason: string;
33
+ bulk_chunk_index?: number;
34
+ correlation_key?: string;
35
+ duplicate?: boolean;
36
+ }
37
+
38
+ export interface ExecutionSummary {
39
+ bank_transactions_count: number;
40
+ ynab_transactions_count: number;
41
+ matches_found: number;
42
+ missing_in_ynab: number;
43
+ missing_in_bank: number;
44
+ transactions_created: number;
45
+ transactions_updated: number;
46
+ dates_adjusted: number;
47
+ dry_run: boolean;
48
+ }
49
+
50
+ /**
51
+ * Bulk operation metrics for reconciliation transaction creation.
52
+ *
53
+ * Note on failure counters:
54
+ * - `transaction_failures` is the canonical counter for per-transaction failures
55
+ * - `failed_transactions` is maintained for backward compatibility and should always
56
+ * mirror `transaction_failures` rather than represent an independent count
57
+ */
58
+ export interface BulkOperationDetails {
59
+ chunks_processed: number;
60
+ bulk_successes: number;
61
+ sequential_fallbacks: number;
62
+ duplicates_detected: number;
63
+ failed_transactions: number; // Backward-compatible alias for transaction_failures
64
+ bulk_chunk_failures: number; // API-level failures (entire chunk failed)
65
+ transaction_failures: number; // Per-transaction failures (from correlation or sequential)
66
+ sequential_attempts?: number; // Number of sequential creations attempted during fallback
67
+ }
68
+
69
+ export interface ExecutionResult {
70
+ summary: ExecutionSummary;
71
+ account_balance: {
72
+ before: AccountSnapshot;
73
+ after: AccountSnapshot;
74
+ };
75
+ actions_taken: ExecutionActionRecord[];
76
+ recommendations: string[];
77
+ balance_reconciliation?: Awaited<ReturnType<typeof buildBalanceReconciliation>>;
78
+ bulk_operation_details?: BulkOperationDetails;
79
+ }
80
+
81
+ interface UpdateFlags {
82
+ needsClearedUpdate: boolean;
83
+ needsDateUpdate: boolean;
84
+ }
85
+
86
+ const MONEY_EPSILON_MILLI = 100; // $0.10
87
+ const DEFAULT_TOLERANCE_CENTS = 1;
88
+ const CENTS_TO_MILLI = 10;
89
+ const MAX_BULK_CREATE_CHUNK = 100;
90
+
91
+ function chunkArray<T>(array: T[], size: number): T[][] {
92
+ if (size <= 0) {
93
+ throw new Error('chunk size must be positive');
94
+ }
95
+ const chunks: T[][] = [];
96
+ for (let i = 0; i < array.length; i += size) {
97
+ chunks.push(array.slice(i, i + size));
98
+ }
99
+ return chunks;
100
+ }
101
+
102
+ interface PreparedBulkCreateEntry {
103
+ bankTransaction: BankTransaction;
104
+ saveTransaction: SaveTransaction;
105
+ amountMilli: number;
106
+ correlationKey: string;
107
+ }
108
+
109
+ /**
110
+ * Generates a deterministic import_id for reconciliation-created transactions.
111
+ *
112
+ * Uses a dedicated `YNAB:bulk:` prefix to distinguish reconciliation-created transactions
113
+ * from manual bulk creates. This namespace separation is intentional:
114
+ * - Reconciliation operations are automated and system-generated
115
+ * - Manual bulk creates via create_transactions tool can use custom import_id formats
116
+ * - Both interact with YNAB's global duplicate detection via the same import_id mechanism
117
+ *
118
+ * The hash-based correlation in transactionTools.ts uses `hash:` prefix for correlation
119
+ * (when no import_id provided), which is separate from this import_id generation.
120
+ */
121
+ function generateBulkImportId(
122
+ accountId: string,
123
+ date: string,
124
+ amountMilli: number,
125
+ payee?: string | null,
126
+ ): string {
127
+ const normalizedPayee = (payee ?? '').trim().toLowerCase();
128
+ const raw = `${accountId}|${date}|${amountMilli}|${normalizedPayee}`;
129
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
130
+ return `YNAB:bulk:${digest}`;
131
+ }
132
+
133
+ export async function executeReconciliation(options: ExecutionOptions): Promise<ExecutionResult> {
134
+ const { analysis, params, ynabAPI, budgetId, accountId, initialAccount, currencyCode } = options;
135
+ const actions_taken: ExecutionActionRecord[] = [];
136
+
137
+ const summary: ExecutionSummary = {
138
+ bank_transactions_count: analysis.summary.bank_transactions_count,
139
+ ynab_transactions_count: analysis.summary.ynab_transactions_count,
140
+ matches_found: analysis.auto_matches.length,
141
+ missing_in_ynab: analysis.summary.unmatched_bank,
142
+ missing_in_bank: analysis.summary.unmatched_ynab,
143
+ transactions_created: 0,
144
+ transactions_updated: 0,
145
+ dates_adjusted: 0,
146
+ dry_run: params.dry_run,
147
+ };
148
+
149
+ let afterAccount: AccountSnapshot = { ...initialAccount };
150
+ let accountSnapshotDirty = false;
151
+ const statementTargetMilli = resolveStatementBalanceMilli(
152
+ analysis.balance_info,
153
+ params.statement_balance,
154
+ );
155
+ let clearedDeltaMilli = addMilli(initialAccount.cleared_balance ?? 0, -statementTargetMilli);
156
+ const balanceToleranceMilli =
157
+ Math.max(0, params.amount_tolerance_cents ?? DEFAULT_TOLERANCE_CENTS) * CENTS_TO_MILLI;
158
+ let balanceAligned = false;
159
+
160
+ const applyClearedDelta = (delta: number) => {
161
+ if (delta === 0) return;
162
+ clearedDeltaMilli = addMilli(clearedDeltaMilli, delta);
163
+ };
164
+
165
+ const recordAlignmentIfNeeded = (trigger: string, { log = true } = {}) => {
166
+ if (balanceAligned) {
167
+ return true;
168
+ }
169
+ if (Math.abs(clearedDeltaMilli) <= balanceToleranceMilli) {
170
+ balanceAligned = true;
171
+ if (log) {
172
+ const deltaDisplay = toMoneyValue(clearedDeltaMilli, currencyCode).value_display;
173
+ const toleranceDisplay = toMoneyValue(balanceToleranceMilli, currencyCode).value_display;
174
+ actions_taken.push({
175
+ type: 'balance_checkpoint',
176
+ transaction: null,
177
+ reason: `Cleared delta ${deltaDisplay} within ±${toleranceDisplay} after ${trigger} - halting newest-to-oldest pass`,
178
+ });
179
+ }
180
+ return true;
181
+ }
182
+ return false;
183
+ };
184
+
185
+ recordAlignmentIfNeeded('initial balance check', { log: false });
186
+
187
+ const orderedUnmatchedBank = params.auto_create_transactions
188
+ ? sortByDateDescending(analysis.unmatched_bank)
189
+ : [];
190
+ const orderedAutoMatches = sortMatchesByBankDateDescending(analysis.auto_matches);
191
+ const orderedUnmatchedYNAB = sortByDateDescending(analysis.unmatched_ynab);
192
+
193
+ let bulkOperationDetails: BulkOperationDetails | undefined;
194
+
195
+ // STEP 1: Auto-create missing transactions (bank -> YNAB)
196
+ if (params.auto_create_transactions && !balanceAligned) {
197
+ const buildPreparedEntry = (bankTxn: BankTransaction): PreparedBulkCreateEntry => {
198
+ const amountMilli = toMilli(bankTxn.amount);
199
+ const saveTransaction: SaveTransaction = {
200
+ account_id: accountId,
201
+ amount: amountMilli,
202
+ date: bankTxn.date,
203
+ payee_name: bankTxn.payee ?? undefined,
204
+ memo: bankTxn.memo ?? 'Auto-reconciled from bank statement',
205
+ cleared: 'cleared',
206
+ approved: true,
207
+ import_id: generateBulkImportId(accountId, bankTxn.date, amountMilli, bankTxn.payee),
208
+ };
209
+ const correlationKey = generateCorrelationKey(toCorrelationPayload(saveTransaction));
210
+ return {
211
+ bankTransaction: bankTxn,
212
+ saveTransaction,
213
+ amountMilli,
214
+ correlationKey,
215
+ };
216
+ };
217
+
218
+ const recordCreateAction = (args: {
219
+ entry: PreparedBulkCreateEntry;
220
+ createdTxn: ynab.TransactionDetail | null;
221
+ chunkIndex?: number;
222
+ prefix?: string;
223
+ }) => {
224
+ const { entry, createdTxn, chunkIndex, prefix } = args;
225
+ summary.transactions_created += 1;
226
+ const action: ExecutionActionRecord = {
227
+ type: 'create_transaction',
228
+ transaction: createdTxn as unknown as Record<string, unknown> | null,
229
+ reason: `${prefix ?? 'Created missing transaction'}: ${
230
+ entry.bankTransaction.payee ?? 'Unknown'
231
+ } (${formatDisplay(entry.bankTransaction.amount, currencyCode)})`,
232
+ correlation_key: entry.correlationKey,
233
+ };
234
+ if (chunkIndex !== undefined) {
235
+ action.bulk_chunk_index = chunkIndex;
236
+ }
237
+ actions_taken.push(action);
238
+ };
239
+
240
+ const processSequentialEntries = async (
241
+ entries: PreparedBulkCreateEntry[],
242
+ options: { chunkIndex?: number; fallbackError?: unknown } = {},
243
+ ) => {
244
+ let sequentialAttempts = 0;
245
+ for (const entry of entries) {
246
+ if (balanceAligned) break;
247
+ if (options.fallbackError) {
248
+ sequentialAttempts += 1;
249
+ }
250
+ try {
251
+ const response = await ynabAPI.transactions.createTransaction(budgetId, {
252
+ transaction: entry.saveTransaction,
253
+ });
254
+ const createdTransaction = response.data.transaction ?? null;
255
+ const recordArgs: Parameters<typeof recordCreateAction>[0] = {
256
+ entry,
257
+ createdTxn: createdTransaction,
258
+ prefix: options.fallbackError
259
+ ? 'Created missing transaction after bulk fallback'
260
+ : 'Created missing transaction',
261
+ };
262
+ if (options.chunkIndex !== undefined) {
263
+ recordArgs.chunkIndex = options.chunkIndex;
264
+ }
265
+ recordCreateAction(recordArgs);
266
+ accountSnapshotDirty = true;
267
+ applyClearedDelta(entry.amountMilli);
268
+ const trigger = options.chunkIndex
269
+ ? `creating ${entry.bankTransaction.payee ?? 'missing transaction'} (chunk ${options.chunkIndex})`
270
+ : `creating ${entry.bankTransaction.payee ?? 'missing transaction'}`;
271
+ recordAlignmentIfNeeded(trigger);
272
+ } catch (error) {
273
+ if (bulkOperationDetails) {
274
+ bulkOperationDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
275
+ }
276
+ const failureReason = error instanceof Error ? error.message : 'Unknown error occurred';
277
+ const failureAction: ExecutionActionRecord = {
278
+ type: 'create_transaction_failed',
279
+ transaction: entry.saveTransaction as unknown as Record<string, unknown>,
280
+ reason: options.fallbackError
281
+ ? `Bulk fallback failed for ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`
282
+ : `Failed to create transaction ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`,
283
+ correlation_key: entry.correlationKey,
284
+ };
285
+ if (options.chunkIndex !== undefined) {
286
+ failureAction.bulk_chunk_index = options.chunkIndex;
287
+ }
288
+ actions_taken.push(failureAction);
289
+ }
290
+ }
291
+ // Update sequential_attempts metric if this was a fallback operation
292
+ if (bulkOperationDetails && options.fallbackError && sequentialAttempts > 0) {
293
+ bulkOperationDetails.sequential_attempts =
294
+ (bulkOperationDetails.sequential_attempts ?? 0) + sequentialAttempts;
295
+ }
296
+ };
297
+
298
+ const processBulkChunk = async (chunk: PreparedBulkCreateEntry[], chunkIndex: number) => {
299
+ // bulkOperationDetails is guaranteed to be defined when this function is called
300
+ // (it's only called from within the bulk operation block where it's initialized)
301
+ const bulkDetails = bulkOperationDetails!;
302
+
303
+ const payload = chunk.map((entry) => entry.saveTransaction);
304
+ const response = await ynabAPI.transactions.createTransactions(budgetId, {
305
+ transactions: payload,
306
+ });
307
+ const responseData = response.data;
308
+ const duplicateImportIds = new Set(responseData.duplicate_import_ids ?? []);
309
+ const correlationRequests = chunk.map((entry) =>
310
+ toCorrelationPayload(entry.saveTransaction),
311
+ ) as Parameters<typeof correlateResults>[0];
312
+ const correlated = correlateResults(correlationRequests, responseData, duplicateImportIds);
313
+ const transactionMap = new Map<string, ynab.TransactionDetail>();
314
+ for (const transaction of responseData.transactions ?? []) {
315
+ if (transaction.id) {
316
+ transactionMap.set(transaction.id, transaction);
317
+ }
318
+ }
319
+ for (const result of correlated) {
320
+ const entry = chunk[result.request_index];
321
+ if (!entry) continue;
322
+ if (result.status === 'created') {
323
+ const createdTransaction = result.transaction_id
324
+ ? (transactionMap.get(result.transaction_id) ?? null)
325
+ : null;
326
+ recordCreateAction({
327
+ entry,
328
+ createdTxn: createdTransaction,
329
+ chunkIndex,
330
+ prefix: 'Created missing transaction via bulk',
331
+ });
332
+ accountSnapshotDirty = true;
333
+ applyClearedDelta(entry.amountMilli);
334
+ recordAlignmentIfNeeded(
335
+ `creating ${entry.bankTransaction.payee ?? 'missing transaction'} via bulk chunk ${chunkIndex}`,
336
+ );
337
+ } else if (result.status === 'duplicate') {
338
+ bulkDetails.duplicates_detected += 1;
339
+ actions_taken.push({
340
+ type: 'create_transaction_duplicate',
341
+ transaction: {
342
+ transaction_id: result.transaction_id ?? null,
343
+ import_id: entry.saveTransaction.import_id,
344
+ },
345
+ reason: `Duplicate import detected for ${
346
+ entry.bankTransaction.payee ?? 'Unknown'
347
+ } (import_id ${entry.saveTransaction.import_id})`,
348
+ bulk_chunk_index: chunkIndex,
349
+ correlation_key: result.correlation_key,
350
+ duplicate: true,
351
+ });
352
+ } else {
353
+ bulkDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
354
+ actions_taken.push({
355
+ type: 'create_transaction_failed',
356
+ transaction: entry.saveTransaction as unknown as Record<string, unknown>,
357
+ reason:
358
+ result.error ?? `Bulk create failed for ${entry.bankTransaction.payee ?? 'Unknown'}`,
359
+ bulk_chunk_index: chunkIndex,
360
+ correlation_key: result.correlation_key,
361
+ });
362
+ }
363
+ }
364
+ };
365
+
366
+ if (params.dry_run) {
367
+ for (const bankTxn of orderedUnmatchedBank) {
368
+ if (balanceAligned) break;
369
+ const entry = buildPreparedEntry(bankTxn);
370
+ summary.transactions_created += 1;
371
+ actions_taken.push({
372
+ type: 'create_transaction',
373
+ transaction: entry.saveTransaction as unknown as Record<string, unknown>,
374
+ reason: `Would create missing transaction: ${bankTxn.payee ?? 'Unknown'} (${formatDisplay(bankTxn.amount, currencyCode)})`,
375
+ correlation_key: entry.correlationKey,
376
+ });
377
+ applyClearedDelta(entry.amountMilli);
378
+ recordAlignmentIfNeeded(`creating ${bankTxn.payee ?? 'missing transaction'}`);
379
+ }
380
+ } else if (orderedUnmatchedBank.length >= 2) {
381
+ bulkOperationDetails = {
382
+ chunks_processed: 0,
383
+ bulk_successes: 0,
384
+ sequential_fallbacks: 0,
385
+ duplicates_detected: 0,
386
+ failed_transactions: 0,
387
+ bulk_chunk_failures: 0,
388
+ transaction_failures: 0,
389
+ };
390
+
391
+ let nextBankIndex = 0;
392
+ while (nextBankIndex < orderedUnmatchedBank.length && !balanceAligned) {
393
+ const batch: PreparedBulkCreateEntry[] = [];
394
+ let projectedDelta = clearedDeltaMilli;
395
+ while (nextBankIndex < orderedUnmatchedBank.length) {
396
+ const bankTxn = orderedUnmatchedBank[nextBankIndex];
397
+ if (!bankTxn) {
398
+ nextBankIndex += 1;
399
+ continue;
400
+ }
401
+ const entry = buildPreparedEntry(bankTxn);
402
+ batch.push(entry);
403
+ nextBankIndex += 1;
404
+ projectedDelta = addMilli(projectedDelta, entry.amountMilli);
405
+ if (Math.abs(projectedDelta) <= balanceToleranceMilli) {
406
+ break;
407
+ }
408
+ }
409
+
410
+ if (batch.length === 0) {
411
+ break;
412
+ }
413
+
414
+ const chunks = chunkArray(batch, MAX_BULK_CREATE_CHUNK);
415
+ for (const chunk of chunks) {
416
+ if (balanceAligned) break;
417
+ bulkOperationDetails.chunks_processed += 1;
418
+ const chunkIndex = bulkOperationDetails.chunks_processed;
419
+ try {
420
+ await processBulkChunk(chunk, chunkIndex);
421
+ bulkOperationDetails.bulk_successes += 1;
422
+ } catch (error) {
423
+ bulkOperationDetails.sequential_fallbacks += 1;
424
+ bulkOperationDetails.bulk_chunk_failures += 1; // API-level failure (entire chunk failed)
425
+ actions_taken.push({
426
+ type: 'bulk_create_fallback',
427
+ transaction: null,
428
+ reason: `Bulk chunk #${chunkIndex} failed (${
429
+ error instanceof Error ? error.message : 'unknown error'
430
+ }) - falling back to sequential creation`,
431
+ bulk_chunk_index: chunkIndex,
432
+ });
433
+ await processSequentialEntries(chunk, { chunkIndex, fallbackError: error });
434
+ }
435
+ }
436
+ }
437
+ } else {
438
+ const entries = orderedUnmatchedBank.map((bankTxn) => buildPreparedEntry(bankTxn));
439
+ await processSequentialEntries(entries);
440
+ }
441
+ }
442
+
443
+ // STEP 2: Update matched YNAB transactions (cleared status / date)
444
+ // Collect all updates for batch processing
445
+ if (!balanceAligned) {
446
+ const transactionsToUpdate: ynab.SaveTransactionWithIdOrImportId[] = [];
447
+
448
+ for (const match of orderedAutoMatches) {
449
+ if (balanceAligned) break;
450
+ const flags = computeUpdateFlags(match, params);
451
+ if (!flags.needsClearedUpdate && !flags.needsDateUpdate) continue;
452
+ if (!match.ynab_transaction) continue;
453
+
454
+ // Build minimal update payload - only include ID and fields that are changing
455
+ // Including unnecessary fields (like amount, payee_name, memo) can cause unexpected behavior
456
+ const updatePayload: ynab.SaveTransactionWithIdOrImportId = {
457
+ id: match.ynab_transaction.id,
458
+ };
459
+
460
+ // Only include fields that are actually changing
461
+ if (flags.needsDateUpdate) {
462
+ updatePayload.date = match.bank_transaction.date;
463
+ }
464
+ if (flags.needsClearedUpdate) {
465
+ updatePayload.cleared = 'cleared' as ynab.TransactionClearedStatus;
466
+ }
467
+
468
+ if (params.dry_run) {
469
+ summary.transactions_updated += 1;
470
+ if (flags.needsDateUpdate) summary.dates_adjusted += 1;
471
+ actions_taken.push({
472
+ type: 'update_transaction',
473
+ transaction: {
474
+ transaction_id: match.ynab_transaction.id,
475
+ new_date: flags.needsDateUpdate ? match.bank_transaction.date : undefined,
476
+ cleared: flags.needsClearedUpdate ? 'cleared' : undefined,
477
+ },
478
+ reason: `Would update transaction: ${updateReason(match, flags, currencyCode)}`,
479
+ });
480
+ if (flags.needsClearedUpdate) {
481
+ applyClearedDelta(match.ynab_transaction.amount);
482
+ if (
483
+ recordAlignmentIfNeeded(
484
+ `clearing ${match.ynab_transaction.id ?? 'transaction'} (dry run)`,
485
+ )
486
+ ) {
487
+ break;
488
+ }
489
+ }
490
+ } else {
491
+ transactionsToUpdate.push(updatePayload);
492
+ if (flags.needsDateUpdate) summary.dates_adjusted += 1;
493
+ if (flags.needsClearedUpdate) {
494
+ applyClearedDelta(match.ynab_transaction.amount);
495
+ if (recordAlignmentIfNeeded(`clearing ${match.ynab_transaction.id}`)) {
496
+ break;
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ // Batch update all transactions in a single API call
503
+ if (!params.dry_run && transactionsToUpdate.length > 0) {
504
+ const response = await ynabAPI.transactions.updateTransactions(budgetId, {
505
+ transactions: transactionsToUpdate,
506
+ });
507
+
508
+ const updatedTransactions = response.data.transactions ?? [];
509
+ summary.transactions_updated += updatedTransactions.length;
510
+
511
+ for (const updatedTransaction of updatedTransactions) {
512
+ const match = orderedAutoMatches.find(
513
+ (m) => m.ynab_transaction?.id === updatedTransaction.id,
514
+ );
515
+ const flags = match
516
+ ? computeUpdateFlags(match, params)
517
+ : { needsClearedUpdate: false, needsDateUpdate: false };
518
+ actions_taken.push({
519
+ type: 'update_transaction',
520
+ transaction: updatedTransaction as unknown as Record<string, unknown> | null,
521
+ reason: `Updated transaction: ${match ? updateReason(match, flags, currencyCode) : 'cleared'}`,
522
+ });
523
+ }
524
+ accountSnapshotDirty = true;
525
+ }
526
+ }
527
+
528
+ // STEP 3: Auto-unclear YNAB transactions missing from bank
529
+ const shouldRunSanityPass = params.auto_unclear_missing && !balanceAligned;
530
+ if (shouldRunSanityPass) {
531
+ const transactionsToUnclear: ynab.SaveTransactionWithIdOrImportId[] = [];
532
+
533
+ for (const ynabTxn of orderedUnmatchedYNAB) {
534
+ if (ynabTxn.cleared !== 'cleared') continue;
535
+ if (balanceAligned) break;
536
+
537
+ if (params.dry_run) {
538
+ summary.transactions_updated += 1;
539
+ actions_taken.push({
540
+ type: 'update_transaction',
541
+ transaction: { transaction_id: ynabTxn.id, cleared: 'uncleared' },
542
+ reason: `Would mark transaction ${ynabTxn.id} as uncleared - not present on statement`,
543
+ });
544
+ applyClearedDelta(-ynabTxn.amount);
545
+ if (recordAlignmentIfNeeded(`unclearing ${ynabTxn.id} (dry run)`)) {
546
+ break;
547
+ }
548
+ } else {
549
+ // Minimal update payload - only include ID and the field we're changing
550
+ transactionsToUnclear.push({
551
+ id: ynabTxn.id,
552
+ cleared: 'uncleared' as ynab.TransactionClearedStatus,
553
+ });
554
+ applyClearedDelta(-ynabTxn.amount);
555
+ if (recordAlignmentIfNeeded(`unclearing ${ynabTxn.id}`)) {
556
+ break;
557
+ }
558
+ }
559
+ }
560
+
561
+ // Batch update all unclear operations in a single API call
562
+ if (!params.dry_run && transactionsToUnclear.length > 0) {
563
+ const response = await ynabAPI.transactions.updateTransactions(budgetId, {
564
+ transactions: transactionsToUnclear,
565
+ });
566
+
567
+ const updatedTransactions = response.data.transactions ?? [];
568
+ summary.transactions_updated += updatedTransactions.length;
569
+
570
+ for (const updatedTransaction of updatedTransactions) {
571
+ actions_taken.push({
572
+ type: 'update_transaction',
573
+ transaction: updatedTransaction as unknown as Record<string, unknown> | null,
574
+ reason: `Marked transaction ${updatedTransaction.id} as uncleared - not found on statement`,
575
+ });
576
+ }
577
+ accountSnapshotDirty = true;
578
+ }
579
+ }
580
+
581
+ // STEP 4: Balance reconciliation snapshot (only once per execution)
582
+ let balance_reconciliation: ExecutionResult['balance_reconciliation'];
583
+ if (params.statement_balance !== undefined && params.statement_date) {
584
+ balance_reconciliation = await buildBalanceReconciliation({
585
+ ynabAPI,
586
+ budgetId,
587
+ accountId,
588
+ statementDate: params.statement_date,
589
+ statementBalance: params.statement_balance,
590
+ analysis,
591
+ });
592
+ }
593
+
594
+ // STEP 5: Recommendations and balance changes
595
+ if (!params.dry_run && accountSnapshotDirty) {
596
+ afterAccount = await refreshAccountSnapshot(ynabAPI, budgetId, accountId);
597
+ }
598
+
599
+ const balanceChangeMilli =
600
+ params.dry_run || !accountSnapshotDirty ? 0 : afterAccount.balance - initialAccount.balance;
601
+
602
+ const recommendations = buildRecommendations({
603
+ summary,
604
+ params,
605
+ analysis,
606
+ balanceChangeMilli,
607
+ currencyCode,
608
+ });
609
+
610
+ const result: ExecutionResult = {
611
+ summary,
612
+ account_balance: {
613
+ before: initialAccount,
614
+ after: afterAccount,
615
+ },
616
+ actions_taken,
617
+ recommendations,
618
+ };
619
+
620
+ if (balance_reconciliation !== undefined) {
621
+ result.balance_reconciliation = balance_reconciliation;
622
+ }
623
+
624
+ if (bulkOperationDetails) {
625
+ // Ensure failed_transactions mirrors transaction_failures for backward compatibility
626
+ bulkOperationDetails.failed_transactions = bulkOperationDetails.transaction_failures;
627
+ result.bulk_operation_details = bulkOperationDetails;
628
+ }
629
+
630
+ return result;
631
+ }
632
+
633
+ function formatDisplay(amount: number, currency: string): string {
634
+ return toMoneyValueFromDecimal(amount, currency).value_display;
635
+ }
636
+
637
+ function computeUpdateFlags(match: TransactionMatch, params: ReconcileAccountRequest): UpdateFlags {
638
+ const ynabTxn = match.ynab_transaction;
639
+ const bankTxn = match.bank_transaction;
640
+ if (!ynabTxn) {
641
+ return { needsClearedUpdate: false, needsDateUpdate: false };
642
+ }
643
+ const needsClearedUpdate = Boolean(
644
+ params.auto_update_cleared_status && ynabTxn.cleared !== 'cleared',
645
+ );
646
+ const needsDateUpdate = Boolean(params.auto_adjust_dates && ynabTxn.date !== bankTxn.date);
647
+ return { needsClearedUpdate, needsDateUpdate };
648
+ }
649
+
650
+ function updateReason(match: TransactionMatch, flags: UpdateFlags, _currency: string): string {
651
+ const parts: string[] = [];
652
+ if (flags.needsClearedUpdate) {
653
+ parts.push('marked as cleared');
654
+ }
655
+ if (flags.needsDateUpdate) {
656
+ parts.push(`date adjusted to ${match.bank_transaction.date}`);
657
+ }
658
+ return parts.join(', ');
659
+ }
660
+
661
+ async function buildBalanceReconciliation(args: {
662
+ ynabAPI: ynab.API;
663
+ budgetId: string;
664
+ accountId: string;
665
+ statementDate: string;
666
+ statementBalance: number;
667
+ analysis: ReconciliationAnalysis;
668
+ }) {
669
+ const { ynabAPI, budgetId, accountId, statementDate, statementBalance } = args;
670
+ const ynabMilli = await clearedBalanceAsOf(ynabAPI, budgetId, accountId, statementDate);
671
+ const bankMilli = toMilli(statementBalance);
672
+ const discrepancy = bankMilli - ynabMilli;
673
+ const status = discrepancy === 0 ? 'PERFECTLY_RECONCILED' : 'DISCREPANCY_FOUND';
674
+
675
+ const precision_calculations = {
676
+ bank_statement_balance_milliunits: bankMilli,
677
+ ynab_calculated_balance_milliunits: ynabMilli,
678
+ discrepancy_milliunits: discrepancy,
679
+ discrepancy_dollars: discrepancy / 1000,
680
+ };
681
+
682
+ const discrepancy_analysis = discrepancy === 0 ? undefined : buildLikelyCauses(discrepancy);
683
+
684
+ const result: {
685
+ status: string;
686
+ precision_calculations: typeof precision_calculations;
687
+ discrepancy_analysis?: ReturnType<typeof buildLikelyCauses>;
688
+ final_verification: {
689
+ balance_matches_exactly: boolean;
690
+ all_transactions_accounted: boolean;
691
+ audit_trail_complete: boolean;
692
+ reconciliation_complete: boolean;
693
+ };
694
+ } = {
695
+ status,
696
+ precision_calculations,
697
+ final_verification: {
698
+ balance_matches_exactly: discrepancy === 0,
699
+ all_transactions_accounted: discrepancy === 0,
700
+ audit_trail_complete: discrepancy === 0,
701
+ reconciliation_complete: discrepancy === 0,
702
+ },
703
+ };
704
+
705
+ if (discrepancy_analysis !== undefined) {
706
+ result.discrepancy_analysis = discrepancy_analysis;
707
+ }
708
+
709
+ return result;
710
+ }
711
+
712
+ async function clearedBalanceAsOf(
713
+ api: ynab.API,
714
+ budgetId: string,
715
+ accountId: string,
716
+ dateISO: string,
717
+ ): Promise<number> {
718
+ const response = await api.transactions.getTransactionsByAccount(budgetId, accountId);
719
+ const asOf = new Date(dateISO);
720
+ const cleared = response.data.transactions.filter(
721
+ (txn) => txn.cleared === 'cleared' && new Date(txn.date) <= asOf,
722
+ );
723
+ const sum = cleared.reduce((acc, txn) => addMilli(acc, txn.amount ?? 0), 0);
724
+ return sum;
725
+ }
726
+
727
+ async function refreshAccountSnapshot(
728
+ api: ynab.API,
729
+ budgetId: string,
730
+ accountId: string,
731
+ ): Promise<AccountSnapshot> {
732
+ const accountsApi = api.accounts as typeof api.accounts & {
733
+ getAccount?: (budgetId: string, accountId: string) => Promise<ynab.AccountResponse>;
734
+ };
735
+ const response = accountsApi.getAccount
736
+ ? await accountsApi.getAccount(budgetId, accountId)
737
+ : await accountsApi.getAccountById(budgetId, accountId);
738
+ const account = response.data.account;
739
+ return {
740
+ balance: account.balance,
741
+ cleared_balance: account.cleared_balance,
742
+ uncleared_balance: account.uncleared_balance,
743
+ };
744
+ }
745
+
746
+ function buildLikelyCauses(discrepancyMilli: number) {
747
+ const causes = [] as {
748
+ cause_type: string;
749
+ description: string;
750
+ confidence: number;
751
+ amount_milliunits: number;
752
+ suggested_resolution: string;
753
+ evidence: unknown[];
754
+ }[];
755
+
756
+ const abs = Math.abs(discrepancyMilli);
757
+ if (abs % 1000 === 0 || abs % 500 === 0) {
758
+ causes.push({
759
+ cause_type: 'bank_fee',
760
+ description: 'Round amount suggests a bank fee or interest adjustment.',
761
+ confidence: 0.8,
762
+ amount_milliunits: discrepancyMilli,
763
+ suggested_resolution:
764
+ discrepancyMilli < 0
765
+ ? 'Create bank fee transaction and mark cleared'
766
+ : 'Record interest income',
767
+ evidence: [],
768
+ });
769
+ }
770
+
771
+ return causes.length > 0
772
+ ? {
773
+ confidence_level: Math.max(...causes.map((cause) => cause.confidence)),
774
+ likely_causes: causes,
775
+ risk_assessment: 'LOW',
776
+ }
777
+ : undefined;
778
+ }
779
+
780
+ function buildRecommendations(args: {
781
+ summary: ExecutionSummary;
782
+ params: ReconcileAccountRequest;
783
+ analysis: ReconciliationAnalysis;
784
+ balanceChangeMilli: number;
785
+ currencyCode: string;
786
+ }): string[] {
787
+ const { summary, params, analysis, balanceChangeMilli, currencyCode } = args;
788
+ const recommendations: string[] = [];
789
+
790
+ if (summary.dates_adjusted > 0) {
791
+ recommendations.push(
792
+ `✅ Adjusted ${summary.dates_adjusted} transaction date(s) to match bank statement dates`,
793
+ );
794
+ }
795
+
796
+ if (analysis.summary.unmatched_bank > 0 && !params.auto_create_transactions) {
797
+ recommendations.push(
798
+ `Consider enabling auto_create_transactions to automatically create ${analysis.summary.unmatched_bank} missing transaction(s)`,
799
+ );
800
+ }
801
+
802
+ if (!params.auto_adjust_dates && analysis.auto_matches.length > 0) {
803
+ recommendations.push(
804
+ 'Consider enabling auto_adjust_dates to align YNAB dates with bank statement dates',
805
+ );
806
+ }
807
+
808
+ if (analysis.summary.unmatched_ynab > 0) {
809
+ recommendations.push(
810
+ `${analysis.summary.unmatched_ynab} transaction(s) exist in YNAB but not on the bank statement — review for duplicates or pending items`,
811
+ );
812
+ }
813
+
814
+ if (params.dry_run) {
815
+ recommendations.push('Dry run only — re-run with dry_run=false to apply these changes');
816
+ }
817
+
818
+ if (Math.abs(balanceChangeMilli) > MONEY_EPSILON_MILLI) {
819
+ recommendations.push(
820
+ `Account balance changed by ${toMoneyValue(balanceChangeMilli, currencyCode).value_display} during reconciliation`,
821
+ );
822
+ }
823
+
824
+ return recommendations;
825
+ }
826
+
827
+ export type { ExecutionResult as LegacyReconciliationResult };
828
+
829
+ function resolveStatementBalanceMilli(
830
+ balanceInfo: ReconciliationAnalysis['balance_info'],
831
+ provided?: number,
832
+ ): number {
833
+ if (typeof provided === 'number' && Number.isFinite(provided)) {
834
+ return toMilli(provided);
835
+ }
836
+
837
+ return (
838
+ extractMoneyValue(balanceInfo?.target_statement) ??
839
+ extractMoneyValue(balanceInfo?.current_cleared) ??
840
+ 0
841
+ );
842
+ }
843
+
844
+ function extractMoneyValue(value: unknown): number | undefined {
845
+ if (typeof value === 'number' && Number.isFinite(value)) {
846
+ return toMilli(value);
847
+ }
848
+ if (
849
+ value &&
850
+ typeof value === 'object' &&
851
+ 'value_milliunits' in value &&
852
+ typeof (value as { value_milliunits: unknown }).value_milliunits === 'number'
853
+ ) {
854
+ return (value as { value_milliunits: number }).value_milliunits;
855
+ }
856
+ return undefined;
857
+ }
858
+
859
+ function sortByDateDescending<T extends { date: string }>(items: T[]): T[] {
860
+ return [...items].sort((a, b) => compareDates(b.date, a.date));
861
+ }
862
+
863
+ function sortMatchesByBankDateDescending(matches: TransactionMatch[]): TransactionMatch[] {
864
+ return [...matches].sort((a, b) =>
865
+ compareDates(b.bank_transaction.date, a.bank_transaction.date),
866
+ );
867
+ }
868
+
869
+ function compareDates(dateA: string, dateB: string): number {
870
+ return toChronoValue(dateA) - toChronoValue(dateB);
871
+ }
872
+
873
+ function toChronoValue(date: string): number {
874
+ const parsed = Date.parse(date);
875
+ if (!Number.isNaN(parsed)) {
876
+ return parsed;
877
+ }
878
+ const fallback = Date.parse(`${date}T00:00:00Z`);
879
+ return Number.isNaN(fallback) ? 0 : fallback;
880
+ }