@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
  2. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
  3. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
  4. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
  5. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
  6. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
  7. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
  8. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
  9. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
  10. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
  11. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
  12. package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
  13. package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
  14. package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
  15. package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
  16. package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
  17. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
  18. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
  19. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
  20. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
  21. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
  22. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
  23. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
  24. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
  25. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
  26. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
  27. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
  28. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
  29. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
  30. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
  31. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
  32. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
  33. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
  34. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
  35. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
  36. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
  37. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
  38. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
  39. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
  40. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
  41. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
  42. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
  43. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
  44. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
  45. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
  46. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
  47. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
  48. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
  49. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
  50. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
  51. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
  52. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
  53. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
  54. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
  55. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
  56. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
  57. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
  58. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
  59. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
  60. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
  61. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
  62. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
  63. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
  64. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
  65. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
  66. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
  67. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
  68. package/.github/workflows/publish.yml +3 -3
  69. package/.github/workflows/release.yml +4 -0
  70. package/CHANGELOG.md +75 -0
  71. package/NUL +1 -0
  72. package/dist/bundle/index.cjs +65 -42
  73. package/dist/server/errorHandler.d.ts +2 -0
  74. package/dist/server/errorHandler.js +49 -5
  75. package/dist/tools/reconcileAdapter.js +10 -5
  76. package/dist/tools/reconciliation/analyzer.d.ts +8 -2
  77. package/dist/tools/reconciliation/analyzer.js +127 -409
  78. package/dist/tools/reconciliation/csvParser.d.ts +51 -0
  79. package/dist/tools/reconciliation/csvParser.js +413 -0
  80. package/dist/tools/reconciliation/executor.d.ts +8 -0
  81. package/dist/tools/reconciliation/executor.js +204 -58
  82. package/dist/tools/reconciliation/index.d.ts +7 -7
  83. package/dist/tools/reconciliation/index.js +115 -39
  84. package/dist/tools/reconciliation/matcher.d.ts +24 -3
  85. package/dist/tools/reconciliation/matcher.js +175 -133
  86. package/dist/tools/reconciliation/recommendationEngine.js +22 -18
  87. package/dist/tools/reconciliation/reportFormatter.js +9 -8
  88. package/dist/tools/reconciliation/signDetector.d.ts +2 -0
  89. package/dist/tools/reconciliation/signDetector.js +54 -0
  90. package/dist/tools/reconciliation/types.d.ts +20 -34
  91. package/dist/tools/reconciliation/types.js +1 -7
  92. package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
  93. package/dist/tools/reconciliation/ynabAdapter.js +15 -0
  94. package/dist/types/reconciliation.d.ts +24 -0
  95. package/dist/types/reconciliation.js +1 -0
  96. package/docs/guides/ARCHITECTURE.md +12 -129
  97. package/docs/plans/2025-11-21-v014-hardening.md +153 -0
  98. package/docs/plans/reconciliation-v2-redesign.md +1571 -0
  99. package/package.json +6 -1
  100. package/scripts/test-recommendations.ts +1 -1
  101. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
  102. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
  103. package/src/server/errorHandler.ts +52 -5
  104. package/src/tools/reconcileAdapter.ts +10 -5
  105. package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
  106. package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
  107. package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
  108. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +1 -1
  109. package/src/tools/reconciliation/__tests__/executor.test.ts +88 -61
  110. package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
  111. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
  112. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
  113. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
  114. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
  115. package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
  116. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
  117. package/src/tools/reconciliation/analyzer.ts +191 -550
  118. package/src/tools/reconciliation/csvParser.ts +617 -0
  119. package/src/tools/reconciliation/executor.ts +249 -66
  120. package/src/tools/reconciliation/index.ts +148 -54
  121. package/src/tools/reconciliation/matcher.ts +234 -214
  122. package/src/tools/reconciliation/recommendationEngine.ts +23 -19
  123. package/src/tools/reconciliation/reportFormatter.ts +16 -11
  124. package/src/tools/reconciliation/signDetector.ts +117 -0
  125. package/src/tools/reconciliation/types.ts +39 -61
  126. package/src/tools/reconciliation/ynabAdapter.ts +33 -0
  127. package/src/types/reconciliation.ts +49 -0
  128. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
  129. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
  130. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
  131. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
  132. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
  133. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
  134. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
  135. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
  136. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
  137. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
  138. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
  139. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
  140. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
  141. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
  142. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
  143. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
  144. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
  145. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
  146. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
  147. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
  148. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
  149. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
  150. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
  151. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
  152. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
  153. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
  154. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
  155. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
  156. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
  157. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
  158. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
  159. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
  160. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
  161. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
  162. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
  163. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
  164. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
  165. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
  166. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
  167. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
  168. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
  169. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
  170. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
  171. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
  172. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
  173. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
  174. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
  175. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
  176. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
  177. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
  178. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
  179. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
  180. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
  181. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
  182. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
  183. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
  184. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
  185. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
  186. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
  187. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
  188. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
  189. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
  190. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
  191. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
  192. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
  193. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
  194. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
  195. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
  196. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
  197. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
  198. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
  199. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
  200. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
  201. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
  202. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
  203. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
  204. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
  205. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
  206. package/.gemini/settings.json +0 -8
  207. package/WARP.md +0 -245
@@ -1,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),
@@ -275,12 +289,13 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
275
289
  bulkOperationDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
276
290
  }
277
291
  const failureReason = ynabError.message || 'Unknown error occurred';
292
+ const statusSuffix = ynabError.status ? ` (HTTP ${ynabError.status})` : '';
278
293
  const failureAction: ExecutionActionRecord = {
279
294
  type: 'create_transaction_failed',
280
295
  transaction: entry.saveTransaction as unknown as Record<string, unknown>,
281
296
  reason: options.fallbackError
282
- ? `Bulk fallback failed for ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`
283
- : `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})`,
284
299
  correlation_key: entry.correlationKey,
285
300
  };
286
301
  if (options.chunkIndex !== undefined) {
@@ -289,7 +304,7 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
289
304
  actions_taken.push(failureAction);
290
305
 
291
306
  if (shouldPropagateYnabError(ynabError)) {
292
- throw attachStatusToError(ynabError);
307
+ throw attachStatusToError(ynabError, error);
293
308
  }
294
309
  }
295
310
  }
@@ -431,14 +446,16 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
431
446
 
432
447
  if (shouldPropagateYnabError(ynabError)) {
433
448
  bulkOperationDetails.transaction_failures += chunk.length;
434
- throw attachStatusToError(ynabError);
449
+ throw attachStatusToError(ynabError, error);
435
450
  }
436
451
 
437
452
  bulkOperationDetails.sequential_fallbacks += 1;
438
453
  actions_taken.push({
439
454
  type: 'bulk_create_fallback',
440
455
  transaction: null,
441
- reason: `Bulk chunk #${chunkIndex} failed (${failureReason}) - falling back to sequential creation`,
456
+ reason: `Bulk chunk #${chunkIndex} failed (${failureReason}${
457
+ ynabError.status ? ` (HTTP ${ynabError.status})` : ''
458
+ }) - falling back to sequential creation`,
442
459
  bulk_chunk_index: chunkIndex,
443
460
  });
444
461
  await processSequentialEntries(chunk, { chunkIndex, fallbackError: ynabError });
@@ -460,17 +477,23 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
460
477
  if (balanceAligned) break;
461
478
  const flags = computeUpdateFlags(match, params);
462
479
  if (!flags.needsClearedUpdate && !flags.needsDateUpdate) continue;
463
- if (!match.ynab_transaction) continue;
480
+ if (!match.ynabTransaction) continue;
464
481
 
465
482
  // Build minimal update payload - only include ID and fields that are changing
466
- // 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
467
485
  const updatePayload: ynab.SaveTransactionWithIdOrImportId = {
468
- id: match.ynab_transaction.id,
486
+ id: match.ynabTransaction.id,
469
487
  };
470
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
+
471
494
  // Only include fields that are actually changing
472
495
  if (flags.needsDateUpdate) {
473
- updatePayload.date = match.bank_transaction.date;
496
+ updatePayload.date = match.bankTransaction.date;
474
497
  }
475
498
  if (flags.needsClearedUpdate) {
476
499
  updatePayload.cleared = 'cleared' as ynab.TransactionClearedStatus;
@@ -482,17 +505,17 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
482
505
  actions_taken.push({
483
506
  type: 'update_transaction',
484
507
  transaction: {
485
- transaction_id: match.ynab_transaction.id,
486
- new_date: flags.needsDateUpdate ? match.bank_transaction.date : undefined,
508
+ transaction_id: match.ynabTransaction.id,
509
+ new_date: flags.needsDateUpdate ? match.bankTransaction.date : undefined,
487
510
  cleared: flags.needsClearedUpdate ? 'cleared' : undefined,
488
511
  },
489
512
  reason: `Would update transaction: ${updateReason(match, flags, currencyCode)}`,
490
513
  });
491
514
  if (flags.needsClearedUpdate) {
492
- applyClearedDelta(match.ynab_transaction.amount);
515
+ applyClearedDelta(match.ynabTransaction.amount);
493
516
  if (
494
517
  recordAlignmentIfNeeded(
495
- `clearing ${match.ynab_transaction.id ?? 'transaction'} (dry run)`,
518
+ `clearing ${match.ynabTransaction.id ?? 'transaction'} (dry run)`,
496
519
  )
497
520
  ) {
498
521
  break;
@@ -502,8 +525,8 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
502
525
  transactionsToUpdate.push(updatePayload);
503
526
  if (flags.needsDateUpdate) summary.dates_adjusted += 1;
504
527
  if (flags.needsClearedUpdate) {
505
- applyClearedDelta(match.ynab_transaction.amount);
506
- if (recordAlignmentIfNeeded(`clearing ${match.ynab_transaction.id}`)) {
528
+ applyClearedDelta(match.ynabTransaction.amount);
529
+ if (recordAlignmentIfNeeded(`clearing ${match.ynabTransaction.id}`)) {
507
530
  break;
508
531
  }
509
532
  }
@@ -511,33 +534,85 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
511
534
  }
512
535
 
513
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
514
538
  if (!params.dry_run && transactionsToUpdate.length > 0) {
515
- const response = await ynabAPI.transactions.updateTransactions(budgetId, {
516
- transactions: transactionsToUpdate,
517
- });
539
+ const updateChunks = chunkArray(transactionsToUpdate, MAX_BULK_UPDATE_CHUNK);
518
540
 
519
- const updatedTransactions = response.data.transactions ?? [];
520
- summary.transactions_updated += updatedTransactions.length;
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
+ });
521
547
 
522
- for (const updatedTransaction of updatedTransactions) {
523
- const match = orderedAutoMatches.find(
524
- (m) => m.ynab_transaction?.id === updatedTransaction.id,
525
- );
526
- const flags = match
527
- ? computeUpdateFlags(match, params)
528
- : { needsClearedUpdate: false, needsDateUpdate: false };
529
- actions_taken.push({
530
- type: 'update_transaction',
531
- transaction: updatedTransaction as unknown as Record<string, unknown> | null,
532
- reason: `Updated transaction: ${match ? updateReason(match, flags, currencyCode) : 'cleared'}`,
533
- });
548
+ const updatedTransactions = response.data.transactions ?? [];
549
+ summary.transactions_updated += updatedTransactions.length;
550
+
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
+ }
534
584
  }
535
- accountSnapshotDirty = true;
536
585
  }
537
586
  }
538
587
 
539
588
  // STEP 3: Auto-unclear YNAB transactions missing from bank
540
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
+
541
616
  if (shouldRunSanityPass) {
542
617
  const transactionsToUnclear: ynab.SaveTransactionWithIdOrImportId[] = [];
543
618
 
@@ -570,26 +645,121 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
570
645
  }
571
646
 
572
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
573
649
  if (!params.dry_run && transactionsToUnclear.length > 0) {
574
- const response = await ynabAPI.transactions.updateTransactions(budgetId, {
575
- 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,
576
705
  });
706
+ }
577
707
 
578
- const updatedTransactions = response.data.transactions ?? [];
579
- 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);
580
711
 
581
- for (const updatedTransaction of updatedTransactions) {
582
- actions_taken.push({
583
- type: 'update_transaction',
584
- transaction: updatedTransaction as unknown as Record<string, unknown> | null,
585
- reason: `Marked transaction ${updatedTransaction.id} as uncleared - not found on statement`,
586
- });
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
+ }
587
752
  }
588
- 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
+ });
589
759
  }
590
760
  }
591
761
 
592
- // STEP 4: Balance reconciliation snapshot (only once per execution)
762
+ // STEP 5: Balance reconciliation snapshot (only once per execution)
593
763
  let balance_reconciliation: ExecutionResult['balance_reconciliation'];
594
764
  if (params.statement_balance !== undefined && params.statement_date) {
595
765
  balance_reconciliation = await buildBalanceReconciliation({
@@ -602,7 +772,7 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
602
772
  });
603
773
  }
604
774
 
605
- // STEP 5: Recommendations and balance changes
775
+ // STEP 6: Recommendations and balance changes
606
776
  if (!params.dry_run && accountSnapshotDirty) {
607
777
  afterAccount = await refreshAccountSnapshot(ynabAPI, budgetId, accountId);
608
778
  }
@@ -641,16 +811,16 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
641
811
  return result;
642
812
  }
643
813
 
644
- interface NormalizedYnabError {
814
+ export interface NormalizedYnabError {
645
815
  status?: number;
646
816
  name?: string;
647
817
  message: string;
648
818
  detail?: string;
649
819
  }
650
820
 
651
- const FATAL_YNAB_STATUS_CODES = new Set([400, 401, 403, 404, 429, 500]);
821
+ const FATAL_YNAB_STATUS_CODES = new Set([400, 401, 403, 404, 429, 500, 503]);
652
822
 
653
- function normalizeYnabError(error: unknown): NormalizedYnabError {
823
+ export function normalizeYnabError(error: unknown): NormalizedYnabError {
654
824
  const parseStatus = (value: unknown): number | undefined => {
655
825
  if (typeof value === 'number' && Number.isFinite(value)) return value;
656
826
  if (typeof value === 'string') {
@@ -661,7 +831,9 @@ function normalizeYnabError(error: unknown): NormalizedYnabError {
661
831
  };
662
832
 
663
833
  if (error instanceof Error) {
664
- const status = parseStatus((error as { status?: unknown }).status);
834
+ const status =
835
+ parseStatus((error as { status?: unknown }).status) ??
836
+ parseStatus((error as { response?: { status?: unknown } }).response?.status);
665
837
  const detailSource = (error as { detail?: unknown }).detail;
666
838
  const detail =
667
839
  typeof detailSource === 'string' && detailSource.trim().length > 0 ? detailSource : undefined;
@@ -714,32 +886,45 @@ function normalizeYnabError(error: unknown): NormalizedYnabError {
714
886
  return { message: 'Unknown error occurred' };
715
887
  }
716
888
 
717
- function shouldPropagateYnabError(error: NormalizedYnabError): boolean {
889
+ export function shouldPropagateYnabError(error: NormalizedYnabError): boolean {
718
890
  return error.status !== undefined && FATAL_YNAB_STATUS_CODES.has(error.status);
719
891
  }
720
892
 
721
- function attachStatusToError(error: NormalizedYnabError): Error {
893
+ function attachStatusToError(error: NormalizedYnabError, originalError?: unknown): Error {
722
894
  const message = error.message || 'YNAB API error';
723
- const err = new Error(message);
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}`);
724
912
  if (error.status !== undefined) {
725
913
  (err as { status?: number }).status = error.status;
726
914
  }
727
915
  if (error.name) {
728
916
  err.name = error.name;
729
917
  }
730
- if (error.detail && !message.includes(error.detail)) {
731
- err.message = `${message} (${error.detail})`;
732
- }
733
918
  return err;
734
919
  }
735
920
 
736
921
  function formatDisplay(amount: number, currency: string): string {
737
- return toMoneyValueFromDecimal(amount, currency).value_display;
922
+ return toMoneyValue(amount, currency).value_display;
738
923
  }
739
924
 
740
925
  function computeUpdateFlags(match: TransactionMatch, params: ReconcileAccountRequest): UpdateFlags {
741
- const ynabTxn = match.ynab_transaction;
742
- const bankTxn = match.bank_transaction;
926
+ const ynabTxn = match.ynabTransaction;
927
+ const bankTxn = match.bankTransaction;
743
928
  if (!ynabTxn) {
744
929
  return { needsClearedUpdate: false, needsDateUpdate: false };
745
930
  }
@@ -756,7 +941,7 @@ function updateReason(match: TransactionMatch, flags: UpdateFlags, _currency: st
756
941
  parts.push('marked as cleared');
757
942
  }
758
943
  if (flags.needsDateUpdate) {
759
- parts.push(`date adjusted to ${match.bank_transaction.date}`);
944
+ parts.push(`date adjusted to ${match.bankTransaction.date}`);
760
945
  }
761
946
  return parts.join(', ');
762
947
  }
@@ -964,9 +1149,7 @@ function sortByDateDescending<T extends { date: string }>(items: T[]): T[] {
964
1149
  }
965
1150
 
966
1151
  function sortMatchesByBankDateDescending(matches: TransactionMatch[]): TransactionMatch[] {
967
- return [...matches].sort((a, b) =>
968
- compareDates(b.bank_transaction.date, a.bank_transaction.date),
969
- );
1152
+ return [...matches].sort((a, b) => compareDates(b.bankTransaction.date, a.bankTransaction.date));
970
1153
  }
971
1154
 
972
1155
  function compareDates(dateA: string, dateB: string): number {