@dizzlkheinz/ynab-mcpb 0.18.3 → 0.19.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 (346) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CLAUDE.md +87 -8
  3. package/bin/ynab-mcp-server.cjs +2 -2
  4. package/bin/ynab-mcp-server.js +3 -3
  5. package/biome.json +39 -0
  6. package/dist/bundle/index.cjs +67 -67
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +27 -27
  9. package/dist/server/YNABMCPServer.d.ts +3 -4
  10. package/dist/server/YNABMCPServer.js +111 -116
  11. package/dist/server/budgetResolver.d.ts +6 -5
  12. package/dist/server/budgetResolver.js +46 -36
  13. package/dist/server/cacheKeys.js +6 -6
  14. package/dist/server/cacheManager.js +14 -11
  15. package/dist/server/completions.d.ts +2 -2
  16. package/dist/server/completions.js +20 -15
  17. package/dist/server/config.d.ts +10 -5
  18. package/dist/server/config.js +24 -7
  19. package/dist/server/deltaCache.d.ts +2 -2
  20. package/dist/server/deltaCache.js +22 -16
  21. package/dist/server/deltaCache.merge.d.ts +2 -2
  22. package/dist/server/diagnostics.d.ts +4 -4
  23. package/dist/server/diagnostics.js +38 -32
  24. package/dist/server/errorHandler.d.ts +5 -12
  25. package/dist/server/errorHandler.js +219 -217
  26. package/dist/server/prompts.d.ts +2 -2
  27. package/dist/server/prompts.js +45 -45
  28. package/dist/server/rateLimiter.js +4 -4
  29. package/dist/server/requestLogger.d.ts +1 -1
  30. package/dist/server/requestLogger.js +40 -35
  31. package/dist/server/resources.d.ts +3 -3
  32. package/dist/server/resources.js +55 -52
  33. package/dist/server/responseFormatter.js +6 -6
  34. package/dist/server/securityMiddleware.d.ts +2 -2
  35. package/dist/server/securityMiddleware.js +22 -20
  36. package/dist/server/serverKnowledgeStore.js +1 -1
  37. package/dist/server/toolRegistry.d.ts +3 -3
  38. package/dist/server/toolRegistry.js +47 -40
  39. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  40. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  41. package/dist/tools/accountTools.d.ts +9 -8
  42. package/dist/tools/accountTools.js +47 -47
  43. package/dist/tools/adapters.d.ts +13 -8
  44. package/dist/tools/adapters.js +21 -11
  45. package/dist/tools/budgetTools.d.ts +8 -7
  46. package/dist/tools/budgetTools.js +22 -22
  47. package/dist/tools/categoryTools.d.ts +9 -8
  48. package/dist/tools/categoryTools.js +68 -59
  49. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  50. package/dist/tools/compareTransactions/formatter.js +9 -9
  51. package/dist/tools/compareTransactions/index.d.ts +6 -6
  52. package/dist/tools/compareTransactions/index.js +58 -43
  53. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  54. package/dist/tools/compareTransactions/matcher.js +28 -15
  55. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  56. package/dist/tools/compareTransactions/parser.js +144 -138
  57. package/dist/tools/compareTransactions/types.d.ts +4 -4
  58. package/dist/tools/compareTransactions.d.ts +1 -1
  59. package/dist/tools/compareTransactions.js +1 -1
  60. package/dist/tools/deltaFetcher.d.ts +2 -2
  61. package/dist/tools/deltaFetcher.js +16 -15
  62. package/dist/tools/deltaSupport.d.ts +4 -4
  63. package/dist/tools/deltaSupport.js +35 -41
  64. package/dist/tools/exportTransactions.d.ts +5 -4
  65. package/dist/tools/exportTransactions.js +61 -59
  66. package/dist/tools/monthTools.d.ts +7 -6
  67. package/dist/tools/monthTools.js +31 -29
  68. package/dist/tools/payeeTools.d.ts +7 -6
  69. package/dist/tools/payeeTools.js +28 -28
  70. package/dist/tools/reconcileAdapter.d.ts +2 -2
  71. package/dist/tools/reconcileAdapter.js +21 -11
  72. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  73. package/dist/tools/reconciliation/analyzer.js +136 -57
  74. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  75. package/dist/tools/reconciliation/csvParser.js +128 -104
  76. package/dist/tools/reconciliation/executor.d.ts +4 -4
  77. package/dist/tools/reconciliation/executor.js +148 -109
  78. package/dist/tools/reconciliation/index.d.ts +10 -10
  79. package/dist/tools/reconciliation/index.js +96 -83
  80. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  81. package/dist/tools/reconciliation/matcher.js +17 -16
  82. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  83. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  84. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  85. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  86. package/dist/tools/reconciliation/reportFormatter.js +79 -54
  87. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  88. package/dist/tools/reconciliation/types.d.ts +19 -16
  89. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  90. package/dist/tools/schemas/common.d.ts +1 -1
  91. package/dist/tools/schemas/common.js +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  93. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  94. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  95. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  96. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  97. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  98. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  99. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  100. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  101. package/dist/tools/schemas/outputs/index.js +14 -14
  102. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  103. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  104. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  105. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  107. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  109. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  110. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  111. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  112. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  113. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  114. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  115. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  116. package/dist/tools/transactionReadTools.d.ts +11 -0
  117. package/dist/tools/transactionReadTools.js +202 -0
  118. package/dist/tools/transactionSchemas.d.ts +309 -0
  119. package/dist/tools/transactionSchemas.js +235 -0
  120. package/dist/tools/transactionTools.d.ts +6 -302
  121. package/dist/tools/transactionTools.js +7 -2054
  122. package/dist/tools/transactionUtils.d.ts +31 -0
  123. package/dist/tools/transactionUtils.js +364 -0
  124. package/dist/tools/transactionWriteTools.d.ts +20 -0
  125. package/dist/tools/transactionWriteTools.js +1342 -0
  126. package/dist/tools/utilityTools.d.ts +5 -4
  127. package/dist/tools/utilityTools.js +11 -11
  128. package/dist/types/index.d.ts +7 -7
  129. package/dist/types/index.js +6 -6
  130. package/dist/types/reconciliation.d.ts +1 -1
  131. package/dist/types/toolRegistration.d.ts +14 -12
  132. package/dist/utils/amountUtils.js +1 -1
  133. package/dist/utils/dateUtils.js +4 -4
  134. package/dist/utils/errors.d.ts +3 -3
  135. package/dist/utils/errors.js +4 -4
  136. package/dist/utils/money.d.ts +2 -2
  137. package/dist/utils/money.js +8 -8
  138. package/dist/utils/validationError.d.ts +1 -1
  139. package/dist/utils/validationError.js +1 -1
  140. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  141. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  142. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  143. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  144. package/esbuild.config.mjs +53 -50
  145. package/meta.json +12548 -12548
  146. package/package.json +98 -109
  147. package/scripts/analyze-bundle.mjs +33 -30
  148. package/scripts/create-pr-description.js +169 -120
  149. package/scripts/run-all-tests.js +205 -0
  150. package/scripts/run-domain-integration-tests.js +28 -18
  151. package/scripts/run-generate-mcpb.js +19 -17
  152. package/scripts/run-throttled-integration-tests.js +92 -83
  153. package/scripts/test-delta-params.mjs +149 -120
  154. package/scripts/test-recommendations.ts +36 -32
  155. package/scripts/tmpTransaction.ts +80 -43
  156. package/scripts/validate-env.js +98 -91
  157. package/scripts/verify-build.js +78 -76
  158. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  159. package/src/__tests__/performance.test.ts +723 -671
  160. package/src/__tests__/setup.ts +442 -395
  161. package/src/__tests__/smoke.e2e.test.ts +41 -39
  162. package/src/__tests__/testRunner.ts +314 -295
  163. package/src/__tests__/testUtils.ts +456 -364
  164. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  165. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  166. package/src/index.ts +68 -59
  167. package/src/server/CLAUDE.md +480 -0
  168. package/src/server/YNABMCPServer.ts +821 -794
  169. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  170. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  171. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  172. package/src/server/__tests__/cacheManager.test.ts +891 -874
  173. package/src/server/__tests__/completions.integration.test.ts +115 -106
  174. package/src/server/__tests__/completions.test.ts +334 -313
  175. package/src/server/__tests__/config.test.ts +98 -86
  176. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  177. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  178. package/src/server/__tests__/deltaCache.test.ts +946 -759
  179. package/src/server/__tests__/diagnostics.test.ts +825 -792
  180. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  181. package/src/server/__tests__/errorHandler.test.ts +402 -397
  182. package/src/server/__tests__/prompts.test.ts +424 -347
  183. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  184. package/src/server/__tests__/requestLogger.test.ts +443 -403
  185. package/src/server/__tests__/resources.template.test.ts +196 -185
  186. package/src/server/__tests__/resources.test.ts +294 -288
  187. package/src/server/__tests__/security.integration.test.ts +487 -421
  188. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  189. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  190. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  191. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  192. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  193. package/src/server/budgetResolver.ts +221 -181
  194. package/src/server/cacheKeys.ts +6 -6
  195. package/src/server/cacheManager.ts +498 -484
  196. package/src/server/completions.ts +267 -243
  197. package/src/server/config.ts +35 -14
  198. package/src/server/deltaCache.merge.ts +146 -128
  199. package/src/server/deltaCache.ts +352 -309
  200. package/src/server/diagnostics.ts +257 -242
  201. package/src/server/errorHandler.ts +747 -744
  202. package/src/server/prompts.ts +181 -176
  203. package/src/server/rateLimiter.ts +131 -129
  204. package/src/server/requestLogger.ts +350 -322
  205. package/src/server/resources.ts +442 -374
  206. package/src/server/responseFormatter.ts +41 -37
  207. package/src/server/securityMiddleware.ts +223 -205
  208. package/src/server/serverKnowledgeStore.ts +67 -67
  209. package/src/server/toolRegistry.ts +508 -474
  210. package/src/tools/CLAUDE.md +604 -0
  211. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  212. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  213. package/src/tools/__tests__/accountTools.test.ts +685 -638
  214. package/src/tools/__tests__/adapters.test.ts +142 -108
  215. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  216. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  217. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  218. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  219. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  220. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  221. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  222. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  223. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  224. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  225. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  226. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  227. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  228. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  229. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  230. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  231. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  232. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  234. package/src/tools/__tests__/monthTools.test.ts +561 -512
  235. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  236. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  237. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  238. package/src/tools/__tests__/transactionSchemas.test.ts +1204 -0
  239. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  240. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  241. package/src/tools/__tests__/transactionUtils.test.ts +1016 -0
  242. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  243. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  244. package/src/tools/accountTools.ts +293 -271
  245. package/src/tools/adapters.ts +120 -63
  246. package/src/tools/budgetTools.ts +121 -116
  247. package/src/tools/categoryTools.ts +379 -339
  248. package/src/tools/compareTransactions/formatter.ts +131 -119
  249. package/src/tools/compareTransactions/index.ts +249 -214
  250. package/src/tools/compareTransactions/matcher.ts +259 -209
  251. package/src/tools/compareTransactions/parser.ts +517 -487
  252. package/src/tools/compareTransactions/types.ts +38 -38
  253. package/src/tools/compareTransactions.ts +1 -1
  254. package/src/tools/deltaFetcher.ts +281 -260
  255. package/src/tools/deltaSupport.ts +264 -259
  256. package/src/tools/exportTransactions.ts +230 -218
  257. package/src/tools/monthTools.ts +180 -165
  258. package/src/tools/payeeTools.ts +152 -140
  259. package/src/tools/reconcileAdapter.ts +297 -246
  260. package/src/tools/reconciliation/CLAUDE.md +506 -0
  261. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +135 -112
  262. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -227
  263. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -335
  264. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  265. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  266. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  267. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  268. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  269. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  270. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  271. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -986
  272. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  273. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -530
  274. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -71
  275. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -58
  276. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  277. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +58 -43
  278. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  279. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  280. package/src/tools/reconciliation/analyzer.ts +582 -406
  281. package/src/tools/reconciliation/csvParser.ts +656 -609
  282. package/src/tools/reconciliation/executor.ts +1290 -1128
  283. package/src/tools/reconciliation/index.ts +580 -528
  284. package/src/tools/reconciliation/matcher.ts +256 -240
  285. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  286. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  287. package/src/tools/reconciliation/reportFormatter.ts +349 -276
  288. package/src/tools/reconciliation/signDetector.ts +89 -83
  289. package/src/tools/reconciliation/types.ts +164 -153
  290. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  291. package/src/tools/schemas/CLAUDE.md +546 -0
  292. package/src/tools/schemas/common.ts +1 -1
  293. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  294. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  295. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  296. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  297. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  298. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  299. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  300. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  301. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  302. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  303. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  304. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  305. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  306. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  307. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  308. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  309. package/src/tools/schemas/outputs/index.ts +163 -163
  310. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  311. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  312. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  313. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  314. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  315. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  316. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  317. package/src/tools/toolCategories.ts +114 -114
  318. package/src/tools/transactionReadTools.ts +327 -0
  319. package/src/tools/transactionSchemas.ts +484 -0
  320. package/src/tools/transactionTools.ts +107 -2990
  321. package/src/tools/transactionUtils.ts +621 -0
  322. package/src/tools/transactionWriteTools.ts +2110 -0
  323. package/src/tools/utilityTools.ts +46 -41
  324. package/src/types/CLAUDE.md +477 -0
  325. package/src/types/__tests__/index.test.ts +51 -51
  326. package/src/types/index.ts +43 -39
  327. package/src/types/integration-tests.d.ts +26 -26
  328. package/src/types/reconciliation.ts +29 -29
  329. package/src/types/toolAnnotations.ts +30 -30
  330. package/src/types/toolRegistration.ts +43 -32
  331. package/src/utils/CLAUDE.md +508 -0
  332. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  333. package/src/utils/__tests__/money.test.ts +193 -187
  334. package/src/utils/amountUtils.ts +5 -5
  335. package/src/utils/baseError.ts +5 -5
  336. package/src/utils/dateUtils.ts +29 -26
  337. package/src/utils/errors.ts +14 -14
  338. package/src/utils/money.ts +66 -52
  339. package/src/utils/validationError.ts +1 -1
  340. package/tsconfig.json +29 -29
  341. package/tsconfig.prod.json +16 -16
  342. package/vitest-reporters/split-json-reporter.ts +247 -204
  343. package/vitest.config.ts +99 -95
  344. package/.prettierignore +0 -10
  345. package/.prettierrc.json +0 -10
  346. package/eslint.config.js +0 -49
@@ -5,320 +5,461 @@
5
5
  * V2 UPDATE: Uses new parser and matcher (milliunits based)
6
6
  */
7
7
 
8
- import type * as ynab from 'ynab';
9
- import { parseCSV, type ParseCSVOptions, type CSVParseResult } from './csvParser.js';
10
- import { findMatches, normalizeConfig, type MatchingConfig, DEFAULT_CONFIG } from './matcher.js';
11
- import { normalizeYNABTransactions } from './ynabAdapter.js';
12
-
8
+ import type * as ynab from "ynab";
9
+ import {
10
+ type CSVParseResult,
11
+ type ParseCSVOptions,
12
+ parseCSV,
13
+ } from "./csvParser.js";
14
+ import {
15
+ DEFAULT_CONFIG,
16
+ type MatchingConfig,
17
+ findMatches,
18
+ normalizeConfig,
19
+ } from "./matcher.js";
20
+ import { normalizeYNABTransactions } from "./ynabAdapter.js";
21
+
22
+ import { toMoneyValue } from "../../utils/money.js";
23
+ import type { MatchResult } from "./matcher.js"; // Import MatchResult
24
+ import { generateRecommendations } from "./recommendationEngine.js";
13
25
  import type {
14
- BankTransaction,
15
- YNABTransaction,
16
- ReconciliationAnalysis,
17
- TransactionMatch,
18
- BalanceInfo,
19
- ReconciliationSummary,
20
- ReconciliationInsight,
21
- } from './types.js';
22
- import type { MatchResult } from './matcher.js'; // Import MatchResult
23
- import { toMoneyValue } from '../../utils/money.js';
24
- import { generateRecommendations } from './recommendationEngine.js';
26
+ BalanceInfo,
27
+ BankTransaction,
28
+ ReconciliationAnalysis,
29
+ ReconciliationInsight,
30
+ ReconciliationSummary,
31
+ TransactionMatch,
32
+ YNABTransaction,
33
+ } from "./types.js";
25
34
 
26
35
  // --- Helper Functions ---
27
36
 
37
+ /**
38
+ * Calculate the date range from bank transactions
39
+ * Returns { minDate, maxDate } as ISO date strings (YYYY-MM-DD)
40
+ */
41
+ function calculateDateRange(bankTransactions: BankTransaction[]): {
42
+ minDate: string;
43
+ maxDate: string;
44
+ } | null {
45
+ if (bankTransactions.length === 0) {
46
+ return null;
47
+ }
48
+
49
+ const dates = bankTransactions
50
+ .map((t) => t.date)
51
+ .filter((d) => d && /^\d{4}-\d{2}-\d{2}$/.test(d))
52
+ .sort();
53
+
54
+ if (dates.length === 0) {
55
+ return null;
56
+ }
57
+
58
+ const minDate = dates[0];
59
+ const maxDate = dates[dates.length - 1];
60
+ if (!minDate || !maxDate) {
61
+ return null;
62
+ }
63
+
64
+ return {
65
+ minDate,
66
+ maxDate,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Filter YNAB transactions to only those within the given date range
72
+ * Returns { inRange, outsideRange } arrays
73
+ *
74
+ * @param dateToleranceDays - Buffer to add to the date range to account for bank posting delays
75
+ */
76
+ function filterByDateRange(
77
+ ynabTransactions: YNABTransaction[],
78
+ dateRange: { minDate: string; maxDate: string },
79
+ dateToleranceDays = 7,
80
+ ): { inRange: YNABTransaction[]; outsideRange: YNABTransaction[] } {
81
+ // Validate dateToleranceDays is non-negative
82
+ const safeToleranceDays = dateToleranceDays < 0 ? 0 : dateToleranceDays;
83
+ if (dateToleranceDays < 0) {
84
+ console.warn(
85
+ `[filterByDateRange] dateToleranceDays must be non-negative, got ${dateToleranceDays}. Using 0.`,
86
+ );
87
+ }
88
+
89
+ const inRange: YNABTransaction[] = [];
90
+ const outsideRange: YNABTransaction[] = [];
91
+
92
+ // Parse date parts and use Date.UTC to avoid timezone issues
93
+ // This prevents 'off-by-one-day' errors from timezone conversions
94
+ const minParts = dateRange.minDate.split("-").map(Number);
95
+ const maxParts = dateRange.maxDate.split("-").map(Number);
96
+
97
+ // Validate date parts are valid numbers
98
+ if (
99
+ minParts.length !== 3 ||
100
+ maxParts.length !== 3 ||
101
+ minParts.some((n) => !Number.isFinite(n)) ||
102
+ maxParts.some((n) => !Number.isFinite(n))
103
+ ) {
104
+ console.warn(
105
+ `[filterByDateRange] Invalid date format in range: ${dateRange.minDate} to ${dateRange.maxDate} - returning all transactions`,
106
+ );
107
+ return { inRange: ynabTransactions, outsideRange: [] };
108
+ }
109
+
110
+ const [minYear, minMonth, minDay] = minParts as [number, number, number];
111
+ const [maxYear, maxMonth, maxDay] = maxParts as [number, number, number];
112
+
113
+ // Add buffer to date range to account for bank posting delays
114
+ // Note: Date.UTC automatically handles month rollover if day goes negative
115
+ // (e.g., day 3 - 7 days = -4 correctly rolls back to previous month)
116
+ const minDateWithBuffer = new Date(
117
+ Date.UTC(minYear, minMonth - 1, minDay - safeToleranceDays),
118
+ );
119
+ const minDateStr = minDateWithBuffer.toISOString().split("T")[0] ?? "";
120
+
121
+ const maxDateWithBuffer = new Date(
122
+ Date.UTC(maxYear, maxMonth - 1, maxDay + safeToleranceDays),
123
+ );
124
+ const maxDateStr = maxDateWithBuffer.toISOString().split("T")[0] ?? "";
125
+
126
+ for (const txn of ynabTransactions) {
127
+ // Compare dates as strings (YYYY-MM-DD format sorts correctly)
128
+ if (txn.date >= minDateStr && txn.date <= maxDateStr) {
129
+ inRange.push(txn);
130
+ } else {
131
+ outsideRange.push(txn);
132
+ }
133
+ }
134
+
135
+ return { inRange, outsideRange };
136
+ }
137
+
28
138
  function mapToTransactionMatch(result: MatchResult): TransactionMatch {
29
- const candidates = result.candidates.map((c) => ({
30
- ynab_transaction: c.ynabTransaction,
31
- confidence: c.scores.combined,
32
- match_reason: c.matchReasons.join(', '),
33
- explanation: c.matchReasons.join(', '),
34
- }));
35
-
36
- const match: TransactionMatch = {
37
- bankTransaction: result.bankTransaction,
38
- candidates,
39
- confidence: result.confidence,
40
- confidenceScore: result.confidenceScore,
41
- matchReason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
42
- actionHint: result.confidence === 'high' ? 'approve' : 'review',
43
- };
44
-
45
- if (result.bestMatch) {
46
- match.ynabTransaction = result.bestMatch.ynabTransaction;
47
- }
48
-
49
- if (result.candidates[0]) {
50
- match.topConfidence = result.candidates[0].scores.combined;
51
- }
52
-
53
- if (result.confidence === 'none') {
54
- match.recommendation = 'This bank transaction is not in YNAB. Consider adding it.';
55
- }
56
-
57
- return match;
139
+ const candidates = result.candidates.map((c) => ({
140
+ ynab_transaction: c.ynabTransaction,
141
+ confidence: c.scores.combined,
142
+ match_reason: c.matchReasons.join(", "),
143
+ explanation: c.matchReasons.join(", "),
144
+ }));
145
+
146
+ const match: TransactionMatch = {
147
+ bankTransaction: result.bankTransaction,
148
+ candidates,
149
+ confidence: result.confidence,
150
+ confidenceScore: result.confidenceScore,
151
+ matchReason: result.bestMatch?.matchReasons.join(", ") ?? "No match found",
152
+ actionHint: result.confidence === "high" ? "approve" : "review",
153
+ };
154
+
155
+ if (result.bestMatch) {
156
+ match.ynabTransaction = result.bestMatch.ynabTransaction;
157
+ }
158
+
159
+ if (result.candidates[0]) {
160
+ match.topConfidence = result.candidates[0].scores.combined;
161
+ }
162
+
163
+ if (result.confidence === "none") {
164
+ match.recommendation =
165
+ "This bank transaction is not in YNAB. Consider adding it.";
166
+ }
167
+
168
+ return match;
58
169
  }
59
170
 
60
171
  function calculateBalances(
61
- ynabTransactions: YNABTransaction[],
62
- statementBalanceDecimal: number,
63
- currency: string,
64
- accountSnapshot?: { balance?: number; cleared_balance?: number; uncleared_balance?: number },
172
+ ynabTransactions: YNABTransaction[],
173
+ statementBalanceDecimal: number,
174
+ currency: string,
175
+ accountSnapshot?: {
176
+ balance?: number;
177
+ cleared_balance?: number;
178
+ uncleared_balance?: number;
179
+ },
65
180
  ): BalanceInfo {
66
- // Compute from the fetched transactions, but prefer the authoritative account snapshot
67
- // because we usually fetch a limited date window.
68
- let computedCleared = 0;
69
- let computedUncleared = 0;
70
-
71
- for (const txn of ynabTransactions) {
72
- const amount = txn.amount; // Milliunits
73
-
74
- if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
75
- computedCleared += amount;
76
- } else {
77
- computedUncleared += amount;
78
- }
79
- }
80
-
81
- const clearedBalance = accountSnapshot?.cleared_balance ?? computedCleared;
82
- const unclearedBalance = accountSnapshot?.uncleared_balance ?? computedUncleared;
83
- const totalBalance = accountSnapshot?.balance ?? clearedBalance + unclearedBalance;
84
-
85
- const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
86
- const discrepancy = clearedBalance - statementBalanceMilli;
87
-
88
- return {
89
- current_cleared: toMoneyValue(clearedBalance, currency),
90
- current_uncleared: toMoneyValue(unclearedBalance, currency),
91
- current_total: toMoneyValue(totalBalance, currency),
92
- target_statement: toMoneyValue(statementBalanceMilli, currency),
93
- discrepancy: toMoneyValue(discrepancy, currency),
94
- on_track: Math.abs(discrepancy) < 10, // Within 1 cent (10 milliunits)
95
- };
181
+ // Compute from the fetched transactions, but prefer the authoritative account snapshot
182
+ // because we usually fetch a limited date window.
183
+ let computedCleared = 0;
184
+ let computedUncleared = 0;
185
+
186
+ for (const txn of ynabTransactions) {
187
+ const amount = txn.amount; // Milliunits
188
+
189
+ if (txn.cleared === "cleared" || txn.cleared === "reconciled") {
190
+ computedCleared += amount;
191
+ } else {
192
+ computedUncleared += amount;
193
+ }
194
+ }
195
+
196
+ const clearedBalance = accountSnapshot?.cleared_balance ?? computedCleared;
197
+ const unclearedBalance =
198
+ accountSnapshot?.uncleared_balance ?? computedUncleared;
199
+ const totalBalance =
200
+ accountSnapshot?.balance ?? clearedBalance + unclearedBalance;
201
+
202
+ const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
203
+ const discrepancy = clearedBalance - statementBalanceMilli;
204
+
205
+ return {
206
+ current_cleared: toMoneyValue(clearedBalance, currency),
207
+ current_uncleared: toMoneyValue(unclearedBalance, currency),
208
+ current_total: toMoneyValue(totalBalance, currency),
209
+ target_statement: toMoneyValue(statementBalanceMilli, currency),
210
+ discrepancy: toMoneyValue(discrepancy, currency),
211
+ on_track: Math.abs(discrepancy) < 10, // Within 1 cent (10 milliunits)
212
+ };
96
213
  }
97
214
 
98
215
  function generateSummary(
99
- bankTransactions: BankTransaction[],
100
- ynabTransactions: YNABTransaction[],
101
- autoMatches: TransactionMatch[],
102
- suggestedMatches: TransactionMatch[],
103
- unmatchedBank: BankTransaction[],
104
- unmatchedYNAB: YNABTransaction[],
105
- balances: BalanceInfo,
216
+ bankTransactions: BankTransaction[],
217
+ ynabTransactionsInRange: YNABTransaction[],
218
+ ynabTransactionsOutsideRange: YNABTransaction[],
219
+ autoMatches: TransactionMatch[],
220
+ suggestedMatches: TransactionMatch[],
221
+ unmatchedBank: BankTransaction[],
222
+ unmatchedYNAB: YNABTransaction[],
223
+ balances: BalanceInfo,
106
224
  ): ReconciliationSummary {
107
- // Determine date range from bank transactions
108
- const dates = bankTransactions.map((t) => t.date).sort();
109
- const dateRange = dates.length > 0 ? `${dates[0]} to ${dates[dates.length - 1]}` : 'Unknown';
110
-
111
- // Build discrepancy explanation
112
- let discrepancyExplanation = '';
113
- if (balances.on_track) {
114
- discrepancyExplanation = 'Cleared balance matches statement';
115
- } else {
116
- const actionsNeeded: string[] = [];
117
- if (autoMatches.length > 0) {
118
- actionsNeeded.push(`clear ${autoMatches.length} transactions`);
119
- }
120
- if (unmatchedBank.length > 0) {
121
- actionsNeeded.push(`add ${unmatchedBank.length} missing`);
122
- }
123
- if (unmatchedYNAB.length > 0) {
124
- actionsNeeded.push(`review ${unmatchedYNAB.length} unmatched YNAB`);
125
- }
126
-
127
- discrepancyExplanation =
128
- actionsNeeded.length > 0 ? `Need to ${actionsNeeded.join(', ')}` : 'Manual review required';
129
- }
130
-
131
- return {
132
- statement_date_range: dateRange,
133
- bank_transactions_count: bankTransactions.length,
134
- ynab_transactions_count: ynabTransactions.length,
135
- auto_matched: autoMatches.length,
136
- suggested_matches: suggestedMatches.length,
137
- unmatched_bank: unmatchedBank.length,
138
- unmatched_ynab: unmatchedYNAB.length,
139
- current_cleared_balance: balances.current_cleared,
140
- target_statement_balance: balances.target_statement,
141
- discrepancy: balances.discrepancy,
142
- discrepancy_explanation: discrepancyExplanation,
143
- };
225
+ // Determine date range from bank transactions
226
+ const dates = bankTransactions.map((t) => t.date).sort();
227
+ const dateRange =
228
+ dates.length > 0 ? `${dates[0]} to ${dates[dates.length - 1]}` : "Unknown";
229
+
230
+ // Total YNAB transactions = in range + outside range
231
+ const totalYnabCount =
232
+ ynabTransactionsInRange.length + ynabTransactionsOutsideRange.length;
233
+
234
+ // Build discrepancy explanation
235
+ let discrepancyExplanation = "";
236
+ if (balances.on_track) {
237
+ discrepancyExplanation = "Cleared balance matches statement";
238
+ } else {
239
+ const actionsNeeded: string[] = [];
240
+ if (autoMatches.length > 0) {
241
+ actionsNeeded.push(`clear ${autoMatches.length} transactions`);
242
+ }
243
+ if (unmatchedBank.length > 0) {
244
+ actionsNeeded.push(`add ${unmatchedBank.length} missing`);
245
+ }
246
+ if (unmatchedYNAB.length > 0) {
247
+ actionsNeeded.push(`review ${unmatchedYNAB.length} unmatched YNAB`);
248
+ }
249
+
250
+ discrepancyExplanation =
251
+ actionsNeeded.length > 0
252
+ ? `Need to ${actionsNeeded.join(", ")}`
253
+ : "Manual review required";
254
+ }
255
+
256
+ return {
257
+ statement_date_range: dateRange,
258
+ bank_transactions_count: bankTransactions.length,
259
+ ynab_transactions_count: totalYnabCount,
260
+ ynab_in_range_count: ynabTransactionsInRange.length,
261
+ ynab_outside_range_count: ynabTransactionsOutsideRange.length,
262
+ auto_matched: autoMatches.length,
263
+ suggested_matches: suggestedMatches.length,
264
+ unmatched_bank: unmatchedBank.length,
265
+ unmatched_ynab: unmatchedYNAB.length,
266
+ current_cleared_balance: balances.current_cleared,
267
+ target_statement_balance: balances.target_statement,
268
+ discrepancy: balances.discrepancy,
269
+ discrepancy_explanation: discrepancyExplanation,
270
+ };
144
271
  }
145
272
 
146
273
  function generateNextSteps(summary: ReconciliationSummary): string[] {
147
- const steps: string[] = [];
148
-
149
- if (summary.auto_matched > 0) {
150
- steps.push(`Review ${summary.auto_matched} auto-matched transactions for approval`);
151
- }
152
-
153
- if (summary.suggested_matches > 0) {
154
- steps.push(`Review ${summary.suggested_matches} suggested matches and choose best match`);
155
- }
156
-
157
- if (summary.unmatched_bank > 0) {
158
- steps.push(`Decide whether to add ${summary.unmatched_bank} missing bank transactions to YNAB`);
159
- }
160
-
161
- if (summary.unmatched_ynab > 0) {
162
- steps.push(
163
- `Decide what to do with ${summary.unmatched_ynab} unmatched YNAB transactions (unclear/delete/ignore)`,
164
- );
165
- }
166
-
167
- if (steps.length === 0) {
168
- steps.push('All transactions matched! Review and approve to complete reconciliation');
169
- }
170
-
171
- return steps;
274
+ const steps: string[] = [];
275
+
276
+ if (summary.auto_matched > 0) {
277
+ steps.push(
278
+ `Review ${summary.auto_matched} auto-matched transactions for approval`,
279
+ );
280
+ }
281
+
282
+ if (summary.suggested_matches > 0) {
283
+ steps.push(
284
+ `Review ${summary.suggested_matches} suggested matches and choose best match`,
285
+ );
286
+ }
287
+
288
+ if (summary.unmatched_bank > 0) {
289
+ steps.push(
290
+ `Decide whether to add ${summary.unmatched_bank} missing bank transactions to YNAB`,
291
+ );
292
+ }
293
+
294
+ if (summary.unmatched_ynab > 0) {
295
+ steps.push(
296
+ `Decide what to do with ${summary.unmatched_ynab} unmatched YNAB transactions (unclear/delete/ignore)`,
297
+ );
298
+ }
299
+
300
+ if (steps.length === 0) {
301
+ steps.push(
302
+ "All transactions matched! Review and approve to complete reconciliation",
303
+ );
304
+ }
305
+
306
+ return steps;
172
307
  }
173
308
 
174
- function formatCurrency(amountMilli: number, currency: string = 'USD'): string {
175
- const formatter = new Intl.NumberFormat('en-US', {
176
- style: 'currency',
177
- currency: currency,
178
- minimumFractionDigits: 2,
179
- maximumFractionDigits: 2,
180
- });
181
- return formatter.format(amountMilli / 1000);
309
+ function formatCurrency(amountMilli: number, currency = "USD"): string {
310
+ const formatter = new Intl.NumberFormat("en-US", {
311
+ style: "currency",
312
+ currency: currency,
313
+ minimumFractionDigits: 2,
314
+ maximumFractionDigits: 2,
315
+ });
316
+ return formatter.format(amountMilli / 1000);
182
317
  }
183
318
 
184
319
  // --- Insight Generation ---
185
320
 
186
321
  function repeatAmountInsights(
187
- unmatchedBank: BankTransaction[],
188
- currency: string = 'USD',
322
+ unmatchedBank: BankTransaction[],
323
+ currency = "USD",
189
324
  ): ReconciliationInsight[] {
190
- const insights: ReconciliationInsight[] = [];
191
- if (unmatchedBank.length === 0) {
192
- return insights;
193
- }
194
-
195
- // Group by milliunits amount
196
- const frequency = new Map<number, { amount: number; txns: BankTransaction[] }>();
197
-
198
- for (const txn of unmatchedBank) {
199
- const key = txn.amount;
200
- const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
201
- entry.txns.push(txn);
202
- frequency.set(key, entry);
203
- }
204
-
205
- const repeated = Array.from(frequency.values())
206
- .filter((entry) => entry.txns.length >= 2)
207
- .sort((a, b) => b.txns.length - a.txns.length);
208
-
209
- if (repeated.length === 0) {
210
- return insights;
211
- }
212
-
213
- const top = repeated[0]!;
214
- insights.push({
215
- id: `repeat-${top.amount}`,
216
- type: 'repeat_amount',
217
- severity: top.txns.length >= 4 ? 'critical' : 'warning',
218
- title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
219
- description:
220
- `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. ` +
221
- 'Repeated amounts are usually the quickest wins — reconcile these first.',
222
- evidence: {
223
- amount: top.amount, // Milliunits
224
- occurrences: top.txns.length,
225
- dates: top.txns.map((txn) => txn.date),
226
- csv_rows: top.txns.map((txn) => txn.sourceRow),
227
- },
228
- });
229
-
230
- return insights;
325
+ const insights: ReconciliationInsight[] = [];
326
+ if (unmatchedBank.length === 0) {
327
+ return insights;
328
+ }
329
+
330
+ // Group by milliunits amount
331
+ const frequency = new Map<
332
+ number,
333
+ { amount: number; txns: BankTransaction[] }
334
+ >();
335
+
336
+ for (const txn of unmatchedBank) {
337
+ const key = txn.amount;
338
+ const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
339
+ entry.txns.push(txn);
340
+ frequency.set(key, entry);
341
+ }
342
+
343
+ const repeated = Array.from(frequency.values())
344
+ .filter((entry) => entry.txns.length >= 2)
345
+ .sort((a, b) => b.txns.length - a.txns.length);
346
+
347
+ if (repeated.length === 0) {
348
+ return insights;
349
+ }
350
+
351
+ const top = repeated[0];
352
+ if (!top) {
353
+ return insights;
354
+ }
355
+ insights.push({
356
+ id: `repeat-${top.amount}`,
357
+ type: "repeat_amount",
358
+ severity: top.txns.length >= 4 ? "critical" : "warning",
359
+ title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
360
+ description: `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. Repeated amounts are usually the quickest wins — reconcile these first.`,
361
+ evidence: {
362
+ amount: top.amount, // Milliunits
363
+ occurrences: top.txns.length,
364
+ dates: top.txns.map((txn) => txn.date),
365
+ csv_rows: top.txns.map((txn) => txn.sourceRow),
366
+ },
367
+ });
368
+
369
+ return insights;
231
370
  }
232
371
 
233
372
  function anomalyInsights(balances: BalanceInfo): ReconciliationInsight[] {
234
- const insights: ReconciliationInsight[] = [];
235
- const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
236
-
237
- if (discrepancyAbs >= 1000) {
238
- // 1 dollar
239
- insights.push({
240
- id: 'balance-gap',
241
- type: 'anomaly',
242
- severity: discrepancyAbs >= 100000 ? 'critical' : 'warning', // 100 dollars
243
- title: `Cleared balance off by ${balances.discrepancy.value_display}`,
244
- description:
245
- `YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
246
- `${balances.target_statement.value_display}. Focus on closing this gap.`,
247
- evidence: {
248
- cleared_balance: balances.current_cleared,
249
- statement_balance: balances.target_statement,
250
- discrepancy: balances.discrepancy,
251
- },
252
- });
253
- }
254
-
255
- return insights;
373
+ const insights: ReconciliationInsight[] = [];
374
+ const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
375
+
376
+ if (discrepancyAbs >= 1000) {
377
+ // 1 dollar
378
+ insights.push({
379
+ id: "balance-gap",
380
+ type: "anomaly",
381
+ severity: discrepancyAbs >= 100000 ? "critical" : "warning", // 100 dollars
382
+ title: `Cleared balance off by ${balances.discrepancy.value_display}`,
383
+ description:
384
+ `YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
385
+ `${balances.target_statement.value_display}. Focus on closing this gap.`,
386
+ evidence: {
387
+ cleared_balance: balances.current_cleared,
388
+ statement_balance: balances.target_statement,
389
+ discrepancy: balances.discrepancy,
390
+ },
391
+ });
392
+ }
393
+
394
+ return insights;
256
395
  }
257
396
 
258
397
  function detectInsights(
259
- unmatchedBank: BankTransaction[],
260
- _summary: ReconciliationSummary,
261
- balances: BalanceInfo,
262
- currency: string,
263
- csvErrors: { row: number; field: string; message: string }[] = [],
264
- csvWarnings: { row: number; message: string }[] = [],
398
+ unmatchedBank: BankTransaction[],
399
+ _summary: ReconciliationSummary,
400
+ balances: BalanceInfo,
401
+ currency: string,
402
+ csvErrors: { row: number; field: string; message: string }[] = [],
403
+ csvWarnings: { row: number; message: string }[] = [],
265
404
  ): ReconciliationInsight[] {
266
- const insights: ReconciliationInsight[] = [];
267
- const seen = new Set<string>();
268
-
269
- const addUnique = (insight: ReconciliationInsight) => {
270
- if (seen.has(insight.id)) return;
271
- seen.add(insight.id);
272
- insights.push(insight);
273
- };
274
-
275
- // Surface CSV parsing errors
276
- if (csvErrors.length > 0) {
277
- addUnique({
278
- id: 'csv-parse-errors',
279
- type: 'anomaly',
280
- severity: csvErrors.length >= 5 ? 'critical' : 'warning',
281
- title: `${csvErrors.length} CSV parsing error(s)`,
282
- description:
283
- csvErrors
284
- .slice(0, 3)
285
- .map((e) => `Row ${e.row}: ${e.message}`)
286
- .join('; ') + (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ''),
287
- evidence: {
288
- error_count: csvErrors.length,
289
- errors: csvErrors.slice(0, 5),
290
- },
291
- });
292
- }
293
-
294
- // Surface CSV parsing warnings
295
- if (csvWarnings.length > 0) {
296
- addUnique({
297
- id: 'csv-parse-warnings',
298
- type: 'anomaly',
299
- severity: 'info',
300
- title: `${csvWarnings.length} CSV parsing warning(s)`,
301
- description:
302
- csvWarnings
303
- .slice(0, 3)
304
- .map((w) => `Row ${w.row}: ${w.message}`)
305
- .join('; ') + (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ''),
306
- evidence: {
307
- warning_count: csvWarnings.length,
308
- warnings: csvWarnings.slice(0, 5),
309
- },
310
- });
311
- }
312
-
313
- for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
314
- addUnique(insight);
315
- }
316
-
317
- for (const insight of anomalyInsights(balances)) {
318
- addUnique(insight);
319
- }
320
-
321
- return insights.slice(0, 5);
405
+ const insights: ReconciliationInsight[] = [];
406
+ const seen = new Set<string>();
407
+
408
+ const addUnique = (insight: ReconciliationInsight) => {
409
+ if (seen.has(insight.id)) return;
410
+ seen.add(insight.id);
411
+ insights.push(insight);
412
+ };
413
+
414
+ // Surface CSV parsing errors
415
+ if (csvErrors.length > 0) {
416
+ addUnique({
417
+ id: "csv-parse-errors",
418
+ type: "anomaly",
419
+ severity: csvErrors.length >= 5 ? "critical" : "warning",
420
+ title: `${csvErrors.length} CSV parsing error(s)`,
421
+ description:
422
+ csvErrors
423
+ .slice(0, 3)
424
+ .map((e) => `Row ${e.row}: ${e.message}`)
425
+ .join("; ") +
426
+ (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ""),
427
+ evidence: {
428
+ error_count: csvErrors.length,
429
+ errors: csvErrors.slice(0, 5),
430
+ },
431
+ });
432
+ }
433
+
434
+ // Surface CSV parsing warnings
435
+ if (csvWarnings.length > 0) {
436
+ addUnique({
437
+ id: "csv-parse-warnings",
438
+ type: "anomaly",
439
+ severity: "info",
440
+ title: `${csvWarnings.length} CSV parsing warning(s)`,
441
+ description:
442
+ csvWarnings
443
+ .slice(0, 3)
444
+ .map((w) => `Row ${w.row}: ${w.message}`)
445
+ .join("; ") +
446
+ (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ""),
447
+ evidence: {
448
+ warning_count: csvWarnings.length,
449
+ warnings: csvWarnings.slice(0, 5),
450
+ },
451
+ });
452
+ }
453
+
454
+ for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
455
+ addUnique(insight);
456
+ }
457
+
458
+ for (const insight of anomalyInsights(balances)) {
459
+ addUnique(insight);
460
+ }
461
+
462
+ return insights.slice(0, 5);
322
463
  }
323
464
 
324
465
  // --- Main Analysis Function ---
@@ -338,128 +479,163 @@ function detectInsights(
338
479
  * @param csvOptions - Optional CSV parsing options (manual overrides)
339
480
  */
340
481
  export function analyzeReconciliation(
341
- csvContentOrParsed: string | CSVParseResult,
342
- _csvFilePath: string | undefined,
343
- ynabTransactions: ynab.TransactionDetail[],
344
- statementBalance: number,
345
- config: MatchingConfig = DEFAULT_CONFIG,
346
- currency: string = 'USD',
347
- accountId?: string,
348
- budgetId?: string,
349
- invertBankAmounts: boolean = false,
350
- csvOptions?: ParseCSVOptions,
351
- accountSnapshot?: { balance?: number; cleared_balance?: number; uncleared_balance?: number },
482
+ csvContentOrParsed: string | CSVParseResult,
483
+ _csvFilePath: string | undefined,
484
+ ynabTransactions: ynab.TransactionDetail[],
485
+ statementBalance: number,
486
+ config: MatchingConfig = DEFAULT_CONFIG,
487
+ currency = "USD",
488
+ accountId?: string,
489
+ budgetId?: string,
490
+ invertBankAmounts = false,
491
+ csvOptions?: ParseCSVOptions,
492
+ accountSnapshot?: {
493
+ balance?: number;
494
+ cleared_balance?: number;
495
+ uncleared_balance?: number;
496
+ },
352
497
  ): ReconciliationAnalysis {
353
- // Step 1: Parse bank CSV using new Parser (or use provided result)
354
- let parseResult: CSVParseResult;
355
-
356
- if (typeof csvContentOrParsed === 'string') {
357
- parseResult = parseCSV(csvContentOrParsed, {
358
- ...csvOptions,
359
- invertAmounts: invertBankAmounts,
360
- });
361
- } else {
362
- parseResult = csvContentOrParsed;
363
- }
364
-
365
- const newBankTransactions = parseResult.transactions;
366
- const csvParseErrors = parseResult.errors;
367
- const csvParseWarnings = parseResult.warnings;
368
-
369
- // Step 2: Normalize YNAB transactions
370
- const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
371
-
372
- // Step 3: Run new matching algorithm
373
- // Use normalizeConfig to convert legacy config to V2 format with defaults
374
- const normalizedConfig = normalizeConfig(config);
375
-
376
- const newMatches = findMatches(newBankTransactions, newYNABTransactions, normalizedConfig);
377
- const matches: TransactionMatch[] = newMatches.map(mapToTransactionMatch);
378
-
379
- // Categorize
380
- const autoMatches = matches.filter((m) => m.confidence === 'high');
381
-
382
- // Build set of YNAB transaction IDs that are already auto-matched
383
- const autoMatchedYnabIds = new Set<string>();
384
- autoMatches.forEach((m) => {
385
- if (m.ynabTransaction) autoMatchedYnabIds.add(m.ynabTransaction.id);
386
- });
387
-
388
- // Only suggest matches for YNAB transactions NOT already auto-matched
389
- const suggestedMatches = matches.filter(
390
- (m) =>
391
- m.confidence === 'medium' &&
392
- (!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)),
393
- );
394
-
395
- const unmatchedBankMatches = matches.filter(
396
- (m) => m.confidence === 'low' || m.confidence === 'none',
397
- );
398
- const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
399
-
400
- // Find unmatched YNAB
401
- const matchedYnabIds = new Set<string>();
402
- matches.forEach((m) => {
403
- if (m.ynabTransaction) matchedYnabIds.add(m.ynabTransaction.id);
404
- });
405
- const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
406
-
407
- // Step 6: Calculate balances
408
- const balances = calculateBalances(
409
- newYNABTransactions,
410
- statementBalance,
411
- currency,
412
- accountSnapshot,
413
- );
414
-
415
- // Step 7: Generate summary
416
- const summary = generateSummary(
417
- matches.map((m) => m.bankTransaction),
418
- newYNABTransactions,
419
- autoMatches,
420
- suggestedMatches,
421
- unmatchedBank,
422
- unmatchedYNAB,
423
- balances,
424
- );
425
-
426
- // Step 8: Generate next steps
427
- const nextSteps = generateNextSteps(summary);
428
-
429
- // Step 9: Detect insights (including any CSV parsing issues)
430
- const insights = detectInsights(
431
- unmatchedBank,
432
- summary,
433
- balances,
434
- currency,
435
- csvParseErrors,
436
- csvParseWarnings,
437
- );
438
-
439
- // Step 10: Build the analysis result
440
- const analysis: ReconciliationAnalysis = {
441
- success: true,
442
- phase: 'analysis',
443
- summary,
444
- auto_matches: autoMatches,
445
- suggested_matches: suggestedMatches,
446
- unmatched_bank: unmatchedBank,
447
- unmatched_ynab: unmatchedYNAB,
448
- balance_info: balances,
449
- next_steps: nextSteps,
450
- insights,
451
- };
452
-
453
- // Step 11: Generate recommendations
454
- if (accountId && budgetId) {
455
- const recommendations = generateRecommendations({
456
- account_id: accountId,
457
- budget_id: budgetId,
458
- analysis,
459
- matching_config: normalizedConfig,
460
- });
461
- analysis.recommendations = recommendations;
462
- }
463
-
464
- return analysis;
498
+ // Step 1: Parse bank CSV using new Parser (or use provided result)
499
+ let parseResult: CSVParseResult;
500
+
501
+ if (typeof csvContentOrParsed === "string") {
502
+ parseResult = parseCSV(csvContentOrParsed, {
503
+ ...csvOptions,
504
+ invertAmounts: invertBankAmounts,
505
+ });
506
+ } else {
507
+ parseResult = csvContentOrParsed;
508
+ }
509
+
510
+ const newBankTransactions = parseResult.transactions;
511
+ const csvParseErrors = parseResult.errors;
512
+ const csvParseWarnings = parseResult.warnings;
513
+
514
+ // Step 2: Normalize YNAB transactions
515
+ const allYNABTransactions = normalizeYNABTransactions(ynabTransactions);
516
+
517
+ // Step 2.5: Filter YNAB transactions by CSV date range
518
+ // Only compare transactions within the statement period (with tolerance buffer)
519
+ const csvDateRange = calculateDateRange(newBankTransactions);
520
+ let ynabInRange: YNABTransaction[];
521
+ let ynabOutsideRange: YNABTransaction[];
522
+
523
+ if (csvDateRange) {
524
+ const dateToleranceDays = config.dateToleranceDays ?? 7;
525
+ const filtered = filterByDateRange(
526
+ allYNABTransactions,
527
+ csvDateRange,
528
+ dateToleranceDays,
529
+ );
530
+ ynabInRange = filtered.inRange;
531
+ ynabOutsideRange = filtered.outsideRange;
532
+ } else {
533
+ // No valid date range from CSV, use all transactions
534
+ ynabInRange = allYNABTransactions;
535
+ ynabOutsideRange = [];
536
+ }
537
+
538
+ // Step 3: Run matching algorithm ONLY on YNAB transactions within date range
539
+ // Use normalizeConfig to convert legacy config to V2 format with defaults
540
+ const normalizedConfig = normalizeConfig(config);
541
+
542
+ const newMatches = findMatches(
543
+ newBankTransactions,
544
+ ynabInRange,
545
+ normalizedConfig,
546
+ );
547
+ const matches: TransactionMatch[] = newMatches.map(mapToTransactionMatch);
548
+
549
+ // Categorize
550
+ const autoMatches = matches.filter((m) => m.confidence === "high");
551
+
552
+ // Build set of YNAB transaction IDs that are already auto-matched
553
+ const autoMatchedYnabIds = new Set<string>();
554
+ for (const match of autoMatches) {
555
+ if (match.ynabTransaction) {
556
+ autoMatchedYnabIds.add(match.ynabTransaction.id);
557
+ }
558
+ }
559
+
560
+ // Only suggest matches for YNAB transactions NOT already auto-matched
561
+ const suggestedMatches = matches.filter(
562
+ (m) =>
563
+ m.confidence === "medium" &&
564
+ (!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)),
565
+ );
566
+
567
+ const unmatchedBankMatches = matches.filter(
568
+ (m) => m.confidence === "low" || m.confidence === "none",
569
+ );
570
+ const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
571
+
572
+ // Find unmatched YNAB (only from in-range transactions)
573
+ const matchedYnabIds = new Set<string>();
574
+ for (const match of matches) {
575
+ if (match.ynabTransaction) {
576
+ matchedYnabIds.add(match.ynabTransaction.id);
577
+ }
578
+ }
579
+ const unmatchedYNAB = ynabInRange.filter((t) => !matchedYnabIds.has(t.id));
580
+
581
+ // Step 6: Calculate balances (use ALL YNAB transactions for balance calculation)
582
+ const balances = calculateBalances(
583
+ allYNABTransactions,
584
+ statementBalance,
585
+ currency,
586
+ accountSnapshot,
587
+ );
588
+
589
+ // Step 7: Generate summary (with date range info)
590
+ const summary = generateSummary(
591
+ matches.map((m) => m.bankTransaction),
592
+ ynabInRange,
593
+ ynabOutsideRange,
594
+ autoMatches,
595
+ suggestedMatches,
596
+ unmatchedBank,
597
+ unmatchedYNAB,
598
+ balances,
599
+ );
600
+
601
+ // Step 8: Generate next steps
602
+ const nextSteps = generateNextSteps(summary);
603
+
604
+ // Step 9: Detect insights (including any CSV parsing issues)
605
+ const insights = detectInsights(
606
+ unmatchedBank,
607
+ summary,
608
+ balances,
609
+ currency,
610
+ csvParseErrors,
611
+ csvParseWarnings,
612
+ );
613
+
614
+ // Step 10: Build the analysis result
615
+ const analysis: ReconciliationAnalysis = {
616
+ success: true,
617
+ phase: "analysis",
618
+ summary,
619
+ auto_matches: autoMatches,
620
+ suggested_matches: suggestedMatches,
621
+ unmatched_bank: unmatchedBank,
622
+ unmatched_ynab: unmatchedYNAB,
623
+ ynab_outside_date_range: ynabOutsideRange,
624
+ balance_info: balances,
625
+ next_steps: nextSteps,
626
+ insights,
627
+ };
628
+
629
+ // Step 11: Generate recommendations
630
+ if (accountId && budgetId) {
631
+ const recommendations = generateRecommendations({
632
+ account_id: accountId,
633
+ budget_id: budgetId,
634
+ analysis,
635
+ matching_config: normalizedConfig,
636
+ });
637
+ analysis.recommendations = recommendations;
638
+ }
639
+
640
+ return analysis;
465
641
  }