@dizzlkheinz/ynab-mcpb 0.12.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
  2. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
  3. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
  4. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
  5. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
  6. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
  7. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
  8. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
  9. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
  10. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
  11. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
  12. package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
  13. package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
  14. package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
  15. package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
  16. package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
  17. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
  18. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
  19. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
  20. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
  21. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
  22. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
  23. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
  24. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
  25. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
  26. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
  27. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
  28. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
  29. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
  30. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
  31. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
  32. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
  33. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
  34. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
  35. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
  36. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
  37. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
  38. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
  39. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
  40. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
  41. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
  42. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
  43. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
  44. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
  45. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
  46. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
  47. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
  48. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
  49. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
  50. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
  51. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
  52. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
  53. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
  54. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
  55. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
  56. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
  57. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
  58. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
  59. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
  60. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
  61. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
  62. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
  63. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
  64. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
  65. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
  66. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
  67. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
  68. package/.github/workflows/ci-tests.yml +6 -2
  69. package/.github/workflows/publish.yml +3 -3
  70. package/.github/workflows/release.yml +4 -0
  71. package/CHANGELOG.md +89 -1
  72. package/NUL +1 -1
  73. package/README.md +36 -10
  74. package/dist/bundle/index.cjs +65 -42
  75. package/dist/index.js +9 -20
  76. package/dist/server/YNABMCPServer.d.ts +2 -1
  77. package/dist/server/YNABMCPServer.js +61 -27
  78. package/dist/server/cacheKeys.d.ts +8 -0
  79. package/dist/server/cacheKeys.js +8 -0
  80. package/dist/server/config.d.ts +22 -3
  81. package/dist/server/config.js +16 -17
  82. package/dist/server/errorHandler.d.ts +2 -0
  83. package/dist/server/errorHandler.js +49 -5
  84. package/dist/server/securityMiddleware.js +3 -6
  85. package/dist/server/toolRegistry.js +8 -10
  86. package/dist/tools/accountTools.js +4 -3
  87. package/dist/tools/categoryTools.js +8 -7
  88. package/dist/tools/monthTools.js +2 -1
  89. package/dist/tools/payeeTools.js +2 -1
  90. package/dist/tools/reconcileAdapter.js +10 -5
  91. package/dist/tools/reconciliation/analyzer.d.ts +4 -2
  92. package/dist/tools/reconciliation/analyzer.js +120 -404
  93. package/dist/tools/reconciliation/csvParser.d.ts +51 -0
  94. package/dist/tools/reconciliation/csvParser.js +413 -0
  95. package/dist/tools/reconciliation/executor.d.ts +8 -0
  96. package/dist/tools/reconciliation/executor.js +277 -50
  97. package/dist/tools/reconciliation/index.d.ts +7 -7
  98. package/dist/tools/reconciliation/index.js +115 -39
  99. package/dist/tools/reconciliation/matcher.d.ts +24 -3
  100. package/dist/tools/reconciliation/matcher.js +175 -133
  101. package/dist/tools/reconciliation/recommendationEngine.js +22 -18
  102. package/dist/tools/reconciliation/reportFormatter.js +9 -8
  103. package/dist/tools/reconciliation/signDetector.d.ts +2 -0
  104. package/dist/tools/reconciliation/signDetector.js +54 -0
  105. package/dist/tools/reconciliation/types.d.ts +20 -34
  106. package/dist/tools/reconciliation/types.js +1 -7
  107. package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
  108. package/dist/tools/reconciliation/ynabAdapter.js +15 -0
  109. package/dist/tools/transactionTools.d.ts +3 -17
  110. package/dist/tools/transactionTools.js +5 -17
  111. package/dist/types/reconciliation.d.ts +24 -0
  112. package/dist/types/reconciliation.js +1 -0
  113. package/dist/utils/baseError.d.ts +3 -0
  114. package/dist/utils/baseError.js +7 -0
  115. package/dist/utils/errors.d.ts +13 -0
  116. package/dist/utils/errors.js +15 -0
  117. package/dist/utils/validationError.d.ts +3 -0
  118. package/dist/utils/validationError.js +3 -0
  119. package/docs/guides/ARCHITECTURE.md +12 -129
  120. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
  121. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
  122. package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
  123. package/docs/plans/2025-11-21-v014-hardening.md +153 -0
  124. package/docs/plans/reconciliation-v2-redesign.md +1571 -0
  125. package/package.json +8 -2
  126. package/scripts/run-throttled-integration-tests.js +9 -3
  127. package/scripts/test-recommendations.ts +1 -1
  128. package/src/__tests__/performance.test.ts +12 -5
  129. package/src/__tests__/testUtils.ts +62 -5
  130. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
  131. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
  132. package/src/__tests__/workflows.e2e.test.ts +33 -0
  133. package/src/index.ts +8 -31
  134. package/src/server/YNABMCPServer.ts +81 -42
  135. package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
  136. package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
  137. package/src/server/__tests__/config.test.ts +76 -152
  138. package/src/server/__tests__/server-startup.integration.test.ts +42 -14
  139. package/src/server/__tests__/toolRegistry.test.ts +1 -1
  140. package/src/server/cacheKeys.ts +8 -0
  141. package/src/server/config.ts +20 -38
  142. package/src/server/errorHandler.ts +52 -5
  143. package/src/server/securityMiddleware.ts +3 -7
  144. package/src/server/toolRegistry.ts +14 -10
  145. package/src/tools/__tests__/categoryTools.test.ts +37 -19
  146. package/src/tools/__tests__/transactionTools.test.ts +58 -2
  147. package/src/tools/accountTools.ts +8 -3
  148. package/src/tools/categoryTools.ts +12 -7
  149. package/src/tools/monthTools.ts +7 -1
  150. package/src/tools/payeeTools.ts +7 -1
  151. package/src/tools/reconcileAdapter.ts +10 -5
  152. package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
  153. package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
  154. package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
  155. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +26 -6
  156. package/src/tools/reconciliation/__tests__/executor.test.ts +133 -60
  157. package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
  158. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
  159. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
  160. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
  161. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
  162. package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
  163. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
  164. package/src/tools/reconciliation/analyzer.ts +174 -545
  165. package/src/tools/reconciliation/csvParser.ts +617 -0
  166. package/src/tools/reconciliation/executor.ts +344 -58
  167. package/src/tools/reconciliation/index.ts +141 -48
  168. package/src/tools/reconciliation/matcher.ts +234 -214
  169. package/src/tools/reconciliation/recommendationEngine.ts +23 -19
  170. package/src/tools/reconciliation/reportFormatter.ts +16 -11
  171. package/src/tools/reconciliation/signDetector.ts +117 -0
  172. package/src/tools/reconciliation/types.ts +39 -61
  173. package/src/tools/reconciliation/ynabAdapter.ts +33 -0
  174. package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
  175. package/src/tools/transactionTools.ts +7 -18
  176. package/src/types/reconciliation.ts +49 -0
  177. package/src/utils/baseError.ts +7 -0
  178. package/src/utils/errors.ts +21 -0
  179. package/src/utils/validationError.ts +3 -0
  180. package/temp-recon.ts +126 -0
  181. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
  182. package/test_mcp_tools.mjs +75 -0
  183. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
  184. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
  185. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
  186. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
  187. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
  188. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
  189. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
  190. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
  191. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
  192. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
  193. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
  194. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
  195. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
  196. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
  197. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
  198. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
  199. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
  200. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
  201. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
  202. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
  203. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
  204. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
  205. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
  206. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
  207. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
  208. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
  209. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
  210. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
  211. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
  212. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
  213. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
  214. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
  215. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
  216. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
  217. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
  218. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
  219. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
  220. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
  221. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
  222. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
  223. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
  224. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
  225. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
  226. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
  227. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
  228. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
  229. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
  230. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
  231. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
  232. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
  233. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
  234. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
  235. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
  236. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
  237. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
  238. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
  239. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
  240. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
  241. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
  242. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
  243. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
  244. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
  245. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
  246. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
  247. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
  248. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
  249. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
  250. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
  251. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
  252. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
  253. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
  254. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
  255. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
  256. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
  257. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
  258. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
  259. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
  260. package/.gemini/settings.json +0 -8
  261. package/ADOS-2-Module-1-Complete-Manual.md +0 -757
  262. package/WARP.md +0 -245
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'crypto';
2
2
  import type * as ynab from 'ynab';
3
3
  import type { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
4
- import { toMilli, toMoneyValue, toMoneyValueFromDecimal, addMilli } from '../../utils/money.js';
4
+ import { YNABAPIError, YNABErrorCode } from '../../server/errorHandler.js';
5
+ import { toMilli, toMoneyValue, addMilli } from '../../utils/money.js';
5
6
  import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from './types.js';
6
7
  import type { ReconcileAccountRequest } from './index.js';
7
8
  import {
@@ -87,6 +88,9 @@ const MONEY_EPSILON_MILLI = 100; // $0.10
87
88
  const DEFAULT_TOLERANCE_CENTS = 1;
88
89
  const CENTS_TO_MILLI = 10;
89
90
  const MAX_BULK_CREATE_CHUNK = 100;
91
+ const MAX_BULK_UPDATE_CHUNK = 100; // YNAB API supports up to 100 transactions per batch for updates
92
+ const BATCH_DELAY_MS = 200; // Delay between batch chunks to avoid rate limiting
93
+ const MAX_MEMO_LENGTH = 500; // YNAB's maximum memo length
90
94
 
91
95
  function chunkArray<T>(array: T[], size: number): T[][] {
92
96
  if (size <= 0) {
@@ -99,6 +103,16 @@ function chunkArray<T>(array: T[], size: number): T[][] {
99
103
  return chunks;
100
104
  }
101
105
 
106
+ function sleep(ms: number): Promise<void> {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+
110
+ function truncateMemo(memo: string | null | undefined): string {
111
+ if (!memo) return 'Auto-reconciled from bank statement';
112
+ if (memo.length <= MAX_MEMO_LENGTH) return memo;
113
+ return memo.substring(0, MAX_MEMO_LENGTH - 3) + '...';
114
+ }
115
+
102
116
  interface PreparedBulkCreateEntry {
103
117
  bankTransaction: BankTransaction;
104
118
  saveTransaction: SaveTransaction;
@@ -195,13 +209,13 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
195
209
  // STEP 1: Auto-create missing transactions (bank -> YNAB)
196
210
  if (params.auto_create_transactions && !balanceAligned) {
197
211
  const buildPreparedEntry = (bankTxn: BankTransaction): PreparedBulkCreateEntry => {
198
- const amountMilli = toMilli(bankTxn.amount);
212
+ const amountMilli = bankTxn.amount;
199
213
  const saveTransaction: SaveTransaction = {
200
214
  account_id: accountId,
201
215
  amount: amountMilli,
202
216
  date: bankTxn.date,
203
217
  payee_name: bankTxn.payee ?? undefined,
204
- memo: bankTxn.memo ?? 'Auto-reconciled from bank statement',
218
+ memo: truncateMemo(bankTxn.memo),
205
219
  cleared: 'cleared',
206
220
  approved: true,
207
221
  import_id: generateBulkImportId(accountId, bankTxn.date, amountMilli, bankTxn.payee),
@@ -270,22 +284,28 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
270
284
  : `creating ${entry.bankTransaction.payee ?? 'missing transaction'}`;
271
285
  recordAlignmentIfNeeded(trigger);
272
286
  } catch (error) {
287
+ const ynabError = normalizeYnabError(error);
273
288
  if (bulkOperationDetails) {
274
289
  bulkOperationDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
275
290
  }
276
- const failureReason = error instanceof Error ? error.message : 'Unknown error occurred';
291
+ const failureReason = ynabError.message || 'Unknown error occurred';
292
+ const statusSuffix = ynabError.status ? ` (HTTP ${ynabError.status})` : '';
277
293
  const failureAction: ExecutionActionRecord = {
278
294
  type: 'create_transaction_failed',
279
295
  transaction: entry.saveTransaction as unknown as Record<string, unknown>,
280
296
  reason: options.fallbackError
281
- ? `Bulk fallback failed for ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`
282
- : `Failed to create transaction ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`,
297
+ ? `Bulk fallback failed for ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason}${statusSuffix})`
298
+ : `Failed to create transaction ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason}${statusSuffix})`,
283
299
  correlation_key: entry.correlationKey,
284
300
  };
285
301
  if (options.chunkIndex !== undefined) {
286
302
  failureAction.bulk_chunk_index = options.chunkIndex;
287
303
  }
288
304
  actions_taken.push(failureAction);
305
+
306
+ if (shouldPropagateYnabError(ynabError)) {
307
+ throw attachStatusToError(ynabError, error);
308
+ }
289
309
  }
290
310
  }
291
311
  // Update sequential_attempts metric if this was a fallback operation
@@ -420,17 +440,25 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
420
440
  await processBulkChunk(chunk, chunkIndex);
421
441
  bulkOperationDetails.bulk_successes += 1;
422
442
  } catch (error) {
423
- bulkOperationDetails.sequential_fallbacks += 1;
443
+ const ynabError = normalizeYnabError(error);
444
+ const failureReason = ynabError.message || 'unknown error';
424
445
  bulkOperationDetails.bulk_chunk_failures += 1; // API-level failure (entire chunk failed)
446
+
447
+ if (shouldPropagateYnabError(ynabError)) {
448
+ bulkOperationDetails.transaction_failures += chunk.length;
449
+ throw attachStatusToError(ynabError, error);
450
+ }
451
+
452
+ bulkOperationDetails.sequential_fallbacks += 1;
425
453
  actions_taken.push({
426
454
  type: 'bulk_create_fallback',
427
455
  transaction: null,
428
- reason: `Bulk chunk #${chunkIndex} failed (${
429
- error instanceof Error ? error.message : 'unknown error'
456
+ reason: `Bulk chunk #${chunkIndex} failed (${failureReason}${
457
+ ynabError.status ? ` (HTTP ${ynabError.status})` : ''
430
458
  }) - falling back to sequential creation`,
431
459
  bulk_chunk_index: chunkIndex,
432
460
  });
433
- await processSequentialEntries(chunk, { chunkIndex, fallbackError: error });
461
+ await processSequentialEntries(chunk, { chunkIndex, fallbackError: ynabError });
434
462
  }
435
463
  }
436
464
  }
@@ -449,17 +477,23 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
449
477
  if (balanceAligned) break;
450
478
  const flags = computeUpdateFlags(match, params);
451
479
  if (!flags.needsClearedUpdate && !flags.needsDateUpdate) continue;
452
- if (!match.ynab_transaction) continue;
480
+ if (!match.ynabTransaction) continue;
453
481
 
454
482
  // Build minimal update payload - only include ID and fields that are changing
455
- // Including unnecessary fields (like amount, payee_name, memo) can cause unexpected behavior
483
+ // Including unnecessary fields (like amount, payee_name) can cause unexpected behavior
484
+ // BUT we must include memo to fix existing memos that exceed YNAB's 500 char limit
456
485
  const updatePayload: ynab.SaveTransactionWithIdOrImportId = {
457
- id: match.ynab_transaction.id,
486
+ id: match.ynabTransaction.id,
458
487
  };
459
488
 
489
+ // Truncate memo if it exists and is too long (YNAB validates on update even if not changed)
490
+ if (match.ynabTransaction.memo) {
491
+ updatePayload.memo = truncateMemo(match.ynabTransaction.memo);
492
+ }
493
+
460
494
  // Only include fields that are actually changing
461
495
  if (flags.needsDateUpdate) {
462
- updatePayload.date = match.bank_transaction.date;
496
+ updatePayload.date = match.bankTransaction.date;
463
497
  }
464
498
  if (flags.needsClearedUpdate) {
465
499
  updatePayload.cleared = 'cleared' as ynab.TransactionClearedStatus;
@@ -471,17 +505,17 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
471
505
  actions_taken.push({
472
506
  type: 'update_transaction',
473
507
  transaction: {
474
- transaction_id: match.ynab_transaction.id,
475
- new_date: flags.needsDateUpdate ? match.bank_transaction.date : undefined,
508
+ transaction_id: match.ynabTransaction.id,
509
+ new_date: flags.needsDateUpdate ? match.bankTransaction.date : undefined,
476
510
  cleared: flags.needsClearedUpdate ? 'cleared' : undefined,
477
511
  },
478
512
  reason: `Would update transaction: ${updateReason(match, flags, currencyCode)}`,
479
513
  });
480
514
  if (flags.needsClearedUpdate) {
481
- applyClearedDelta(match.ynab_transaction.amount);
515
+ applyClearedDelta(match.ynabTransaction.amount);
482
516
  if (
483
517
  recordAlignmentIfNeeded(
484
- `clearing ${match.ynab_transaction.id ?? 'transaction'} (dry run)`,
518
+ `clearing ${match.ynabTransaction.id ?? 'transaction'} (dry run)`,
485
519
  )
486
520
  ) {
487
521
  break;
@@ -491,8 +525,8 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
491
525
  transactionsToUpdate.push(updatePayload);
492
526
  if (flags.needsDateUpdate) summary.dates_adjusted += 1;
493
527
  if (flags.needsClearedUpdate) {
494
- applyClearedDelta(match.ynab_transaction.amount);
495
- if (recordAlignmentIfNeeded(`clearing ${match.ynab_transaction.id}`)) {
528
+ applyClearedDelta(match.ynabTransaction.amount);
529
+ if (recordAlignmentIfNeeded(`clearing ${match.ynabTransaction.id}`)) {
496
530
  break;
497
531
  }
498
532
  }
@@ -500,33 +534,85 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
500
534
  }
501
535
 
502
536
  // Batch update all transactions in a single API call
537
+ // YNAB API has a limit of ~100 transactions per batch, so we chunk the updates
503
538
  if (!params.dry_run && transactionsToUpdate.length > 0) {
504
- const response = await ynabAPI.transactions.updateTransactions(budgetId, {
505
- transactions: transactionsToUpdate,
506
- });
539
+ const updateChunks = chunkArray(transactionsToUpdate, MAX_BULK_UPDATE_CHUNK);
540
+
541
+ for (let chunkIdx = 0; chunkIdx < updateChunks.length; chunkIdx++) {
542
+ const chunk = updateChunks[chunkIdx]!;
543
+ try {
544
+ const response = await ynabAPI.transactions.updateTransactions(budgetId, {
545
+ transactions: chunk,
546
+ });
507
547
 
508
- const updatedTransactions = response.data.transactions ?? [];
509
- summary.transactions_updated += updatedTransactions.length;
548
+ const updatedTransactions = response.data.transactions ?? [];
549
+ summary.transactions_updated += updatedTransactions.length;
510
550
 
511
- for (const updatedTransaction of updatedTransactions) {
512
- const match = orderedAutoMatches.find(
513
- (m) => m.ynab_transaction?.id === updatedTransaction.id,
514
- );
515
- const flags = match
516
- ? computeUpdateFlags(match, params)
517
- : { needsClearedUpdate: false, needsDateUpdate: false };
518
- actions_taken.push({
519
- type: 'update_transaction',
520
- transaction: updatedTransaction as unknown as Record<string, unknown> | null,
521
- reason: `Updated transaction: ${match ? updateReason(match, flags, currencyCode) : 'cleared'}`,
522
- });
551
+ for (const updatedTransaction of updatedTransactions) {
552
+ const match = orderedAutoMatches.find(
553
+ (m) => m.ynabTransaction?.id === updatedTransaction.id,
554
+ );
555
+ const flags = match
556
+ ? computeUpdateFlags(match, params)
557
+ : { needsClearedUpdate: false, needsDateUpdate: false };
558
+ actions_taken.push({
559
+ type: 'update_transaction',
560
+ transaction: updatedTransaction as unknown as Record<string, unknown> | null,
561
+ reason: `Updated transaction: ${match ? updateReason(match, flags, currencyCode) : 'cleared'}`,
562
+ });
563
+ }
564
+ accountSnapshotDirty = true;
565
+ } catch (error) {
566
+ const ynabError = normalizeYnabError(error);
567
+ const failureReason = ynabError.message || 'Unknown error occurred';
568
+ const statusSuffix = ynabError.status ? ` (HTTP ${ynabError.status})` : '';
569
+ actions_taken.push({
570
+ type: 'batch_update_failed',
571
+ transaction: null,
572
+ reason: `Failed to update chunk ${chunkIdx + 1}/${updateChunks.length} (${chunk.length} transaction(s)): ${failureReason}${statusSuffix}`,
573
+ });
574
+
575
+ if (shouldPropagateYnabError(ynabError)) {
576
+ throw attachStatusToError(ynabError, error);
577
+ }
578
+ }
579
+
580
+ // Add delay between chunks to avoid rate limiting (except after last chunk)
581
+ if (chunkIdx < updateChunks.length - 1) {
582
+ await sleep(BATCH_DELAY_MS);
583
+ }
523
584
  }
524
- accountSnapshotDirty = true;
525
585
  }
526
586
  }
527
587
 
528
588
  // STEP 3: Auto-unclear YNAB transactions missing from bank
529
589
  const shouldRunSanityPass = params.auto_unclear_missing && !balanceAligned;
590
+
591
+ // Diagnostic logging for auto_unclear_missing debugging
592
+ actions_taken.push({
593
+ type: 'diagnostic_step3_entry',
594
+ transaction: null,
595
+ reason: `STEP 3 diagnostics: auto_unclear_missing=${params.auto_unclear_missing}, balanceAligned=${balanceAligned}, shouldRunSanityPass=${shouldRunSanityPass}, orderedUnmatchedYNAB.length=${orderedUnmatchedYNAB.length}`,
596
+ });
597
+
598
+ if (orderedUnmatchedYNAB.length > 0) {
599
+ const unmatchedDetails = orderedUnmatchedYNAB.slice(0, 10).map((t) => ({
600
+ id: t.id,
601
+ date: t.date,
602
+ cleared: t.cleared,
603
+ amount: formatDisplay(t.amount, currencyCode),
604
+ payee: t.payee ?? 'Unknown',
605
+ }));
606
+ actions_taken.push({
607
+ type: 'diagnostic_unmatched_ynab',
608
+ transaction: { unmatched_transactions: unmatchedDetails } as unknown as Record<
609
+ string,
610
+ unknown
611
+ >,
612
+ reason: `First ${Math.min(10, orderedUnmatchedYNAB.length)} unmatched YNAB transactions (cleared status and amounts)`,
613
+ });
614
+ }
615
+
530
616
  if (shouldRunSanityPass) {
531
617
  const transactionsToUnclear: ynab.SaveTransactionWithIdOrImportId[] = [];
532
618
 
@@ -559,26 +645,121 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
559
645
  }
560
646
 
561
647
  // Batch update all unclear operations in a single API call
648
+ // YNAB API has a limit of ~100 transactions per batch, so we chunk the updates
562
649
  if (!params.dry_run && transactionsToUnclear.length > 0) {
563
- const response = await ynabAPI.transactions.updateTransactions(budgetId, {
564
- transactions: transactionsToUnclear,
650
+ const unclearChunks = chunkArray(transactionsToUnclear, MAX_BULK_UPDATE_CHUNK);
651
+
652
+ for (let chunkIdx = 0; chunkIdx < unclearChunks.length; chunkIdx++) {
653
+ const chunk = unclearChunks[chunkIdx]!;
654
+ try {
655
+ const response = await ynabAPI.transactions.updateTransactions(budgetId, {
656
+ transactions: chunk,
657
+ });
658
+
659
+ const updatedTransactions = response.data.transactions ?? [];
660
+ summary.transactions_updated += updatedTransactions.length;
661
+
662
+ for (const updatedTransaction of updatedTransactions) {
663
+ actions_taken.push({
664
+ type: 'update_transaction',
665
+ transaction: updatedTransaction as unknown as Record<string, unknown> | null,
666
+ reason: `Marked transaction ${updatedTransaction.id} as uncleared - not found on statement`,
667
+ });
668
+ }
669
+ accountSnapshotDirty = true;
670
+ } catch (error) {
671
+ const ynabError = normalizeYnabError(error);
672
+ const failureReason = ynabError.message || 'Unknown error occurred';
673
+ const statusSuffix = ynabError.status ? ` (HTTP ${ynabError.status})` : '';
674
+ actions_taken.push({
675
+ type: 'batch_unclear_failed',
676
+ transaction: null,
677
+ reason: `Failed to unclear chunk ${chunkIdx + 1}/${unclearChunks.length} (${chunk.length} transaction(s)): ${failureReason}${statusSuffix}`,
678
+ });
679
+
680
+ if (shouldPropagateYnabError(ynabError)) {
681
+ throw attachStatusToError(ynabError, error);
682
+ }
683
+ }
684
+
685
+ // Add delay between chunks to avoid rate limiting (except after last chunk)
686
+ if (chunkIdx < unclearChunks.length - 1) {
687
+ await sleep(BATCH_DELAY_MS);
688
+ }
689
+ }
690
+ }
691
+ }
692
+
693
+ // STEP 4: Mark all matched transactions as reconciled when balance aligns
694
+ if (balanceAligned && !params.dry_run) {
695
+ const transactionsToReconcile: ynab.SaveTransactionWithIdOrImportId[] = [];
696
+
697
+ for (const match of orderedAutoMatches) {
698
+ if (!match.ynabTransaction) continue;
699
+ // Only reconcile transactions that are not already reconciled
700
+ if (match.ynabTransaction.cleared === 'reconciled') continue;
701
+
702
+ transactionsToReconcile.push({
703
+ id: match.ynabTransaction.id,
704
+ cleared: 'reconciled' as ynab.TransactionClearedStatus,
565
705
  });
706
+ }
566
707
 
567
- const updatedTransactions = response.data.transactions ?? [];
568
- summary.transactions_updated += updatedTransactions.length;
708
+ // Batch update all reconciliations in chunks
709
+ if (transactionsToReconcile.length > 0) {
710
+ const reconcileChunks = chunkArray(transactionsToReconcile, MAX_BULK_UPDATE_CHUNK);
569
711
 
570
- for (const updatedTransaction of updatedTransactions) {
571
- actions_taken.push({
572
- type: 'update_transaction',
573
- transaction: updatedTransaction as unknown as Record<string, unknown> | null,
574
- reason: `Marked transaction ${updatedTransaction.id} as uncleared - not found on statement`,
575
- });
712
+ for (let chunkIdx = 0; chunkIdx < reconcileChunks.length; chunkIdx++) {
713
+ const chunk = reconcileChunks[chunkIdx]!;
714
+ try {
715
+ const response = await ynabAPI.transactions.updateTransactions(budgetId, {
716
+ transactions: chunk,
717
+ });
718
+
719
+ const reconciledTransactions = response.data.transactions ?? [];
720
+ summary.transactions_updated += reconciledTransactions.length;
721
+
722
+ for (const reconciledTransaction of reconciledTransactions) {
723
+ const match = orderedAutoMatches.find(
724
+ (m) => m.ynabTransaction?.id === reconciledTransaction.id,
725
+ );
726
+ actions_taken.push({
727
+ type: 'update_transaction',
728
+ transaction: reconciledTransaction as unknown as Record<string, unknown> | null,
729
+ reason: `Marked as reconciled: ${match?.bankTransaction.payee ?? 'transaction'} (${formatDisplay(reconciledTransaction.amount, currencyCode)})`,
730
+ });
731
+ }
732
+ accountSnapshotDirty = true;
733
+ } catch (error) {
734
+ const ynabError = normalizeYnabError(error);
735
+ const failureReason = ynabError.message || 'Unknown error occurred';
736
+ const statusSuffix = ynabError.status ? ` (HTTP ${ynabError.status})` : '';
737
+ actions_taken.push({
738
+ type: 'batch_reconcile_failed',
739
+ transaction: null,
740
+ reason: `Failed to reconcile chunk ${chunkIdx + 1}/${reconcileChunks.length} (${chunk.length} transaction(s)): ${failureReason}${statusSuffix}`,
741
+ });
742
+
743
+ if (shouldPropagateYnabError(ynabError)) {
744
+ throw attachStatusToError(ynabError, error);
745
+ }
746
+ }
747
+
748
+ // Add delay between chunks to avoid rate limiting (except after last chunk)
749
+ if (chunkIdx < reconcileChunks.length - 1) {
750
+ await sleep(BATCH_DELAY_MS);
751
+ }
576
752
  }
577
- accountSnapshotDirty = true;
753
+
754
+ actions_taken.push({
755
+ type: 'reconciliation_complete',
756
+ transaction: null,
757
+ reason: `Marked ${transactionsToReconcile.length} matched transaction(s) as reconciled - balance aligned within tolerance`,
758
+ });
578
759
  }
579
760
  }
580
761
 
581
- // STEP 4: Balance reconciliation snapshot (only once per execution)
762
+ // STEP 5: Balance reconciliation snapshot (only once per execution)
582
763
  let balance_reconciliation: ExecutionResult['balance_reconciliation'];
583
764
  if (params.statement_balance !== undefined && params.statement_date) {
584
765
  balance_reconciliation = await buildBalanceReconciliation({
@@ -591,7 +772,7 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
591
772
  });
592
773
  }
593
774
 
594
- // STEP 5: Recommendations and balance changes
775
+ // STEP 6: Recommendations and balance changes
595
776
  if (!params.dry_run && accountSnapshotDirty) {
596
777
  afterAccount = await refreshAccountSnapshot(ynabAPI, budgetId, accountId);
597
778
  }
@@ -630,13 +811,120 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
630
811
  return result;
631
812
  }
632
813
 
814
+ export interface NormalizedYnabError {
815
+ status?: number;
816
+ name?: string;
817
+ message: string;
818
+ detail?: string;
819
+ }
820
+
821
+ const FATAL_YNAB_STATUS_CODES = new Set([400, 401, 403, 404, 429, 500, 503]);
822
+
823
+ export function normalizeYnabError(error: unknown): NormalizedYnabError {
824
+ const parseStatus = (value: unknown): number | undefined => {
825
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
826
+ if (typeof value === 'string') {
827
+ const numeric = Number(value);
828
+ if (Number.isFinite(numeric)) return numeric;
829
+ }
830
+ return undefined;
831
+ };
832
+
833
+ if (error instanceof Error) {
834
+ const status =
835
+ parseStatus((error as { status?: unknown }).status) ??
836
+ parseStatus((error as { response?: { status?: unknown } }).response?.status);
837
+ const detailSource = (error as { detail?: unknown }).detail;
838
+ const detail =
839
+ typeof detailSource === 'string' && detailSource.trim().length > 0 ? detailSource : undefined;
840
+
841
+ const result: NormalizedYnabError = {
842
+ name: error.name,
843
+ message: error.message || 'Unknown error occurred',
844
+ };
845
+
846
+ if (status !== undefined) result.status = status;
847
+ if (detail !== undefined) result.detail = detail;
848
+
849
+ return result;
850
+ }
851
+
852
+ if (error && typeof error === 'object') {
853
+ const errObj = (error as { error?: unknown }).error ?? error;
854
+ const status = parseStatus(
855
+ (errObj as { id?: unknown }).id ?? (errObj as { status?: unknown }).status,
856
+ );
857
+ const detailCandidate =
858
+ (errObj as { detail?: unknown }).detail ??
859
+ (errObj as { message?: unknown }).message ??
860
+ (errObj as { name?: unknown }).name;
861
+ const detail =
862
+ typeof detailCandidate === 'string' && detailCandidate.trim().length > 0
863
+ ? detailCandidate
864
+ : undefined;
865
+ const message =
866
+ detail ??
867
+ (typeof errObj === 'string' && errObj.trim().length > 0 ? errObj : 'Unknown error occurred');
868
+ const name =
869
+ typeof (errObj as { name?: unknown }).name === 'string'
870
+ ? ((errObj as { name: string }).name as string)
871
+ : undefined;
872
+
873
+ const result: NormalizedYnabError = { message };
874
+
875
+ if (status !== undefined) result.status = status;
876
+ if (name !== undefined) result.name = name;
877
+ if (detail !== undefined) result.detail = detail;
878
+
879
+ return result;
880
+ }
881
+
882
+ if (typeof error === 'string') {
883
+ return { message: error };
884
+ }
885
+
886
+ return { message: 'Unknown error occurred' };
887
+ }
888
+
889
+ export function shouldPropagateYnabError(error: NormalizedYnabError): boolean {
890
+ return error.status !== undefined && FATAL_YNAB_STATUS_CODES.has(error.status);
891
+ }
892
+
893
+ function attachStatusToError(error: NormalizedYnabError, originalError?: unknown): Error {
894
+ const message = error.message || 'YNAB API error';
895
+
896
+ const isKnownCode =
897
+ error.status === YNABErrorCode.BAD_REQUEST ||
898
+ error.status === YNABErrorCode.UNAUTHORIZED ||
899
+ error.status === YNABErrorCode.FORBIDDEN ||
900
+ error.status === YNABErrorCode.NOT_FOUND ||
901
+ error.status === YNABErrorCode.TOO_MANY_REQUESTS ||
902
+ error.status === YNABErrorCode.INTERNAL_SERVER_ERROR;
903
+
904
+ if (isKnownCode) {
905
+ return new YNABAPIError(error.status as YNABErrorCode, message, originalError);
906
+ }
907
+
908
+ const statusFragment = error.status ? ` (HTTP ${error.status})` : '';
909
+ const detailFragment =
910
+ error.detail && !message.includes(error.detail) ? ` (${error.detail})` : '';
911
+ const err = new Error(`${message}${statusFragment}${detailFragment}`);
912
+ if (error.status !== undefined) {
913
+ (err as { status?: number }).status = error.status;
914
+ }
915
+ if (error.name) {
916
+ err.name = error.name;
917
+ }
918
+ return err;
919
+ }
920
+
633
921
  function formatDisplay(amount: number, currency: string): string {
634
- return toMoneyValueFromDecimal(amount, currency).value_display;
922
+ return toMoneyValue(amount, currency).value_display;
635
923
  }
636
924
 
637
925
  function computeUpdateFlags(match: TransactionMatch, params: ReconcileAccountRequest): UpdateFlags {
638
- const ynabTxn = match.ynab_transaction;
639
- const bankTxn = match.bank_transaction;
926
+ const ynabTxn = match.ynabTransaction;
927
+ const bankTxn = match.bankTransaction;
640
928
  if (!ynabTxn) {
641
929
  return { needsClearedUpdate: false, needsDateUpdate: false };
642
930
  }
@@ -653,7 +941,7 @@ function updateReason(match: TransactionMatch, flags: UpdateFlags, _currency: st
653
941
  parts.push('marked as cleared');
654
942
  }
655
943
  if (flags.needsDateUpdate) {
656
- parts.push(`date adjusted to ${match.bank_transaction.date}`);
944
+ parts.push(`date adjusted to ${match.bankTransaction.date}`);
657
945
  }
658
946
  return parts.join(', ');
659
947
  }
@@ -861,9 +1149,7 @@ function sortByDateDescending<T extends { date: string }>(items: T[]): T[] {
861
1149
  }
862
1150
 
863
1151
  function sortMatchesByBankDateDescending(matches: TransactionMatch[]): TransactionMatch[] {
864
- return [...matches].sort((a, b) =>
865
- compareDates(b.bank_transaction.date, a.bank_transaction.date),
866
- );
1152
+ return [...matches].sort((a, b) => compareDates(b.bankTransaction.date, a.bankTransaction.date));
867
1153
  }
868
1154
 
869
1155
  function compareDates(dateA: string, dateB: string): number {