@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,3521 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as ynab from 'ynab';
3
+ import {
4
+ handleListTransactions,
5
+ handleGetTransaction,
6
+ handleCreateTransaction,
7
+ handleCreateTransactions,
8
+ handleCreateReceiptSplitTransaction,
9
+ handleUpdateTransaction,
10
+ handleUpdateTransactions,
11
+ handleDeleteTransaction,
12
+ ListTransactionsSchema,
13
+ GetTransactionSchema,
14
+ CreateTransactionSchema,
15
+ CreateTransactionsSchema,
16
+ CreateReceiptSplitTransactionSchema,
17
+ UpdateTransactionSchema,
18
+ UpdateTransactionsSchema,
19
+ DeleteTransactionSchema,
20
+ } from '../transactionTools.js';
21
+
22
+ // Mock the cache manager
23
+ vi.mock('../../server/cacheManager.js', () => ({
24
+ cacheManager: {
25
+ wrap: vi.fn(),
26
+ has: vi.fn(),
27
+ get: vi.fn(),
28
+ set: vi.fn(),
29
+ delete: vi.fn(),
30
+ deleteMany: vi.fn(),
31
+ deleteByPrefix: vi.fn(),
32
+ deleteByBudgetId: vi.fn(),
33
+ clear: vi.fn(),
34
+ },
35
+ CacheManager: {
36
+ generateKey: vi.fn(),
37
+ },
38
+ CACHE_TTLS: {
39
+ TRANSACTIONS: 180000,
40
+ },
41
+ }));
42
+
43
+ // Mock the YNAB API
44
+ const mockYnabAPI = {
45
+ transactions: {
46
+ getTransactions: vi.fn(),
47
+ getTransactionsByAccount: vi.fn(),
48
+ getTransactionsByCategory: vi.fn(),
49
+ getTransactionById: vi.fn(),
50
+ createTransaction: vi.fn(),
51
+ createTransactions: vi.fn(),
52
+ updateTransaction: vi.fn(),
53
+ updateTransactions: vi.fn(),
54
+ deleteTransaction: vi.fn(),
55
+ },
56
+ accounts: {
57
+ getAccountById: vi.fn(),
58
+ },
59
+ } as unknown as ynab.API;
60
+
61
+ // Import mocked cache manager
62
+ const { cacheManager, CacheManager } = await import('../../server/cacheManager.js');
63
+ const { globalRequestLogger } = await import('../../server/requestLogger.js');
64
+
65
+ describe('transactionTools', () => {
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ // Reset NODE_ENV to test to ensure cache bypassing in tests
69
+ process.env['NODE_ENV'] = 'test';
70
+ });
71
+
72
+ describe('ListTransactionsSchema', () => {
73
+ it('should validate valid parameters', () => {
74
+ const validParams = {
75
+ budget_id: 'budget-123',
76
+ account_id: 'account-456',
77
+ category_id: 'category-789',
78
+ since_date: '2024-01-01',
79
+ type: 'uncategorized' as const,
80
+ };
81
+
82
+ const result = ListTransactionsSchema.safeParse(validParams);
83
+ expect(result.success).toBe(true);
84
+ });
85
+
86
+ it('should require budget_id', () => {
87
+ const invalidParams = {
88
+ account_id: 'account-456',
89
+ };
90
+
91
+ const result = ListTransactionsSchema.safeParse(invalidParams);
92
+ expect(result.success).toBe(false);
93
+ if (!result.success) {
94
+ expect(result.error.issues[0].code).toBe('invalid_type');
95
+ expect(result.error.issues[0].path).toEqual(['budget_id']);
96
+ }
97
+ });
98
+
99
+ it('should validate date format', () => {
100
+ const invalidParams = {
101
+ budget_id: 'budget-123',
102
+ since_date: '01/01/2024', // Invalid format
103
+ };
104
+
105
+ const result = ListTransactionsSchema.safeParse(invalidParams);
106
+ expect(result.success).toBe(false);
107
+ if (!result.success) {
108
+ expect(result.error.issues[0].message).toContain('Date must be in ISO format');
109
+ }
110
+ });
111
+
112
+ it('should validate type enum', () => {
113
+ const invalidParams = {
114
+ budget_id: 'budget-123',
115
+ type: 'invalid-type',
116
+ };
117
+
118
+ const result = ListTransactionsSchema.safeParse(invalidParams);
119
+ expect(result.success).toBe(false);
120
+ });
121
+
122
+ it('should allow optional parameters to be undefined', () => {
123
+ const minimalParams = {
124
+ budget_id: 'budget-123',
125
+ };
126
+
127
+ const result = ListTransactionsSchema.safeParse(minimalParams);
128
+ expect(result.success).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe('handleListTransactions', () => {
133
+ const mockTransaction = {
134
+ id: 'transaction-123',
135
+ date: '2024-01-01',
136
+ amount: -50000, // $50.00 outflow in milliunits
137
+ memo: 'Test transaction',
138
+ cleared: 'cleared' as any,
139
+ approved: true,
140
+ flag_color: null,
141
+ account_id: 'account-456',
142
+ payee_id: 'payee-789',
143
+ category_id: 'category-101',
144
+ transfer_account_id: null,
145
+ transfer_transaction_id: null,
146
+ matched_transaction_id: null,
147
+ import_id: null,
148
+ deleted: false,
149
+ subtransactions: [],
150
+ };
151
+
152
+ it('should bypass cache in test environment for unfiltered requests', async () => {
153
+ const mockResponse = {
154
+ data: {
155
+ transactions: [mockTransaction],
156
+ },
157
+ };
158
+
159
+ (mockYnabAPI.transactions.getTransactions as any).mockResolvedValue(mockResponse);
160
+
161
+ const params = { budget_id: 'budget-123' };
162
+ const result = await handleListTransactions(mockYnabAPI, params);
163
+
164
+ // In test environment, cache should be bypassed
165
+ expect(cacheManager.wrap).not.toHaveBeenCalled();
166
+ expect(mockYnabAPI.transactions.getTransactions).toHaveBeenCalledWith(
167
+ 'budget-123',
168
+ undefined,
169
+ undefined,
170
+ undefined,
171
+ );
172
+
173
+ const parsedContent = JSON.parse(result.content[0].text);
174
+ expect(parsedContent.cached).toBe(false);
175
+ expect(parsedContent.cache_info).toBe('Fresh data retrieved from YNAB API');
176
+ expect(parsedContent.transactions[0].id).toBe('transaction-123');
177
+ });
178
+
179
+ it.skip('should use cache when NODE_ENV is not test for unfiltered requests - obsolete test, caching now handled by DeltaFetcher', async () => {
180
+ // This test is obsolete as caching is now handled by DeltaFetcher
181
+ // Keeping for reference but skipping to avoid test failures
182
+ });
183
+
184
+ it.skip('should not cache filtered requests - obsolete test (account_id)', async () => {
185
+ // Temporarily set NODE_ENV to non-test
186
+ process.env['NODE_ENV'] = 'development';
187
+
188
+ const mockResponse = {
189
+ data: {
190
+ transactions: [mockTransaction],
191
+ },
192
+ };
193
+
194
+ (mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
195
+
196
+ const params = {
197
+ budget_id: 'budget-123',
198
+ account_id: 'account-456',
199
+ };
200
+ const result = await handleListTransactions(mockYnabAPI, params);
201
+
202
+ // Verify cache was NOT used for filtered request
203
+ expect(cacheManager.wrap).not.toHaveBeenCalled();
204
+ expect(mockYnabAPI.transactions.getTransactionsByAccount).toHaveBeenCalledWith(
205
+ 'budget-123',
206
+ 'account-456',
207
+ undefined,
208
+ );
209
+
210
+ const parsedContent = JSON.parse(result.content[0].text);
211
+ expect(parsedContent.cached).toBe(false);
212
+ expect(parsedContent.cache_info).toBe('Fresh data retrieved from YNAB API');
213
+
214
+ // Reset NODE_ENV
215
+ process.env['NODE_ENV'] = 'test';
216
+ });
217
+
218
+ it('should list all transactions when no filters are provided', async () => {
219
+ const mockResponse = {
220
+ data: {
221
+ transactions: [mockTransaction],
222
+ },
223
+ };
224
+
225
+ (mockYnabAPI.transactions.getTransactions as any).mockResolvedValue(mockResponse);
226
+
227
+ const params = { budget_id: 'budget-123' };
228
+ const result = await handleListTransactions(mockYnabAPI, params);
229
+
230
+ expect(mockYnabAPI.transactions.getTransactions).toHaveBeenCalledWith(
231
+ 'budget-123',
232
+ undefined,
233
+ undefined,
234
+ undefined,
235
+ );
236
+ expect(result.content[0].text).toContain('transaction-123');
237
+ expect(result.content[0].text).toContain('-50');
238
+ });
239
+
240
+ it('should filter by account_id when provided', async () => {
241
+ const mockResponse = {
242
+ data: {
243
+ transactions: [mockTransaction],
244
+ },
245
+ };
246
+
247
+ (mockYnabAPI.transactions.getTransactionsByAccount as any).mockResolvedValue(mockResponse);
248
+
249
+ const params = {
250
+ budget_id: 'budget-123',
251
+ account_id: 'account-456',
252
+ };
253
+ const result = await handleListTransactions(mockYnabAPI, params);
254
+
255
+ expect(mockYnabAPI.transactions.getTransactionsByAccount).toHaveBeenCalledWith(
256
+ 'budget-123',
257
+ 'account-456',
258
+ undefined,
259
+ undefined,
260
+ undefined,
261
+ );
262
+ expect(result.content[0].text).toContain('transaction-123');
263
+ });
264
+
265
+ it('should filter by category_id when provided', async () => {
266
+ const mockResponse = {
267
+ data: {
268
+ transactions: [mockTransaction],
269
+ },
270
+ };
271
+
272
+ (mockYnabAPI.transactions.getTransactionsByCategory as any).mockResolvedValue(mockResponse);
273
+
274
+ const params = {
275
+ budget_id: 'budget-123',
276
+ category_id: 'category-789',
277
+ };
278
+ const result = await handleListTransactions(mockYnabAPI, params);
279
+
280
+ expect(mockYnabAPI.transactions.getTransactionsByCategory).toHaveBeenCalledWith(
281
+ 'budget-123',
282
+ 'category-789',
283
+ undefined,
284
+ );
285
+ expect(result.content[0].text).toContain('transaction-123');
286
+ });
287
+
288
+ it('should include since_date parameter when provided', async () => {
289
+ const mockResponse = {
290
+ data: {
291
+ transactions: [mockTransaction],
292
+ },
293
+ };
294
+
295
+ (mockYnabAPI.transactions.getTransactions as any).mockResolvedValue(mockResponse);
296
+
297
+ const params = {
298
+ budget_id: 'budget-123',
299
+ since_date: '2024-01-01',
300
+ type: 'uncategorized' as const,
301
+ };
302
+ await handleListTransactions(mockYnabAPI, params);
303
+
304
+ expect(mockYnabAPI.transactions.getTransactions).toHaveBeenCalledWith(
305
+ 'budget-123',
306
+ '2024-01-01',
307
+ 'uncategorized',
308
+ undefined,
309
+ );
310
+ });
311
+
312
+ it('should handle 401 authentication errors', async () => {
313
+ const error = new Error('401 Unauthorized');
314
+ (mockYnabAPI.transactions.getTransactions as any).mockRejectedValue(error);
315
+
316
+ const params = { budget_id: 'budget-123' };
317
+ const result = await handleListTransactions(mockYnabAPI, params);
318
+
319
+ const response = JSON.parse(result.content[0].text);
320
+ expect(response.error.message).toBe('Invalid or expired YNAB access token');
321
+ });
322
+
323
+ it('should handle 404 not found errors', async () => {
324
+ const error = new Error('404 Not Found');
325
+ (mockYnabAPI.transactions.getTransactions as any).mockRejectedValue(error);
326
+
327
+ const params = { budget_id: 'invalid-budget' };
328
+ const result = await handleListTransactions(mockYnabAPI, params);
329
+
330
+ const response = JSON.parse(result.content[0].text);
331
+ expect(response.error.message).toBe('Budget, account, category, or transaction not found');
332
+ });
333
+
334
+ it('should handle 429 rate limit errors', async () => {
335
+ const error = new Error('429 Too Many Requests');
336
+ (mockYnabAPI.transactions.getTransactions as any).mockRejectedValue(error);
337
+
338
+ const params = { budget_id: 'budget-123' };
339
+ const result = await handleListTransactions(mockYnabAPI, params);
340
+
341
+ const response = JSON.parse(result.content[0].text);
342
+ expect(response.error.message).toBe('Rate limit exceeded. Please try again later');
343
+ });
344
+
345
+ it('should handle generic errors', async () => {
346
+ const error = new Error('Network error');
347
+ (mockYnabAPI.transactions.getTransactions as any).mockRejectedValue(error);
348
+
349
+ const params = { budget_id: 'budget-123' };
350
+ const result = await handleListTransactions(mockYnabAPI, params);
351
+
352
+ const response = JSON.parse(result.content[0].text);
353
+ expect(response.error.message).toBe('Failed to list transactions');
354
+ });
355
+ });
356
+
357
+ describe('GetTransactionSchema', () => {
358
+ it('should validate valid parameters', () => {
359
+ const validParams = {
360
+ budget_id: 'budget-123',
361
+ transaction_id: 'transaction-456',
362
+ };
363
+
364
+ const result = GetTransactionSchema.safeParse(validParams);
365
+ expect(result.success).toBe(true);
366
+ });
367
+
368
+ it('should require budget_id', () => {
369
+ const invalidParams = {
370
+ transaction_id: 'transaction-456',
371
+ };
372
+
373
+ const result = GetTransactionSchema.safeParse(invalidParams);
374
+ expect(result.success).toBe(false);
375
+ if (!result.success) {
376
+ expect(result.error.issues[0].code).toBe('invalid_type');
377
+ expect(result.error.issues[0].path).toEqual(['budget_id']);
378
+ }
379
+ });
380
+
381
+ it('should require transaction_id', () => {
382
+ const invalidParams = {
383
+ budget_id: 'budget-123',
384
+ };
385
+
386
+ const result = GetTransactionSchema.safeParse(invalidParams);
387
+ expect(result.success).toBe(false);
388
+ if (!result.success) {
389
+ expect(result.error.issues[0].code).toBe('invalid_type');
390
+ expect(result.error.issues[0].path).toEqual(['transaction_id']);
391
+ }
392
+ });
393
+
394
+ it('should reject empty strings', () => {
395
+ const invalidParams = {
396
+ budget_id: '',
397
+ transaction_id: '',
398
+ };
399
+
400
+ const result = GetTransactionSchema.safeParse(invalidParams);
401
+ expect(result.success).toBe(false);
402
+ });
403
+ });
404
+
405
+ describe('handleGetTransaction', () => {
406
+ const mockTransactionDetail = {
407
+ id: 'transaction-123',
408
+ date: '2024-01-01',
409
+ amount: -50000,
410
+ memo: 'Test transaction',
411
+ cleared: 'cleared' as any,
412
+ approved: true,
413
+ flag_color: null,
414
+ account_id: 'account-456',
415
+ payee_id: 'payee-789',
416
+ category_id: 'category-101',
417
+ transfer_account_id: null,
418
+ transfer_transaction_id: null,
419
+ matched_transaction_id: null,
420
+ import_id: null,
421
+ deleted: false,
422
+ account_name: 'Test Account',
423
+ payee_name: 'Test Payee',
424
+ category_name: 'Test Category',
425
+ };
426
+
427
+ it('should get transaction details successfully', async () => {
428
+ const mockResponse = {
429
+ data: {
430
+ transaction: mockTransactionDetail,
431
+ },
432
+ };
433
+
434
+ (mockYnabAPI.transactions.getTransactionById as any).mockResolvedValue(mockResponse);
435
+
436
+ const params = {
437
+ budget_id: 'budget-123',
438
+ transaction_id: 'transaction-456',
439
+ };
440
+ const result = await handleGetTransaction(mockYnabAPI, params);
441
+
442
+ expect(mockYnabAPI.transactions.getTransactionById).toHaveBeenCalledWith(
443
+ 'budget-123',
444
+ 'transaction-456',
445
+ );
446
+
447
+ const response = JSON.parse(result.content[0].text);
448
+ expect(response.transaction.id).toBe('transaction-123');
449
+ expect(response.transaction.amount).toBe(-50);
450
+ expect(response.transaction.account_name).toBe('Test Account');
451
+ expect(response.transaction.payee_name).toBe('Test Payee');
452
+ expect(response.transaction.category_name).toBe('Test Category');
453
+ });
454
+
455
+ it('should handle 404 not found errors', async () => {
456
+ const error = new Error('404 Not Found');
457
+ (mockYnabAPI.transactions.getTransactionById as any).mockRejectedValue(error);
458
+
459
+ const params = {
460
+ budget_id: 'budget-123',
461
+ transaction_id: 'invalid-transaction',
462
+ };
463
+ const result = await handleGetTransaction(mockYnabAPI, params);
464
+
465
+ const response = JSON.parse(result.content[0].text);
466
+ expect(response.error.message).toBe('Budget, account, category, or transaction not found');
467
+ });
468
+
469
+ it('should handle authentication errors', async () => {
470
+ const error = new Error('401 Unauthorized');
471
+ (mockYnabAPI.transactions.getTransactionById as any).mockRejectedValue(error);
472
+
473
+ const params = {
474
+ budget_id: 'budget-123',
475
+ transaction_id: 'transaction-456',
476
+ };
477
+ const result = await handleGetTransaction(mockYnabAPI, params);
478
+
479
+ const response = JSON.parse(result.content[0].text);
480
+ expect(response.error.message).toBe('Invalid or expired YNAB access token');
481
+ });
482
+
483
+ it('should handle generic errors', async () => {
484
+ const error = new Error('Network error');
485
+ (mockYnabAPI.transactions.getTransactionById as any).mockRejectedValue(error);
486
+
487
+ const params = {
488
+ budget_id: 'budget-123',
489
+ transaction_id: 'transaction-456',
490
+ };
491
+ const result = await handleGetTransaction(mockYnabAPI, params);
492
+
493
+ const response = JSON.parse(result.content[0].text);
494
+ expect(response.error.message).toBe('Failed to get transaction');
495
+ });
496
+ });
497
+
498
+ describe('CreateTransactionSchema', () => {
499
+ it('should validate valid parameters with required fields only', () => {
500
+ const validParams = {
501
+ budget_id: 'budget-123',
502
+ account_id: 'account-456',
503
+ amount: -50000, // $50.00 outflow in milliunits
504
+ date: '2024-01-01',
505
+ };
506
+
507
+ const result = CreateTransactionSchema.safeParse(validParams);
508
+ expect(result.success).toBe(true);
509
+ });
510
+
511
+ it('should validate valid parameters with all optional fields', () => {
512
+ const validParams = {
513
+ budget_id: 'budget-123',
514
+ account_id: 'account-456',
515
+ amount: -50000,
516
+ date: '2024-01-01',
517
+ payee_name: 'Test Payee',
518
+ payee_id: 'payee-789',
519
+ category_id: 'category-101',
520
+ memo: 'Test memo',
521
+ cleared: 'cleared' as const,
522
+ approved: true,
523
+ flag_color: 'red' as const,
524
+ };
525
+
526
+ const result = CreateTransactionSchema.safeParse(validParams);
527
+ expect(result.success).toBe(true);
528
+ });
529
+
530
+ it('should require budget_id', () => {
531
+ const invalidParams = {
532
+ account_id: 'account-456',
533
+ amount: -50000,
534
+ date: '2024-01-01',
535
+ };
536
+
537
+ const result = CreateTransactionSchema.safeParse(invalidParams);
538
+ expect(result.success).toBe(false);
539
+ });
540
+
541
+ it('should require account_id', () => {
542
+ const invalidParams = {
543
+ budget_id: 'budget-123',
544
+ amount: -50000,
545
+ date: '2024-01-01',
546
+ };
547
+
548
+ const result = CreateTransactionSchema.safeParse(invalidParams);
549
+ expect(result.success).toBe(false);
550
+ });
551
+
552
+ it('should require amount to be an integer', () => {
553
+ const invalidParams = {
554
+ budget_id: 'budget-123',
555
+ account_id: 'account-456',
556
+ amount: -500.5, // Decimal not allowed
557
+ date: '2024-01-01',
558
+ };
559
+
560
+ const result = CreateTransactionSchema.safeParse(invalidParams);
561
+ expect(result.success).toBe(false);
562
+ if (!result.success) {
563
+ expect(result.error.issues[0].message).toContain('Amount must be an integer in milliunits');
564
+ }
565
+ });
566
+
567
+ it('should validate date format', () => {
568
+ const invalidParams = {
569
+ budget_id: 'budget-123',
570
+ account_id: 'account-456',
571
+ amount: -50000,
572
+ date: '01/01/2024', // Invalid format
573
+ };
574
+
575
+ const result = CreateTransactionSchema.safeParse(invalidParams);
576
+ expect(result.success).toBe(false);
577
+ if (!result.success) {
578
+ expect(result.error.issues[0].message).toContain('Date must be in ISO format');
579
+ }
580
+ });
581
+
582
+ it('should validate cleared status enum', () => {
583
+ const invalidParams = {
584
+ budget_id: 'budget-123',
585
+ account_id: 'account-456',
586
+ amount: -50000,
587
+ date: '2024-01-01',
588
+ cleared: 'invalid-status',
589
+ };
590
+
591
+ const result = CreateTransactionSchema.safeParse(invalidParams);
592
+ expect(result.success).toBe(false);
593
+ });
594
+
595
+ it('should validate flag_color enum', () => {
596
+ const invalidParams = {
597
+ budget_id: 'budget-123',
598
+ account_id: 'account-456',
599
+ amount: -50000,
600
+ date: '2024-01-01',
601
+ flag_color: 'invalid-color',
602
+ };
603
+
604
+ const result = CreateTransactionSchema.safeParse(invalidParams);
605
+ expect(result.success).toBe(false);
606
+ });
607
+
608
+ it('should validate parameters with subtransactions when totals match', () => {
609
+ const validParams = {
610
+ budget_id: 'budget-123',
611
+ account_id: 'account-456',
612
+ amount: -75000,
613
+ date: '2024-01-01',
614
+ subtransactions: [
615
+ {
616
+ amount: -25000,
617
+ memo: 'Groceries',
618
+ category_id: 'category-groceries',
619
+ },
620
+ {
621
+ amount: -50000,
622
+ memo: 'Rent',
623
+ category_id: 'category-rent',
624
+ },
625
+ ],
626
+ };
627
+
628
+ const result = CreateTransactionSchema.safeParse(validParams);
629
+ expect(result.success).toBe(true);
630
+ });
631
+
632
+ it('should reject parameters when subtransaction totals do not match amount', () => {
633
+ const invalidParams = {
634
+ budget_id: 'budget-123',
635
+ account_id: 'account-456',
636
+ amount: -70000,
637
+ date: '2024-01-01',
638
+ subtransactions: [
639
+ {
640
+ amount: -25000,
641
+ },
642
+ {
643
+ amount: -40000,
644
+ },
645
+ ],
646
+ };
647
+
648
+ const result = CreateTransactionSchema.safeParse(invalidParams);
649
+ expect(result.success).toBe(false);
650
+ if (!result.success) {
651
+ expect(result.error.issues[0].message).toBe(
652
+ 'Amount must equal the sum of subtransaction amounts',
653
+ );
654
+ }
655
+ });
656
+ });
657
+
658
+ describe('handleCreateTransaction', () => {
659
+ const mockCreatedTransaction = {
660
+ id: 'new-transaction-123',
661
+ date: '2024-01-01',
662
+ amount: -50000,
663
+ memo: 'Test transaction',
664
+ cleared: 'cleared' as any,
665
+ approved: true,
666
+ flag_color: 'red' as any,
667
+ account_id: 'account-456',
668
+ payee_id: 'payee-789',
669
+ category_id: 'category-101',
670
+ transfer_account_id: null,
671
+ transfer_transaction_id: null,
672
+ matched_transaction_id: null,
673
+ import_id: null,
674
+ deleted: false,
675
+ };
676
+
677
+ it('should create transaction with required fields only', async () => {
678
+ const mockResponse = {
679
+ data: {
680
+ transaction: mockCreatedTransaction,
681
+ server_knowledge: 1,
682
+ },
683
+ };
684
+
685
+ const mockAccountResponse = {
686
+ data: {
687
+ account: {
688
+ id: 'account-456',
689
+ balance: 100000,
690
+ cleared_balance: 95000,
691
+ },
692
+ },
693
+ };
694
+
695
+ (mockYnabAPI.transactions.createTransaction as any).mockResolvedValue(mockResponse);
696
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
697
+
698
+ const params = {
699
+ budget_id: 'budget-123',
700
+ account_id: 'account-456',
701
+ amount: -50000,
702
+ date: '2024-01-01',
703
+ };
704
+ const result = await handleCreateTransaction(mockYnabAPI, params);
705
+
706
+ const createCall = (mockYnabAPI.transactions.createTransaction as any).mock.calls[0];
707
+ expect(createCall[0]).toBe('budget-123');
708
+ const payload = createCall[1];
709
+ expect(payload.transaction).toMatchObject({
710
+ account_id: 'account-456',
711
+ amount: -50000,
712
+ date: '2024-01-01',
713
+ cleared: undefined,
714
+ flag_color: undefined,
715
+ });
716
+ expect(payload.transaction).not.toHaveProperty('subtransactions');
717
+ expect(payload.transaction).not.toHaveProperty('approved');
718
+
719
+ const response = JSON.parse(result.content[0].text);
720
+ expect(response.transaction.id).toBe('new-transaction-123');
721
+ expect(response.transaction.amount).toBe(-50);
722
+ });
723
+
724
+ it('should create transaction with all optional fields', async () => {
725
+ const mockResponse = {
726
+ data: {
727
+ transaction: mockCreatedTransaction,
728
+ server_knowledge: 1,
729
+ },
730
+ };
731
+
732
+ const mockAccountResponse = {
733
+ data: {
734
+ account: {
735
+ id: 'account-456',
736
+ balance: 100000,
737
+ cleared_balance: 95000,
738
+ },
739
+ },
740
+ };
741
+
742
+ (mockYnabAPI.transactions.createTransaction as any).mockResolvedValue(mockResponse);
743
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
744
+
745
+ const params = {
746
+ budget_id: 'budget-123',
747
+ account_id: 'account-456',
748
+ amount: -50000,
749
+ date: '2024-01-01',
750
+ payee_name: 'Test Payee',
751
+ payee_id: 'payee-789',
752
+ category_id: 'category-101',
753
+ memo: 'Test memo',
754
+ cleared: 'cleared' as const,
755
+ approved: true,
756
+ flag_color: 'red' as const,
757
+ };
758
+ const result = await handleCreateTransaction(mockYnabAPI, params);
759
+
760
+ const createCall = (mockYnabAPI.transactions.createTransaction as any).mock.calls[0];
761
+ expect(createCall[0]).toBe('budget-123');
762
+ const payload = createCall[1];
763
+ expect(payload.transaction).toMatchObject({
764
+ account_id: 'account-456',
765
+ amount: -50000,
766
+ date: '2024-01-01',
767
+ payee_name: 'Test Payee',
768
+ payee_id: 'payee-789',
769
+ category_id: 'category-101',
770
+ memo: 'Test memo',
771
+ cleared: 'cleared',
772
+ approved: true,
773
+ flag_color: 'red',
774
+ });
775
+ expect(payload.transaction).not.toHaveProperty('subtransactions');
776
+
777
+ const response = JSON.parse(result.content[0].text);
778
+ expect(response.transaction.id).toBe('new-transaction-123');
779
+ });
780
+
781
+ it('should create split transaction with subtransactions', async () => {
782
+ const mockSplitTransaction = {
783
+ ...mockCreatedTransaction,
784
+ amount: -75000,
785
+ subtransactions: [
786
+ {
787
+ id: 'sub-1',
788
+ transaction_id: 'new-transaction-123',
789
+ amount: -25000,
790
+ memo: 'Groceries',
791
+ payee_id: null,
792
+ payee_name: 'Corner Store',
793
+ category_id: 'category-groceries',
794
+ category_name: 'Groceries',
795
+ transfer_account_id: null,
796
+ transfer_transaction_id: null,
797
+ deleted: false,
798
+ },
799
+ {
800
+ id: 'sub-2',
801
+ transaction_id: 'new-transaction-123',
802
+ amount: -50000,
803
+ memo: 'Rent',
804
+ payee_id: 'payee-landlord',
805
+ payee_name: null,
806
+ category_id: 'category-rent',
807
+ category_name: 'Rent',
808
+ transfer_account_id: null,
809
+ transfer_transaction_id: null,
810
+ deleted: false,
811
+ },
812
+ ],
813
+ };
814
+
815
+ const mockResponse = {
816
+ data: {
817
+ transaction: mockSplitTransaction,
818
+ server_knowledge: 1,
819
+ },
820
+ };
821
+
822
+ const mockAccountResponse = {
823
+ data: {
824
+ account: {
825
+ id: 'account-456',
826
+ balance: 250000,
827
+ cleared_balance: 225000,
828
+ },
829
+ },
830
+ };
831
+
832
+ (mockYnabAPI.transactions.createTransaction as any).mockResolvedValue(mockResponse);
833
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
834
+
835
+ const params = {
836
+ budget_id: 'budget-123',
837
+ account_id: 'account-456',
838
+ amount: -75000,
839
+ date: '2024-01-01',
840
+ subtransactions: [
841
+ {
842
+ amount: -25000,
843
+ memo: 'Groceries',
844
+ payee_name: 'Corner Store',
845
+ category_id: 'category-groceries',
846
+ },
847
+ {
848
+ amount: -50000,
849
+ memo: 'Rent',
850
+ payee_id: 'payee-landlord',
851
+ category_id: 'category-rent',
852
+ },
853
+ ],
854
+ };
855
+
856
+ const result = await handleCreateTransaction(mockYnabAPI, params);
857
+
858
+ const createCall = (mockYnabAPI.transactions.createTransaction as any).mock.calls[0];
859
+ expect(createCall[0]).toBe('budget-123');
860
+ const payload = createCall[1];
861
+ expect(payload.transaction).toMatchObject({
862
+ account_id: 'account-456',
863
+ amount: -75000,
864
+ date: '2024-01-01',
865
+ subtransactions: [
866
+ {
867
+ amount: -25000,
868
+ memo: 'Groceries',
869
+ payee_name: 'Corner Store',
870
+ category_id: 'category-groceries',
871
+ },
872
+ {
873
+ amount: -50000,
874
+ memo: 'Rent',
875
+ payee_id: 'payee-landlord',
876
+ category_id: 'category-rent',
877
+ },
878
+ ],
879
+ });
880
+
881
+ const response = JSON.parse(result.content[0].text);
882
+ expect(response.transaction.amount).toBe(-75);
883
+ expect(response.transaction.account_balance).toBe(250000);
884
+ expect(response.transaction.account_cleared_balance).toBe(225000);
885
+ expect(response.transaction.subtransactions).toEqual([
886
+ {
887
+ id: 'sub-1',
888
+ transaction_id: 'new-transaction-123',
889
+ amount: -25,
890
+ memo: 'Groceries',
891
+ payee_id: null,
892
+ payee_name: 'Corner Store',
893
+ category_id: 'category-groceries',
894
+ category_name: 'Groceries',
895
+ transfer_account_id: null,
896
+ transfer_transaction_id: null,
897
+ deleted: false,
898
+ },
899
+ {
900
+ id: 'sub-2',
901
+ transaction_id: 'new-transaction-123',
902
+ amount: -50,
903
+ memo: 'Rent',
904
+ payee_id: 'payee-landlord',
905
+ payee_name: null,
906
+ category_id: 'category-rent',
907
+ category_name: 'Rent',
908
+ transfer_account_id: null,
909
+ transfer_transaction_id: null,
910
+ deleted: false,
911
+ },
912
+ ]);
913
+ });
914
+
915
+ it('should handle 404 not found errors', async () => {
916
+ const error = new Error('404 Not Found');
917
+ (mockYnabAPI.transactions.createTransaction as any).mockRejectedValue(error);
918
+
919
+ const params = {
920
+ budget_id: 'invalid-budget',
921
+ account_id: 'account-456',
922
+ amount: -50000,
923
+ date: '2024-01-01',
924
+ };
925
+ const result = await handleCreateTransaction(mockYnabAPI, params);
926
+
927
+ const response = JSON.parse(result.content[0].text);
928
+ expect(response.error.message).toBe('Budget, account, category, or transaction not found');
929
+ });
930
+
931
+ it('should handle authentication errors', async () => {
932
+ const error = new Error('401 Unauthorized');
933
+ (mockYnabAPI.transactions.createTransaction as any).mockRejectedValue(error);
934
+
935
+ const params = {
936
+ budget_id: 'budget-123',
937
+ account_id: 'account-456',
938
+ amount: -50000,
939
+ date: '2024-01-01',
940
+ };
941
+ const result = await handleCreateTransaction(mockYnabAPI, params);
942
+
943
+ const response = JSON.parse(result.content[0].text);
944
+ expect(response.error.message).toBe('Invalid or expired YNAB access token');
945
+ });
946
+
947
+ it('should handle generic errors', async () => {
948
+ const error = new Error('Network error');
949
+ (mockYnabAPI.transactions.createTransaction as any).mockRejectedValue(error);
950
+
951
+ const params = {
952
+ budget_id: 'budget-123',
953
+ account_id: 'account-456',
954
+ amount: -50000,
955
+ date: '2024-01-01',
956
+ };
957
+ const result = await handleCreateTransaction(mockYnabAPI, params);
958
+
959
+ const response = JSON.parse(result.content[0].text);
960
+ expect(response.error.message).toBe('Failed to create transaction');
961
+ });
962
+
963
+ it('should invalidate transaction cache on successful transaction creation', async () => {
964
+ const mockResponse = {
965
+ data: {
966
+ transaction: mockCreatedTransaction,
967
+ },
968
+ };
969
+
970
+ const mockAccountResponse = {
971
+ data: {
972
+ account: {
973
+ id: 'account-456',
974
+ balance: 100000,
975
+ cleared_balance: 95000,
976
+ },
977
+ },
978
+ };
979
+
980
+ (mockYnabAPI.transactions.createTransaction as any).mockResolvedValue(mockResponse);
981
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
982
+
983
+ const mockCacheKey = 'transactions:list:budget-123:generated-key';
984
+ (CacheManager.generateKey as any).mockReturnValue(mockCacheKey);
985
+
986
+ const result = await handleCreateTransaction(mockYnabAPI, {
987
+ budget_id: 'budget-123',
988
+ account_id: 'account-456',
989
+ amount: -50000,
990
+ date: '2024-01-01',
991
+ });
992
+
993
+ // Verify cache was invalidated for transaction list
994
+ expect(CacheManager.generateKey).toHaveBeenCalledWith('transactions', 'list', 'budget-123');
995
+ expect(cacheManager.delete).toHaveBeenCalledWith(mockCacheKey);
996
+
997
+ expect(result.content).toHaveLength(1);
998
+ const parsedContent = JSON.parse(result.content[0].text);
999
+ expect(parsedContent.transaction.id).toBe('new-transaction-123');
1000
+ });
1001
+
1002
+ it('should not invalidate cache on dry_run transaction creation', async () => {
1003
+ const mockResponse = {
1004
+ data: {
1005
+ transaction: mockCreatedTransaction,
1006
+ },
1007
+ };
1008
+
1009
+ const mockAccountResponse = {
1010
+ data: {
1011
+ account: {
1012
+ id: 'account-456',
1013
+ balance: 100000,
1014
+ cleared_balance: 95000,
1015
+ },
1016
+ },
1017
+ };
1018
+
1019
+ (mockYnabAPI.transactions.createTransaction as any).mockResolvedValue(mockResponse);
1020
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1021
+
1022
+ const result = await handleCreateTransaction(mockYnabAPI, {
1023
+ budget_id: 'budget-123',
1024
+ account_id: 'account-456',
1025
+ amount: -50000,
1026
+ date: '2024-01-01',
1027
+ dry_run: true,
1028
+ });
1029
+
1030
+ // Verify cache was NOT invalidated for dry run
1031
+ expect(cacheManager.delete).not.toHaveBeenCalled();
1032
+ expect(CacheManager.generateKey).not.toHaveBeenCalled();
1033
+
1034
+ expect(result.content).toHaveLength(1);
1035
+ const parsedContent = JSON.parse(result.content[0].text);
1036
+ expect(parsedContent.dry_run).toBe(true);
1037
+ expect(parsedContent.action).toBe('create_transaction');
1038
+ expect(parsedContent.request).toMatchObject({
1039
+ budget_id: 'budget-123',
1040
+ account_id: 'account-456',
1041
+ amount: -50000,
1042
+ date: '2024-01-01',
1043
+ dry_run: true,
1044
+ });
1045
+ });
1046
+ });
1047
+
1048
+ describe('handleCreateTransactions', () => {
1049
+ it('surfaces top-level validation errors with a reserved transaction index', async () => {
1050
+ const invalidParams = {
1051
+ budget_id: '',
1052
+ transactions: [],
1053
+ };
1054
+
1055
+ const result = await handleCreateTransactions(mockYnabAPI, invalidParams as any);
1056
+
1057
+ expect(result.content).toHaveLength(1);
1058
+ const parsed = JSON.parse(result.content[0].text);
1059
+ expect(parsed.error.code).toBe('VALIDATION_ERROR');
1060
+ expect(parsed.error.details).toBeDefined();
1061
+
1062
+ const details = JSON.parse(parsed.error.details ?? '[]');
1063
+ expect(details).toHaveLength(1);
1064
+ expect(details[0].transaction_index).toBeNull();
1065
+ expect(details[0].errors).toEqual(
1066
+ expect.arrayContaining(['Budget ID is required', 'At least one transaction is required']),
1067
+ );
1068
+ });
1069
+
1070
+ it('combines reserved and per-transaction validation errors', async () => {
1071
+ const invalidParams = {
1072
+ budget_id: 'budget-123',
1073
+ dry_run: 'later',
1074
+ transactions: [
1075
+ {
1076
+ account_id: '',
1077
+ amount: -50000,
1078
+ date: '2024-01-01',
1079
+ },
1080
+ ],
1081
+ };
1082
+
1083
+ const result = await handleCreateTransactions(mockYnabAPI, invalidParams as any);
1084
+
1085
+ const parsed = JSON.parse(result.content[0].text);
1086
+ expect(parsed.error.code).toBe('VALIDATION_ERROR');
1087
+ expect(parsed.error.details).toBeDefined();
1088
+
1089
+ const details = JSON.parse(parsed.error.details ?? '[]');
1090
+ const generalEntry = details.find((entry: any) => entry.transaction_index === null);
1091
+ const transactionEntry = details.find((entry: any) => entry.transaction_index === 0);
1092
+
1093
+ expect(generalEntry).toBeDefined();
1094
+ expect(generalEntry.errors).toEqual(
1095
+ expect.arrayContaining([expect.stringContaining('expected boolean')]),
1096
+ );
1097
+
1098
+ expect(transactionEntry).toBeDefined();
1099
+ expect(transactionEntry.errors).toEqual(expect.arrayContaining(['Account ID is required']));
1100
+ });
1101
+ });
1102
+
1103
+ describe('CreateReceiptSplitTransactionSchema', () => {
1104
+ const basePayload = {
1105
+ budget_id: 'budget-123',
1106
+ account_id: 'account-456',
1107
+ payee_name: 'IKEA',
1108
+ receipt_subtotal: 50,
1109
+ receipt_tax: 5,
1110
+ receipt_total: 55,
1111
+ categories: [
1112
+ {
1113
+ category_id: 'category-a',
1114
+ category_name: 'Home',
1115
+ items: [
1116
+ { name: 'Lamp', amount: 20 },
1117
+ { name: 'Rug', amount: 30 },
1118
+ ],
1119
+ },
1120
+ ],
1121
+ } as const;
1122
+
1123
+ it('should validate a well-formed receipt split payload', () => {
1124
+ const result = CreateReceiptSplitTransactionSchema.safeParse(basePayload);
1125
+ expect(result.success).toBe(true);
1126
+ });
1127
+
1128
+ it('should reject when subtotal does not match categorized items', () => {
1129
+ const invalidPayload = {
1130
+ ...basePayload,
1131
+ receipt_subtotal: 40,
1132
+ };
1133
+
1134
+ const result = CreateReceiptSplitTransactionSchema.safeParse(invalidPayload);
1135
+ expect(result.success).toBe(false);
1136
+ });
1137
+
1138
+ it('should reject when total does not equal subtotal plus tax', () => {
1139
+ const invalidPayload = {
1140
+ ...basePayload,
1141
+ receipt_total: 56,
1142
+ };
1143
+
1144
+ const result = CreateReceiptSplitTransactionSchema.safeParse(invalidPayload);
1145
+ expect(result.success).toBe(false);
1146
+ });
1147
+ });
1148
+
1149
+ describe('handleCreateReceiptSplitTransaction', () => {
1150
+ const mockSplitTransaction = {
1151
+ id: 'new-transaction-456',
1152
+ date: '2025-10-13',
1153
+ amount: -55000,
1154
+ memo: 'Receipt import',
1155
+ cleared: 'uncleared' as const,
1156
+ approved: false,
1157
+ flag_color: null,
1158
+ account_id: 'account-456',
1159
+ payee_id: null,
1160
+ category_id: null,
1161
+ transfer_account_id: null,
1162
+ transfer_transaction_id: null,
1163
+ matched_transaction_id: null,
1164
+ import_id: null,
1165
+ deleted: false,
1166
+ subtransactions: [
1167
+ {
1168
+ id: 'sub-1',
1169
+ transaction_id: 'new-transaction-456',
1170
+ amount: -20000,
1171
+ memo: 'Lamp',
1172
+ payee_id: null,
1173
+ payee_name: null,
1174
+ category_id: 'category-home',
1175
+ category_name: 'Home',
1176
+ transfer_account_id: null,
1177
+ transfer_transaction_id: null,
1178
+ deleted: false,
1179
+ },
1180
+ {
1181
+ id: 'sub-2',
1182
+ transaction_id: 'new-transaction-456',
1183
+ amount: -10000,
1184
+ memo: 'Shelf',
1185
+ payee_id: null,
1186
+ payee_name: null,
1187
+ category_id: 'category-home',
1188
+ category_name: 'Home',
1189
+ transfer_account_id: null,
1190
+ transfer_transaction_id: null,
1191
+ deleted: false,
1192
+ },
1193
+ {
1194
+ id: 'sub-3',
1195
+ transaction_id: 'new-transaction-456',
1196
+ amount: -15000,
1197
+ memo: 'Pan',
1198
+ payee_id: null,
1199
+ payee_name: null,
1200
+ category_id: 'category-kitchen',
1201
+ category_name: 'Kitchen',
1202
+ transfer_account_id: null,
1203
+ transfer_transaction_id: null,
1204
+ deleted: false,
1205
+ },
1206
+ {
1207
+ id: 'sub-4',
1208
+ transaction_id: 'new-transaction-456',
1209
+ amount: -10000,
1210
+ memo: 'Tax - Home',
1211
+ payee_id: null,
1212
+ payee_name: null,
1213
+ category_id: 'category-home',
1214
+ category_name: 'Home',
1215
+ transfer_account_id: null,
1216
+ transfer_transaction_id: null,
1217
+ deleted: false,
1218
+ },
1219
+ {
1220
+ id: 'sub-5',
1221
+ transaction_id: 'new-transaction-456',
1222
+ amount: -5000,
1223
+ memo: 'Tax - Kitchen',
1224
+ payee_id: null,
1225
+ payee_name: null,
1226
+ category_id: 'category-kitchen',
1227
+ category_name: 'Kitchen',
1228
+ transfer_account_id: null,
1229
+ transfer_transaction_id: null,
1230
+ deleted: false,
1231
+ },
1232
+ ],
1233
+ };
1234
+
1235
+ const mockAccountResponse = {
1236
+ data: {
1237
+ account: {
1238
+ id: 'account-456',
1239
+ balance: 500000,
1240
+ cleared_balance: 450000,
1241
+ },
1242
+ },
1243
+ };
1244
+
1245
+ beforeEach(() => {
1246
+ (mockYnabAPI.transactions.createTransaction as any).mockReset();
1247
+ (mockYnabAPI.accounts.getAccountById as any).mockReset();
1248
+ });
1249
+
1250
+ it('should return a detailed dry-run summary without calling the API', async () => {
1251
+ const params = {
1252
+ budget_id: 'budget-123',
1253
+ account_id: 'account-456',
1254
+ payee_name: 'IKEA',
1255
+ date: '2025-10-13',
1256
+ receipt_tax: 5,
1257
+ receipt_total: 55,
1258
+ categories: [
1259
+ {
1260
+ category_id: 'category-home',
1261
+ category_name: 'Home',
1262
+ items: [
1263
+ { name: 'Lamp', amount: 20 },
1264
+ { name: 'Shelf', amount: 10 },
1265
+ ],
1266
+ },
1267
+ {
1268
+ category_id: 'category-kitchen',
1269
+ category_name: 'Kitchen',
1270
+ items: [{ name: 'Pan', amount: 20 }],
1271
+ },
1272
+ ],
1273
+ receipt_subtotal: 50,
1274
+ dry_run: true,
1275
+ } as const;
1276
+
1277
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1278
+
1279
+ expect(mockYnabAPI.transactions.createTransaction).not.toHaveBeenCalled();
1280
+ const parsed = JSON.parse(result.content[0].text);
1281
+ expect(parsed.dry_run).toBe(true);
1282
+ expect(parsed.receipt_summary.total).toBe(55);
1283
+ expect(parsed.subtransactions).toHaveLength(5);
1284
+ });
1285
+
1286
+ it('should create a split transaction and attach receipt summary', async () => {
1287
+ (mockYnabAPI.transactions.createTransaction as any).mockResolvedValue({
1288
+ data: {
1289
+ transaction: mockSplitTransaction,
1290
+ },
1291
+ });
1292
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1293
+
1294
+ const params = {
1295
+ budget_id: 'budget-123',
1296
+ account_id: 'account-456',
1297
+ payee_name: 'IKEA',
1298
+ memo: 'Store receipt import',
1299
+ date: '2025-10-13',
1300
+ receipt_tax: 5,
1301
+ receipt_total: 55,
1302
+ categories: [
1303
+ {
1304
+ category_id: 'category-home',
1305
+ category_name: 'Home',
1306
+ items: [
1307
+ { name: 'Lamp', amount: 20 },
1308
+ { name: 'Shelf', amount: 10 },
1309
+ ],
1310
+ },
1311
+ {
1312
+ category_id: 'category-kitchen',
1313
+ category_name: 'Kitchen',
1314
+ items: [{ name: 'Pan', amount: 20 }],
1315
+ },
1316
+ ],
1317
+ receipt_subtotal: 50,
1318
+ } as const;
1319
+
1320
+ const result = await handleCreateReceiptSplitTransaction(mockYnabAPI, params);
1321
+
1322
+ expect(mockYnabAPI.transactions.createTransaction).toHaveBeenCalledTimes(1);
1323
+ const callArgs = (mockYnabAPI.transactions.createTransaction as any).mock.calls[0];
1324
+ expect(callArgs[0]).toBe('budget-123');
1325
+ expect(callArgs[1].transaction.amount).toBe(-55000);
1326
+ expect(callArgs[1].transaction.subtransactions).toHaveLength(5);
1327
+
1328
+ const parsed = JSON.parse(result.content[0].text);
1329
+ expect(parsed.receipt_summary.total).toBe(55);
1330
+ const homeCategory = parsed.receipt_summary.categories.find(
1331
+ (category: any) => category.category_id === 'category-home',
1332
+ );
1333
+ expect(homeCategory).toBeDefined();
1334
+ expect(homeCategory.tax).toBeCloseTo(3);
1335
+ expect(homeCategory.total).toBeCloseTo(33);
1336
+ });
1337
+ });
1338
+
1339
+ describe('UpdateTransactionSchema', () => {
1340
+ it('should validate valid parameters with minimal fields', () => {
1341
+ const validParams = {
1342
+ budget_id: 'budget-123',
1343
+ transaction_id: 'transaction-456',
1344
+ amount: -60000, // Updated amount
1345
+ };
1346
+
1347
+ const result = UpdateTransactionSchema.safeParse(validParams);
1348
+ expect(result.success).toBe(true);
1349
+ });
1350
+
1351
+ it('should validate valid parameters with all optional fields', () => {
1352
+ const validParams = {
1353
+ budget_id: 'budget-123',
1354
+ transaction_id: 'transaction-456',
1355
+ account_id: 'account-789',
1356
+ amount: -60000,
1357
+ date: '2024-01-02',
1358
+ payee_name: 'Updated Payee',
1359
+ payee_id: 'payee-999',
1360
+ category_id: 'category-202',
1361
+ memo: 'Updated memo',
1362
+ cleared: 'reconciled' as const,
1363
+ approved: false,
1364
+ flag_color: 'blue' as const,
1365
+ };
1366
+
1367
+ const result = UpdateTransactionSchema.safeParse(validParams);
1368
+ expect(result.success).toBe(true);
1369
+ });
1370
+
1371
+ it('should require budget_id', () => {
1372
+ const invalidParams = {
1373
+ transaction_id: 'transaction-456',
1374
+ amount: -60000,
1375
+ };
1376
+
1377
+ const result = UpdateTransactionSchema.safeParse(invalidParams);
1378
+ expect(result.success).toBe(false);
1379
+ });
1380
+
1381
+ it('should require transaction_id', () => {
1382
+ const invalidParams = {
1383
+ budget_id: 'budget-123',
1384
+ amount: -60000,
1385
+ };
1386
+
1387
+ const result = UpdateTransactionSchema.safeParse(invalidParams);
1388
+ expect(result.success).toBe(false);
1389
+ });
1390
+
1391
+ it('should require amount to be an integer when provided', () => {
1392
+ const invalidParams = {
1393
+ budget_id: 'budget-123',
1394
+ transaction_id: 'transaction-456',
1395
+ amount: -600.5, // Decimal not allowed
1396
+ };
1397
+
1398
+ const result = UpdateTransactionSchema.safeParse(invalidParams);
1399
+ expect(result.success).toBe(false);
1400
+ if (!result.success) {
1401
+ expect(result.error.issues[0].message).toContain('Amount must be an integer in milliunits');
1402
+ }
1403
+ });
1404
+
1405
+ it('should validate date format when provided', () => {
1406
+ const invalidParams = {
1407
+ budget_id: 'budget-123',
1408
+ transaction_id: 'transaction-456',
1409
+ date: '01/02/2024', // Invalid format
1410
+ };
1411
+
1412
+ const result = UpdateTransactionSchema.safeParse(invalidParams);
1413
+ expect(result.success).toBe(false);
1414
+ if (!result.success) {
1415
+ expect(result.error.issues[0].message).toContain('Date must be in ISO format');
1416
+ }
1417
+ });
1418
+
1419
+ it('should validate cleared status enum when provided', () => {
1420
+ const invalidParams = {
1421
+ budget_id: 'budget-123',
1422
+ transaction_id: 'transaction-456',
1423
+ cleared: 'invalid-status',
1424
+ };
1425
+
1426
+ const result = UpdateTransactionSchema.safeParse(invalidParams);
1427
+ expect(result.success).toBe(false);
1428
+ });
1429
+
1430
+ it('should validate flag_color enum when provided', () => {
1431
+ const invalidParams = {
1432
+ budget_id: 'budget-123',
1433
+ transaction_id: 'transaction-456',
1434
+ flag_color: 'invalid-color',
1435
+ };
1436
+
1437
+ const result = UpdateTransactionSchema.safeParse(invalidParams);
1438
+ expect(result.success).toBe(false);
1439
+ });
1440
+ });
1441
+
1442
+ describe('handleUpdateTransaction', () => {
1443
+ const mockUpdatedTransaction = {
1444
+ id: 'transaction-456',
1445
+ date: '2024-01-02',
1446
+ amount: -60000,
1447
+ memo: 'Updated memo',
1448
+ cleared: 'reconciled' as any,
1449
+ approved: false,
1450
+ flag_color: 'blue' as any,
1451
+ account_id: 'account-789',
1452
+ payee_id: 'payee-999',
1453
+ category_id: 'category-202',
1454
+ transfer_account_id: null,
1455
+ transfer_transaction_id: null,
1456
+ matched_transaction_id: null,
1457
+ import_id: null,
1458
+ deleted: false,
1459
+ };
1460
+
1461
+ const mockOriginalTransaction = {
1462
+ id: 'transaction-456',
1463
+ account_id: 'account-123',
1464
+ amount: -50000,
1465
+ date: '2024-01-01',
1466
+ memo: 'Original memo',
1467
+ };
1468
+
1469
+ beforeEach(() => {
1470
+ (mockYnabAPI.transactions.getTransactionById as any).mockResolvedValue({
1471
+ data: { transaction: mockOriginalTransaction },
1472
+ });
1473
+ });
1474
+
1475
+ it('should update transaction with single field', async () => {
1476
+ const mockResponse = {
1477
+ data: {
1478
+ transaction: mockUpdatedTransaction,
1479
+ },
1480
+ };
1481
+
1482
+ const mockAccountResponse = {
1483
+ data: {
1484
+ account: {
1485
+ id: 'account-789',
1486
+ balance: 150000,
1487
+ cleared_balance: 140000,
1488
+ },
1489
+ },
1490
+ };
1491
+
1492
+ (mockYnabAPI.transactions.updateTransaction as any).mockResolvedValue(mockResponse);
1493
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1494
+
1495
+ const params = {
1496
+ budget_id: 'budget-123',
1497
+ transaction_id: 'transaction-456',
1498
+ amount: -60000,
1499
+ };
1500
+ const result = await handleUpdateTransaction(mockYnabAPI, params);
1501
+
1502
+ expect(mockYnabAPI.transactions.updateTransaction).toHaveBeenCalledWith(
1503
+ 'budget-123',
1504
+ 'transaction-456',
1505
+ {
1506
+ transaction: {
1507
+ amount: -60000,
1508
+ },
1509
+ },
1510
+ );
1511
+
1512
+ const response = JSON.parse(result.content[0].text);
1513
+ expect(response.transaction.id).toBe('transaction-456');
1514
+ expect(response.transaction.amount).toBe(-60);
1515
+ });
1516
+
1517
+ it('should update transaction with multiple fields', async () => {
1518
+ const mockResponse = {
1519
+ data: {
1520
+ transaction: mockUpdatedTransaction,
1521
+ },
1522
+ };
1523
+
1524
+ const mockAccountResponse = {
1525
+ data: {
1526
+ account: {
1527
+ id: 'account-789',
1528
+ balance: 150000,
1529
+ cleared_balance: 140000,
1530
+ },
1531
+ },
1532
+ };
1533
+
1534
+ (mockYnabAPI.transactions.updateTransaction as any).mockResolvedValue(mockResponse);
1535
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1536
+
1537
+ const params = {
1538
+ budget_id: 'budget-123',
1539
+ transaction_id: 'transaction-456',
1540
+ account_id: 'account-789',
1541
+ amount: -60000,
1542
+ date: '2024-01-02',
1543
+ memo: 'Updated memo',
1544
+ cleared: 'reconciled' as const,
1545
+ approved: false,
1546
+ flag_color: 'blue' as const,
1547
+ };
1548
+ const result = await handleUpdateTransaction(mockYnabAPI, params);
1549
+
1550
+ expect(mockYnabAPI.transactions.updateTransaction).toHaveBeenCalledWith(
1551
+ 'budget-123',
1552
+ 'transaction-456',
1553
+ {
1554
+ transaction: {
1555
+ account_id: 'account-789',
1556
+ amount: -60000,
1557
+ date: '2024-01-02',
1558
+ memo: 'Updated memo',
1559
+ cleared: 'reconciled',
1560
+ approved: false,
1561
+ flag_color: 'blue',
1562
+ },
1563
+ },
1564
+ );
1565
+
1566
+ const response = JSON.parse(result.content[0].text);
1567
+ expect(response.transaction.id).toBe('transaction-456');
1568
+ });
1569
+
1570
+ it('should handle 404 not found errors', async () => {
1571
+ const error = new Error('404 Not Found');
1572
+ (mockYnabAPI.transactions.updateTransaction as any).mockRejectedValue(error);
1573
+
1574
+ const params = {
1575
+ budget_id: 'budget-123',
1576
+ transaction_id: 'invalid-transaction',
1577
+ amount: -60000,
1578
+ };
1579
+ const result = await handleUpdateTransaction(mockYnabAPI, params);
1580
+
1581
+ const response = JSON.parse(result.content[0].text);
1582
+ expect(response.error.message).toBe('Budget, account, category, or transaction not found');
1583
+ });
1584
+
1585
+ it('should handle authentication errors', async () => {
1586
+ const error = new Error('401 Unauthorized');
1587
+ (mockYnabAPI.transactions.updateTransaction as any).mockRejectedValue(error);
1588
+
1589
+ const params = {
1590
+ budget_id: 'budget-123',
1591
+ transaction_id: 'transaction-456',
1592
+ amount: -60000,
1593
+ };
1594
+ const result = await handleUpdateTransaction(mockYnabAPI, params);
1595
+
1596
+ const response = JSON.parse(result.content[0].text);
1597
+ expect(response.error.message).toBe('Invalid or expired YNAB access token');
1598
+ });
1599
+
1600
+ it('should handle generic errors', async () => {
1601
+ const error = new Error('Network error');
1602
+ (mockYnabAPI.transactions.updateTransaction as any).mockRejectedValue(error);
1603
+
1604
+ const params = {
1605
+ budget_id: 'budget-123',
1606
+ transaction_id: 'transaction-456',
1607
+ amount: -60000,
1608
+ };
1609
+ const result = await handleUpdateTransaction(mockYnabAPI, params);
1610
+
1611
+ const response = JSON.parse(result.content[0].text);
1612
+ expect(response.error.message).toBe('Failed to update transaction');
1613
+ });
1614
+
1615
+ it('should invalidate transaction cache on successful transaction update', async () => {
1616
+ const mockResponse = {
1617
+ data: {
1618
+ transaction: mockUpdatedTransaction,
1619
+ },
1620
+ };
1621
+
1622
+ const mockAccountResponse = {
1623
+ data: {
1624
+ account: {
1625
+ id: 'account-789',
1626
+ balance: 150000,
1627
+ cleared_balance: 140000,
1628
+ },
1629
+ },
1630
+ };
1631
+
1632
+ (mockYnabAPI.transactions.updateTransaction as any).mockResolvedValue(mockResponse);
1633
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1634
+
1635
+ const mockCacheKey = 'transactions:list:budget-123:generated-key';
1636
+ (CacheManager.generateKey as any).mockReturnValue(mockCacheKey);
1637
+
1638
+ const result = await handleUpdateTransaction(mockYnabAPI, {
1639
+ budget_id: 'budget-123',
1640
+ transaction_id: 'transaction-456',
1641
+ amount: -60000,
1642
+ });
1643
+
1644
+ // Verify cache was invalidated for transaction list
1645
+ expect(CacheManager.generateKey).toHaveBeenCalledWith('transactions', 'list', 'budget-123');
1646
+ expect(cacheManager.delete).toHaveBeenCalledWith(mockCacheKey);
1647
+
1648
+ expect(result.content).toHaveLength(1);
1649
+ const parsedContent = JSON.parse(result.content[0].text);
1650
+ expect(parsedContent.transaction.id).toBe('transaction-456');
1651
+ });
1652
+
1653
+ it('should not invalidate cache on dry_run transaction update', async () => {
1654
+ const mockResponse = {
1655
+ data: {
1656
+ transaction: mockUpdatedTransaction,
1657
+ },
1658
+ };
1659
+
1660
+ const mockAccountResponse = {
1661
+ data: {
1662
+ account: {
1663
+ id: 'account-789',
1664
+ balance: 150000,
1665
+ cleared_balance: 140000,
1666
+ },
1667
+ },
1668
+ };
1669
+
1670
+ (mockYnabAPI.transactions.updateTransaction as any).mockResolvedValue(mockResponse);
1671
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1672
+
1673
+ const result = await handleUpdateTransaction(mockYnabAPI, {
1674
+ budget_id: 'budget-123',
1675
+ transaction_id: 'transaction-456',
1676
+ amount: -60000,
1677
+ dry_run: true,
1678
+ });
1679
+
1680
+ // Verify cache was NOT invalidated for dry run
1681
+ expect(cacheManager.delete).not.toHaveBeenCalled();
1682
+ expect(CacheManager.generateKey).not.toHaveBeenCalled();
1683
+
1684
+ expect(result.content).toHaveLength(1);
1685
+ const parsedContent = JSON.parse(result.content[0].text);
1686
+ expect(parsedContent.dry_run).toBe(true);
1687
+ expect(parsedContent.action).toBe('update_transaction');
1688
+ expect(parsedContent.request).toEqual({
1689
+ budget_id: 'budget-123',
1690
+ transaction_id: 'transaction-456',
1691
+ amount: -60000,
1692
+ dry_run: true,
1693
+ });
1694
+ });
1695
+ });
1696
+
1697
+ describe('DeleteTransactionSchema', () => {
1698
+ it('should validate valid parameters', () => {
1699
+ const validParams = {
1700
+ budget_id: 'budget-123',
1701
+ transaction_id: 'transaction-456',
1702
+ };
1703
+
1704
+ const result = DeleteTransactionSchema.safeParse(validParams);
1705
+ expect(result.success).toBe(true);
1706
+ });
1707
+
1708
+ it('should require budget_id', () => {
1709
+ const invalidParams = {
1710
+ transaction_id: 'transaction-456',
1711
+ };
1712
+
1713
+ const result = DeleteTransactionSchema.safeParse(invalidParams);
1714
+ expect(result.success).toBe(false);
1715
+ if (!result.success) {
1716
+ expect(result.error.issues[0].code).toBe('invalid_type');
1717
+ expect(result.error.issues[0].path).toEqual(['budget_id']);
1718
+ }
1719
+ });
1720
+
1721
+ it('should require transaction_id', () => {
1722
+ const invalidParams = {
1723
+ budget_id: 'budget-123',
1724
+ };
1725
+
1726
+ const result = DeleteTransactionSchema.safeParse(invalidParams);
1727
+ expect(result.success).toBe(false);
1728
+ if (!result.success) {
1729
+ expect(result.error.issues[0].code).toBe('invalid_type');
1730
+ expect(result.error.issues[0].path).toEqual(['transaction_id']);
1731
+ }
1732
+ });
1733
+
1734
+ it('should reject empty strings', () => {
1735
+ const invalidParams = {
1736
+ budget_id: '',
1737
+ transaction_id: '',
1738
+ };
1739
+
1740
+ const result = DeleteTransactionSchema.safeParse(invalidParams);
1741
+ expect(result.success).toBe(false);
1742
+ });
1743
+ });
1744
+
1745
+ describe('CreateTransactionsSchema', () => {
1746
+ const buildTransaction = (overrides: Record<string, unknown> = {}) => ({
1747
+ account_id: 'account-123',
1748
+ amount: -5000,
1749
+ date: '2024-01-01',
1750
+ memo: 'Bulk entry',
1751
+ ...overrides,
1752
+ });
1753
+
1754
+ it('should accept a valid batch of multiple transactions', () => {
1755
+ const params = {
1756
+ budget_id: 'budget-123',
1757
+ transactions: [buildTransaction(), buildTransaction({ account_id: 'account-456' })],
1758
+ };
1759
+ const result = CreateTransactionsSchema.safeParse(params);
1760
+ expect(result.success).toBe(true);
1761
+ if (result.success) {
1762
+ expect(result.data.transactions).toHaveLength(2);
1763
+ }
1764
+ });
1765
+
1766
+ it('should accept the minimum batch size of one transaction', () => {
1767
+ const params = { budget_id: 'budget-123', transactions: [buildTransaction()] };
1768
+ const result = CreateTransactionsSchema.safeParse(params);
1769
+ expect(result.success).toBe(true);
1770
+ });
1771
+
1772
+ it('should accept the maximum batch size of 100 transactions', () => {
1773
+ const hundred = Array.from({ length: 100 }, (_, index) =>
1774
+ buildTransaction({ import_id: `YNAB:-5000:2024-01-01:${index + 1}` }),
1775
+ );
1776
+ const params = { budget_id: 'budget-123', transactions: hundred };
1777
+ const result = CreateTransactionsSchema.safeParse(params);
1778
+ expect(result.success).toBe(true);
1779
+ });
1780
+
1781
+ it('should reject an empty transactions array', () => {
1782
+ const params = { budget_id: 'budget-123', transactions: [] as unknown[] };
1783
+ const result = CreateTransactionsSchema.safeParse(params);
1784
+ expect(result.success).toBe(false);
1785
+ if (!result.success) {
1786
+ expect(result.error.issues[0].message).toContain('At least one transaction is required');
1787
+ }
1788
+ });
1789
+
1790
+ it('should reject batches exceeding 100 transactions', () => {
1791
+ const overLimit = Array.from({ length: 101 }, () => buildTransaction());
1792
+ const result = CreateTransactionsSchema.safeParse({
1793
+ budget_id: 'budget-123',
1794
+ transactions: overLimit,
1795
+ });
1796
+ expect(result.success).toBe(false);
1797
+ if (!result.success) {
1798
+ expect(result.error.issues[0].message).toContain('A maximum of 100 transactions');
1799
+ }
1800
+ });
1801
+
1802
+ it('should reject transactions missing required fields', () => {
1803
+ const params = {
1804
+ budget_id: 'budget-123',
1805
+ transactions: [buildTransaction({ account_id: undefined })],
1806
+ };
1807
+ const result = CreateTransactionsSchema.safeParse(params);
1808
+ expect(result.success).toBe(false);
1809
+ if (!result.success) {
1810
+ expect(result.error.issues[0].path).toEqual(['transactions', 0, 'account_id']);
1811
+ }
1812
+ });
1813
+
1814
+ it('should validate ISO date format for each transaction', () => {
1815
+ const params = {
1816
+ budget_id: 'budget-123',
1817
+ transactions: [buildTransaction({ date: '01/01/2024' })],
1818
+ };
1819
+ const result = CreateTransactionsSchema.safeParse(params);
1820
+ expect(result.success).toBe(false);
1821
+ if (!result.success) {
1822
+ expect(result.error.issues[0].path).toEqual(['transactions', 0, 'date']);
1823
+ }
1824
+ });
1825
+
1826
+ it('should require integer milliunit amounts', () => {
1827
+ const params = {
1828
+ budget_id: 'budget-123',
1829
+ transactions: [buildTransaction({ amount: -50.25 })],
1830
+ };
1831
+ const result = CreateTransactionsSchema.safeParse(params);
1832
+ expect(result.success).toBe(false);
1833
+ if (!result.success) {
1834
+ expect(result.error.issues[0].path).toEqual(['transactions', 0, 'amount']);
1835
+ }
1836
+ });
1837
+
1838
+ it('should reject invalid cleared enum values', () => {
1839
+ const params = {
1840
+ budget_id: 'budget-123',
1841
+ transactions: [buildTransaction({ cleared: 'pending' })],
1842
+ };
1843
+ const result = CreateTransactionsSchema.safeParse(params);
1844
+ expect(result.success).toBe(false);
1845
+ });
1846
+
1847
+ it('should reject transactions containing subtransactions', () => {
1848
+ const params = {
1849
+ budget_id: 'budget-123',
1850
+ transactions: [buildTransaction({ subtransactions: [{ amount: -2500 }] })],
1851
+ };
1852
+ const result = CreateTransactionsSchema.safeParse(params);
1853
+ expect(result.success).toBe(false);
1854
+ if (!result.success) {
1855
+ const issue = result.error.issues.find((i) => i.path.includes('subtransactions'));
1856
+ expect(issue?.message).toContain('Subtransactions are not supported');
1857
+ }
1858
+ });
1859
+
1860
+ it('should fail when any transaction in the batch is invalid', () => {
1861
+ const params = {
1862
+ budget_id: 'budget-123',
1863
+ transactions: [buildTransaction(), buildTransaction({ amount: 'invalid' })],
1864
+ };
1865
+ const result = CreateTransactionsSchema.safeParse(params);
1866
+ expect(result.success).toBe(false);
1867
+ });
1868
+
1869
+ it('should accept optional import_id values', () => {
1870
+ const params = {
1871
+ budget_id: 'budget-123',
1872
+ transactions: [buildTransaction({ import_id: 'YNAB:-5000:2024-01-01:1' })],
1873
+ };
1874
+ const result = CreateTransactionsSchema.safeParse(params);
1875
+ expect(result.success).toBe(true);
1876
+ if (result.success) {
1877
+ expect(result.data.transactions[0].import_id).toBe('YNAB:-5000:2024-01-01:1');
1878
+ }
1879
+ });
1880
+ });
1881
+
1882
+ describe('handleDeleteTransaction', () => {
1883
+ const mockDeletedTransaction = {
1884
+ id: 'transaction-456',
1885
+ deleted: true,
1886
+ account_id: 'account-456',
1887
+ date: '2024-01-15',
1888
+ amount: -5000,
1889
+ cleared: 'cleared',
1890
+ approved: true,
1891
+ payee_id: null,
1892
+ category_id: 'category-789',
1893
+ transfer_account_id: null,
1894
+ transfer_transaction_id: null,
1895
+ matched_transaction_id: null,
1896
+ import_id: null,
1897
+ import_payee_name: null,
1898
+ import_payee_name_original: null,
1899
+ debt_transaction_type: null,
1900
+ subtransactions: [],
1901
+ } as ynab.TransactionDetail;
1902
+
1903
+ it('should delete transaction successfully', async () => {
1904
+ const mockResponse = {
1905
+ data: {
1906
+ transaction: mockDeletedTransaction,
1907
+ server_knowledge: 12345,
1908
+ },
1909
+ };
1910
+
1911
+ const mockAccountResponse = {
1912
+ data: {
1913
+ account: {
1914
+ id: 'account-456',
1915
+ balance: 50000,
1916
+ cleared_balance: 45000,
1917
+ },
1918
+ },
1919
+ };
1920
+
1921
+ (mockYnabAPI.transactions.deleteTransaction as any).mockResolvedValue(mockResponse);
1922
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
1923
+
1924
+ const params = {
1925
+ budget_id: 'budget-123',
1926
+ transaction_id: 'transaction-456',
1927
+ };
1928
+ const result = await handleDeleteTransaction(mockYnabAPI, params);
1929
+
1930
+ expect(mockYnabAPI.transactions.deleteTransaction).toHaveBeenCalledWith(
1931
+ 'budget-123',
1932
+ 'transaction-456',
1933
+ );
1934
+
1935
+ const response = JSON.parse(result.content[0].text);
1936
+ expect(response.message).toBe('Transaction deleted successfully');
1937
+ expect(response.transaction.id).toBe('transaction-456');
1938
+ expect(response.transaction.deleted).toBe(true);
1939
+ });
1940
+
1941
+ it('should handle 404 not found errors', async () => {
1942
+ const error = new Error('404 Not Found');
1943
+ (mockYnabAPI.transactions.deleteTransaction as any).mockRejectedValue(error);
1944
+
1945
+ const params = {
1946
+ budget_id: 'budget-123',
1947
+ transaction_id: 'invalid-transaction',
1948
+ };
1949
+ const result = await handleDeleteTransaction(mockYnabAPI, params);
1950
+
1951
+ const response = JSON.parse(result.content[0].text);
1952
+ expect(response.error.message).toBe('Budget, account, category, or transaction not found');
1953
+ });
1954
+
1955
+ it('should handle authentication errors', async () => {
1956
+ const error = new Error('401 Unauthorized');
1957
+ (mockYnabAPI.transactions.deleteTransaction as any).mockRejectedValue(error);
1958
+
1959
+ const params = {
1960
+ budget_id: 'budget-123',
1961
+ transaction_id: 'transaction-456',
1962
+ };
1963
+ const result = await handleDeleteTransaction(mockYnabAPI, params);
1964
+
1965
+ const response = JSON.parse(result.content[0].text);
1966
+ expect(response.error.message).toBe('Invalid or expired YNAB access token');
1967
+ });
1968
+
1969
+ it('should handle generic errors', async () => {
1970
+ const error = new Error('Network error');
1971
+ (mockYnabAPI.transactions.deleteTransaction as any).mockRejectedValue(error);
1972
+
1973
+ const params = {
1974
+ budget_id: 'budget-123',
1975
+ transaction_id: 'transaction-456',
1976
+ };
1977
+ const result = await handleDeleteTransaction(mockYnabAPI, params);
1978
+
1979
+ const response = JSON.parse(result.content[0].text);
1980
+ expect(response.error.message).toBe('Failed to delete transaction');
1981
+ });
1982
+
1983
+ it('should invalidate transaction cache on successful transaction deletion', async () => {
1984
+ const mockResponse = {
1985
+ data: {
1986
+ transaction: mockDeletedTransaction,
1987
+ server_knowledge: 12345,
1988
+ },
1989
+ };
1990
+
1991
+ const mockAccountResponse = {
1992
+ data: {
1993
+ account: {
1994
+ id: 'account-456',
1995
+ balance: 50000,
1996
+ cleared_balance: 45000,
1997
+ },
1998
+ },
1999
+ };
2000
+
2001
+ (mockYnabAPI.transactions.deleteTransaction as any).mockResolvedValue(mockResponse);
2002
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
2003
+
2004
+ const mockCacheKey = 'transaction:get:budget-123:transaction-456';
2005
+ (CacheManager.generateKey as any).mockReturnValue(mockCacheKey);
2006
+
2007
+ const result = await handleDeleteTransaction(mockYnabAPI, {
2008
+ budget_id: 'budget-123',
2009
+ transaction_id: 'transaction-456',
2010
+ });
2011
+
2012
+ // Verify cache was invalidated for specific transaction
2013
+ expect(CacheManager.generateKey).toHaveBeenCalledWith(
2014
+ 'transaction',
2015
+ 'get',
2016
+ 'budget-123',
2017
+ 'transaction-456',
2018
+ );
2019
+ expect(cacheManager.delete).toHaveBeenCalledWith(mockCacheKey);
2020
+
2021
+ expect(result.content).toHaveLength(1);
2022
+ const parsedContent = JSON.parse(result.content[0].text);
2023
+ expect(parsedContent.transaction.id).toBe('transaction-456');
2024
+ expect(parsedContent.transaction.deleted).toBe(true);
2025
+ });
2026
+
2027
+ it('should not invalidate cache on dry_run transaction deletion', async () => {
2028
+ const mockResponse = {
2029
+ data: {
2030
+ transaction: mockDeletedTransaction,
2031
+ },
2032
+ };
2033
+
2034
+ const mockAccountResponse = {
2035
+ data: {
2036
+ account: {
2037
+ id: 'account-456',
2038
+ balance: 50000,
2039
+ cleared_balance: 45000,
2040
+ },
2041
+ },
2042
+ };
2043
+
2044
+ (mockYnabAPI.transactions.deleteTransaction as any).mockResolvedValue(mockResponse);
2045
+ (mockYnabAPI.accounts.getAccountById as any).mockResolvedValue(mockAccountResponse);
2046
+
2047
+ const result = await handleDeleteTransaction(mockYnabAPI, {
2048
+ budget_id: 'budget-123',
2049
+ transaction_id: 'transaction-456',
2050
+ dry_run: true,
2051
+ });
2052
+
2053
+ // Verify cache was NOT invalidated for dry run
2054
+ expect(cacheManager.delete).not.toHaveBeenCalled();
2055
+ expect(CacheManager.generateKey).not.toHaveBeenCalled();
2056
+
2057
+ expect(result.content).toHaveLength(1);
2058
+ const parsedContent = JSON.parse(result.content[0].text);
2059
+ expect(parsedContent.dry_run).toBe(true);
2060
+ expect(parsedContent.action).toBe('delete_transaction');
2061
+ expect(parsedContent.request).toEqual({
2062
+ budget_id: 'budget-123',
2063
+ transaction_id: 'transaction-456',
2064
+ dry_run: true,
2065
+ });
2066
+ });
2067
+ });
2068
+
2069
+ describe('handleCreateTransactions', () => {
2070
+ let transactionCounter = 0;
2071
+
2072
+ const buildTransaction = (overrides: Record<string, unknown> = {}) => ({
2073
+ account_id: 'account-001',
2074
+ amount: -1500,
2075
+ date: '2024-01-01',
2076
+ memo: 'Bulk test',
2077
+ cleared: 'cleared',
2078
+ approved: true,
2079
+ ...overrides,
2080
+ });
2081
+
2082
+ const buildParams = (overrides: Record<string, unknown> = {}) => ({
2083
+ budget_id: 'budget-123',
2084
+ transactions: [buildTransaction()],
2085
+ ...overrides,
2086
+ });
2087
+
2088
+ const buildApiTransaction = (overrides: Record<string, unknown> = {}) => ({
2089
+ id: overrides['id'] ?? `transaction-${++transactionCounter}`,
2090
+ account_id: overrides['account_id'] ?? 'account-001',
2091
+ date: overrides['date'] ?? '2024-01-01',
2092
+ amount: overrides['amount'] ?? -1500,
2093
+ memo: overrides['memo'] ?? 'Bulk test',
2094
+ cleared: overrides['cleared'] ?? 'cleared',
2095
+ approved: overrides['approved'] ?? true,
2096
+ flag_color: overrides['flag_color'] ?? null,
2097
+ account_name: overrides['account_name'] ?? 'Checking',
2098
+ payee_id: overrides['payee_id'] ?? null,
2099
+ payee_name: overrides['payee_name'] ?? null,
2100
+ category_id: overrides['category_id'] ?? null,
2101
+ category_name: overrides['category_name'] ?? null,
2102
+ transfer_account_id: overrides['transfer_account_id'] ?? null,
2103
+ transfer_transaction_id: overrides['transfer_transaction_id'] ?? null,
2104
+ matched_transaction_id: overrides['matched_transaction_id'] ?? null,
2105
+ import_id: overrides['import_id'] ?? null,
2106
+ deleted: overrides['deleted'] ?? false,
2107
+ subtransactions: [],
2108
+ });
2109
+
2110
+ const buildApiResponse = (
2111
+ transactions: Record<string, unknown>[],
2112
+ extras: Record<string, unknown> = {},
2113
+ ) => ({
2114
+ data: {
2115
+ transaction_ids: transactions.map((txn) => String(txn['id'])),
2116
+ transactions,
2117
+ duplicate_import_ids: extras['duplicate_import_ids'] ?? [],
2118
+ server_knowledge: extras['server_knowledge'] ?? 1,
2119
+ },
2120
+ });
2121
+
2122
+ const parseResponse = async (resultPromise: ReturnType<typeof handleCreateTransactions>) => {
2123
+ const result = await resultPromise;
2124
+ const text = result.content?.[0]?.text ?? '{}';
2125
+ return JSON.parse(text) as Record<string, any>;
2126
+ };
2127
+
2128
+ beforeEach(() => {
2129
+ transactionCounter = 0;
2130
+ (mockYnabAPI.transactions.createTransactions as any).mockReset();
2131
+ cacheManager.delete.mockReset();
2132
+ cacheManager.deleteMany.mockReset();
2133
+ (CacheManager.generateKey as any).mockReset();
2134
+ });
2135
+
2136
+ describe('dry run', () => {
2137
+ it('returns validation summary without calling the API', async () => {
2138
+ const params = buildParams({
2139
+ dry_run: true,
2140
+ transactions: [
2141
+ buildTransaction({ amount: -2000, account_id: 'account-foo' }),
2142
+ buildTransaction({ amount: -1000, account_id: 'account-bar', date: '2024-02-15' }),
2143
+ ],
2144
+ });
2145
+
2146
+ const response = await parseResponse(handleCreateTransactions(mockYnabAPI, params));
2147
+
2148
+ expect(mockYnabAPI.transactions.createTransactions).not.toHaveBeenCalled();
2149
+ expect(response.dry_run).toBe(true);
2150
+ expect(response.summary.total_transactions).toBe(2);
2151
+ expect(response.summary.accounts_affected).toEqual(['account-foo', 'account-bar']);
2152
+ expect(response.transactions_preview).toHaveLength(2);
2153
+ expect(cacheManager.deleteMany).not.toHaveBeenCalled();
2154
+ });
2155
+
2156
+ it('surfaces validation errors before execution', async () => {
2157
+ const invalidParams = buildParams({
2158
+ transactions: [
2159
+ {
2160
+ amount: -2000,
2161
+ date: '2024-01-01',
2162
+ },
2163
+ ],
2164
+ });
2165
+
2166
+ const result = await handleCreateTransactions(mockYnabAPI, invalidParams as any);
2167
+ const parsed = JSON.parse(result.content?.[0]?.text ?? '{}');
2168
+ expect(parsed.error).toBeDefined();
2169
+ expect(parsed.error.message).toContain('validation failed');
2170
+ expect(mockYnabAPI.transactions.createTransactions).not.toHaveBeenCalled();
2171
+ });
2172
+ });
2173
+
2174
+ describe('successful creation', () => {
2175
+ it('creates a small batch with import_ids and correlates results', async () => {
2176
+ const transactions = [
2177
+ buildTransaction({ import_id: 'YNAB:-1500:2024-01-01:1' }),
2178
+ buildTransaction({
2179
+ account_id: 'account-002',
2180
+ amount: -2500,
2181
+ import_id: 'YNAB:-2500:2024-01-02:1',
2182
+ }),
2183
+ ];
2184
+
2185
+ const apiTransactions = transactions.map((transaction, index) =>
2186
+ buildApiTransaction({
2187
+ ...transaction,
2188
+ id: `ynab-${index + 1}`,
2189
+ }),
2190
+ );
2191
+
2192
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2193
+ buildApiResponse(apiTransactions),
2194
+ );
2195
+
2196
+ (CacheManager.generateKey as any).mockImplementation(
2197
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
2198
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
2199
+ );
2200
+
2201
+ const response = await parseResponse(
2202
+ handleCreateTransactions(
2203
+ mockYnabAPI,
2204
+ buildParams({
2205
+ transactions,
2206
+ }),
2207
+ ),
2208
+ );
2209
+
2210
+ expect(response.summary.created).toBe(2);
2211
+ expect(response.results).toHaveLength(2);
2212
+ expect(response.results.every((result: any) => result.status === 'created')).toBe(true);
2213
+ // Cache invalidation now uses individual delete calls, not deleteMany
2214
+ expect(cacheManager.delete).toHaveBeenCalled();
2215
+ });
2216
+
2217
+ it('correlates transactions without import_ids using hashes', async () => {
2218
+ const batch = [
2219
+ buildTransaction({ memo: 'Hash me' }),
2220
+ buildTransaction({ memo: 'Hash me', date: '2024-01-02' }),
2221
+ ];
2222
+ const apiTransactions = batch.map((txn, index) =>
2223
+ buildApiTransaction({ ...txn, id: `hash-${index + 1}` }),
2224
+ );
2225
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2226
+ buildApiResponse(apiTransactions),
2227
+ );
2228
+
2229
+ const response = await parseResponse(
2230
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2231
+ );
2232
+ expect(response.results.map((result: any) => result.transaction_id)).toEqual([
2233
+ 'hash-1',
2234
+ 'hash-2',
2235
+ ]);
2236
+ });
2237
+
2238
+ it('handles mixed import_id and hash correlation scenarios', async () => {
2239
+ const batch = [
2240
+ buildTransaction({ import_id: 'YNAB:-1500:2024-01-01:mix' }),
2241
+ buildTransaction({ memo: 'no id' }),
2242
+ ];
2243
+ const apiTransactions = [
2244
+ buildApiTransaction({ ...batch[0], id: 'mix-1' }),
2245
+ buildApiTransaction({ ...batch[1], id: 'mix-2' }),
2246
+ ];
2247
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2248
+ buildApiResponse(apiTransactions),
2249
+ );
2250
+
2251
+ const response = await parseResponse(
2252
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2253
+ );
2254
+ expect(response.results.find((r: any) => r.transaction_id === 'mix-1')?.status).toBe(
2255
+ 'created',
2256
+ );
2257
+ expect(response.results.find((r: any) => r.transaction_id === 'mix-2')?.status).toBe(
2258
+ 'created',
2259
+ );
2260
+ });
2261
+ });
2262
+
2263
+ describe('duplicate handling', () => {
2264
+ it('marks all transactions as duplicates when import_ids already exist', async () => {
2265
+ const batch = [
2266
+ buildTransaction({ import_id: 'dup-1' }),
2267
+ buildTransaction({ import_id: 'dup-2' }),
2268
+ ];
2269
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue({
2270
+ data: {
2271
+ transaction_ids: [],
2272
+ transactions: [],
2273
+ duplicate_import_ids: ['dup-1', 'dup-2'],
2274
+ server_knowledge: 5,
2275
+ },
2276
+ });
2277
+
2278
+ const response = await parseResponse(
2279
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2280
+ );
2281
+
2282
+ expect(response.summary.duplicates).toBe(2);
2283
+ expect(response.results.every((result: any) => result.status === 'duplicate')).toBe(true);
2284
+ });
2285
+
2286
+ it('marks partial duplicates while creating the rest', async () => {
2287
+ const batch = [
2288
+ buildTransaction({ import_id: 'dup-1' }),
2289
+ buildTransaction({ import_id: 'new-1', memo: 'fresh' }),
2290
+ ];
2291
+ const apiTransactions = [buildApiTransaction({ ...batch[1], id: 'created-new' })];
2292
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue({
2293
+ data: {
2294
+ transaction_ids: ['created-new'],
2295
+ transactions: apiTransactions,
2296
+ duplicate_import_ids: ['dup-1'],
2297
+ server_knowledge: 10,
2298
+ },
2299
+ });
2300
+
2301
+ const response = await parseResponse(
2302
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2303
+ );
2304
+
2305
+ const duplicateResult = response.results.find(
2306
+ (result: any) => result.correlation_key === 'dup-1',
2307
+ );
2308
+ const createdResult = response.results.find(
2309
+ (result: any) => result.transaction_id === 'created-new',
2310
+ );
2311
+ expect(duplicateResult?.status).toBe('duplicate');
2312
+ expect(createdResult?.status).toBe('created');
2313
+ });
2314
+ });
2315
+
2316
+ describe('response size management', () => {
2317
+ it('keeps full response when under 64KB', async () => {
2318
+ const apiTransactions = [buildApiTransaction()];
2319
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2320
+ buildApiResponse(apiTransactions),
2321
+ );
2322
+ const response = await parseResponse(handleCreateTransactions(mockYnabAPI, buildParams()));
2323
+ expect(response.transactions).toBeDefined();
2324
+ expect(response.mode).toBe('full');
2325
+ });
2326
+
2327
+ it('downgrades to summary mode when response exceeds 64KB', async () => {
2328
+ const byteSpy = vi.spyOn(Buffer, 'byteLength');
2329
+ byteSpy.mockImplementationOnce(() => 70 * 1024).mockImplementationOnce(() => 80 * 1024);
2330
+ const apiTransactions = [buildApiTransaction()];
2331
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2332
+ buildApiResponse(apiTransactions),
2333
+ );
2334
+
2335
+ const response = await parseResponse(handleCreateTransactions(mockYnabAPI, buildParams()));
2336
+
2337
+ expect(response.transactions).toBeUndefined();
2338
+ expect(response.mode).toBe('summary');
2339
+ byteSpy.mockRestore();
2340
+ });
2341
+
2342
+ it('downgrades to ids_only mode when necessary', async () => {
2343
+ const byteSpy = vi.spyOn(Buffer, 'byteLength');
2344
+ byteSpy
2345
+ .mockImplementationOnce(() => 80 * 1024)
2346
+ .mockImplementationOnce(() => 97 * 1024)
2347
+ .mockImplementationOnce(() => 98 * 1024);
2348
+ const apiTransactions = [buildApiTransaction()];
2349
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2350
+ buildApiResponse(apiTransactions),
2351
+ );
2352
+
2353
+ const response = await parseResponse(handleCreateTransactions(mockYnabAPI, buildParams()));
2354
+
2355
+ expect(response.mode).toBe('ids_only');
2356
+ expect(response.results[0].transaction_id).toBeDefined();
2357
+ byteSpy.mockRestore();
2358
+ });
2359
+
2360
+ it('errors when response cannot fit under 100KB', async () => {
2361
+ const byteSpy = vi.spyOn(Buffer, 'byteLength');
2362
+ byteSpy
2363
+ .mockImplementationOnce(() => 90 * 1024)
2364
+ .mockImplementationOnce(() => 99 * 1024)
2365
+ .mockImplementationOnce(() => 101 * 1024);
2366
+ const apiTransactions = [buildApiTransaction()];
2367
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2368
+ buildApiResponse(apiTransactions),
2369
+ );
2370
+
2371
+ const result = await handleCreateTransactions(mockYnabAPI, buildParams());
2372
+ const parsed = JSON.parse(result.content?.[0]?.text ?? '{}');
2373
+ expect(parsed.error).toBeDefined();
2374
+ expect(parsed.error.message).toContain('RESPONSE_TOO_LARGE');
2375
+ byteSpy.mockRestore();
2376
+ });
2377
+ });
2378
+
2379
+ describe('correlation edge cases', () => {
2380
+ it('supports multi-bucket matching for identical transactions', async () => {
2381
+ const batch = [
2382
+ buildTransaction({ memo: 'repeat' }),
2383
+ buildTransaction({ memo: 'repeat' }),
2384
+ buildTransaction({ memo: 'repeat' }),
2385
+ ];
2386
+ const apiTransactions = batch.map((txn, index) =>
2387
+ buildApiTransaction({ ...txn, id: `repeat-${index + 1}` }),
2388
+ );
2389
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2390
+ buildApiResponse(apiTransactions),
2391
+ );
2392
+
2393
+ const response = await parseResponse(
2394
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2395
+ );
2396
+
2397
+ expect(response.results.map((r: any) => r.request_index)).toEqual([0, 1, 2]);
2398
+ });
2399
+
2400
+ it('records failures and logs correlation errors when correlation fails', async () => {
2401
+ const logErrorSpy = vi.spyOn(globalRequestLogger, 'logError').mockImplementation(() => {
2402
+ // Mock implementation
2403
+ });
2404
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2405
+ buildApiResponse([]),
2406
+ );
2407
+ const response = await parseResponse(handleCreateTransactions(mockYnabAPI, buildParams()));
2408
+ expect(response.results[0].status).toBe('failed');
2409
+ expect(response.results[0].error_code).toBe('correlation_failed');
2410
+ expect(logErrorSpy).toHaveBeenCalledWith(
2411
+ 'ynab:create_transactions',
2412
+ 'correlate_results',
2413
+ expect.objectContaining({
2414
+ request_index: 0,
2415
+ correlation_key: expect.any(String),
2416
+ }),
2417
+ 'correlation_failed',
2418
+ );
2419
+ logErrorSpy.mockRestore();
2420
+ });
2421
+ });
2422
+
2423
+ describe('cache invalidation', () => {
2424
+ it('invalidates transaction, account, and month caches for affected resources', async () => {
2425
+ const batch = [
2426
+ buildTransaction({ account_id: 'account-A', date: '2024-03-15' }),
2427
+ buildTransaction({ account_id: 'account-B', date: '2024-04-01' }),
2428
+ ];
2429
+ const apiTransactions = batch.map((txn, index) =>
2430
+ buildApiTransaction({ ...txn, id: `cache-${index + 1}` }),
2431
+ );
2432
+ (CacheManager.generateKey as any).mockImplementation(
2433
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
2434
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
2435
+ );
2436
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2437
+ buildApiResponse(apiTransactions),
2438
+ );
2439
+
2440
+ await handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch }));
2441
+
2442
+ // Cache invalidation now uses individual delete calls
2443
+ const deleteCalls = cacheManager.delete.mock.calls.map((call) => call[0]);
2444
+ expect(deleteCalls).toEqual(
2445
+ expect.arrayContaining([
2446
+ 'transactions:list:budget-123:all',
2447
+ 'account:get:budget-123:account-A',
2448
+ 'account:get:budget-123:account-B',
2449
+ 'month:get:budget-123:2024-03-01',
2450
+ 'month:get:budget-123:2024-04-01',
2451
+ ]),
2452
+ );
2453
+ });
2454
+
2455
+ it('deduplicates cache keys for repeated accounts and months', async () => {
2456
+ const batch = [
2457
+ buildTransaction({ account_id: 'repeat-account', date: '2024-05-10' }),
2458
+ buildTransaction({ account_id: 'repeat-account', date: '2024-05-20' }),
2459
+ ];
2460
+ const apiTransactions = batch.map((txn, index) =>
2461
+ buildApiTransaction({ ...txn, id: `repeat-cache-${index + 1}` }),
2462
+ );
2463
+ (CacheManager.generateKey as any).mockImplementation(
2464
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
2465
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
2466
+ );
2467
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2468
+ buildApiResponse(apiTransactions),
2469
+ );
2470
+
2471
+ await handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch }));
2472
+
2473
+ // Cache invalidation uses individual delete calls - check for account and month keys
2474
+ const deleteCalls = cacheManager.delete.mock.calls.map((call) => call[0]);
2475
+ const uniqueKeys = new Set(deleteCalls);
2476
+ expect(uniqueKeys.has('account:get:budget-123:repeat-account')).toBe(true);
2477
+ expect(uniqueKeys.has('month:get:budget-123:2024-05-01')).toBe(true);
2478
+ // The implementation naturally deduplicates via Set, so we should only see one delete call per key
2479
+ expect(
2480
+ deleteCalls.filter((key) => key === 'account:get:budget-123:repeat-account').length,
2481
+ ).toBeGreaterThanOrEqual(1);
2482
+ });
2483
+
2484
+ it('does not invalidate caches during dry runs', async () => {
2485
+ await handleCreateTransactions(
2486
+ mockYnabAPI,
2487
+ buildParams({
2488
+ dry_run: true,
2489
+ }),
2490
+ );
2491
+ expect(cacheManager.delete).not.toHaveBeenCalled();
2492
+ });
2493
+ });
2494
+
2495
+ describe('error handling and edge cases', () => {
2496
+ it('propagates API failures', async () => {
2497
+ (mockYnabAPI.transactions.createTransactions as any).mockRejectedValue(
2498
+ new Error('500 Internal Server Error'),
2499
+ );
2500
+ const result = await handleCreateTransactions(mockYnabAPI, buildParams());
2501
+ const parsed = JSON.parse(result.content?.[0]?.text ?? '{}');
2502
+ expect(parsed.error).toBeDefined();
2503
+ });
2504
+
2505
+ it('handles general validation errors outside of dry run', async () => {
2506
+ const invalidParams = {
2507
+ budget_id: 'budget-123',
2508
+ transactions: [],
2509
+ };
2510
+ const result = await handleCreateTransactions(mockYnabAPI, invalidParams as any);
2511
+ const parsed = JSON.parse(result.content?.[0]?.text ?? '{}');
2512
+ expect(parsed.error).toBeDefined();
2513
+ });
2514
+
2515
+ it('supports transactions without payees or categories and special memo characters', async () => {
2516
+ const batch = [
2517
+ buildTransaction({
2518
+ memo: 'Special | memo',
2519
+ payee_name: undefined,
2520
+ category_id: undefined,
2521
+ }),
2522
+ ];
2523
+ const apiTransactions = batch.map((txn) => buildApiTransaction(txn));
2524
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2525
+ buildApiResponse(apiTransactions),
2526
+ );
2527
+ const response = await parseResponse(
2528
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2529
+ );
2530
+ expect(response.results[0].status).toBe('created');
2531
+ });
2532
+
2533
+ it('allows transfer transactions by passing transfer payee ids', async () => {
2534
+ const batch = [
2535
+ buildTransaction({
2536
+ payee_id: 'transfer_payee_account_ABC',
2537
+ memo: 'Transfer out',
2538
+ }),
2539
+ ];
2540
+ const apiTransactions = batch.map((txn) => buildApiTransaction(txn));
2541
+ (mockYnabAPI.transactions.createTransactions as any).mockResolvedValue(
2542
+ buildApiResponse(apiTransactions),
2543
+ );
2544
+ const response = await parseResponse(
2545
+ handleCreateTransactions(mockYnabAPI, buildParams({ transactions: batch })),
2546
+ );
2547
+ expect(response.results[0].status).toBe('created');
2548
+ });
2549
+ });
2550
+ });
2551
+
2552
+ describe('UpdateTransactionsSchema', () => {
2553
+ const buildUpdateTransaction = (overrides: Record<string, unknown> = {}) => ({
2554
+ id: 'transaction-123',
2555
+ ...overrides,
2556
+ });
2557
+
2558
+ it('should accept a valid batch of updates with all fields optional except id', () => {
2559
+ const params = {
2560
+ budget_id: 'budget-123',
2561
+ transactions: [
2562
+ buildUpdateTransaction({ amount: -10000, memo: 'Updated' }),
2563
+ buildUpdateTransaction({ id: 'transaction-456', cleared: 'cleared' }),
2564
+ ],
2565
+ };
2566
+ const result = UpdateTransactionsSchema.safeParse(params);
2567
+ expect(result.success).toBe(true);
2568
+ if (result.success) {
2569
+ expect(result.data.transactions).toHaveLength(2);
2570
+ }
2571
+ });
2572
+
2573
+ it('should require id field for each transaction', () => {
2574
+ const params = {
2575
+ budget_id: 'budget-123',
2576
+ transactions: [{ amount: -5000 }],
2577
+ };
2578
+ const result = UpdateTransactionsSchema.safeParse(params);
2579
+ expect(result.success).toBe(false);
2580
+ if (!result.success) {
2581
+ expect(result.error.issues[0].path).toContain('id');
2582
+ }
2583
+ });
2584
+
2585
+ it('should accept minimum batch size of one transaction', () => {
2586
+ const params = {
2587
+ budget_id: 'budget-123',
2588
+ transactions: [buildUpdateTransaction()],
2589
+ };
2590
+ const result = UpdateTransactionsSchema.safeParse(params);
2591
+ expect(result.success).toBe(true);
2592
+ });
2593
+
2594
+ it('should accept maximum batch size of 100 transactions', () => {
2595
+ const hundred = Array.from({ length: 100 }, (_, index) =>
2596
+ buildUpdateTransaction({ id: `transaction-${index + 1}` }),
2597
+ );
2598
+ const params = { budget_id: 'budget-123', transactions: hundred };
2599
+ const result = UpdateTransactionsSchema.safeParse(params);
2600
+ expect(result.success).toBe(true);
2601
+ });
2602
+
2603
+ it('should reject empty transactions array', () => {
2604
+ const params = { budget_id: 'budget-123', transactions: [] };
2605
+ const result = UpdateTransactionsSchema.safeParse(params);
2606
+ expect(result.success).toBe(false);
2607
+ if (!result.success) {
2608
+ expect(result.error.issues[0].message).toContain('At least one transaction is required');
2609
+ }
2610
+ });
2611
+
2612
+ it('should reject batches exceeding 100 transactions', () => {
2613
+ const overLimit = Array.from({ length: 101 }, (_, i) =>
2614
+ buildUpdateTransaction({ id: `transaction-${i}` }),
2615
+ );
2616
+ const params = { budget_id: 'budget-123', transactions: overLimit };
2617
+ const result = UpdateTransactionsSchema.safeParse(params);
2618
+ expect(result.success).toBe(false);
2619
+ if (!result.success) {
2620
+ expect(result.error.issues[0].message).toContain('A maximum of 100 transactions');
2621
+ }
2622
+ });
2623
+
2624
+ it('should validate ISO date format for optional date field', () => {
2625
+ const params = {
2626
+ budget_id: 'budget-123',
2627
+ transactions: [buildUpdateTransaction({ date: '01/01/2024' })],
2628
+ };
2629
+ const result = UpdateTransactionsSchema.safeParse(params);
2630
+ expect(result.success).toBe(false);
2631
+ if (!result.success) {
2632
+ expect(result.error.issues[0].path).toEqual(['transactions', 0, 'date']);
2633
+ }
2634
+ });
2635
+
2636
+ it('should validate ISO date format for optional original_date field', () => {
2637
+ const params = {
2638
+ budget_id: 'budget-123',
2639
+ transactions: [buildUpdateTransaction({ original_date: '01/01/2024' })],
2640
+ };
2641
+ const result = UpdateTransactionsSchema.safeParse(params);
2642
+ expect(result.success).toBe(false);
2643
+ if (!result.success) {
2644
+ expect(result.error.issues[0].path).toEqual(['transactions', 0, 'original_date']);
2645
+ }
2646
+ });
2647
+
2648
+ it('should require integer milliunit amounts when provided', () => {
2649
+ const params = {
2650
+ budget_id: 'budget-123',
2651
+ transactions: [buildUpdateTransaction({ amount: -50.25 })],
2652
+ };
2653
+ const result = UpdateTransactionsSchema.safeParse(params);
2654
+ expect(result.success).toBe(false);
2655
+ if (!result.success) {
2656
+ expect(result.error.issues[0].path).toEqual(['transactions', 0, 'amount']);
2657
+ }
2658
+ });
2659
+
2660
+ it('should reject invalid cleared enum values', () => {
2661
+ const params = {
2662
+ budget_id: 'budget-123',
2663
+ transactions: [buildUpdateTransaction({ cleared: 'pending' })],
2664
+ };
2665
+ const result = UpdateTransactionsSchema.safeParse(params);
2666
+ expect(result.success).toBe(false);
2667
+ });
2668
+
2669
+ it('should accept optional metadata fields for cache invalidation', () => {
2670
+ const params = {
2671
+ budget_id: 'budget-123',
2672
+ transactions: [
2673
+ buildUpdateTransaction({
2674
+ original_account_id: 'account-old',
2675
+ original_date: '2024-01-01',
2676
+ }),
2677
+ ],
2678
+ };
2679
+ const result = UpdateTransactionsSchema.safeParse(params);
2680
+ expect(result.success).toBe(true);
2681
+ if (result.success) {
2682
+ expect(result.data.transactions[0].original_account_id).toBe('account-old');
2683
+ expect(result.data.transactions[0].original_date).toBe('2024-01-01');
2684
+ }
2685
+ });
2686
+
2687
+ it('should accept update with only id field (no changes)', () => {
2688
+ const params = {
2689
+ budget_id: 'budget-123',
2690
+ transactions: [{ id: 'transaction-123' }],
2691
+ };
2692
+ const result = UpdateTransactionsSchema.safeParse(params);
2693
+ expect(result.success).toBe(true);
2694
+ });
2695
+
2696
+ it('should reject account_id field (account moves not supported)', () => {
2697
+ const params = {
2698
+ budget_id: 'budget-123',
2699
+ transactions: [buildUpdateTransaction({ account_id: 'new-account-456' })],
2700
+ };
2701
+ const result = UpdateTransactionsSchema.safeParse(params);
2702
+ expect(result.success).toBe(false);
2703
+ if (!result.success) {
2704
+ // Expect "unrecognized_keys" error from strict() schema
2705
+ expect(result.error.issues[0].code).toBe('unrecognized_keys');
2706
+ expect(result.error.issues[0].path).toEqual(['transactions', 0]);
2707
+ }
2708
+ });
2709
+ });
2710
+
2711
+ describe('handleUpdateTransactions', () => {
2712
+ let transactionCounter = 0;
2713
+
2714
+ const buildUpdateTransaction = (overrides: Record<string, unknown> = {}) => ({
2715
+ id: 'transaction-001',
2716
+ original_account_id: 'account-001',
2717
+ original_date: '2024-01-01',
2718
+ ...overrides,
2719
+ });
2720
+
2721
+ const buildUpdateTransactionWithoutMetadata = (overrides: Record<string, unknown> = {}) =>
2722
+ buildUpdateTransaction({
2723
+ original_account_id: undefined,
2724
+ original_date: undefined,
2725
+ ...overrides,
2726
+ });
2727
+
2728
+ const buildParams = (overrides: Record<string, unknown> = {}) => ({
2729
+ budget_id: 'budget-123',
2730
+ transactions: [buildUpdateTransaction()],
2731
+ ...overrides,
2732
+ });
2733
+
2734
+ const buildApiTransaction = (overrides: Record<string, unknown> = {}) => ({
2735
+ id: overrides['id'] ?? `transaction-${++transactionCounter}`,
2736
+ account_id: overrides['account_id'] ?? 'account-001',
2737
+ date: overrides['date'] ?? '2024-01-01',
2738
+ amount: overrides['amount'] ?? -1500,
2739
+ memo: overrides['memo'] ?? 'Updated',
2740
+ cleared: overrides['cleared'] ?? 'cleared',
2741
+ approved: overrides['approved'] ?? true,
2742
+ flag_color: overrides['flag_color'] ?? null,
2743
+ account_name: overrides['account_name'] ?? 'Checking',
2744
+ payee_id: overrides['payee_id'] ?? null,
2745
+ payee_name: overrides['payee_name'] ?? null,
2746
+ category_id: overrides['category_id'] ?? null,
2747
+ category_name: overrides['category_name'] ?? null,
2748
+ transfer_account_id: overrides['transfer_account_id'] ?? null,
2749
+ transfer_transaction_id: overrides['transfer_transaction_id'] ?? null,
2750
+ matched_transaction_id: overrides['matched_transaction_id'] ?? null,
2751
+ import_id: overrides['import_id'] ?? null,
2752
+ deleted: overrides['deleted'] ?? false,
2753
+ subtransactions: [],
2754
+ });
2755
+
2756
+ const buildApiResponse = (
2757
+ transactions: Record<string, unknown>[],
2758
+ extras: Record<string, unknown> = {},
2759
+ ) => ({
2760
+ data: {
2761
+ transactions,
2762
+ server_knowledge: extras['server_knowledge'] ?? 1,
2763
+ },
2764
+ });
2765
+
2766
+ const parseResponse = async (resultPromise: ReturnType<typeof handleUpdateTransactions>) => {
2767
+ const result = await resultPromise;
2768
+ const text = result.content?.[0]?.text ?? '{}';
2769
+ return JSON.parse(text) as Record<string, any>;
2770
+ };
2771
+
2772
+ beforeEach(() => {
2773
+ transactionCounter = 0;
2774
+ (mockYnabAPI.transactions.updateTransactions as any).mockReset();
2775
+ (mockYnabAPI.transactions.getTransactionById as any).mockReset();
2776
+ cacheManager.delete.mockReset();
2777
+ cacheManager.deleteMany.mockReset();
2778
+ (cacheManager.get as any).mockReset();
2779
+ (CacheManager.generateKey as any).mockReset();
2780
+ });
2781
+
2782
+ describe('dry run', () => {
2783
+ it('returns validation summary without calling the API', async () => {
2784
+ const params = buildParams({
2785
+ dry_run: true,
2786
+ transactions: [
2787
+ buildUpdateTransaction({ id: 'transaction-001', amount: -2000 }),
2788
+ buildUpdateTransaction({ id: 'transaction-002', memo: 'Updated memo' }),
2789
+ ],
2790
+ });
2791
+
2792
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, params));
2793
+
2794
+ expect(mockYnabAPI.transactions.updateTransactions).not.toHaveBeenCalled();
2795
+ expect(response.dry_run).toBe(true);
2796
+ expect(response.summary.total_transactions).toBe(2);
2797
+ expect(response.transactions_preview).toHaveLength(2);
2798
+ expect(cacheManager.deleteMany).not.toHaveBeenCalled();
2799
+ });
2800
+
2801
+ it('provides before/after preview showing only changed fields', async () => {
2802
+ const currentTransaction = buildApiTransaction({
2803
+ id: 'transaction-001',
2804
+ amount: -5000,
2805
+ memo: 'Old memo',
2806
+ cleared: 'uncleared',
2807
+ });
2808
+
2809
+ (cacheManager.get as any).mockReturnValue(null);
2810
+ (mockYnabAPI.transactions.getTransactionById as any).mockResolvedValue({
2811
+ data: { transaction: currentTransaction },
2812
+ });
2813
+
2814
+ const params = buildParams({
2815
+ dry_run: true,
2816
+ transactions: [
2817
+ buildUpdateTransaction({
2818
+ id: 'transaction-001',
2819
+ amount: -10000,
2820
+ memo: 'New memo',
2821
+ }),
2822
+ ],
2823
+ });
2824
+
2825
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, params));
2826
+
2827
+ expect(response.dry_run).toBe(true);
2828
+ expect(response.transactions_preview).toHaveLength(1);
2829
+
2830
+ const preview = response.transactions_preview[0];
2831
+ expect(preview.transaction_id).toBe('transaction-001');
2832
+ expect(preview.before).toEqual({
2833
+ amount: -5,
2834
+ memo: 'Old memo',
2835
+ });
2836
+ expect(preview.after).toEqual({
2837
+ amount: -10,
2838
+ memo: 'New memo',
2839
+ });
2840
+ });
2841
+
2842
+ it('sets before to "unavailable" when current state cannot be fetched', async () => {
2843
+ (cacheManager.get as any).mockReturnValue(null);
2844
+ (mockYnabAPI.transactions.getTransactionById as any).mockRejectedValue(
2845
+ new Error('Not found'),
2846
+ );
2847
+
2848
+ const params = buildParams({
2849
+ dry_run: true,
2850
+ transactions: [
2851
+ buildUpdateTransaction({
2852
+ id: 'transaction-001',
2853
+ amount: -10000,
2854
+ }),
2855
+ ],
2856
+ });
2857
+
2858
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, params));
2859
+
2860
+ expect(response.dry_run).toBe(true);
2861
+ const preview = response.transactions_preview[0];
2862
+ expect(preview.before).toBe('unavailable');
2863
+ expect(preview.after).toBeDefined();
2864
+ });
2865
+
2866
+ it('includes summary with accounts_affected and fields_to_update', async () => {
2867
+ (cacheManager.get as any).mockReturnValue(null);
2868
+ (mockYnabAPI.transactions.getTransactionById as any).mockResolvedValue({
2869
+ data: {
2870
+ transaction: buildApiTransaction({
2871
+ id: 'transaction-001',
2872
+ account_id: 'account-001',
2873
+ date: '2024-01-01',
2874
+ }),
2875
+ },
2876
+ });
2877
+
2878
+ const params = buildParams({
2879
+ dry_run: true,
2880
+ transactions: [
2881
+ buildUpdateTransaction({
2882
+ id: 'transaction-001',
2883
+ amount: -10000,
2884
+ memo: 'Updated',
2885
+ original_account_id: 'account-001',
2886
+ original_date: '2024-01-01',
2887
+ }),
2888
+ ],
2889
+ });
2890
+
2891
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, params));
2892
+
2893
+ expect(response.summary.accounts_affected).toContain('account-001');
2894
+ expect(response.summary.fields_to_update).toContain('amount');
2895
+ expect(response.summary.fields_to_update).toContain('memo');
2896
+ });
2897
+
2898
+ it('surfaces validation errors before execution', async () => {
2899
+ const invalidParams = buildParams({
2900
+ transactions: [{ amount: -2000 }],
2901
+ });
2902
+
2903
+ const result = await handleUpdateTransactions(mockYnabAPI, invalidParams as any);
2904
+ const parsed = JSON.parse(result.content?.[0]?.text ?? '{}');
2905
+ expect(parsed.error).toBeDefined();
2906
+ expect(parsed.error.message).toContain('validation failed');
2907
+ expect(mockYnabAPI.transactions.updateTransactions).not.toHaveBeenCalled();
2908
+ });
2909
+
2910
+ it('reuses metadata resolution output for preview without duplicate fetches', async () => {
2911
+ const currentTransaction = buildApiTransaction({
2912
+ id: 'transaction-001',
2913
+ amount: -5000,
2914
+ memo: 'Old memo',
2915
+ account_id: 'account-001',
2916
+ date: '2024-01-01',
2917
+ });
2918
+
2919
+ // Mock cache to return the transaction for metadata resolution
2920
+ (cacheManager.get as any).mockImplementation((key: string) => {
2921
+ if (key.includes('transaction-001')) {
2922
+ return currentTransaction;
2923
+ }
2924
+ return null;
2925
+ });
2926
+ (CacheManager.generateKey as any).mockImplementation(
2927
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
2928
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
2929
+ );
2930
+
2931
+ const params = buildParams({
2932
+ dry_run: true,
2933
+ transactions: [
2934
+ buildUpdateTransaction({
2935
+ id: 'transaction-001',
2936
+ amount: -10000,
2937
+ memo: 'New memo',
2938
+ }),
2939
+ ],
2940
+ });
2941
+
2942
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, params));
2943
+
2944
+ // Verify the transaction was fetched only once during metadata resolution
2945
+ // After refactoring, getTransactionById should NOT be called because
2946
+ // resolveMetadata already provided the full TransactionDetail
2947
+ expect(mockYnabAPI.transactions.getTransactionById).not.toHaveBeenCalled();
2948
+
2949
+ // Verify preview still works correctly
2950
+ expect(response.dry_run).toBe(true);
2951
+ expect(response.transactions_preview).toHaveLength(1);
2952
+ const preview = response.transactions_preview[0];
2953
+ expect(preview.transaction_id).toBe('transaction-001');
2954
+ expect(preview.before).toEqual({
2955
+ amount: -5,
2956
+ memo: 'Old memo',
2957
+ });
2958
+ expect(preview.after).toEqual({
2959
+ amount: -10,
2960
+ memo: 'New memo',
2961
+ });
2962
+ });
2963
+ });
2964
+
2965
+ describe('successful updates', () => {
2966
+ it('updates transactions and correlates results', async () => {
2967
+ const transactions = [
2968
+ buildUpdateTransaction({ id: 'transaction-001', amount: -10000 }),
2969
+ buildUpdateTransaction({ id: 'transaction-002', memo: 'New memo' }),
2970
+ ];
2971
+
2972
+ const apiTransactions = transactions.map((transaction) =>
2973
+ buildApiTransaction({ id: transaction.id }),
2974
+ );
2975
+
2976
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
2977
+ buildApiResponse(apiTransactions),
2978
+ );
2979
+
2980
+ (CacheManager.generateKey as any).mockImplementation(
2981
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
2982
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
2983
+ );
2984
+
2985
+ const response = await parseResponse(
2986
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions })),
2987
+ );
2988
+
2989
+ expect(response.summary.updated).toBe(2);
2990
+ expect(response.results).toHaveLength(2);
2991
+ expect(response.results.every((result: any) => result.status === 'updated')).toBe(true);
2992
+ // Cache invalidation now uses individual delete calls
2993
+ expect(cacheManager.delete).toHaveBeenCalled();
2994
+ });
2995
+
2996
+ it('only updates provided fields (partial updates)', async () => {
2997
+ const transaction = buildUpdateTransaction({
2998
+ id: 'transaction-001',
2999
+ memo: 'Updated memo only',
3000
+ });
3001
+
3002
+ const apiTransaction = buildApiTransaction({ id: 'transaction-001' });
3003
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3004
+ buildApiResponse([apiTransaction]),
3005
+ );
3006
+
3007
+ await handleUpdateTransactions(mockYnabAPI, buildParams({ transactions: [transaction] }));
3008
+
3009
+ const updateCall = (mockYnabAPI.transactions.updateTransactions as any).mock.calls[0];
3010
+ const updatePayload = updateCall[1].transactions[0];
3011
+ expect(updatePayload.transaction.memo).toBe('Updated memo only');
3012
+ expect(updatePayload.transaction.amount).toBeUndefined();
3013
+ expect(updatePayload.transaction.account_id).toBeUndefined();
3014
+ });
3015
+ });
3016
+
3017
+ describe('metadata resolution for cache invalidation', () => {
3018
+ it('uses client-supplied original_* metadata when provided', async () => {
3019
+ const transaction = buildUpdateTransaction({
3020
+ id: 'transaction-001',
3021
+ amount: -5000,
3022
+ original_account_id: 'account-old',
3023
+ original_date: '2024-01-01',
3024
+ });
3025
+
3026
+ const apiTransaction = buildApiTransaction({ id: 'transaction-001' });
3027
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3028
+ buildApiResponse([apiTransaction]),
3029
+ );
3030
+
3031
+ (CacheManager.generateKey as any).mockImplementation(
3032
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
3033
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
3034
+ );
3035
+
3036
+ await handleUpdateTransactions(mockYnabAPI, buildParams({ transactions: [transaction] }));
3037
+
3038
+ // Should not need to fetch transaction since metadata was provided
3039
+ expect(mockYnabAPI.transactions.getTransactionById).not.toHaveBeenCalled();
3040
+
3041
+ // Should invalidate cache using provided metadata (uses individual delete calls)
3042
+ const deleteCalls = cacheManager.delete.mock.calls.map((call) => call[0]);
3043
+ expect(deleteCalls).toEqual(
3044
+ expect.arrayContaining([
3045
+ 'account:get:budget-123:account-old',
3046
+ 'month:get:budget-123:2024-01-01',
3047
+ ]),
3048
+ );
3049
+ });
3050
+
3051
+ it('falls back to cache when metadata not provided', async () => {
3052
+ const transaction = buildUpdateTransactionWithoutMetadata({
3053
+ id: 'transaction-001',
3054
+ amount: -5000,
3055
+ });
3056
+
3057
+ const cachedTransaction = {
3058
+ id: 'transaction-001',
3059
+ account_id: 'account-cached',
3060
+ date: '2024-02-01',
3061
+ };
3062
+
3063
+ (cacheManager.get as any).mockReturnValue(cachedTransaction);
3064
+
3065
+ const apiTransaction = buildApiTransaction({ id: 'transaction-001' });
3066
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3067
+ buildApiResponse([apiTransaction]),
3068
+ );
3069
+
3070
+ (CacheManager.generateKey as any).mockImplementation(
3071
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
3072
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
3073
+ );
3074
+
3075
+ await handleUpdateTransactions(mockYnabAPI, buildParams({ transactions: [transaction] }));
3076
+
3077
+ // Should use cache and not fetch from API
3078
+ expect(cacheManager.get).toHaveBeenCalled();
3079
+ expect(mockYnabAPI.transactions.getTransactionById).not.toHaveBeenCalled();
3080
+
3081
+ // Should invalidate cache using cached metadata (uses individual delete calls)
3082
+ const deleteCalls = cacheManager.delete.mock.calls.map((call) => call[0]);
3083
+ expect(deleteCalls).toEqual(
3084
+ expect.arrayContaining([
3085
+ 'account:get:budget-123:account-cached',
3086
+ 'month:get:budget-123:2024-02-01',
3087
+ ]),
3088
+ );
3089
+ });
3090
+
3091
+ it('falls back to API when metadata not in cache', async () => {
3092
+ const transaction = buildUpdateTransactionWithoutMetadata({
3093
+ id: 'transaction-001',
3094
+ amount: -5000,
3095
+ });
3096
+
3097
+ (cacheManager.get as any).mockReturnValue(null);
3098
+
3099
+ const fetchedTransaction = {
3100
+ data: {
3101
+ transaction: {
3102
+ id: 'transaction-001',
3103
+ account_id: 'account-fetched',
3104
+ date: '2024-03-01',
3105
+ },
3106
+ },
3107
+ };
3108
+
3109
+ (mockYnabAPI.transactions.getTransactionById as any).mockResolvedValue(fetchedTransaction);
3110
+
3111
+ const apiTransaction = buildApiTransaction({ id: 'transaction-001' });
3112
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3113
+ buildApiResponse([apiTransaction]),
3114
+ );
3115
+
3116
+ (CacheManager.generateKey as any).mockImplementation(
3117
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
3118
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
3119
+ );
3120
+
3121
+ await handleUpdateTransactions(mockYnabAPI, buildParams({ transactions: [transaction] }));
3122
+
3123
+ // Should fetch from API when not in cache
3124
+ expect(cacheManager.get).toHaveBeenCalled();
3125
+ expect(mockYnabAPI.transactions.getTransactionById).toHaveBeenCalledWith(
3126
+ 'budget-123',
3127
+ 'transaction-001',
3128
+ );
3129
+
3130
+ // Should invalidate cache using fetched metadata (uses individual delete calls)
3131
+ const deleteCalls = cacheManager.delete.mock.calls.map((call) => call[0]);
3132
+ expect(deleteCalls).toEqual(
3133
+ expect.arrayContaining([
3134
+ 'account:get:budget-123:account-fetched',
3135
+ 'month:get:budget-123:2024-03-01',
3136
+ ]),
3137
+ );
3138
+ });
3139
+ });
3140
+
3141
+ describe('error handling', () => {
3142
+ it('handles network failures', async () => {
3143
+ const error = new Error('Network error');
3144
+ (mockYnabAPI.transactions.updateTransactions as any).mockRejectedValue(error);
3145
+
3146
+ const result = await handleUpdateTransactions(
3147
+ mockYnabAPI,
3148
+ buildParams({
3149
+ transactions: [buildUpdateTransaction()],
3150
+ }),
3151
+ );
3152
+
3153
+ const response = JSON.parse(result.content[0].text);
3154
+ expect(response.error).toBeDefined();
3155
+ });
3156
+
3157
+ it('handles invalid transaction IDs gracefully', async () => {
3158
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue({
3159
+ data: {
3160
+ transactions: [],
3161
+ server_knowledge: 1,
3162
+ },
3163
+ });
3164
+
3165
+ (cacheManager.get as any).mockReturnValue({
3166
+ account_id: 'account-valid',
3167
+ date: '2024-01-01',
3168
+ });
3169
+
3170
+ const response = await parseResponse(
3171
+ handleUpdateTransactions(
3172
+ mockYnabAPI,
3173
+ buildParams({
3174
+ transactions: [buildUpdateTransaction({ id: 'invalid-id' })],
3175
+ }),
3176
+ ),
3177
+ );
3178
+
3179
+ expect(response.results[0].status).toBe('failed');
3180
+ expect(response.results[0].error_code).toBe('update_failed');
3181
+ });
3182
+
3183
+ it('handles metadata resolution failures without crashing', async () => {
3184
+ const transaction = buildUpdateTransaction({
3185
+ id: 'transaction-001',
3186
+ amount: -5000,
3187
+ });
3188
+
3189
+ (cacheManager.get as any).mockReturnValue(null);
3190
+ (mockYnabAPI.transactions.getTransactionById as any).mockRejectedValue(
3191
+ new Error('Transaction not found'),
3192
+ );
3193
+
3194
+ const apiTransaction = buildApiTransaction({ id: 'transaction-001' });
3195
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3196
+ buildApiResponse([apiTransaction]),
3197
+ );
3198
+
3199
+ (CacheManager.generateKey as any).mockImplementation(
3200
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
3201
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
3202
+ );
3203
+
3204
+ // Should complete successfully even if metadata resolution fails
3205
+ const response = await parseResponse(
3206
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions: [transaction] })),
3207
+ );
3208
+
3209
+ expect(response.summary.updated).toBe(1);
3210
+ });
3211
+ });
3212
+
3213
+ describe('metadata completeness threshold', () => {
3214
+ it('throws ValidationError when >5% of transactions have missing metadata (live mode)', async () => {
3215
+ // Create 20 transactions, 2 (10%) with missing metadata - exceeds 5% threshold
3216
+ const transactions = Array.from({ length: 20 }, (_, i) =>
3217
+ i < 2
3218
+ ? buildUpdateTransactionWithoutMetadata({
3219
+ id: `transaction-${i + 1}`,
3220
+ amount: -1000,
3221
+ })
3222
+ : buildUpdateTransaction({
3223
+ id: `transaction-${i + 1}`,
3224
+ amount: -1000,
3225
+ }),
3226
+ );
3227
+
3228
+ // Mock cache miss for all transactions
3229
+ (cacheManager.get as any).mockReturnValue(null);
3230
+
3231
+ // Mock API to fail for 2 transactions (10% > 5% threshold)
3232
+ (mockYnabAPI.transactions.getTransactionById as any).mockImplementation(
3233
+ async (budgetId: string, transactionId: string) => {
3234
+ if (transactionId === 'transaction-1' || transactionId === 'transaction-2') {
3235
+ throw new Error('Transaction not found');
3236
+ }
3237
+ return {
3238
+ data: {
3239
+ transaction: {
3240
+ id: transactionId,
3241
+ account_id: 'account-001',
3242
+ date: '2024-01-01',
3243
+ },
3244
+ },
3245
+ };
3246
+ },
3247
+ );
3248
+
3249
+ const result = await handleUpdateTransactions(mockYnabAPI, buildParams({ transactions }));
3250
+ const response = JSON.parse(result.content[0].text);
3251
+
3252
+ expect(response.error).toBeDefined();
3253
+ expect(response.error.code).toBe('VALIDATION_ERROR');
3254
+ expect(response.error.message).toContain('METADATA_INCOMPLETE');
3255
+ expect(response.error.details).toContain('10.0%');
3256
+ });
3257
+
3258
+ it('succeeds when <=5% of transactions have missing metadata (live mode)', async () => {
3259
+ // Create 20 transactions, 1 (5%) with missing metadata - at threshold
3260
+ const transactions = Array.from({ length: 20 }, (_, i) =>
3261
+ i === 0
3262
+ ? buildUpdateTransactionWithoutMetadata({
3263
+ id: `transaction-${i + 1}`,
3264
+ amount: -1000,
3265
+ })
3266
+ : buildUpdateTransaction({
3267
+ id: `transaction-${i + 1}`,
3268
+ amount: -1000,
3269
+ }),
3270
+ );
3271
+
3272
+ (cacheManager.get as any).mockReturnValue(null);
3273
+
3274
+ // Mock API to fail for only 1 transaction (5% = threshold)
3275
+ (mockYnabAPI.transactions.getTransactionById as any).mockImplementation(
3276
+ async (budgetId: string, transactionId: string) => {
3277
+ if (transactionId === 'transaction-1') {
3278
+ throw new Error('Transaction not found');
3279
+ }
3280
+ return {
3281
+ data: {
3282
+ transaction: {
3283
+ id: transactionId,
3284
+ account_id: 'account-001',
3285
+ date: '2024-01-01',
3286
+ },
3287
+ },
3288
+ };
3289
+ },
3290
+ );
3291
+
3292
+ const apiTransactions = transactions.map((t) => buildApiTransaction({ id: t.id }));
3293
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3294
+ buildApiResponse(apiTransactions),
3295
+ );
3296
+
3297
+ const response = await parseResponse(
3298
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions })),
3299
+ );
3300
+
3301
+ expect(response.summary.updated).toBe(20);
3302
+ });
3303
+
3304
+ it('returns warnings in dry_run mode when metadata is missing', async () => {
3305
+ const transactions = [
3306
+ buildUpdateTransactionWithoutMetadata({ id: 'transaction-001' }),
3307
+ buildUpdateTransactionWithoutMetadata({ id: 'transaction-002' }),
3308
+ ];
3309
+
3310
+ (cacheManager.get as any).mockReturnValue(null);
3311
+ (mockYnabAPI.transactions.getTransactionById as any).mockRejectedValue(
3312
+ new Error('Not found'),
3313
+ );
3314
+
3315
+ const response = await parseResponse(
3316
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions, dry_run: true })),
3317
+ );
3318
+
3319
+ expect(response.dry_run).toBe(true);
3320
+ expect(response.warnings).toBeDefined();
3321
+ expect(response.warnings).toHaveLength(1);
3322
+ expect(response.warnings[0].code).toBe('metadata_unavailable');
3323
+ expect(response.warnings[0].count).toBe(2);
3324
+ expect(response.warnings[0].sample_ids).toEqual(['transaction-001', 'transaction-002']);
3325
+ });
3326
+
3327
+ it('does not return warnings in dry_run when all metadata is resolved', async () => {
3328
+ const transactions = [
3329
+ buildUpdateTransaction({
3330
+ id: 'transaction-001',
3331
+ original_account_id: 'account-001',
3332
+ original_date: '2024-01-01',
3333
+ }),
3334
+ ];
3335
+
3336
+ (cacheManager.get as any).mockImplementation((key: string) => {
3337
+ if (key.includes('transaction-001')) {
3338
+ return buildApiTransaction({ id: 'transaction-001' });
3339
+ }
3340
+ return null;
3341
+ });
3342
+
3343
+ (CacheManager.generateKey as any).mockImplementation(
3344
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
3345
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
3346
+ );
3347
+
3348
+ const response = await parseResponse(
3349
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions, dry_run: true })),
3350
+ );
3351
+
3352
+ expect(response.dry_run).toBe(true);
3353
+ expect(response.warnings).toBeUndefined();
3354
+ });
3355
+ });
3356
+
3357
+ describe('correlation_key in results', () => {
3358
+ it('includes correlation_key in successful update results', async () => {
3359
+ const transactions = [
3360
+ buildUpdateTransaction({ id: 'transaction-001', amount: -2000 }),
3361
+ buildUpdateTransaction({ id: 'transaction-002', memo: 'Updated' }),
3362
+ ];
3363
+
3364
+ const apiTransactions = transactions.map((transaction) =>
3365
+ buildApiTransaction({ id: transaction.id }),
3366
+ );
3367
+
3368
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3369
+ buildApiResponse(apiTransactions),
3370
+ );
3371
+
3372
+ const response = await parseResponse(
3373
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions })),
3374
+ );
3375
+
3376
+ expect(response.results).toHaveLength(2);
3377
+ expect(response.results[0].correlation_key).toBe('transaction-001');
3378
+ expect(response.results[1].correlation_key).toBe('transaction-002');
3379
+ });
3380
+
3381
+ it('includes correlation_key in failed update results', async () => {
3382
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue({
3383
+ data: {
3384
+ transactions: [],
3385
+ server_knowledge: 1,
3386
+ },
3387
+ });
3388
+
3389
+ const response = await parseResponse(
3390
+ handleUpdateTransactions(
3391
+ mockYnabAPI,
3392
+ buildParams({
3393
+ transactions: [buildUpdateTransaction({ id: 'failed-id' })],
3394
+ }),
3395
+ ),
3396
+ );
3397
+
3398
+ expect(response.results[0].status).toBe('failed');
3399
+ expect(response.results[0].correlation_key).toBe('failed-id');
3400
+ });
3401
+
3402
+ it('preserves correlation_key in ids_only downgrade mode', async () => {
3403
+ const byteSpy = vi.spyOn(Buffer, 'byteLength');
3404
+ byteSpy
3405
+ .mockImplementationOnce(() => 70 * 1024)
3406
+ .mockImplementationOnce(() => 97 * 1024)
3407
+ .mockImplementationOnce(() => 80 * 1024);
3408
+
3409
+ const apiTransactions = [buildApiTransaction({ id: 'transaction-001' })];
3410
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3411
+ buildApiResponse(apiTransactions),
3412
+ );
3413
+
3414
+ const response = await parseResponse(
3415
+ handleUpdateTransactions(
3416
+ mockYnabAPI,
3417
+ buildParams({ transactions: [buildUpdateTransaction({ id: 'transaction-001' })] }),
3418
+ ),
3419
+ );
3420
+
3421
+ expect(response.mode).toBe('ids_only');
3422
+ expect(response.results[0].correlation_key).toBe('transaction-001');
3423
+ expect(response.results[0].transaction_id).toBe('transaction-001');
3424
+ expect(response.results[0].status).toBe('updated');
3425
+
3426
+ byteSpy.mockRestore();
3427
+ });
3428
+ });
3429
+
3430
+ describe('response size management', () => {
3431
+ beforeEach(() => {
3432
+ (cacheManager.get as any).mockReturnValue({
3433
+ account_id: 'account-default',
3434
+ date: '2024-01-01',
3435
+ });
3436
+ });
3437
+
3438
+ it('keeps full response when under 64KB', async () => {
3439
+ const apiTransactions = [buildApiTransaction()];
3440
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3441
+ buildApiResponse(apiTransactions),
3442
+ );
3443
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, buildParams()));
3444
+ expect(response.transactions).toBeDefined();
3445
+ expect(response.mode).toBe('full');
3446
+ });
3447
+
3448
+ it('downgrades to summary mode when response exceeds 64KB', async () => {
3449
+ const byteSpy = vi.spyOn(Buffer, 'byteLength');
3450
+ byteSpy.mockImplementationOnce(() => 70 * 1024).mockImplementationOnce(() => 80 * 1024);
3451
+ const apiTransactions = [buildApiTransaction()];
3452
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3453
+ buildApiResponse(apiTransactions),
3454
+ );
3455
+
3456
+ const response = await parseResponse(handleUpdateTransactions(mockYnabAPI, buildParams()));
3457
+
3458
+ expect(response.transactions).toBeUndefined();
3459
+ expect(response.mode).toBe('summary');
3460
+ byteSpy.mockRestore();
3461
+ });
3462
+ });
3463
+
3464
+ describe('cache invalidation', () => {
3465
+ it('invalidates transaction and account caches after successful updates', async () => {
3466
+ const transaction = buildUpdateTransaction({
3467
+ id: 'transaction-001',
3468
+ // Note: account_id is not included because account moves are not supported
3469
+ original_account_id: 'account-old',
3470
+ original_date: '2024-01-01',
3471
+ amount: -2000, // Include a change so the update does something
3472
+ });
3473
+
3474
+ // Mock cache to return null (no cached data)
3475
+ (cacheManager.get as any).mockReturnValue(null);
3476
+
3477
+ const apiTransaction = buildApiTransaction({ id: 'transaction-001' });
3478
+ (mockYnabAPI.transactions.updateTransactions as any).mockResolvedValue(
3479
+ buildApiResponse([apiTransaction]),
3480
+ );
3481
+
3482
+ (CacheManager.generateKey as any).mockImplementation(
3483
+ (scope: string, action: string, budgetId: string, qualifier?: string) =>
3484
+ `${scope}:${action}:${budgetId}:${qualifier ?? 'all'}`,
3485
+ );
3486
+
3487
+ const response = await parseResponse(
3488
+ handleUpdateTransactions(mockYnabAPI, buildParams({ transactions: [transaction] })),
3489
+ );
3490
+
3491
+ // Verify the update was successful
3492
+ expect(response.error).toBeUndefined();
3493
+ expect(response.summary).toBeDefined();
3494
+ expect(response.summary.updated).toBe(1);
3495
+
3496
+ // Cache invalidation uses individual delete calls
3497
+ const deleteCalls = cacheManager.delete.mock.calls.map((call) => call[0]);
3498
+ expect(deleteCalls).toEqual(
3499
+ expect.arrayContaining([
3500
+ 'transactions:list:budget-123:all',
3501
+ 'account:get:budget-123:account-old',
3502
+ 'month:get:budget-123:2024-01-01', // Month from original_date
3503
+ ]),
3504
+ );
3505
+ });
3506
+
3507
+ it('does not invalidate cache on dry run', async () => {
3508
+ await handleUpdateTransactions(
3509
+ mockYnabAPI,
3510
+ buildParams({
3511
+ dry_run: true,
3512
+ transactions: [buildUpdateTransaction()],
3513
+ }),
3514
+ );
3515
+
3516
+ expect(cacheManager.deleteMany).not.toHaveBeenCalled();
3517
+ expect(cacheManager.delete).not.toHaveBeenCalled();
3518
+ });
3519
+ });
3520
+ });
3521
+ });