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