@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,149 +1,151 @@
1
- import Papa from 'papaparse';
2
- import * as chrono from 'chrono-node';
3
- import { randomUUID } from 'crypto';
4
- import type { BankTransaction } from '../../types/reconciliation.js';
1
+ import { randomUUID } from "node:crypto";
2
+ import * as chrono from "chrono-node";
3
+ import Papa from "papaparse";
4
+ import type { BankTransaction } from "../../types/reconciliation.js";
5
5
 
6
6
  export interface CSVParseResult {
7
- transactions: BankTransaction[];
8
- errors: ParseError[];
9
- warnings: ParseWarning[];
10
- meta: {
11
- detectedDelimiter: string;
12
- detectedColumns: string[];
13
- totalRows: number;
14
- validRows: number;
15
- skippedRows: number;
16
- };
7
+ transactions: BankTransaction[];
8
+ errors: ParseError[];
9
+ warnings: ParseWarning[];
10
+ meta: {
11
+ detectedDelimiter: string;
12
+ detectedColumns: string[];
13
+ totalRows: number;
14
+ validRows: number;
15
+ skippedRows: number;
16
+ };
17
17
  }
18
18
 
19
19
  export interface ParseError {
20
- row: number;
21
- field: string;
22
- message: string;
23
- rawValue: string;
20
+ row: number;
21
+ field: string;
22
+ message: string;
23
+ rawValue: string;
24
24
  }
25
25
 
26
26
  export interface ParseWarning {
27
- row: number;
28
- message: string;
27
+ row: number;
28
+ message: string;
29
29
  }
30
30
 
31
31
  export interface BankPreset {
32
- name: string;
33
- dateColumn: string | string[];
34
- amountColumn?: string | string[];
35
- debitColumn?: string;
36
- creditColumn?: string;
37
- descriptionColumn: string | string[];
38
- amountMultiplier?: number;
39
- /** Expected date format hint: 'YMD', 'MDY', 'DMY' */
40
- dateFormat?: 'YMD' | 'MDY' | 'DMY';
41
- /** Whether the CSV has a header row */
42
- header?: boolean;
32
+ name: string;
33
+ dateColumn: string | string[];
34
+ amountColumn?: string | string[];
35
+ debitColumn?: string;
36
+ creditColumn?: string;
37
+ descriptionColumn: string | string[];
38
+ amountMultiplier?: number;
39
+ /** Expected date format hint: 'YMD', 'MDY', 'DMY' */
40
+ dateFormat?: "YMD" | "MDY" | "DMY";
41
+ /** Whether the CSV has a header row */
42
+ header?: boolean;
43
43
  }
44
44
 
45
45
  // Presets for Canadian banks
46
46
  export const BANK_PRESETS: Record<string, BankPreset> = {
47
- td: {
48
- name: 'TD Canada Trust',
49
- // Real TD credit card exports are typically
50
- // headerless with columns:
51
- // [Date, Description, Debit, Credit, Balance]
52
- // but some tools/scrapers produce a
53
- // headered variant: Date,Description,Amount.
54
- //
55
- // We default to headerless here and rely on
56
- // auto-detection + flexible column candidates
57
- // so both forms are supported.
58
- header: false,
59
- dateColumn: ['0', 'Date'],
60
- amountColumn: ['Amount'],
61
- debitColumn: '2',
62
- creditColumn: '3',
63
- descriptionColumn: ['1', 'Description'],
64
- dateFormat: 'MDY', // TD typically uses MM/DD/YYYY
65
- },
66
- rbc: {
67
- name: 'RBC Royal Bank',
68
- dateColumn: ['Transaction Date', 'Date'],
69
- debitColumn: 'Debit',
70
- creditColumn: 'Credit',
71
- descriptionColumn: ['Description 1', 'Description', 'Transaction'],
72
- dateFormat: 'YMD', // RBC typically uses YYYY-MM-DD
73
- },
74
- scotiabank: {
75
- name: 'Scotiabank',
76
- dateColumn: ['Date', 'Transaction Date'],
77
- amountColumn: ['Amount'],
78
- descriptionColumn: ['Description', 'Transaction Details'],
79
- dateFormat: 'DMY', // Scotiabank often uses DD/MM/YYYY
80
- },
81
- wealthsimple: {
82
- name: 'Wealthsimple',
83
- dateColumn: ['Date'],
84
- amountColumn: ['Amount'],
85
- descriptionColumn: ['Description', 'Payee'],
86
- amountMultiplier: 1,
87
- dateFormat: 'YMD',
88
- },
89
- tangerine: {
90
- name: 'Tangerine',
91
- dateColumn: ['Date', 'Transaction date'],
92
- amountColumn: ['Amount'],
93
- descriptionColumn: ['Name', 'Transaction name', 'Memo'],
94
- dateFormat: 'MDY',
95
- },
47
+ td: {
48
+ name: "TD Canada Trust",
49
+ // Real TD credit card exports are typically
50
+ // headerless with columns:
51
+ // [Date, Description, Debit, Credit, Balance]
52
+ // but some tools/scrapers produce a
53
+ // headered variant: Date,Description,Amount.
54
+ //
55
+ // We default to headerless here and rely on
56
+ // auto-detection + flexible column candidates
57
+ // so both forms are supported.
58
+ header: false,
59
+ dateColumn: ["0", "Date"],
60
+ amountColumn: ["Amount"],
61
+ debitColumn: "2",
62
+ creditColumn: "3",
63
+ descriptionColumn: ["1", "Description"],
64
+ dateFormat: "MDY", // TD typically uses MM/DD/YYYY
65
+ },
66
+ rbc: {
67
+ name: "RBC Royal Bank",
68
+ dateColumn: ["Transaction Date", "Date"],
69
+ debitColumn: "Debit",
70
+ creditColumn: "Credit",
71
+ descriptionColumn: ["Description 1", "Description", "Transaction"],
72
+ dateFormat: "YMD", // RBC typically uses YYYY-MM-DD
73
+ },
74
+ scotiabank: {
75
+ name: "Scotiabank",
76
+ dateColumn: ["Date", "Transaction Date"],
77
+ amountColumn: ["Amount"],
78
+ descriptionColumn: ["Description", "Transaction Details"],
79
+ dateFormat: "DMY", // Scotiabank often uses DD/MM/YYYY
80
+ },
81
+ wealthsimple: {
82
+ name: "Wealthsimple",
83
+ dateColumn: ["Date"],
84
+ amountColumn: ["Amount"],
85
+ descriptionColumn: ["Description", "Payee"],
86
+ amountMultiplier: 1,
87
+ dateFormat: "YMD",
88
+ },
89
+ tangerine: {
90
+ name: "Tangerine",
91
+ dateColumn: ["Date", "Transaction date"],
92
+ amountColumn: ["Amount"],
93
+ descriptionColumn: ["Name", "Transaction name", "Memo"],
94
+ dateFormat: "MDY",
95
+ },
96
96
  };
97
97
 
98
98
  /**
99
99
  * Safe delimiters allowed for CSV parsing.
100
100
  * Restricted to common, safe characters to prevent injection attacks.
101
101
  */
102
- export const SAFE_DELIMITERS = [',', ';', '\t', '|', ' '] as const;
102
+ export const SAFE_DELIMITERS = [",", ";", "\t", "|", " "] as const;
103
103
  export type SafeDelimiter = (typeof SAFE_DELIMITERS)[number];
104
104
 
105
105
  /**
106
106
  * Validates that a delimiter is safe for CSV parsing.
107
107
  * @throws {Error} if delimiter is not in the safe list
108
108
  */
109
- function validateDelimiter(delimiter: string): asserts delimiter is SafeDelimiter {
110
- if (!SAFE_DELIMITERS.includes(delimiter as SafeDelimiter)) {
111
- throw new Error(
112
- `Unsafe delimiter "${delimiter}". Allowed delimiters: ${SAFE_DELIMITERS.join(', ')}`,
113
- );
114
- }
109
+ function validateDelimiter(
110
+ delimiter: string,
111
+ ): asserts delimiter is SafeDelimiter {
112
+ if (!SAFE_DELIMITERS.includes(delimiter as SafeDelimiter)) {
113
+ throw new Error(
114
+ `Unsafe delimiter "${delimiter}". Allowed delimiters: ${SAFE_DELIMITERS.join(", ")}`,
115
+ );
116
+ }
115
117
  }
116
118
 
117
119
  export interface ParseCSVOptions {
118
- /** Bank preset key (e.g., 'td', 'rbc') */
119
- preset?: string;
120
- /** Multiply all amounts by -1 */
121
- invertAmounts?: boolean;
122
- /**
123
- * Explicit CSV delimiter override (defaults to PapaParse auto-detection).
124
- * Must be one of: comma (,), semicolon (;), tab (\t), pipe (|), or space ( )
125
- */
126
- delimiter?: string;
127
- /** Manual column overrides */
128
- columns?: {
129
- date?: string;
130
- amount?: string;
131
- debit?: string;
132
- credit?: string;
133
- description?: string;
134
- };
135
- /** Date format hint */
136
- dateFormat?: 'YMD' | 'MDY' | 'DMY';
137
- /**
138
- * Whether the CSV has a header row.
139
- * If false, columns must be specified by index (e.g., "0", "1").
140
- * Defaults to true.
141
- */
142
- header?: boolean;
143
- /** Maximum number of rows to process (default: 10000) */
144
- maxRows?: number;
145
- /** Maximum file size in bytes (default: 10MB) */
146
- maxBytes?: number;
120
+ /** Bank preset key (e.g., 'td', 'rbc') */
121
+ preset?: string;
122
+ /** Multiply all amounts by -1 */
123
+ invertAmounts?: boolean;
124
+ /**
125
+ * Explicit CSV delimiter override (defaults to PapaParse auto-detection).
126
+ * Must be one of: comma (,), semicolon (;), tab (\t), pipe (|), or space ( )
127
+ */
128
+ delimiter?: string;
129
+ /** Manual column overrides */
130
+ columns?: {
131
+ date?: string;
132
+ amount?: string;
133
+ debit?: string;
134
+ credit?: string;
135
+ description?: string;
136
+ };
137
+ /** Date format hint */
138
+ dateFormat?: "YMD" | "MDY" | "DMY";
139
+ /**
140
+ * Whether the CSV has a header row.
141
+ * If false, columns must be specified by index (e.g., "0", "1").
142
+ * Defaults to true.
143
+ */
144
+ header?: boolean;
145
+ /** Maximum number of rows to process (default: 10000) */
146
+ maxRows?: number;
147
+ /** Maximum file size in bytes (default: 10MB) */
148
+ maxBytes?: number;
147
149
  }
148
150
 
149
151
  /**
@@ -153,66 +155,70 @@ export interface ParseCSVOptions {
153
155
  * 2. Check for header matches (existing logic).
154
156
  * 3. Check for headerless patterns (TD specific: date, desc, debit, credit, balance).
155
157
  */
156
- function autoDetectFormat(content: string): { preset?: string; header?: boolean } | undefined {
157
- const preview = Papa.parse(content, {
158
- preview: 5,
159
- header: false, // Parse as array first to inspect structure
160
- skipEmptyLines: true,
161
- });
162
-
163
- if (preview.errors.length > 0 || preview.data.length === 0) return undefined;
164
-
165
- const rows = preview.data as string[][];
166
- const firstRow = rows[0];
167
- if (!firstRow) return undefined;
168
-
169
- // 1. Check for known headers (RBC, etc.)
170
- const headerMatch = detectPreset(firstRow);
171
- if (headerMatch) {
172
- // Find key in BANK_PRESETS
173
- const key = Object.keys(BANK_PRESETS).find((k) => BANK_PRESETS[k] === headerMatch);
174
- if (key) return { preset: key, header: true };
175
- }
176
-
177
- // 2. Check for TD Headerless Pattern
178
- // Typical TD row: [Date, Description, Debit, Credit, Balance]
179
- // Date: MM/DD/YYYY (e.g., 11/21/2025)
180
- // Debit/Credit: Numbers or empty
181
- // Balance: Number
182
- if (checkTDPattern(rows)) {
183
- return { preset: 'td', header: false };
184
- }
185
-
186
- return undefined;
158
+ function autoDetectFormat(
159
+ content: string,
160
+ ): { preset?: string; header?: boolean } | undefined {
161
+ const preview = Papa.parse(content, {
162
+ preview: 5,
163
+ header: false, // Parse as array first to inspect structure
164
+ skipEmptyLines: true,
165
+ });
166
+
167
+ if (preview.errors.length > 0 || preview.data.length === 0) return undefined;
168
+
169
+ const rows = preview.data as string[][];
170
+ const firstRow = rows[0];
171
+ if (!firstRow) return undefined;
172
+
173
+ // 1. Check for known headers (RBC, etc.)
174
+ const headerMatch = detectPreset(firstRow);
175
+ if (headerMatch) {
176
+ // Find key in BANK_PRESETS
177
+ const key = Object.keys(BANK_PRESETS).find(
178
+ (k) => BANK_PRESETS[k] === headerMatch,
179
+ );
180
+ if (key) return { preset: key, header: true };
181
+ }
182
+
183
+ // 2. Check for TD Headerless Pattern
184
+ // Typical TD row: [Date, Description, Debit, Credit, Balance]
185
+ // Date: MM/DD/YYYY (e.g., 11/21/2025)
186
+ // Debit/Credit: Numbers or empty
187
+ // Balance: Number
188
+ if (checkTDPattern(rows)) {
189
+ return { preset: "td", header: false };
190
+ }
191
+
192
+ return undefined;
187
193
  }
188
194
 
189
195
  function checkTDPattern(rows: string[][]): boolean {
190
- // Needs at least one valid row
191
- // Headerless TD exports typically have at least
192
- // Date, Description, Debit, Credit columns. Require
193
- // 4+ columns to avoid misclassifying generic
194
- // Date/Description/Amount formats as TD.
195
- const validRows = rows.filter((r) => r.length >= 4);
196
- if (validRows.length === 0) return false;
197
-
198
- // Check first few rows for MM/DD/YYYY date in column 0
199
- // AND numeric values in columns 2, 3, 4 (if present)
200
- let matchCount = 0;
201
- for (const row of validRows) {
202
- // Col 0: Date MM/DD/YYYY
203
- if (!/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(row[0] || '')) continue;
204
-
205
- // Col 2 (Debit) or Col 3 (Credit) must be numeric-ish if present
206
- const isDebitNumeric = !row[2] || /^-?[\d,.]+$/.test(row[2]);
207
- const isCreditNumeric = !row[3] || /^-?[\d,.]+$/.test(row[3]);
208
-
209
- if (isDebitNumeric && isCreditNumeric) {
210
- matchCount++;
211
- }
212
- }
213
-
214
- // If majority of preview rows match, it's likely TD
215
- return matchCount > validRows.length / 2;
196
+ // Needs at least one valid row
197
+ // Headerless TD exports typically have at least
198
+ // Date, Description, Debit, Credit columns. Require
199
+ // 4+ columns to avoid misclassifying generic
200
+ // Date/Description/Amount formats as TD.
201
+ const validRows = rows.filter((r) => r.length >= 4);
202
+ if (validRows.length === 0) return false;
203
+
204
+ // Check first few rows for MM/DD/YYYY date in column 0
205
+ // AND numeric values in columns 2, 3, 4 (if present)
206
+ let matchCount = 0;
207
+ for (const row of validRows) {
208
+ // Col 0: Date MM/DD/YYYY
209
+ if (!/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(row[0] || "")) continue;
210
+
211
+ // Col 2 (Debit) or Col 3 (Credit) must be numeric-ish if present
212
+ const isDebitNumeric = !row[2] || /^-?[\d,.]+$/.test(row[2]);
213
+ const isCreditNumeric = !row[3] || /^-?[\d,.]+$/.test(row[3]);
214
+
215
+ if (isDebitNumeric && isCreditNumeric) {
216
+ matchCount++;
217
+ }
218
+ }
219
+
220
+ // If majority of preview rows match, it's likely TD
221
+ return matchCount > validRows.length / 2;
216
222
  }
217
223
 
218
224
  /**
@@ -221,426 +227,460 @@ function checkTDPattern(rows: string[][]): boolean {
221
227
  * IMPORTANT: Amounts are converted to MILLIUNITS (integers) at this boundary.
222
228
  * This is the ONLY place where float-to-milliunit conversion happens.
223
229
  */
224
- export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVParseResult {
225
- const errors: ParseError[] = [];
226
- const warnings: ParseWarning[] = [];
227
-
228
- // Security: Validate delimiter if provided
229
- if (options.delimiter) {
230
- validateDelimiter(options.delimiter);
231
- }
232
-
233
- // Security: Check file size limit
234
- const MAX_BYTES = options.maxBytes ?? 10 * 1024 * 1024; // 10MB default
235
- if (content.length > MAX_BYTES) {
236
- throw new Error(`File size exceeds limit of ${Math.round(MAX_BYTES / 1024 / 1024)}MB`);
237
- }
238
-
239
- // Auto-detect format when preset or header are not fully specified
240
- let detectedPreset: string | undefined = options.preset;
241
- let detectedHeader: boolean | undefined = options.header;
242
-
243
- if (!detectedPreset || detectedHeader === undefined) {
244
- const autoResult = autoDetectFormat(content);
245
- if (autoResult) {
246
- if (!detectedPreset) {
247
- detectedPreset = autoResult.preset;
248
- }
249
- if (detectedHeader === undefined && autoResult.header !== undefined) {
250
- detectedHeader = autoResult.header;
251
- }
252
- }
253
- }
254
-
255
- // Determine header setting: Explicit > Detected > Preset > Default (true)
256
- let hasHeader = true;
257
- if (detectedHeader !== undefined) {
258
- hasHeader = detectedHeader;
259
- } else if (detectedPreset) {
260
- const preset = BANK_PRESETS[detectedPreset];
261
- if (preset && preset.header !== undefined) {
262
- hasHeader = preset.header;
263
- }
264
- }
265
-
266
- const maxRows = options.maxRows ?? 10000;
267
-
268
- // Parse with PapaParse
269
- // Security: Use preview to limit rows parsed into memory (prevents memory exhaustion)
270
- const parsed = Papa.parse(content, {
271
- header: hasHeader,
272
- preview: maxRows + (hasHeader ? 1 : 0), // +1 for header row if present
273
- dynamicTyping: false, // We'll handle type conversion ourselves
274
- skipEmptyLines: true,
275
- transformHeader: (h) => h.trim(),
276
- ...(options.delimiter ? { delimiter: options.delimiter } : {}),
277
- });
278
-
279
- if (parsed.errors.length > 0) {
280
- for (const err of parsed.errors) {
281
- errors.push({
282
- row: err.row ?? 0,
283
- field: 'csv',
284
- message: err.message,
285
- rawValue: '',
286
- });
287
- }
288
- }
289
-
290
- const rows = parsed.data as (Record<string, string> | string[])[];
291
- let columns: string[] = [];
292
-
293
- if (hasHeader) {
294
- columns = parsed.meta.fields ?? [];
295
- } else {
296
- // If no header, rows are arrays. Create dummy columns based on max length
297
- const maxLen = rows.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0);
298
- columns = Array.from({ length: maxLen }, (_, i) => String(i));
299
- }
300
-
301
- const preset = detectedPreset
302
- ? BANK_PRESETS[detectedPreset]
303
- : hasHeader
304
- ? detectPreset(columns)
305
- : undefined;
306
-
307
- // Determine column names (Priority: Options > Preset > Defaults)
308
-
309
- const dateCandidates = options.columns?.date
310
- ? [options.columns.date]
311
- : (preset?.dateColumn ?? ['Date', 'Transaction Date', 'Posted Date']);
312
- const descCandidates = options.columns?.description
313
- ? [options.columns.description]
314
- : (preset?.descriptionColumn ?? ['Description', 'Payee', 'Merchant', 'Name']);
315
-
316
- const dateCol = findColumn(columns, dateCandidates, !hasHeader);
317
- const descCol = findColumn(columns, descCandidates, !hasHeader);
318
-
319
- let amountCol: string | null = null;
320
- let debitCol: string | null = null;
321
- let creditCol: string | null = null;
322
-
323
- if (options.columns?.debit && options.columns?.credit) {
324
- debitCol = findColumn(columns, [options.columns.debit], !hasHeader);
325
- creditCol = findColumn(columns, [options.columns.credit], !hasHeader);
326
- } else if (
327
- preset?.debitColumn &&
328
- preset?.creditColumn &&
329
- !options.columns?.amount &&
330
- // If a preset also defines an amount column, prefer that when headers
331
- // are present. This lets TD support both headerless (debit/credit)
332
- // and headered (Amount) variants while RBC still uses debit/credit
333
- // with headers.
334
- (hasHeader ? !preset?.amountColumn : true)
335
- ) {
336
- debitCol = findColumn(columns, [preset.debitColumn], !hasHeader);
337
- creditCol = findColumn(columns, [preset.creditColumn], !hasHeader);
338
- } else {
339
- const amountCandidates = options.columns?.amount
340
- ? [options.columns.amount]
341
- : (preset?.amountColumn ?? ['Amount', 'CAD$', 'Value']);
342
- amountCol = findColumn(columns, amountCandidates, !hasHeader);
343
- }
344
-
345
- if (!dateCol) {
346
- errors.push({
347
- row: 0,
348
- field: 'date',
349
- message: `Could not identify date column from: ${columns.join(', ')}. Try using preset option (td, rbc, scotiabank, etc.) or specify columns manually with columns.date`,
350
- rawValue: columns.join(', '),
351
- });
352
- }
353
- if (!amountCol && (!debitCol || !creditCol)) {
354
- if (!debitCol && !creditCol) {
355
- errors.push({
356
- row: 0,
357
- field: 'amount',
358
- message: `Could not identify amount column from: ${columns.join(', ')}. Try using preset option or specify columns manually with columns.amount (or columns.debit/credit for split columns)`,
359
- rawValue: columns.join(', '),
360
- });
361
- } else if (!debitCol || !creditCol) {
362
- errors.push({
363
- row: 0,
364
- field: 'amount',
365
- message: `Could not identify debit/credit columns pair from: ${columns.join(', ')}. Found ${debitCol ? 'debit' : 'credit'} but missing ${debitCol ? 'credit' : 'debit'}. Specify both with columns.debit and columns.credit`,
366
- rawValue: columns.join(', '),
367
- });
368
- }
369
- }
370
-
371
- const transactions: BankTransaction[] = [];
372
-
373
- const dateFormat = options.dateFormat ?? preset?.dateFormat;
374
-
375
- // Papa.parse preview already limited rows, but keep defensive check
376
- for (let i = 0; i < Math.min(rows.length, maxRows); i++) {
377
- const row = rows[i];
378
- if (!row) continue;
379
-
380
- // Helper to get value
381
- const getValue = (colName: string | null): string => {
382
- if (!colName) return '';
383
- if (Array.isArray(row)) {
384
- const idx = parseInt(colName, 10);
385
- return String(row[idx] ?? '');
386
- }
387
- return String(row[colName as keyof typeof row] ?? '');
388
- };
389
-
390
- const rowNum = i + (hasHeader ? 2 : 1); // 1-indexed. Header consumes line 1.
391
- const rowWarnings: string[] = [];
392
-
393
- // Parse date
394
- const rawDate = getValue(dateCol)?.trim() ?? '';
395
- const parsedDate = parseDate(rawDate, dateFormat);
396
- if (!parsedDate) {
397
- errors.push({
398
- row: rowNum,
399
- field: 'date',
400
- message: `Could not parse date: "${rawDate}"`,
401
- rawValue: rawDate,
402
- });
403
- continue;
404
- }
405
- // Use LOCAL date components (now derived from UTC date object)
406
- const dateStr = formatLocalDate(parsedDate);
407
-
408
- // Parse amount
409
- let amountMilliunits: number;
410
- let rawAmount: string;
411
-
412
- if (amountCol) {
413
- rawAmount = getValue(amountCol)?.trim() ?? '';
414
- const parsedAmount = parseAmount(rawAmount);
415
- if (!parsedAmount.valid) {
416
- errors.push({
417
- row: rowNum,
418
- field: 'amount',
419
- message: parsedAmount.reason ?? `Invalid amount: "${rawAmount}"`,
420
- rawValue: rawAmount,
421
- });
422
- continue;
423
- }
424
- amountMilliunits = parsedAmount.valueMilliunits;
425
- } else if (debitCol && creditCol) {
426
- const debit = getValue(debitCol)?.trim() ?? '';
427
- const credit = getValue(creditCol)?.trim() ?? '';
428
- rawAmount = debit || credit;
429
-
430
- const parsedDebit = parseAmount(debit);
431
- const parsedCredit = parseAmount(credit);
432
-
433
- if (!parsedDebit.valid && debit) {
434
- errors.push({
435
- row: rowNum,
436
- field: 'amount',
437
- message: parsedDebit.reason ?? `Invalid debit amount: "${debit}"`,
438
- rawValue: debit,
439
- });
440
- continue;
441
- }
442
- if (!parsedCredit.valid && credit) {
443
- errors.push({
444
- row: rowNum,
445
- field: 'amount',
446
- message: parsedCredit.reason ?? `Invalid credit amount: "${credit}"`,
447
- rawValue: credit,
448
- });
449
- continue;
450
- }
451
-
452
- const debitMilliunits = parsedDebit.valid ? parsedDebit.valueMilliunits : 0;
453
- const creditMilliunits = parsedCredit.valid ? parsedCredit.valueMilliunits : 0;
454
-
455
- // Warn if both debit and credit have values (ambiguous)
456
- if (Math.abs(debitMilliunits) > 0 && Math.abs(creditMilliunits) > 0) {
457
- const warning = `Both Debit (${debit}) and Credit (${credit}) have values - using Debit`;
458
- rowWarnings.push(warning);
459
- warnings.push({ row: rowNum, message: warning });
460
- }
461
-
462
- if (Math.abs(debitMilliunits) > 0) {
463
- amountMilliunits = -Math.abs(debitMilliunits); // Debits are outflows (negative)
464
- } else if (Math.abs(creditMilliunits) > 0) {
465
- amountMilliunits = Math.abs(creditMilliunits); // Credits are inflows (positive)
466
- } else {
467
- errors.push({
468
- row: rowNum,
469
- field: 'amount',
470
- message: 'Missing debit/credit amount',
471
- rawValue: `${debit}|${credit}`,
472
- });
473
- continue;
474
- }
475
-
476
- // Warn if debit column contains negative value (unusual)
477
- if (debitMilliunits < 0) {
478
- const warning = `Debit column contains negative value (${debit}) - treating as positive debit`;
479
- rowWarnings.push(warning);
480
- warnings.push({ row: rowNum, message: warning });
481
- }
482
- } else {
483
- continue;
484
- }
485
-
486
- // Apply amount inversion if needed
487
- const multiplier = options.invertAmounts ? -1 : (preset?.amountMultiplier ?? 1);
488
- amountMilliunits *= multiplier;
489
-
490
- // Parse description & Sanitize
491
- let rawDesc = getValue(descCol)?.trim() ?? '';
492
- // Security: Remove potentially malicious/confusing Unicode characters:
493
- // - ASCII control chars (0x00-0x1F, 0x7F)
494
- // - C1 control chars (0x80-0x9F)
495
- // - Bidirectional text overrides (U+202A-202E, U+2066-2069)
496
- // - Zero-width characters (U+200B-200D, U+FEFF)
497
- // - Unicode line/paragraph separators (U+2028-2029)
498
-
499
- rawDesc = rawDesc
500
- // eslint-disable-next-line no-control-regex
501
- .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') // ASCII + C1 control chars
502
- .replace(/[\u202A-\u202E\u2066-\u2069]/g, '') // Bidirectional overrides
503
- .replace(/[\u200B-\u200D\uFEFF]/g, '') // Zero-width chars
504
- .replace(/[\u2028-\u2029]/g, '') // Line/paragraph separators
505
- .substring(0, 500);
506
-
507
- transactions.push({
508
- id: randomUUID(),
509
- date: dateStr,
510
- amount: amountMilliunits,
511
- payee: rawDesc || 'Unknown',
512
- sourceRow: rowNum,
513
- raw: {
514
- date: rawDate,
515
- amount: rawAmount,
516
- description: rawDesc,
517
- },
518
- ...(rowWarnings.length > 0 && { warnings: rowWarnings }),
519
- });
520
- }
521
-
522
- return {
523
- transactions,
524
- errors,
525
- warnings,
526
- meta: {
527
- detectedDelimiter: parsed.meta.delimiter || ',',
528
- detectedColumns: columns,
529
- totalRows: rows.length,
530
- validRows: transactions.length,
531
- skippedRows: rows.length - transactions.length,
532
- },
533
- };
230
+ export function parseCSV(
231
+ content: string,
232
+ options: ParseCSVOptions = {},
233
+ ): CSVParseResult {
234
+ const errors: ParseError[] = [];
235
+ const warnings: ParseWarning[] = [];
236
+
237
+ // Security: Validate delimiter if provided
238
+ if (options.delimiter) {
239
+ validateDelimiter(options.delimiter);
240
+ }
241
+
242
+ // Security: Check file size limit
243
+ const MAX_BYTES = options.maxBytes ?? 10 * 1024 * 1024; // 10MB default
244
+ if (content.length > MAX_BYTES) {
245
+ throw new Error(
246
+ `File size exceeds limit of ${Math.round(MAX_BYTES / 1024 / 1024)}MB`,
247
+ );
248
+ }
249
+
250
+ // Auto-detect format when preset or header are not fully specified
251
+ let detectedPreset: string | undefined = options.preset;
252
+ let detectedHeader: boolean | undefined = options.header;
253
+
254
+ if (!detectedPreset || detectedHeader === undefined) {
255
+ const autoResult = autoDetectFormat(content);
256
+ if (autoResult) {
257
+ if (!detectedPreset) {
258
+ detectedPreset = autoResult.preset;
259
+ }
260
+ if (detectedHeader === undefined && autoResult.header !== undefined) {
261
+ detectedHeader = autoResult.header;
262
+ }
263
+ }
264
+ }
265
+
266
+ // Determine header setting: Explicit > Detected > Preset > Default (true)
267
+ let hasHeader = true;
268
+ if (detectedHeader !== undefined) {
269
+ hasHeader = detectedHeader;
270
+ } else if (detectedPreset) {
271
+ const preset = BANK_PRESETS[detectedPreset];
272
+ if (preset && preset.header !== undefined) {
273
+ hasHeader = preset.header;
274
+ }
275
+ }
276
+
277
+ const maxRows = options.maxRows ?? 10000;
278
+
279
+ // Parse with PapaParse
280
+ // Security: Use preview to limit rows parsed into memory (prevents memory exhaustion)
281
+ const parsed = Papa.parse(content, {
282
+ header: hasHeader,
283
+ preview: maxRows + (hasHeader ? 1 : 0), // +1 for header row if present
284
+ dynamicTyping: false, // We'll handle type conversion ourselves
285
+ skipEmptyLines: true,
286
+ transformHeader: (h) => h.trim(),
287
+ ...(options.delimiter ? { delimiter: options.delimiter } : {}),
288
+ });
289
+
290
+ if (parsed.errors.length > 0) {
291
+ for (const err of parsed.errors) {
292
+ errors.push({
293
+ row: err.row ?? 0,
294
+ field: "csv",
295
+ message: err.message,
296
+ rawValue: "",
297
+ });
298
+ }
299
+ }
300
+
301
+ const rows = parsed.data as (Record<string, string> | string[])[];
302
+ let columns: string[] = [];
303
+
304
+ if (hasHeader) {
305
+ columns = parsed.meta.fields ?? [];
306
+ } else {
307
+ // If no header, rows are arrays. Create dummy columns based on max length
308
+ const maxLen = rows.reduce(
309
+ (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0),
310
+ 0,
311
+ );
312
+ columns = Array.from({ length: maxLen }, (_, i) => String(i));
313
+ }
314
+
315
+ const preset = detectedPreset
316
+ ? BANK_PRESETS[detectedPreset]
317
+ : hasHeader
318
+ ? detectPreset(columns)
319
+ : undefined;
320
+
321
+ // Determine column names (Priority: Options > Preset > Defaults)
322
+
323
+ const dateCandidates = options.columns?.date
324
+ ? [options.columns.date]
325
+ : (preset?.dateColumn ?? ["Date", "Transaction Date", "Posted Date"]);
326
+ const descCandidates = options.columns?.description
327
+ ? [options.columns.description]
328
+ : (preset?.descriptionColumn ?? [
329
+ "Description",
330
+ "Payee",
331
+ "Merchant",
332
+ "Name",
333
+ ]);
334
+
335
+ const dateCol = findColumn(columns, dateCandidates, !hasHeader);
336
+ const descCol = findColumn(columns, descCandidates, !hasHeader);
337
+
338
+ let amountCol: string | null = null;
339
+ let debitCol: string | null = null;
340
+ let creditCol: string | null = null;
341
+
342
+ if (options.columns?.debit && options.columns?.credit) {
343
+ debitCol = findColumn(columns, [options.columns.debit], !hasHeader);
344
+ creditCol = findColumn(columns, [options.columns.credit], !hasHeader);
345
+ } else if (
346
+ preset?.debitColumn &&
347
+ preset?.creditColumn &&
348
+ !options.columns?.amount &&
349
+ // If a preset also defines an amount column, prefer that when headers
350
+ // are present. This lets TD support both headerless (debit/credit)
351
+ // and headered (Amount) variants while RBC still uses debit/credit
352
+ // with headers.
353
+ (hasHeader ? !preset?.amountColumn : true)
354
+ ) {
355
+ debitCol = findColumn(columns, [preset.debitColumn], !hasHeader);
356
+ creditCol = findColumn(columns, [preset.creditColumn], !hasHeader);
357
+ } else {
358
+ const amountCandidates = options.columns?.amount
359
+ ? [options.columns.amount]
360
+ : (preset?.amountColumn ?? ["Amount", "CAD$", "Value"]);
361
+ amountCol = findColumn(columns, amountCandidates, !hasHeader);
362
+ }
363
+
364
+ if (!dateCol) {
365
+ errors.push({
366
+ row: 0,
367
+ field: "date",
368
+ message: `Could not identify date column from: ${columns.join(", ")}. Try using preset option (td, rbc, scotiabank, etc.) or specify columns manually with columns.date`,
369
+ rawValue: columns.join(", "),
370
+ });
371
+ }
372
+ if (!amountCol && (!debitCol || !creditCol)) {
373
+ if (!debitCol && !creditCol) {
374
+ errors.push({
375
+ row: 0,
376
+ field: "amount",
377
+ message: `Could not identify amount column from: ${columns.join(", ")}. Try using preset option or specify columns manually with columns.amount (or columns.debit/credit for split columns)`,
378
+ rawValue: columns.join(", "),
379
+ });
380
+ } else if (!debitCol || !creditCol) {
381
+ errors.push({
382
+ row: 0,
383
+ field: "amount",
384
+ message: `Could not identify debit/credit columns pair from: ${columns.join(", ")}. Found ${debitCol ? "debit" : "credit"} but missing ${debitCol ? "credit" : "debit"}. Specify both with columns.debit and columns.credit`,
385
+ rawValue: columns.join(", "),
386
+ });
387
+ }
388
+ }
389
+
390
+ const transactions: BankTransaction[] = [];
391
+
392
+ const dateFormat = options.dateFormat ?? preset?.dateFormat;
393
+
394
+ // Papa.parse preview already limited rows, but keep defensive check
395
+ for (let i = 0; i < Math.min(rows.length, maxRows); i++) {
396
+ const row = rows[i];
397
+ if (!row) continue;
398
+
399
+ // Helper to get value
400
+ const getValue = (colName: string | null): string => {
401
+ if (!colName) return "";
402
+ if (Array.isArray(row)) {
403
+ const idx = Number.parseInt(colName, 10);
404
+ return String(row[idx] ?? "");
405
+ }
406
+ return String(row[colName as keyof typeof row] ?? "");
407
+ };
408
+
409
+ const rowNum = i + (hasHeader ? 2 : 1); // 1-indexed. Header consumes line 1.
410
+ const rowWarnings: string[] = [];
411
+
412
+ // Parse date
413
+ const rawDate = getValue(dateCol)?.trim() ?? "";
414
+ const parsedDate = parseDate(rawDate, dateFormat);
415
+ if (!parsedDate) {
416
+ errors.push({
417
+ row: rowNum,
418
+ field: "date",
419
+ message: `Could not parse date: "${rawDate}"`,
420
+ rawValue: rawDate,
421
+ });
422
+ continue;
423
+ }
424
+ // Use LOCAL date components (now derived from UTC date object)
425
+ const dateStr = formatLocalDate(parsedDate);
426
+
427
+ // Parse amount
428
+ let amountMilliunits: number;
429
+ let rawAmount: string;
430
+
431
+ if (amountCol) {
432
+ rawAmount = getValue(amountCol)?.trim() ?? "";
433
+ const parsedAmount = parseAmount(rawAmount);
434
+ if (!parsedAmount.valid) {
435
+ errors.push({
436
+ row: rowNum,
437
+ field: "amount",
438
+ message: parsedAmount.reason ?? `Invalid amount: "${rawAmount}"`,
439
+ rawValue: rawAmount,
440
+ });
441
+ continue;
442
+ }
443
+ amountMilliunits = parsedAmount.valueMilliunits;
444
+ } else if (debitCol && creditCol) {
445
+ const debit = getValue(debitCol)?.trim() ?? "";
446
+ const credit = getValue(creditCol)?.trim() ?? "";
447
+ rawAmount = debit || credit;
448
+
449
+ const parsedDebit = parseAmount(debit);
450
+ const parsedCredit = parseAmount(credit);
451
+
452
+ if (!parsedDebit.valid && debit) {
453
+ errors.push({
454
+ row: rowNum,
455
+ field: "amount",
456
+ message: parsedDebit.reason ?? `Invalid debit amount: "${debit}"`,
457
+ rawValue: debit,
458
+ });
459
+ continue;
460
+ }
461
+ if (!parsedCredit.valid && credit) {
462
+ errors.push({
463
+ row: rowNum,
464
+ field: "amount",
465
+ message: parsedCredit.reason ?? `Invalid credit amount: "${credit}"`,
466
+ rawValue: credit,
467
+ });
468
+ continue;
469
+ }
470
+
471
+ const debitMilliunits = parsedDebit.valid
472
+ ? parsedDebit.valueMilliunits
473
+ : 0;
474
+ const creditMilliunits = parsedCredit.valid
475
+ ? parsedCredit.valueMilliunits
476
+ : 0;
477
+
478
+ // Warn if both debit and credit have values (ambiguous)
479
+ if (Math.abs(debitMilliunits) > 0 && Math.abs(creditMilliunits) > 0) {
480
+ const warning = `Both Debit (${debit}) and Credit (${credit}) have values - using Debit`;
481
+ rowWarnings.push(warning);
482
+ warnings.push({ row: rowNum, message: warning });
483
+ }
484
+
485
+ if (Math.abs(debitMilliunits) > 0) {
486
+ amountMilliunits = -Math.abs(debitMilliunits); // Debits are outflows (negative)
487
+ } else if (Math.abs(creditMilliunits) > 0) {
488
+ amountMilliunits = Math.abs(creditMilliunits); // Credits are inflows (positive)
489
+ } else {
490
+ errors.push({
491
+ row: rowNum,
492
+ field: "amount",
493
+ message: "Missing debit/credit amount",
494
+ rawValue: `${debit}|${credit}`,
495
+ });
496
+ continue;
497
+ }
498
+
499
+ // Warn if debit column contains negative value (unusual)
500
+ if (debitMilliunits < 0) {
501
+ const warning = `Debit column contains negative value (${debit}) - treating as positive debit`;
502
+ rowWarnings.push(warning);
503
+ warnings.push({ row: rowNum, message: warning });
504
+ }
505
+ } else {
506
+ continue;
507
+ }
508
+
509
+ // Apply amount inversion if needed
510
+ const multiplier = options.invertAmounts
511
+ ? -1
512
+ : (preset?.amountMultiplier ?? 1);
513
+ amountMilliunits *= multiplier;
514
+
515
+ // Parse description & Sanitize
516
+ let rawDesc = getValue(descCol)?.trim() ?? "";
517
+ // Security: Remove potentially malicious/confusing Unicode characters:
518
+ // - ASCII control chars (0x00-0x1F, 0x7F)
519
+ // - C1 control chars (0x80-0x9F)
520
+ // - Bidirectional text overrides (U+202A-202E, U+2066-2069)
521
+ // - Zero-width characters (U+200B-200D, U+FEFF)
522
+ // - Unicode line/paragraph separators (U+2028-2029)
523
+
524
+ rawDesc = rawDesc
525
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strip ASCII/C1 control chars
526
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, "") // ASCII + C1 control chars
527
+ .replace(/[\u202A-\u202E\u2066-\u2069]/g, "") // Bidirectional overrides
528
+ .replace(/\u200B|\u200C|\u200D|\uFEFF/g, "") // Zero-width chars
529
+ .replace(/[\u2028-\u2029]/g, "") // Line/paragraph separators
530
+ .substring(0, 500);
531
+
532
+ transactions.push({
533
+ id: randomUUID(),
534
+ date: dateStr,
535
+ amount: amountMilliunits,
536
+ payee: rawDesc || "Unknown",
537
+ sourceRow: rowNum,
538
+ raw: {
539
+ date: rawDate,
540
+ amount: rawAmount,
541
+ description: rawDesc,
542
+ },
543
+ ...(rowWarnings.length > 0 && { warnings: rowWarnings }),
544
+ });
545
+ }
546
+
547
+ return {
548
+ transactions,
549
+ errors,
550
+ warnings,
551
+ meta: {
552
+ detectedDelimiter: parsed.meta.delimiter || ",",
553
+ detectedColumns: columns,
554
+ totalRows: rows.length,
555
+ validRows: transactions.length,
556
+ skippedRows: rows.length - transactions.length,
557
+ },
558
+ };
534
559
  }
535
560
 
536
- function parseDate(raw: string, formatHint?: 'YMD' | 'MDY' | 'DMY'): Date | null {
537
- if (!raw) return null;
538
-
539
- // 1. Try ISO format first (unambiguous)
540
- const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
541
- if (isoMatch) {
542
- const [, year, month, day] = isoMatch;
543
- return new Date(Date.UTC(parseInt(year!), parseInt(month!) - 1, parseInt(day!)));
544
- }
545
-
546
- // 2. Try explicit format hint for ambiguous numeric dates
547
- // Pattern: X/X/X or X-X-X where X can be 1-4 digits
548
- const numericMatch = raw.match(/^(\d{1,4})[/-](\d{1,2})[/-](\d{1,4})$/);
549
- if (numericMatch && formatHint) {
550
- const [, a, b, c] = numericMatch;
551
-
552
- let year: number, month: number, day: number;
553
- switch (formatHint) {
554
- case 'YMD': // YYYY/MM/DD or YY/MM/DD
555
- year = parseInt(a!);
556
- month = parseInt(b!);
557
- day = parseInt(c!);
558
- break;
559
- case 'MDY': // US format: MM/DD/YYYY or MM/DD/YY
560
- month = parseInt(a!);
561
- day = parseInt(b!);
562
- year = parseInt(c!);
563
- break;
564
- case 'DMY': // European/UK format: DD/MM/YYYY or DD/MM/YY
565
- day = parseInt(a!);
566
- month = parseInt(b!);
567
- year = parseInt(c!);
568
- break;
569
- }
570
-
571
- // Handle 2-digit years
572
- if (year < 100) year += 2000; // 25 -> 2025
573
-
574
- if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
575
- return new Date(Date.UTC(year, month - 1, day));
576
- }
577
- }
578
-
579
- // 3. Fallback to chrono-node (handles natural language, many formats)
580
- // Timezone strategy: chrono-node returns local time, but we extract only date components
581
- // and reconstruct as UTC to ensure consistent date handling across all parsing paths.
582
- // This prevents "off-by-one-day" errors from timezone conversions during date comparison.
583
- const parsed = chrono.parseDate(raw);
584
- if (parsed) {
585
- return new Date(Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()));
586
- }
587
-
588
- return null;
561
+ function parseDate(
562
+ raw: string,
563
+ formatHint?: "YMD" | "MDY" | "DMY",
564
+ ): Date | null {
565
+ if (!raw) return null;
566
+
567
+ // 1. Try ISO format first (unambiguous)
568
+ const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
569
+ if (isoMatch) {
570
+ const [, year, month, day] = isoMatch;
571
+ if (!year || !month || !day) return null;
572
+ return new Date(
573
+ Date.UTC(
574
+ Number.parseInt(year, 10),
575
+ Number.parseInt(month, 10) - 1,
576
+ Number.parseInt(day, 10),
577
+ ),
578
+ );
579
+ }
580
+
581
+ // 2. Try explicit format hint for ambiguous numeric dates
582
+ // Pattern: X/X/X or X-X-X where X can be 1-4 digits
583
+ const numericMatch = raw.match(/^(\d{1,4})[/-](\d{1,2})[/-](\d{1,4})$/);
584
+ if (numericMatch && formatHint) {
585
+ const [, a, b, c] = numericMatch;
586
+ if (!a || !b || !c) return null;
587
+
588
+ let year: number;
589
+ let month: number;
590
+ let day: number;
591
+ switch (formatHint) {
592
+ case "YMD": // YYYY/MM/DD or YY/MM/DD
593
+ year = Number.parseInt(a, 10);
594
+ month = Number.parseInt(b, 10);
595
+ day = Number.parseInt(c, 10);
596
+ break;
597
+ case "MDY": // US format: MM/DD/YYYY or MM/DD/YY
598
+ month = Number.parseInt(a, 10);
599
+ day = Number.parseInt(b, 10);
600
+ year = Number.parseInt(c, 10);
601
+ break;
602
+ case "DMY": // European/UK format: DD/MM/YYYY or DD/MM/YY
603
+ day = Number.parseInt(a, 10);
604
+ month = Number.parseInt(b, 10);
605
+ year = Number.parseInt(c, 10);
606
+ break;
607
+ }
608
+
609
+ // Handle 2-digit years
610
+ if (year < 100) year += 2000; // 25 -> 2025
611
+
612
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
613
+ return new Date(Date.UTC(year, month - 1, day));
614
+ }
615
+ }
616
+
617
+ // 3. Fallback to chrono-node (handles natural language, many formats)
618
+ // Timezone strategy: chrono-node returns local time, but we extract only date components
619
+ // and reconstruct as UTC to ensure consistent date handling across all parsing paths.
620
+ // This prevents "off-by-one-day" errors from timezone conversions during date comparison.
621
+ const parsed = chrono.parseDate(raw);
622
+ if (parsed) {
623
+ return new Date(
624
+ Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()),
625
+ );
626
+ }
627
+
628
+ return null;
589
629
  }
590
630
 
591
631
  function formatLocalDate(date: Date): string {
592
- const year = date.getUTCFullYear();
593
- const month = String(date.getUTCMonth() + 1).padStart(2, '0');
594
- const day = String(date.getUTCDate()).padStart(2, '0');
595
- return `${year}-${month}-${day}`;
632
+ const year = date.getUTCFullYear();
633
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
634
+ const day = String(date.getUTCDate()).padStart(2, "0");
635
+ return `${year}-${month}-${day}`;
596
636
  }
597
637
 
598
638
  function findColumn(
599
- available: string[],
600
- candidates: string | string[],
601
- exactIndex: boolean = false,
639
+ available: string[],
640
+ candidates: string | string[],
641
+ exactIndex = false,
602
642
  ): string | null {
603
- const candidateList = Array.isArray(candidates) ? candidates : [candidates];
604
-
605
- for (const candidate of candidateList) {
606
- if (exactIndex) {
607
- // If exact index required (no header), check if candidate matches an index
608
- if (available.includes(candidate)) return candidate;
609
- } else {
610
- const lower = candidate.toLowerCase();
611
- const found = available.find((col) => col.toLowerCase() === lower);
612
- if (found) return found;
613
- }
614
- }
615
-
616
- if (!exactIndex) {
617
- // Try partial match
618
- for (const candidate of candidateList) {
619
- const lower = candidate.toLowerCase();
620
- const found = available.find((col) => col.toLowerCase().includes(lower));
621
- if (found) return found;
622
- }
623
- }
624
-
625
- return null;
643
+ const candidateList = Array.isArray(candidates) ? candidates : [candidates];
644
+
645
+ for (const candidate of candidateList) {
646
+ if (exactIndex) {
647
+ // If exact index required (no header), check if candidate matches an index
648
+ if (available.includes(candidate)) return candidate;
649
+ } else {
650
+ const lower = candidate.toLowerCase();
651
+ const found = available.find((col) => col.toLowerCase() === lower);
652
+ if (found) return found;
653
+ }
654
+ }
655
+
656
+ if (!exactIndex) {
657
+ // Try partial match
658
+ for (const candidate of candidateList) {
659
+ const lower = candidate.toLowerCase();
660
+ const found = available.find((col) => col.toLowerCase().includes(lower));
661
+ if (found) return found;
662
+ }
663
+ }
664
+
665
+ return null;
626
666
  }
627
667
 
628
668
  function detectPreset(columns: string[]): BankPreset | undefined {
629
- const colSet = new Set(columns.map((c) => c.toLowerCase()));
669
+ const colSet = new Set(columns.map((c) => c.toLowerCase()));
630
670
 
631
- if (colSet.has('description 1') || colSet.has('account type')) {
632
- return BANK_PRESETS['rbc'];
633
- }
634
- if (columns.some((c) => c.toLowerCase().includes('cad$'))) {
635
- return BANK_PRESETS['td'];
636
- }
671
+ if (colSet.has("description 1") || colSet.has("account type")) {
672
+ return BANK_PRESETS["rbc"];
673
+ }
674
+ if (columns.some((c) => c.toLowerCase().includes("cad$"))) {
675
+ return BANK_PRESETS["td"];
676
+ }
637
677
 
638
- // Generic headered TD-style exports: Date, Description, Amount
639
- if (colSet.has('date') && colSet.has('description') && colSet.has('amount')) {
640
- return BANK_PRESETS['td'];
641
- }
678
+ // Generic headered TD-style exports: Date, Description, Amount
679
+ if (colSet.has("date") && colSet.has("description") && colSet.has("amount")) {
680
+ return BANK_PRESETS["td"];
681
+ }
642
682
 
643
- return undefined;
683
+ return undefined;
644
684
  }
645
685
 
646
686
  // Currency helpers remain the same
@@ -648,36 +688,43 @@ const CURRENCY_SYMBOLS = /[$€£¥]/g;
648
688
  const CURRENCY_CODES = /\b(CAD|USD|EUR|GBP)\b/gi;
649
689
 
650
690
  function parseAmount(str: string): {
651
- valid: boolean;
652
- valueMilliunits: number;
653
- reason?: string;
691
+ valid: boolean;
692
+ valueMilliunits: number;
693
+ reason?: string;
654
694
  } {
655
- if (!str || !str.trim()) {
656
- return { valid: false, valueMilliunits: 0, reason: 'Missing amount value' };
657
- }
658
-
659
- let cleaned = str.replace(CURRENCY_SYMBOLS, '').replace(CURRENCY_CODES, '').trim();
660
-
661
- // Handle parentheses as negative: (123.45) → -123.45
662
- if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
663
- cleaned = '-' + cleaned.slice(1, -1);
664
- }
665
-
666
- // Detect European format: 1.234,56 → 1234.56
667
- if (/^-?\d{1,3}(\.\d{3})+,\d{2}$/.test(cleaned)) {
668
- cleaned = cleaned.replace(/\./g, '').replace(',', '.');
669
- }
670
-
671
- // Handle thousands separator: 1,234.56 → 1234.56
672
- if (cleaned.includes('.')) {
673
- cleaned = cleaned.replace(/,/g, '');
674
- }
675
-
676
- const dollars = parseFloat(cleaned);
677
- if (!Number.isFinite(dollars)) {
678
- return { valid: false, valueMilliunits: 0, reason: `Invalid amount: "${str}"` };
679
- }
680
-
681
- // Convert to milliunits: $1.00 → 1000
682
- return { valid: true, valueMilliunits: Math.round(dollars * 1000) };
695
+ if (!str || !str.trim()) {
696
+ return { valid: false, valueMilliunits: 0, reason: "Missing amount value" };
697
+ }
698
+
699
+ let cleaned = str
700
+ .replace(CURRENCY_SYMBOLS, "")
701
+ .replace(CURRENCY_CODES, "")
702
+ .trim();
703
+
704
+ // Handle parentheses as negative: (123.45) → -123.45
705
+ if (cleaned.startsWith("(") && cleaned.endsWith(")")) {
706
+ cleaned = `-${cleaned.slice(1, -1)}`;
707
+ }
708
+
709
+ // Detect European format: 1.234,56 → 1234.56
710
+ if (/^-?\d{1,3}(\.\d{3})+,\d{2}$/.test(cleaned)) {
711
+ cleaned = cleaned.replace(/\./g, "").replace(",", ".");
712
+ }
713
+
714
+ // Handle thousands separator: 1,234.56 → 1234.56
715
+ if (cleaned.includes(".")) {
716
+ cleaned = cleaned.replace(/,/g, "");
717
+ }
718
+
719
+ const dollars = Number.parseFloat(cleaned);
720
+ if (!Number.isFinite(dollars)) {
721
+ return {
722
+ valid: false,
723
+ valueMilliunits: 0,
724
+ reason: `Invalid amount: "${str}"`,
725
+ };
726
+ }
727
+
728
+ // Convert to milliunits: $1.00 → 1000
729
+ return { valid: true, valueMilliunits: Math.round(dollars * 1000) };
683
730
  }