@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
@@ -1,764 +1,782 @@
1
- import { describe, test, expect, vi, beforeEach } from 'vitest';
2
- import { readFileSync } from 'fs';
1
+ import { readFileSync } from "node:fs";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import {
4
- parseBankCSV,
5
- autoDetectCSVFormat,
6
- parseDate,
7
- amountToMilliunits,
8
- readCSVFile,
9
- detectDateFormat,
10
- extractDateRangeFromCSV,
11
- } from '../../compareTransactions/parser.js';
12
- import { CSVFormat } from '../../compareTransactions/types.js';
4
+ amountToMilliunits,
5
+ autoDetectCSVFormat,
6
+ detectDateFormat,
7
+ extractDateRangeFromCSV,
8
+ parseBankCSV,
9
+ parseDate,
10
+ readCSVFile,
11
+ } from "../../compareTransactions/parser.js";
12
+ import type { CSVFormat } from "../../compareTransactions/types.js";
13
13
 
14
14
  // Mock fs module
15
- vi.mock('fs', () => ({
16
- readFileSync: vi.fn(),
15
+ vi.mock("fs", () => ({
16
+ readFileSync: vi.fn(),
17
17
  }));
18
18
 
19
19
  const mockReadFileSync = vi.mocked(readFileSync);
20
20
 
21
- describe('parser', () => {
22
- beforeEach(() => {
23
- vi.clearAllMocks();
24
- });
25
-
26
- describe('parseDate', () => {
27
- test('should parse MM/DD/YYYY format', () => {
28
- const result = parseDate('09/15/2023', 'MM/DD/YYYY');
29
- expect(result.getFullYear()).toBe(2023);
30
- expect(result.getMonth()).toBe(8); // 0-indexed
31
- expect(result.getDate()).toBe(15);
32
- });
33
-
34
- test('should parse YYYY-MM-DD format', () => {
35
- const result = parseDate('2023-09-15', 'YYYY-MM-DD');
36
- expect(result.getFullYear()).toBe(2023);
37
- expect(result.getMonth()).toBe(8);
38
- expect(result.getDate()).toBe(15);
39
- });
40
-
41
- test('should parse MMM dd, yyyy format', () => {
42
- const result = parseDate('Sep 15, 2023', 'MMM dd, yyyy');
43
- expect(result.getFullYear()).toBe(2023);
44
- expect(result.getMonth()).toBe(8);
45
- expect(result.getDate()).toBe(15);
46
- });
47
-
48
- test('should handle single digit dates', () => {
49
- const result = parseDate('9/5/2023', 'M/D/YYYY');
50
- expect(result.getFullYear()).toBe(2023);
51
- expect(result.getMonth()).toBe(8);
52
- expect(result.getDate()).toBe(5);
53
- });
54
-
55
- test('should throw error for invalid date', () => {
56
- expect(() => parseDate('invalid-date', 'MM/DD/YYYY')).toThrow(
57
- 'Unable to parse date: invalid-date with format: MM/DD/YYYY',
58
- );
59
- });
60
-
61
- test('should fallback to native Date parsing for unrecognized formats', () => {
62
- const result = parseDate('2023-09-15', 'UNKNOWN_FORMAT');
63
- expect(result.getFullYear()).toBe(2023);
64
- expect(result.getMonth()).toBe(8);
65
- // Native Date parsing might interpret '2023-09-15' differently, so check valid date range
66
- expect(result.getDate()).toBeGreaterThanOrEqual(14);
67
- expect(result.getDate()).toBeLessThanOrEqual(15);
68
- });
69
-
70
- test('should handle whitespace in dates', () => {
71
- const result = parseDate(' 09/15/2023 ', 'MM/DD/YYYY');
72
- expect(result.getFullYear()).toBe(2023);
73
- expect(result.getMonth()).toBe(8);
74
- expect(result.getDate()).toBe(15);
75
- });
76
- });
77
-
78
- describe('amountToMilliunits', () => {
79
- test('should convert positive amounts', () => {
80
- expect(amountToMilliunits('123.45')).toBe(123450);
81
- });
82
-
83
- test('should convert negative amounts', () => {
84
- expect(amountToMilliunits('-123.45')).toBe(-123450);
85
- });
86
-
87
- test('should handle parentheses for negative amounts', () => {
88
- expect(amountToMilliunits('(123.45)')).toBe(-123450);
89
- });
90
-
91
- test('should handle currency symbols', () => {
92
- expect(amountToMilliunits('$123.45')).toBe(123450);
93
- });
94
-
95
- test('should handle commas in amounts', () => {
96
- expect(amountToMilliunits('1,234.56')).toBe(1234560);
97
- });
98
-
99
- test('should handle positive sign', () => {
100
- expect(amountToMilliunits('+123.45')).toBe(123450);
101
- });
102
-
103
- test('should handle zero amounts', () => {
104
- expect(amountToMilliunits('0.00')).toBe(0);
105
- });
106
-
107
- test('should handle amounts with spaces', () => {
108
- expect(amountToMilliunits(' 123.45 ')).toBe(123450);
109
- });
110
-
111
- test('should handle very large amounts', () => {
112
- expect(amountToMilliunits('999999.99')).toBe(999999990);
113
- });
114
- });
115
-
116
- describe('detectDateFormat', () => {
117
- test('should detect MM/DD/YYYY format', () => {
118
- expect(detectDateFormat('09/15/2023')).toBe('MM/DD/YYYY');
119
- });
120
-
121
- test('should detect YYYY-MM-DD format', () => {
122
- expect(detectDateFormat('2023-09-15')).toBe('YYYY-MM-DD');
123
- });
124
-
125
- test('should detect MM-DD-YYYY format', () => {
126
- expect(detectDateFormat('09-15-2023')).toBe('MM-DD-YYYY');
127
- });
128
-
129
- test('should detect MMM dd, yyyy format', () => {
130
- expect(detectDateFormat('Sep 15, 2023')).toBe('MMM dd, yyyy');
131
- });
132
-
133
- test('should default to MM/DD/YYYY for undefined input', () => {
134
- expect(detectDateFormat(undefined)).toBe('MM/DD/YYYY');
135
- });
136
-
137
- test('should default to MM/DD/YYYY for unrecognized format', () => {
138
- expect(detectDateFormat('15.09.2023')).toBe('MM/DD/YYYY');
139
- });
140
- });
141
-
142
- describe('autoDetectCSVFormat', () => {
143
- test('should detect header format with standard columns', () => {
144
- const csvContent = 'Date,Amount,Description\n09/15/2023,123.45,Test Transaction';
145
- const format = autoDetectCSVFormat(csvContent);
146
-
147
- expect(format.has_header).toBe(true);
148
- expect(format.date_column).toBe('Date');
149
- expect(format.amount_column).toBe('Amount');
150
- expect(format.description_column).toBe('Description');
151
- });
152
-
153
- test('should detect no-header format', () => {
154
- const csvContent = '09/15/2023,123.45,Test Transaction\n09/16/2023,67.89,Another Transaction';
155
- const format = autoDetectCSVFormat(csvContent);
156
-
157
- expect(format.has_header).toBe(false);
158
- expect(format.date_column).toBe(0);
159
- expect(format.amount_column).toBe(1);
160
- expect(format.description_column).toBe(2);
161
- });
162
-
163
- test('should detect debit/credit columns', () => {
164
- const csvContent =
165
- 'Date,Description,Debit,Credit\n09/15/2023,Test,123.45,\n09/16/2023,Test2,,67.89';
166
- const format = autoDetectCSVFormat(csvContent);
167
-
168
- expect(format.has_header).toBe(true);
169
- expect(format.debit_column).toBe('Debit');
170
- expect(format.credit_column).toBe('Credit');
171
- expect(format.amount_column).toBeUndefined();
172
- });
173
-
174
- test('should throw error for empty CSV', () => {
175
- expect(() => autoDetectCSVFormat('')).toThrow('CSV file contains empty first line');
176
- });
177
-
178
- test('should throw error for CSV with empty first line', () => {
179
- expect(() => autoDetectCSVFormat('\n')).toThrow('CSV file contains empty first line');
180
- });
181
-
182
- test('should detect date format from data rows', () => {
183
- const csvContent = 'Date,Amount,Description\n2023-09-15,123.45,Test Transaction';
184
- const format = autoDetectCSVFormat(csvContent);
185
-
186
- expect(format.date_format).toBe('YYYY-MM-DD');
187
- });
188
-
189
- test('should derive column names from non-standard headers - Transaction Date', () => {
190
- const csvContent = 'Transaction Date,Dollar Amount,Memo\n09/15/2023,123.45,Test Transaction';
191
- const format = autoDetectCSVFormat(csvContent);
192
-
193
- expect(format.has_header).toBe(true);
194
- expect(format.date_column).toBe('Transaction Date');
195
- expect(format.amount_column).toBe('Dollar Amount');
196
- expect(format.description_column).toBe('Memo');
197
- });
198
-
199
- test('should derive column names from non-standard headers - Post Date and Desc', () => {
200
- const csvContent = 'Post Date,Amt,Desc\n09/15/2023,123.45,Test Transaction';
201
- const format = autoDetectCSVFormat(csvContent);
202
-
203
- expect(format.has_header).toBe(true);
204
- expect(format.date_column).toBe('Post Date');
205
- expect(format.amount_column).toBe('Amt');
206
- expect(format.description_column).toBe('Desc');
207
- });
208
-
209
- test('should derive column names from non-standard headers - Payee column', () => {
210
- const csvContent = 'Date,Amount,Payee\n09/15/2023,123.45,Test Merchant';
211
- const format = autoDetectCSVFormat(csvContent);
212
-
213
- expect(format.has_header).toBe(true);
214
- expect(format.date_column).toBe('Date');
215
- expect(format.amount_column).toBe('Amount');
216
- expect(format.description_column).toBe('Payee');
217
- });
218
-
219
- test('should detect debit/credit columns with non-standard headers', () => {
220
- const csvContent =
221
- 'Date,Merchant,Withdrawal,Deposit\n09/15/2023,Test,123.45,\n09/16/2023,Test2,,67.89';
222
- const format = autoDetectCSVFormat(csvContent);
223
-
224
- expect(format.has_header).toBe(true);
225
- expect(format.date_column).toBe('Date');
226
- expect(format.description_column).toBe('Merchant');
227
- expect(format.debit_column).toBe('Withdrawal');
228
- expect(format.credit_column).toBe('Deposit');
229
- expect(format.amount_column).toBeUndefined();
230
- });
231
-
232
- test('should fallback to original headers when patterns do not match', () => {
233
- const csvContent = 'Col1,Col2,Col3\n09/15/2023,123.45,Test Transaction';
234
- const format = autoDetectCSVFormat(csvContent);
235
-
236
- expect(format.has_header).toBe(true);
237
- expect(format.date_column).toBe('Col1'); // Falls back to first column
238
- expect(format.amount_column).toBe('Col2'); // Falls back to second column
239
- expect(format.description_column).toBe('Col3'); // Falls back to third column
240
- });
241
-
242
- test('should handle case-insensitive header matching', () => {
243
- const csvContent = 'DATE,AMOUNT,DESCRIPTION\n09/15/2023,123.45,Test Transaction';
244
- const format = autoDetectCSVFormat(csvContent);
245
-
246
- expect(format.has_header).toBe(true);
247
- expect(format.date_column).toBe('DATE');
248
- expect(format.amount_column).toBe('AMOUNT');
249
- expect(format.description_column).toBe('DESCRIPTION');
250
- });
251
-
252
- test('should detect semicolon delimiter', () => {
253
- const csvContent = 'Date;Amount;Description\n09/15/2023;123.45;Test Transaction';
254
- const format = autoDetectCSVFormat(csvContent);
255
-
256
- expect(format.has_header).toBe(true);
257
- expect(format.delimiter).toBe(';');
258
- expect(format.date_column).toBe('Date');
259
- expect(format.amount_column).toBe('Amount');
260
- expect(format.description_column).toBe('Description');
261
- });
262
-
263
- test('should detect tab delimiter', () => {
264
- const csvContent = 'Date\tAmount\tDescription\n09/15/2023\t123.45\tTest Transaction';
265
- const format = autoDetectCSVFormat(csvContent);
266
-
267
- expect(format.has_header).toBe(true);
268
- expect(format.delimiter).toBe('\t');
269
- expect(format.date_column).toBe('Date');
270
- expect(format.amount_column).toBe('Amount');
271
- expect(format.description_column).toBe('Description');
272
- });
273
-
274
- test('should detect pipe delimiter', () => {
275
- const csvContent = 'Date|Amount|Description\n09/15/2023|123.45|Test Transaction';
276
- const format = autoDetectCSVFormat(csvContent);
277
-
278
- expect(format.has_header).toBe(true);
279
- expect(format.delimiter).toBe('|');
280
- expect(format.date_column).toBe('Date');
281
- expect(format.amount_column).toBe('Amount');
282
- expect(format.description_column).toBe('Description');
283
- });
284
-
285
- test('should detect semicolon delimiter without headers', () => {
286
- const csvContent = '09/15/2023;123.45;Test Transaction\n09/16/2023;67.89;Another Transaction';
287
- const format = autoDetectCSVFormat(csvContent);
288
-
289
- expect(format.has_header).toBe(false);
290
- expect(format.delimiter).toBe(';');
291
- expect(format.date_column).toBe(0);
292
- expect(format.amount_column).toBe(1);
293
- expect(format.description_column).toBe(2);
294
- });
295
-
296
- test('should detect tab delimiter without headers', () => {
297
- const csvContent =
298
- '09/15/2023\t123.45\tTest Transaction\n09/16/2023\t67.89\tAnother Transaction';
299
- const format = autoDetectCSVFormat(csvContent);
300
-
301
- expect(format.has_header).toBe(false);
302
- expect(format.delimiter).toBe('\t');
303
- expect(format.date_column).toBe(0);
304
- expect(format.amount_column).toBe(1);
305
- expect(format.description_column).toBe(2);
306
- });
307
-
308
- test('should detect semicolon delimiter with debit/credit columns', () => {
309
- const csvContent =
310
- 'Date;Description;Debit;Credit\n09/15/2023;Test;123.45;\n09/16/2023;Test2;;67.89';
311
- const format = autoDetectCSVFormat(csvContent);
312
-
313
- expect(format.has_header).toBe(true);
314
- expect(format.delimiter).toBe(';');
315
- expect(format.debit_column).toBe('Debit');
316
- expect(format.credit_column).toBe('Credit');
317
- expect(format.amount_column).toBeUndefined();
318
- });
319
-
320
- test('should detect semicolon delimiter when quoted fields contain delimiter', () => {
321
- const csvContent =
322
- 'Date;Description;Amount\n2025-09-20;"Utility;Gas";-50.00\n2025-09-21;"Store;Purchase";-25.00';
323
- const format = autoDetectCSVFormat(csvContent);
324
-
325
- expect(format.has_header).toBe(true);
326
- expect(format.delimiter).toBe(';');
327
- expect(format.date_column).toBe('Date');
328
- expect(format.amount_column).toBe('Amount');
329
- expect(format.description_column).toBe('Description');
330
- });
331
-
332
- test('should detect comma delimiter when quoted fields contain commas', () => {
333
- const csvContent =
334
- 'Date,Description,Amount\n2025-09-20,"Service, Inc",50.00\n2025-09-21,"Store, LLC",-25.00';
335
- const format = autoDetectCSVFormat(csvContent);
336
-
337
- expect(format.has_header).toBe(true);
338
- expect(format.delimiter).toBe(',');
339
- expect(format.date_column).toBe('Date');
340
- expect(format.amount_column).toBe('Amount');
341
- expect(format.description_column).toBe('Description');
342
- });
343
-
344
- test('should handle complex quoted fields with multiple delimiter types', () => {
345
- const csvContent =
346
- 'Date;Description;Amount\n2025-09-20;"Utility;Gas,Electric";-50.00\n2025-09-21;"Store;Purchase,Tax";-25.00';
347
- const format = autoDetectCSVFormat(csvContent);
348
-
349
- expect(format.has_header).toBe(true);
350
- expect(format.delimiter).toBe(';');
351
- expect(format.date_column).toBe('Date');
352
- expect(format.amount_column).toBe('Amount');
353
- expect(format.description_column).toBe('Description');
354
- });
355
-
356
- test('should detect tab delimiter with debit/credit columns', () => {
357
- const csvContent =
358
- 'Date\tDescription\tDebit\tCredit\n09/15/2023\tTest\t123.45\t\n09/16/2023\tTest2\t\t67.89';
359
- const format = autoDetectCSVFormat(csvContent);
360
-
361
- expect(format.has_header).toBe(true);
362
- expect(format.delimiter).toBe('\t');
363
- expect(format.debit_column).toBe('Debit');
364
- expect(format.credit_column).toBe('Credit');
365
- expect(format.amount_column).toBeUndefined();
366
- });
367
- });
368
-
369
- describe('parseBankCSV', () => {
370
- test('should parse CSV with headers', () => {
371
- const csvContent =
372
- 'Date,Amount,Description\n09/15/2023,123.45,Test Transaction\n09/16/2023,-67.89,Another Transaction';
373
- const format: CSVFormat = {
374
- date_column: 'Date',
375
- amount_column: 'Amount',
376
- description_column: 'Description',
377
- date_format: 'MM/DD/YYYY',
378
- has_header: true,
379
- delimiter: ',',
380
- };
381
-
382
- const transactions = parseBankCSV(csvContent, format);
383
-
384
- expect(transactions).toHaveLength(2);
385
- expect(transactions[0].description).toBe('Test Transaction');
386
- expect(transactions[0].amount).toBe(123450);
387
- expect(transactions[0].row_number).toBe(2);
388
- expect(transactions[1].amount).toBe(-67890);
389
- expect(transactions[1].row_number).toBe(3);
390
- });
391
-
392
- test('should parse CSV without headers', () => {
393
- const csvContent =
394
- '09/15/2023,123.45,Test Transaction\n09/16/2023,-67.89,Another Transaction';
395
- const format: CSVFormat = {
396
- date_column: 0,
397
- amount_column: 1,
398
- description_column: 2,
399
- date_format: 'MM/DD/YYYY',
400
- has_header: false,
401
- delimiter: ',',
402
- };
403
-
404
- const transactions = parseBankCSV(csvContent, format);
405
-
406
- expect(transactions).toHaveLength(2);
407
- expect(transactions[0].description).toBe('Test Transaction');
408
- expect(transactions[0].amount).toBe(123450);
409
- expect(transactions[0].row_number).toBe(1);
410
- expect(transactions[1].row_number).toBe(2);
411
- });
412
-
413
- test('should handle debit/credit columns with headers', () => {
414
- const csvContent =
415
- 'Date,Description,Debit,Credit\n09/15/2023,Test Transaction,123.45,\n09/16/2023,Credit Transaction,,67.89';
416
- const format: CSVFormat = {
417
- date_column: 'Date',
418
- description_column: 'Description',
419
- debit_column: 'Debit',
420
- credit_column: 'Credit',
421
- date_format: 'MM/DD/YYYY',
422
- has_header: true,
423
- delimiter: ',',
424
- };
425
-
426
- const transactions = parseBankCSV(csvContent, format);
427
-
428
- expect(transactions).toHaveLength(2);
429
- expect(transactions[0].amount).toBe(-123450); // Debit is negative
430
- expect(transactions[1].amount).toBe(67890); // Credit is positive
431
- });
432
-
433
- test('should handle debit/credit columns without headers', () => {
434
- const csvContent =
435
- '09/15/2023,Test Transaction,123.45,0\n09/16/2023,Credit Transaction,0,67.89';
436
- const format: CSVFormat = {
437
- date_column: 0,
438
- description_column: 1,
439
- debit_column: 2,
440
- credit_column: 3,
441
- date_format: 'MM/DD/YYYY',
442
- has_header: false,
443
- delimiter: ',',
444
- };
445
-
446
- const transactions = parseBankCSV(csvContent, format);
447
-
448
- expect(transactions).toHaveLength(2);
449
- expect(transactions[0].amount).toBe(-123450); // Debit is negative
450
- expect(transactions[1].amount).toBe(67890); // Credit is positive
451
- });
452
-
453
- test('should skip rows with missing data', () => {
454
- const csvContent =
455
- 'Date,Amount,Description\n09/15/2023,123.45,Test Transaction\n,67.89,Missing Date\n09/17/2023,,Missing Amount';
456
- const format: CSVFormat = {
457
- date_column: 'Date',
458
- amount_column: 'Amount',
459
- description_column: 'Description',
460
- date_format: 'MM/DD/YYYY',
461
- has_header: true,
462
- delimiter: ',',
463
- };
464
-
465
- const transactions = parseBankCSV(csvContent, format);
466
-
467
- expect(transactions).toHaveLength(1);
468
- expect(transactions[0].description).toBe('Test Transaction');
469
- });
470
-
471
- test('should handle quoted dates with commas (MMM dd, yyyy format)', () => {
472
- const csvContent = 'Date,Amount,Description\n"Sep 15, 2023",123.45,Test Transaction';
473
- const format: CSVFormat = {
474
- date_column: 'Date',
475
- amount_column: 'Amount',
476
- description_column: 'Description',
477
- date_format: 'MMM dd, yyyy',
478
- has_header: true,
479
- delimiter: ',',
480
- };
481
-
482
- const transactions = parseBankCSV(csvContent, format);
483
-
484
- expect(transactions).toHaveLength(1);
485
- expect(transactions[0].date.getFullYear()).toBe(2023);
486
- expect(transactions[0].date.getMonth()).toBe(8); // September is month 8
487
- });
488
-
489
- test('should parse quoted fields with commas in descriptions', () => {
490
- const csvContent =
491
- 'Date,Amount,Description\n09/15/2023,123.45,"Transaction with, comma"\n09/16/2023,67.89,"Another, test, transaction"';
492
- const format: CSVFormat = {
493
- date_column: 'Date',
494
- amount_column: 'Amount',
495
- description_column: 'Description',
496
- date_format: 'MM/DD/YYYY',
497
- has_header: true,
498
- delimiter: ',',
499
- };
500
-
501
- const transactions = parseBankCSV(csvContent, format);
502
-
503
- expect(transactions).toHaveLength(2);
504
- expect(transactions[0].description).toBe('Transaction with, comma');
505
- expect(transactions[1].description).toBe('Another, test, transaction');
506
- });
507
-
508
- test('should throw error when no amount column configuration', () => {
509
- const csvContent = 'Date,Description\n09/15/2023,Test Transaction';
510
- const format: CSVFormat = {
511
- date_column: 'Date',
512
- description_column: 'Description',
513
- date_format: 'MM/DD/YYYY',
514
- has_header: true,
515
- delimiter: ',',
516
- };
517
-
518
- expect(() => parseBankCSV(csvContent, format)).not.toThrow();
519
- // Since the function continues on error, check that no transactions are returned
520
- const transactions = parseBankCSV(csvContent, format);
521
- expect(transactions).toHaveLength(0);
522
- });
523
-
524
- test('should handle invalid column indices gracefully', () => {
525
- const csvContent = '09/15/2023,123.45';
526
- const format: CSVFormat = {
527
- date_column: 0,
528
- amount_column: 1,
529
- description_column: 99, // Invalid index
530
- date_format: 'MM/DD/YYYY',
531
- has_header: false,
532
- delimiter: ',',
533
- };
534
-
535
- const transactions = parseBankCSV(csvContent, format);
536
-
537
- expect(transactions).toHaveLength(1);
538
- expect(transactions[0].description).toBe(''); // Empty description for missing column
539
- });
540
-
541
- test('should parse semicolon-delimited CSV with headers', () => {
542
- const csvContent =
543
- 'Date;Amount;Description\n09/15/2023;123.45;Test Transaction\n09/16/2023;-67.89;Another Transaction';
544
- const format: CSVFormat = {
545
- date_column: 'Date',
546
- amount_column: 'Amount',
547
- description_column: 'Description',
548
- date_format: 'MM/DD/YYYY',
549
- has_header: true,
550
- delimiter: ';',
551
- };
552
-
553
- const transactions = parseBankCSV(csvContent, format);
554
-
555
- expect(transactions).toHaveLength(2);
556
- expect(transactions[0].description).toBe('Test Transaction');
557
- expect(transactions[0].amount).toBe(123450);
558
- expect(transactions[0].row_number).toBe(2);
559
- expect(transactions[1].amount).toBe(-67890);
560
- expect(transactions[1].row_number).toBe(3);
561
- });
562
-
563
- test('should parse tab-delimited CSV with headers', () => {
564
- const csvContent =
565
- 'Date\tAmount\tDescription\n09/15/2023\t123.45\tTest Transaction\n09/16/2023\t-67.89\tAnother Transaction';
566
- const format: CSVFormat = {
567
- date_column: 'Date',
568
- amount_column: 'Amount',
569
- description_column: 'Description',
570
- date_format: 'MM/DD/YYYY',
571
- has_header: true,
572
- delimiter: '\t',
573
- };
574
-
575
- const transactions = parseBankCSV(csvContent, format);
576
-
577
- expect(transactions).toHaveLength(2);
578
- expect(transactions[0].description).toBe('Test Transaction');
579
- expect(transactions[0].amount).toBe(123450);
580
- expect(transactions[0].row_number).toBe(2);
581
- expect(transactions[1].amount).toBe(-67890);
582
- expect(transactions[1].row_number).toBe(3);
583
- });
584
-
585
- test('should parse semicolon-delimited CSV without headers', () => {
586
- const csvContent =
587
- '09/15/2023;123.45;Test Transaction\n09/16/2023;-67.89;Another Transaction';
588
- const format: CSVFormat = {
589
- date_column: 0,
590
- amount_column: 1,
591
- description_column: 2,
592
- date_format: 'MM/DD/YYYY',
593
- has_header: false,
594
- delimiter: ';',
595
- };
596
-
597
- const transactions = parseBankCSV(csvContent, format);
598
-
599
- expect(transactions).toHaveLength(2);
600
- expect(transactions[0].description).toBe('Test Transaction');
601
- expect(transactions[0].amount).toBe(123450);
602
- expect(transactions[0].row_number).toBe(1);
603
- expect(transactions[1].row_number).toBe(2);
604
- });
605
-
606
- test('should parse tab-delimited CSV without headers', () => {
607
- const csvContent =
608
- '09/15/2023\t123.45\tTest Transaction\n09/16/2023\t-67.89\tAnother Transaction';
609
- const format: CSVFormat = {
610
- date_column: 0,
611
- amount_column: 1,
612
- description_column: 2,
613
- date_format: 'MM/DD/YYYY',
614
- has_header: false,
615
- delimiter: '\t',
616
- };
617
-
618
- const transactions = parseBankCSV(csvContent, format);
619
-
620
- expect(transactions).toHaveLength(2);
621
- expect(transactions[0].description).toBe('Test Transaction');
622
- expect(transactions[0].amount).toBe(123450);
623
- expect(transactions[0].row_number).toBe(1);
624
- expect(transactions[1].row_number).toBe(2);
625
- });
626
-
627
- test('should handle semicolon-delimited debit/credit columns with headers', () => {
628
- const csvContent =
629
- 'Date;Description;Debit;Credit\n09/15/2023;Test Transaction;123.45;\n09/16/2023;Credit Transaction;;67.89';
630
- const format: CSVFormat = {
631
- date_column: 'Date',
632
- description_column: 'Description',
633
- debit_column: 'Debit',
634
- credit_column: 'Credit',
635
- date_format: 'MM/DD/YYYY',
636
- has_header: true,
637
- delimiter: ';',
638
- };
639
-
640
- const transactions = parseBankCSV(csvContent, format);
641
-
642
- expect(transactions).toHaveLength(2);
643
- expect(transactions[0].amount).toBe(-123450); // Debit is negative
644
- expect(transactions[1].amount).toBe(67890); // Credit is positive
645
- });
646
-
647
- test('should handle tab-delimited debit/credit columns with headers', () => {
648
- const csvContent =
649
- 'Date\tDescription\tDebit\tCredit\n09/15/2023\tTest Transaction\t123.45\t\n09/16/2023\tCredit Transaction\t\t67.89';
650
- const format: CSVFormat = {
651
- date_column: 'Date',
652
- description_column: 'Description',
653
- debit_column: 'Debit',
654
- credit_column: 'Credit',
655
- date_format: 'MM/DD/YYYY',
656
- has_header: true,
657
- delimiter: '\t',
658
- };
659
-
660
- const transactions = parseBankCSV(csvContent, format);
661
-
662
- expect(transactions).toHaveLength(2);
663
- expect(transactions[0].amount).toBe(-123450); // Debit is negative
664
- expect(transactions[1].amount).toBe(67890); // Credit is positive
665
- });
666
-
667
- test('should parse semicolon-delimited CSV with quoted fields containing delimiter', () => {
668
- const csvContent =
669
- 'Date;Description;Amount\n2025-09-20;"Utility;Gas";-50.00\n2025-09-21;"Store;Purchase";-25.00';
670
- const format: CSVFormat = {
671
- date_column: 'Date',
672
- amount_column: 'Amount',
673
- description_column: 'Description',
674
- date_format: 'YYYY-MM-DD',
675
- has_header: true,
676
- delimiter: ';',
677
- };
678
-
679
- const transactions = parseBankCSV(csvContent, format);
680
-
681
- expect(transactions).toHaveLength(2);
682
- expect(transactions[0].description).toBe('Utility;Gas');
683
- expect(transactions[0].amount).toBe(-50000);
684
- expect(transactions[1].description).toBe('Store;Purchase');
685
- expect(transactions[1].amount).toBe(-25000);
686
- });
687
- });
688
-
689
- describe('extractDateRangeFromCSV', () => {
690
- test('should extract min and max dates from CSV transactions', () => {
691
- const csvContent = `Date,Description,Debit,Credit,Balance
21
+ describe("parser", () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe("parseDate", () => {
27
+ test("should parse MM/DD/YYYY format", () => {
28
+ const result = parseDate("09/15/2023", "MM/DD/YYYY");
29
+ expect(result.getFullYear()).toBe(2023);
30
+ expect(result.getMonth()).toBe(8); // 0-indexed
31
+ expect(result.getDate()).toBe(15);
32
+ });
33
+
34
+ test("should parse YYYY-MM-DD format", () => {
35
+ const result = parseDate("2023-09-15", "YYYY-MM-DD");
36
+ expect(result.getFullYear()).toBe(2023);
37
+ expect(result.getMonth()).toBe(8);
38
+ expect(result.getDate()).toBe(15);
39
+ });
40
+
41
+ test("should parse MMM dd, yyyy format", () => {
42
+ const result = parseDate("Sep 15, 2023", "MMM dd, yyyy");
43
+ expect(result.getFullYear()).toBe(2023);
44
+ expect(result.getMonth()).toBe(8);
45
+ expect(result.getDate()).toBe(15);
46
+ });
47
+
48
+ test("should handle single digit dates", () => {
49
+ const result = parseDate("9/5/2023", "M/D/YYYY");
50
+ expect(result.getFullYear()).toBe(2023);
51
+ expect(result.getMonth()).toBe(8);
52
+ expect(result.getDate()).toBe(5);
53
+ });
54
+
55
+ test("should throw error for invalid date", () => {
56
+ expect(() => parseDate("invalid-date", "MM/DD/YYYY")).toThrow(
57
+ "Unable to parse date: invalid-date with format: MM/DD/YYYY",
58
+ );
59
+ });
60
+
61
+ test("should fallback to native Date parsing for unrecognized formats", () => {
62
+ const result = parseDate("2023-09-15", "UNKNOWN_FORMAT");
63
+ expect(result.getFullYear()).toBe(2023);
64
+ expect(result.getMonth()).toBe(8);
65
+ // Native Date parsing might interpret '2023-09-15' differently, so check valid date range
66
+ expect(result.getDate()).toBeGreaterThanOrEqual(14);
67
+ expect(result.getDate()).toBeLessThanOrEqual(15);
68
+ });
69
+
70
+ test("should handle whitespace in dates", () => {
71
+ const result = parseDate(" 09/15/2023 ", "MM/DD/YYYY");
72
+ expect(result.getFullYear()).toBe(2023);
73
+ expect(result.getMonth()).toBe(8);
74
+ expect(result.getDate()).toBe(15);
75
+ });
76
+ });
77
+
78
+ describe("amountToMilliunits", () => {
79
+ test("should convert positive amounts", () => {
80
+ expect(amountToMilliunits("123.45")).toBe(123450);
81
+ });
82
+
83
+ test("should convert negative amounts", () => {
84
+ expect(amountToMilliunits("-123.45")).toBe(-123450);
85
+ });
86
+
87
+ test("should handle parentheses for negative amounts", () => {
88
+ expect(amountToMilliunits("(123.45)")).toBe(-123450);
89
+ });
90
+
91
+ test("should handle currency symbols", () => {
92
+ expect(amountToMilliunits("$123.45")).toBe(123450);
93
+ });
94
+
95
+ test("should handle commas in amounts", () => {
96
+ expect(amountToMilliunits("1,234.56")).toBe(1234560);
97
+ });
98
+
99
+ test("should handle positive sign", () => {
100
+ expect(amountToMilliunits("+123.45")).toBe(123450);
101
+ });
102
+
103
+ test("should handle zero amounts", () => {
104
+ expect(amountToMilliunits("0.00")).toBe(0);
105
+ });
106
+
107
+ test("should handle amounts with spaces", () => {
108
+ expect(amountToMilliunits(" 123.45 ")).toBe(123450);
109
+ });
110
+
111
+ test("should handle very large amounts", () => {
112
+ expect(amountToMilliunits("999999.99")).toBe(999999990);
113
+ });
114
+ });
115
+
116
+ describe("detectDateFormat", () => {
117
+ test("should detect MM/DD/YYYY format", () => {
118
+ expect(detectDateFormat("09/15/2023")).toBe("MM/DD/YYYY");
119
+ });
120
+
121
+ test("should detect YYYY-MM-DD format", () => {
122
+ expect(detectDateFormat("2023-09-15")).toBe("YYYY-MM-DD");
123
+ });
124
+
125
+ test("should detect MM-DD-YYYY format", () => {
126
+ expect(detectDateFormat("09-15-2023")).toBe("MM-DD-YYYY");
127
+ });
128
+
129
+ test("should detect MMM dd, yyyy format", () => {
130
+ expect(detectDateFormat("Sep 15, 2023")).toBe("MMM dd, yyyy");
131
+ });
132
+
133
+ test("should default to MM/DD/YYYY for undefined input", () => {
134
+ expect(detectDateFormat(undefined)).toBe("MM/DD/YYYY");
135
+ });
136
+
137
+ test("should default to MM/DD/YYYY for unrecognized format", () => {
138
+ expect(detectDateFormat("15.09.2023")).toBe("MM/DD/YYYY");
139
+ });
140
+ });
141
+
142
+ describe("autoDetectCSVFormat", () => {
143
+ test("should detect header format with standard columns", () => {
144
+ const csvContent =
145
+ "Date,Amount,Description\n09/15/2023,123.45,Test Transaction";
146
+ const format = autoDetectCSVFormat(csvContent);
147
+
148
+ expect(format.has_header).toBe(true);
149
+ expect(format.date_column).toBe("Date");
150
+ expect(format.amount_column).toBe("Amount");
151
+ expect(format.description_column).toBe("Description");
152
+ });
153
+
154
+ test("should detect no-header format", () => {
155
+ const csvContent =
156
+ "09/15/2023,123.45,Test Transaction\n09/16/2023,67.89,Another Transaction";
157
+ const format = autoDetectCSVFormat(csvContent);
158
+
159
+ expect(format.has_header).toBe(false);
160
+ expect(format.date_column).toBe(0);
161
+ expect(format.amount_column).toBe(1);
162
+ expect(format.description_column).toBe(2);
163
+ });
164
+
165
+ test("should detect debit/credit columns", () => {
166
+ const csvContent =
167
+ "Date,Description,Debit,Credit\n09/15/2023,Test,123.45,\n09/16/2023,Test2,,67.89";
168
+ const format = autoDetectCSVFormat(csvContent);
169
+
170
+ expect(format.has_header).toBe(true);
171
+ expect(format.debit_column).toBe("Debit");
172
+ expect(format.credit_column).toBe("Credit");
173
+ expect(format.amount_column).toBeUndefined();
174
+ });
175
+
176
+ test("should throw error for empty CSV", () => {
177
+ expect(() => autoDetectCSVFormat("")).toThrow(
178
+ "CSV file contains empty first line",
179
+ );
180
+ });
181
+
182
+ test("should throw error for CSV with empty first line", () => {
183
+ expect(() => autoDetectCSVFormat("\n")).toThrow(
184
+ "CSV file contains empty first line",
185
+ );
186
+ });
187
+
188
+ test("should detect date format from data rows", () => {
189
+ const csvContent =
190
+ "Date,Amount,Description\n2023-09-15,123.45,Test Transaction";
191
+ const format = autoDetectCSVFormat(csvContent);
192
+
193
+ expect(format.date_format).toBe("YYYY-MM-DD");
194
+ });
195
+
196
+ test("should derive column names from non-standard headers - Transaction Date", () => {
197
+ const csvContent =
198
+ "Transaction Date,Dollar Amount,Memo\n09/15/2023,123.45,Test Transaction";
199
+ const format = autoDetectCSVFormat(csvContent);
200
+
201
+ expect(format.has_header).toBe(true);
202
+ expect(format.date_column).toBe("Transaction Date");
203
+ expect(format.amount_column).toBe("Dollar Amount");
204
+ expect(format.description_column).toBe("Memo");
205
+ });
206
+
207
+ test("should derive column names from non-standard headers - Post Date and Desc", () => {
208
+ const csvContent =
209
+ "Post Date,Amt,Desc\n09/15/2023,123.45,Test Transaction";
210
+ const format = autoDetectCSVFormat(csvContent);
211
+
212
+ expect(format.has_header).toBe(true);
213
+ expect(format.date_column).toBe("Post Date");
214
+ expect(format.amount_column).toBe("Amt");
215
+ expect(format.description_column).toBe("Desc");
216
+ });
217
+
218
+ test("should derive column names from non-standard headers - Payee column", () => {
219
+ const csvContent = "Date,Amount,Payee\n09/15/2023,123.45,Test Merchant";
220
+ const format = autoDetectCSVFormat(csvContent);
221
+
222
+ expect(format.has_header).toBe(true);
223
+ expect(format.date_column).toBe("Date");
224
+ expect(format.amount_column).toBe("Amount");
225
+ expect(format.description_column).toBe("Payee");
226
+ });
227
+
228
+ test("should detect debit/credit columns with non-standard headers", () => {
229
+ const csvContent =
230
+ "Date,Merchant,Withdrawal,Deposit\n09/15/2023,Test,123.45,\n09/16/2023,Test2,,67.89";
231
+ const format = autoDetectCSVFormat(csvContent);
232
+
233
+ expect(format.has_header).toBe(true);
234
+ expect(format.date_column).toBe("Date");
235
+ expect(format.description_column).toBe("Merchant");
236
+ expect(format.debit_column).toBe("Withdrawal");
237
+ expect(format.credit_column).toBe("Deposit");
238
+ expect(format.amount_column).toBeUndefined();
239
+ });
240
+
241
+ test("should fallback to original headers when patterns do not match", () => {
242
+ const csvContent = "Col1,Col2,Col3\n09/15/2023,123.45,Test Transaction";
243
+ const format = autoDetectCSVFormat(csvContent);
244
+
245
+ expect(format.has_header).toBe(true);
246
+ expect(format.date_column).toBe("Col1"); // Falls back to first column
247
+ expect(format.amount_column).toBe("Col2"); // Falls back to second column
248
+ expect(format.description_column).toBe("Col3"); // Falls back to third column
249
+ });
250
+
251
+ test("should handle case-insensitive header matching", () => {
252
+ const csvContent =
253
+ "DATE,AMOUNT,DESCRIPTION\n09/15/2023,123.45,Test Transaction";
254
+ const format = autoDetectCSVFormat(csvContent);
255
+
256
+ expect(format.has_header).toBe(true);
257
+ expect(format.date_column).toBe("DATE");
258
+ expect(format.amount_column).toBe("AMOUNT");
259
+ expect(format.description_column).toBe("DESCRIPTION");
260
+ });
261
+
262
+ test("should detect semicolon delimiter", () => {
263
+ const csvContent =
264
+ "Date;Amount;Description\n09/15/2023;123.45;Test Transaction";
265
+ const format = autoDetectCSVFormat(csvContent);
266
+
267
+ expect(format.has_header).toBe(true);
268
+ expect(format.delimiter).toBe(";");
269
+ expect(format.date_column).toBe("Date");
270
+ expect(format.amount_column).toBe("Amount");
271
+ expect(format.description_column).toBe("Description");
272
+ });
273
+
274
+ test("should detect tab delimiter", () => {
275
+ const csvContent =
276
+ "Date\tAmount\tDescription\n09/15/2023\t123.45\tTest Transaction";
277
+ const format = autoDetectCSVFormat(csvContent);
278
+
279
+ expect(format.has_header).toBe(true);
280
+ expect(format.delimiter).toBe("\t");
281
+ expect(format.date_column).toBe("Date");
282
+ expect(format.amount_column).toBe("Amount");
283
+ expect(format.description_column).toBe("Description");
284
+ });
285
+
286
+ test("should detect pipe delimiter", () => {
287
+ const csvContent =
288
+ "Date|Amount|Description\n09/15/2023|123.45|Test Transaction";
289
+ const format = autoDetectCSVFormat(csvContent);
290
+
291
+ expect(format.has_header).toBe(true);
292
+ expect(format.delimiter).toBe("|");
293
+ expect(format.date_column).toBe("Date");
294
+ expect(format.amount_column).toBe("Amount");
295
+ expect(format.description_column).toBe("Description");
296
+ });
297
+
298
+ test("should detect semicolon delimiter without headers", () => {
299
+ const csvContent =
300
+ "09/15/2023;123.45;Test Transaction\n09/16/2023;67.89;Another Transaction";
301
+ const format = autoDetectCSVFormat(csvContent);
302
+
303
+ expect(format.has_header).toBe(false);
304
+ expect(format.delimiter).toBe(";");
305
+ expect(format.date_column).toBe(0);
306
+ expect(format.amount_column).toBe(1);
307
+ expect(format.description_column).toBe(2);
308
+ });
309
+
310
+ test("should detect tab delimiter without headers", () => {
311
+ const csvContent =
312
+ "09/15/2023\t123.45\tTest Transaction\n09/16/2023\t67.89\tAnother Transaction";
313
+ const format = autoDetectCSVFormat(csvContent);
314
+
315
+ expect(format.has_header).toBe(false);
316
+ expect(format.delimiter).toBe("\t");
317
+ expect(format.date_column).toBe(0);
318
+ expect(format.amount_column).toBe(1);
319
+ expect(format.description_column).toBe(2);
320
+ });
321
+
322
+ test("should detect semicolon delimiter with debit/credit columns", () => {
323
+ const csvContent =
324
+ "Date;Description;Debit;Credit\n09/15/2023;Test;123.45;\n09/16/2023;Test2;;67.89";
325
+ const format = autoDetectCSVFormat(csvContent);
326
+
327
+ expect(format.has_header).toBe(true);
328
+ expect(format.delimiter).toBe(";");
329
+ expect(format.debit_column).toBe("Debit");
330
+ expect(format.credit_column).toBe("Credit");
331
+ expect(format.amount_column).toBeUndefined();
332
+ });
333
+
334
+ test("should detect semicolon delimiter when quoted fields contain delimiter", () => {
335
+ const csvContent =
336
+ 'Date;Description;Amount\n2025-09-20;"Utility;Gas";-50.00\n2025-09-21;"Store;Purchase";-25.00';
337
+ const format = autoDetectCSVFormat(csvContent);
338
+
339
+ expect(format.has_header).toBe(true);
340
+ expect(format.delimiter).toBe(";");
341
+ expect(format.date_column).toBe("Date");
342
+ expect(format.amount_column).toBe("Amount");
343
+ expect(format.description_column).toBe("Description");
344
+ });
345
+
346
+ test("should detect comma delimiter when quoted fields contain commas", () => {
347
+ const csvContent =
348
+ 'Date,Description,Amount\n2025-09-20,"Service, Inc",50.00\n2025-09-21,"Store, LLC",-25.00';
349
+ const format = autoDetectCSVFormat(csvContent);
350
+
351
+ expect(format.has_header).toBe(true);
352
+ expect(format.delimiter).toBe(",");
353
+ expect(format.date_column).toBe("Date");
354
+ expect(format.amount_column).toBe("Amount");
355
+ expect(format.description_column).toBe("Description");
356
+ });
357
+
358
+ test("should handle complex quoted fields with multiple delimiter types", () => {
359
+ const csvContent =
360
+ 'Date;Description;Amount\n2025-09-20;"Utility;Gas,Electric";-50.00\n2025-09-21;"Store;Purchase,Tax";-25.00';
361
+ const format = autoDetectCSVFormat(csvContent);
362
+
363
+ expect(format.has_header).toBe(true);
364
+ expect(format.delimiter).toBe(";");
365
+ expect(format.date_column).toBe("Date");
366
+ expect(format.amount_column).toBe("Amount");
367
+ expect(format.description_column).toBe("Description");
368
+ });
369
+
370
+ test("should detect tab delimiter with debit/credit columns", () => {
371
+ const csvContent =
372
+ "Date\tDescription\tDebit\tCredit\n09/15/2023\tTest\t123.45\t\n09/16/2023\tTest2\t\t67.89";
373
+ const format = autoDetectCSVFormat(csvContent);
374
+
375
+ expect(format.has_header).toBe(true);
376
+ expect(format.delimiter).toBe("\t");
377
+ expect(format.debit_column).toBe("Debit");
378
+ expect(format.credit_column).toBe("Credit");
379
+ expect(format.amount_column).toBeUndefined();
380
+ });
381
+ });
382
+
383
+ describe("parseBankCSV", () => {
384
+ test("should parse CSV with headers", () => {
385
+ const csvContent =
386
+ "Date,Amount,Description\n09/15/2023,123.45,Test Transaction\n09/16/2023,-67.89,Another Transaction";
387
+ const format: CSVFormat = {
388
+ date_column: "Date",
389
+ amount_column: "Amount",
390
+ description_column: "Description",
391
+ date_format: "MM/DD/YYYY",
392
+ has_header: true,
393
+ delimiter: ",",
394
+ };
395
+
396
+ const transactions = parseBankCSV(csvContent, format);
397
+
398
+ expect(transactions).toHaveLength(2);
399
+ expect(transactions[0].description).toBe("Test Transaction");
400
+ expect(transactions[0].amount).toBe(123450);
401
+ expect(transactions[0].row_number).toBe(2);
402
+ expect(transactions[1].amount).toBe(-67890);
403
+ expect(transactions[1].row_number).toBe(3);
404
+ });
405
+
406
+ test("should parse CSV without headers", () => {
407
+ const csvContent =
408
+ "09/15/2023,123.45,Test Transaction\n09/16/2023,-67.89,Another Transaction";
409
+ const format: CSVFormat = {
410
+ date_column: 0,
411
+ amount_column: 1,
412
+ description_column: 2,
413
+ date_format: "MM/DD/YYYY",
414
+ has_header: false,
415
+ delimiter: ",",
416
+ };
417
+
418
+ const transactions = parseBankCSV(csvContent, format);
419
+
420
+ expect(transactions).toHaveLength(2);
421
+ expect(transactions[0].description).toBe("Test Transaction");
422
+ expect(transactions[0].amount).toBe(123450);
423
+ expect(transactions[0].row_number).toBe(1);
424
+ expect(transactions[1].row_number).toBe(2);
425
+ });
426
+
427
+ test("should handle debit/credit columns with headers", () => {
428
+ const csvContent =
429
+ "Date,Description,Debit,Credit\n09/15/2023,Test Transaction,123.45,\n09/16/2023,Credit Transaction,,67.89";
430
+ const format: CSVFormat = {
431
+ date_column: "Date",
432
+ description_column: "Description",
433
+ debit_column: "Debit",
434
+ credit_column: "Credit",
435
+ date_format: "MM/DD/YYYY",
436
+ has_header: true,
437
+ delimiter: ",",
438
+ };
439
+
440
+ const transactions = parseBankCSV(csvContent, format);
441
+
442
+ expect(transactions).toHaveLength(2);
443
+ expect(transactions[0].amount).toBe(-123450); // Debit is negative
444
+ expect(transactions[1].amount).toBe(67890); // Credit is positive
445
+ });
446
+
447
+ test("should handle debit/credit columns without headers", () => {
448
+ const csvContent =
449
+ "09/15/2023,Test Transaction,123.45,0\n09/16/2023,Credit Transaction,0,67.89";
450
+ const format: CSVFormat = {
451
+ date_column: 0,
452
+ description_column: 1,
453
+ debit_column: 2,
454
+ credit_column: 3,
455
+ date_format: "MM/DD/YYYY",
456
+ has_header: false,
457
+ delimiter: ",",
458
+ };
459
+
460
+ const transactions = parseBankCSV(csvContent, format);
461
+
462
+ expect(transactions).toHaveLength(2);
463
+ expect(transactions[0].amount).toBe(-123450); // Debit is negative
464
+ expect(transactions[1].amount).toBe(67890); // Credit is positive
465
+ });
466
+
467
+ test("should skip rows with missing data", () => {
468
+ const csvContent =
469
+ "Date,Amount,Description\n09/15/2023,123.45,Test Transaction\n,67.89,Missing Date\n09/17/2023,,Missing Amount";
470
+ const format: CSVFormat = {
471
+ date_column: "Date",
472
+ amount_column: "Amount",
473
+ description_column: "Description",
474
+ date_format: "MM/DD/YYYY",
475
+ has_header: true,
476
+ delimiter: ",",
477
+ };
478
+
479
+ const transactions = parseBankCSV(csvContent, format);
480
+
481
+ expect(transactions).toHaveLength(1);
482
+ expect(transactions[0].description).toBe("Test Transaction");
483
+ });
484
+
485
+ test("should handle quoted dates with commas (MMM dd, yyyy format)", () => {
486
+ const csvContent =
487
+ 'Date,Amount,Description\n"Sep 15, 2023",123.45,Test Transaction';
488
+ const format: CSVFormat = {
489
+ date_column: "Date",
490
+ amount_column: "Amount",
491
+ description_column: "Description",
492
+ date_format: "MMM dd, yyyy",
493
+ has_header: true,
494
+ delimiter: ",",
495
+ };
496
+
497
+ const transactions = parseBankCSV(csvContent, format);
498
+
499
+ expect(transactions).toHaveLength(1);
500
+ expect(transactions[0].date.getFullYear()).toBe(2023);
501
+ expect(transactions[0].date.getMonth()).toBe(8); // September is month 8
502
+ });
503
+
504
+ test("should parse quoted fields with commas in descriptions", () => {
505
+ const csvContent =
506
+ 'Date,Amount,Description\n09/15/2023,123.45,"Transaction with, comma"\n09/16/2023,67.89,"Another, test, transaction"';
507
+ const format: CSVFormat = {
508
+ date_column: "Date",
509
+ amount_column: "Amount",
510
+ description_column: "Description",
511
+ date_format: "MM/DD/YYYY",
512
+ has_header: true,
513
+ delimiter: ",",
514
+ };
515
+
516
+ const transactions = parseBankCSV(csvContent, format);
517
+
518
+ expect(transactions).toHaveLength(2);
519
+ expect(transactions[0].description).toBe("Transaction with, comma");
520
+ expect(transactions[1].description).toBe("Another, test, transaction");
521
+ });
522
+
523
+ test("should throw error when no amount column configuration", () => {
524
+ const csvContent = "Date,Description\n09/15/2023,Test Transaction";
525
+ const format: CSVFormat = {
526
+ date_column: "Date",
527
+ description_column: "Description",
528
+ date_format: "MM/DD/YYYY",
529
+ has_header: true,
530
+ delimiter: ",",
531
+ };
532
+
533
+ expect(() => parseBankCSV(csvContent, format)).not.toThrow();
534
+ // Since the function continues on error, check that no transactions are returned
535
+ const transactions = parseBankCSV(csvContent, format);
536
+ expect(transactions).toHaveLength(0);
537
+ });
538
+
539
+ test("should handle invalid column indices gracefully", () => {
540
+ const csvContent = "09/15/2023,123.45";
541
+ const format: CSVFormat = {
542
+ date_column: 0,
543
+ amount_column: 1,
544
+ description_column: 99, // Invalid index
545
+ date_format: "MM/DD/YYYY",
546
+ has_header: false,
547
+ delimiter: ",",
548
+ };
549
+
550
+ const transactions = parseBankCSV(csvContent, format);
551
+
552
+ expect(transactions).toHaveLength(1);
553
+ expect(transactions[0].description).toBe(""); // Empty description for missing column
554
+ });
555
+
556
+ test("should parse semicolon-delimited CSV with headers", () => {
557
+ const csvContent =
558
+ "Date;Amount;Description\n09/15/2023;123.45;Test Transaction\n09/16/2023;-67.89;Another Transaction";
559
+ const format: CSVFormat = {
560
+ date_column: "Date",
561
+ amount_column: "Amount",
562
+ description_column: "Description",
563
+ date_format: "MM/DD/YYYY",
564
+ has_header: true,
565
+ delimiter: ";",
566
+ };
567
+
568
+ const transactions = parseBankCSV(csvContent, format);
569
+
570
+ expect(transactions).toHaveLength(2);
571
+ expect(transactions[0].description).toBe("Test Transaction");
572
+ expect(transactions[0].amount).toBe(123450);
573
+ expect(transactions[0].row_number).toBe(2);
574
+ expect(transactions[1].amount).toBe(-67890);
575
+ expect(transactions[1].row_number).toBe(3);
576
+ });
577
+
578
+ test("should parse tab-delimited CSV with headers", () => {
579
+ const csvContent =
580
+ "Date\tAmount\tDescription\n09/15/2023\t123.45\tTest Transaction\n09/16/2023\t-67.89\tAnother Transaction";
581
+ const format: CSVFormat = {
582
+ date_column: "Date",
583
+ amount_column: "Amount",
584
+ description_column: "Description",
585
+ date_format: "MM/DD/YYYY",
586
+ has_header: true,
587
+ delimiter: "\t",
588
+ };
589
+
590
+ const transactions = parseBankCSV(csvContent, format);
591
+
592
+ expect(transactions).toHaveLength(2);
593
+ expect(transactions[0].description).toBe("Test Transaction");
594
+ expect(transactions[0].amount).toBe(123450);
595
+ expect(transactions[0].row_number).toBe(2);
596
+ expect(transactions[1].amount).toBe(-67890);
597
+ expect(transactions[1].row_number).toBe(3);
598
+ });
599
+
600
+ test("should parse semicolon-delimited CSV without headers", () => {
601
+ const csvContent =
602
+ "09/15/2023;123.45;Test Transaction\n09/16/2023;-67.89;Another Transaction";
603
+ const format: CSVFormat = {
604
+ date_column: 0,
605
+ amount_column: 1,
606
+ description_column: 2,
607
+ date_format: "MM/DD/YYYY",
608
+ has_header: false,
609
+ delimiter: ";",
610
+ };
611
+
612
+ const transactions = parseBankCSV(csvContent, format);
613
+
614
+ expect(transactions).toHaveLength(2);
615
+ expect(transactions[0].description).toBe("Test Transaction");
616
+ expect(transactions[0].amount).toBe(123450);
617
+ expect(transactions[0].row_number).toBe(1);
618
+ expect(transactions[1].row_number).toBe(2);
619
+ });
620
+
621
+ test("should parse tab-delimited CSV without headers", () => {
622
+ const csvContent =
623
+ "09/15/2023\t123.45\tTest Transaction\n09/16/2023\t-67.89\tAnother Transaction";
624
+ const format: CSVFormat = {
625
+ date_column: 0,
626
+ amount_column: 1,
627
+ description_column: 2,
628
+ date_format: "MM/DD/YYYY",
629
+ has_header: false,
630
+ delimiter: "\t",
631
+ };
632
+
633
+ const transactions = parseBankCSV(csvContent, format);
634
+
635
+ expect(transactions).toHaveLength(2);
636
+ expect(transactions[0].description).toBe("Test Transaction");
637
+ expect(transactions[0].amount).toBe(123450);
638
+ expect(transactions[0].row_number).toBe(1);
639
+ expect(transactions[1].row_number).toBe(2);
640
+ });
641
+
642
+ test("should handle semicolon-delimited debit/credit columns with headers", () => {
643
+ const csvContent =
644
+ "Date;Description;Debit;Credit\n09/15/2023;Test Transaction;123.45;\n09/16/2023;Credit Transaction;;67.89";
645
+ const format: CSVFormat = {
646
+ date_column: "Date",
647
+ description_column: "Description",
648
+ debit_column: "Debit",
649
+ credit_column: "Credit",
650
+ date_format: "MM/DD/YYYY",
651
+ has_header: true,
652
+ delimiter: ";",
653
+ };
654
+
655
+ const transactions = parseBankCSV(csvContent, format);
656
+
657
+ expect(transactions).toHaveLength(2);
658
+ expect(transactions[0].amount).toBe(-123450); // Debit is negative
659
+ expect(transactions[1].amount).toBe(67890); // Credit is positive
660
+ });
661
+
662
+ test("should handle tab-delimited debit/credit columns with headers", () => {
663
+ const csvContent =
664
+ "Date\tDescription\tDebit\tCredit\n09/15/2023\tTest Transaction\t123.45\t\n09/16/2023\tCredit Transaction\t\t67.89";
665
+ const format: CSVFormat = {
666
+ date_column: "Date",
667
+ description_column: "Description",
668
+ debit_column: "Debit",
669
+ credit_column: "Credit",
670
+ date_format: "MM/DD/YYYY",
671
+ has_header: true,
672
+ delimiter: "\t",
673
+ };
674
+
675
+ const transactions = parseBankCSV(csvContent, format);
676
+
677
+ expect(transactions).toHaveLength(2);
678
+ expect(transactions[0].amount).toBe(-123450); // Debit is negative
679
+ expect(transactions[1].amount).toBe(67890); // Credit is positive
680
+ });
681
+
682
+ test("should parse semicolon-delimited CSV with quoted fields containing delimiter", () => {
683
+ const csvContent =
684
+ 'Date;Description;Amount\n2025-09-20;"Utility;Gas";-50.00\n2025-09-21;"Store;Purchase";-25.00';
685
+ const format: CSVFormat = {
686
+ date_column: "Date",
687
+ amount_column: "Amount",
688
+ description_column: "Description",
689
+ date_format: "YYYY-MM-DD",
690
+ has_header: true,
691
+ delimiter: ";",
692
+ };
693
+
694
+ const transactions = parseBankCSV(csvContent, format);
695
+
696
+ expect(transactions).toHaveLength(2);
697
+ expect(transactions[0].description).toBe("Utility;Gas");
698
+ expect(transactions[0].amount).toBe(-50000);
699
+ expect(transactions[1].description).toBe("Store;Purchase");
700
+ expect(transactions[1].amount).toBe(-25000);
701
+ });
702
+ });
703
+
704
+ describe("extractDateRangeFromCSV", () => {
705
+ test("should extract min and max dates from CSV transactions", () => {
706
+ const csvContent = `Date,Description,Debit,Credit,Balance
692
707
  11/10/2025,DOLLARAMA # 109,10.91,,
693
708
  10/15/2025,Uber Trip,30.50,,
694
709
  09/22/2025,CIRCLE K # 05844 A,18.84,,`;
695
710
 
696
- const format: CSVFormat = {
697
- date_column: 'Date',
698
- description_column: 'Description',
699
- debit_column: 'Debit',
700
- credit_column: 'Credit',
701
- date_format: 'MM/DD/YYYY',
702
- has_header: true,
703
- delimiter: ',',
704
- };
711
+ const format: CSVFormat = {
712
+ date_column: "Date",
713
+ description_column: "Description",
714
+ debit_column: "Debit",
715
+ credit_column: "Credit",
716
+ date_format: "MM/DD/YYYY",
717
+ has_header: true,
718
+ delimiter: ",",
719
+ };
705
720
 
706
- const { minDate, maxDate } = extractDateRangeFromCSV(csvContent, format);
721
+ const { minDate, maxDate } = extractDateRangeFromCSV(csvContent, format);
707
722
 
708
- expect(minDate).toBe('2025-09-22');
709
- expect(maxDate).toBe('2025-11-10');
710
- });
723
+ expect(minDate).toBe("2025-09-22");
724
+ expect(maxDate).toBe("2025-11-10");
725
+ });
711
726
 
712
- test('should handle single transaction', () => {
713
- const csvContent = `Date,Description,Amount
727
+ test("should handle single transaction", () => {
728
+ const csvContent = `Date,Description,Amount
714
729
  10/15/2025,Test,100.00`;
715
730
 
716
- const format: CSVFormat = {
717
- date_column: 'Date',
718
- description_column: 'Description',
719
- amount_column: 'Amount',
720
- date_format: 'MM/DD/YYYY',
721
- has_header: true,
722
- delimiter: ',',
723
- };
724
-
725
- const { minDate, maxDate } = extractDateRangeFromCSV(csvContent, format);
726
-
727
- expect(minDate).toBe('2025-10-15');
728
- expect(maxDate).toBe('2025-10-15');
729
- });
730
- });
731
-
732
- describe('readCSVFile', () => {
733
- test('should read file successfully', () => {
734
- const mockContent = 'Date,Amount,Description\n09/15/2023,123.45,Test';
735
- mockReadFileSync.mockReturnValue(mockContent);
736
-
737
- const result = readCSVFile('/path/to/file.csv');
738
-
739
- expect(result).toBe(mockContent);
740
- expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/file.csv', 'utf-8');
741
- });
742
-
743
- test('should throw error when file reading fails', () => {
744
- const error = new Error('File not found');
745
- mockReadFileSync.mockImplementation(() => {
746
- throw error;
747
- });
748
-
749
- expect(() => readCSVFile('/nonexistent/file.csv')).toThrow(
750
- 'Unable to read CSV file: File not found',
751
- );
752
- });
753
-
754
- test('should handle unknown errors', () => {
755
- mockReadFileSync.mockImplementation(() => {
756
- throw 'String error';
757
- });
758
-
759
- expect(() => readCSVFile('/path/to/file.csv')).toThrow(
760
- 'Unable to read CSV file: Unknown error',
761
- );
762
- });
763
- });
731
+ const format: CSVFormat = {
732
+ date_column: "Date",
733
+ description_column: "Description",
734
+ amount_column: "Amount",
735
+ date_format: "MM/DD/YYYY",
736
+ has_header: true,
737
+ delimiter: ",",
738
+ };
739
+
740
+ const { minDate, maxDate } = extractDateRangeFromCSV(csvContent, format);
741
+
742
+ expect(minDate).toBe("2025-10-15");
743
+ expect(maxDate).toBe("2025-10-15");
744
+ });
745
+ });
746
+
747
+ describe("readCSVFile", () => {
748
+ test("should read file successfully", () => {
749
+ const mockContent = "Date,Amount,Description\n09/15/2023,123.45,Test";
750
+ mockReadFileSync.mockReturnValue(mockContent);
751
+
752
+ const result = readCSVFile("/path/to/file.csv");
753
+
754
+ expect(result).toBe(mockContent);
755
+ expect(mockReadFileSync).toHaveBeenCalledWith(
756
+ "/path/to/file.csv",
757
+ "utf-8",
758
+ );
759
+ });
760
+
761
+ test("should throw error when file reading fails", () => {
762
+ const error = new Error("File not found");
763
+ mockReadFileSync.mockImplementation(() => {
764
+ throw error;
765
+ });
766
+
767
+ expect(() => readCSVFile("/nonexistent/file.csv")).toThrow(
768
+ "Unable to read CSV file: File not found",
769
+ );
770
+ });
771
+
772
+ test("should handle unknown errors", () => {
773
+ mockReadFileSync.mockImplementation(() => {
774
+ throw "String error";
775
+ });
776
+
777
+ expect(() => readCSVFile("/path/to/file.csv")).toThrow(
778
+ "Unable to read CSV file: Unknown error",
779
+ );
780
+ });
781
+ });
764
782
  });