@dizzlkheinz/ynab-mcpb 0.18.4 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/CLAUDE.md +87 -8
  2. package/bin/ynab-mcp-server.cjs +2 -2
  3. package/bin/ynab-mcp-server.js +3 -3
  4. package/biome.json +39 -0
  5. package/dist/bundle/index.cjs +67 -67
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +27 -27
  8. package/dist/server/YNABMCPServer.d.ts +3 -4
  9. package/dist/server/YNABMCPServer.js +111 -116
  10. package/dist/server/budgetResolver.d.ts +6 -5
  11. package/dist/server/budgetResolver.js +46 -36
  12. package/dist/server/cacheKeys.js +6 -6
  13. package/dist/server/cacheManager.js +14 -11
  14. package/dist/server/completions.d.ts +2 -2
  15. package/dist/server/completions.js +20 -15
  16. package/dist/server/config.d.ts +10 -5
  17. package/dist/server/config.js +24 -7
  18. package/dist/server/deltaCache.d.ts +2 -2
  19. package/dist/server/deltaCache.js +22 -16
  20. package/dist/server/deltaCache.merge.d.ts +2 -2
  21. package/dist/server/diagnostics.d.ts +4 -4
  22. package/dist/server/diagnostics.js +38 -32
  23. package/dist/server/errorHandler.d.ts +5 -12
  24. package/dist/server/errorHandler.js +219 -217
  25. package/dist/server/prompts.d.ts +2 -2
  26. package/dist/server/prompts.js +45 -45
  27. package/dist/server/rateLimiter.js +4 -4
  28. package/dist/server/requestLogger.d.ts +1 -1
  29. package/dist/server/requestLogger.js +40 -35
  30. package/dist/server/resources.d.ts +3 -3
  31. package/dist/server/resources.js +55 -52
  32. package/dist/server/responseFormatter.js +6 -6
  33. package/dist/server/securityMiddleware.d.ts +2 -2
  34. package/dist/server/securityMiddleware.js +22 -20
  35. package/dist/server/serverKnowledgeStore.js +1 -1
  36. package/dist/server/toolRegistry.d.ts +3 -3
  37. package/dist/server/toolRegistry.js +47 -40
  38. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  39. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  40. package/dist/tools/accountTools.d.ts +9 -8
  41. package/dist/tools/accountTools.js +47 -47
  42. package/dist/tools/adapters.d.ts +13 -8
  43. package/dist/tools/adapters.js +21 -11
  44. package/dist/tools/budgetTools.d.ts +8 -7
  45. package/dist/tools/budgetTools.js +22 -22
  46. package/dist/tools/categoryTools.d.ts +9 -8
  47. package/dist/tools/categoryTools.js +68 -59
  48. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  49. package/dist/tools/compareTransactions/formatter.js +9 -9
  50. package/dist/tools/compareTransactions/index.d.ts +6 -6
  51. package/dist/tools/compareTransactions/index.js +58 -43
  52. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  53. package/dist/tools/compareTransactions/matcher.js +28 -15
  54. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  55. package/dist/tools/compareTransactions/parser.js +144 -138
  56. package/dist/tools/compareTransactions/types.d.ts +4 -4
  57. package/dist/tools/compareTransactions.d.ts +1 -1
  58. package/dist/tools/compareTransactions.js +1 -1
  59. package/dist/tools/deltaFetcher.d.ts +2 -2
  60. package/dist/tools/deltaFetcher.js +16 -15
  61. package/dist/tools/deltaSupport.d.ts +4 -4
  62. package/dist/tools/deltaSupport.js +35 -41
  63. package/dist/tools/exportTransactions.d.ts +5 -4
  64. package/dist/tools/exportTransactions.js +61 -59
  65. package/dist/tools/monthTools.d.ts +7 -6
  66. package/dist/tools/monthTools.js +31 -29
  67. package/dist/tools/payeeTools.d.ts +7 -6
  68. package/dist/tools/payeeTools.js +28 -28
  69. package/dist/tools/reconcileAdapter.d.ts +2 -2
  70. package/dist/tools/reconcileAdapter.js +19 -12
  71. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  72. package/dist/tools/reconciliation/analyzer.js +73 -59
  73. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  74. package/dist/tools/reconciliation/csvParser.js +128 -104
  75. package/dist/tools/reconciliation/executor.d.ts +4 -4
  76. package/dist/tools/reconciliation/executor.js +148 -109
  77. package/dist/tools/reconciliation/index.d.ts +10 -10
  78. package/dist/tools/reconciliation/index.js +96 -83
  79. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  80. package/dist/tools/reconciliation/matcher.js +17 -16
  81. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  82. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  83. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  84. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  85. package/dist/tools/reconciliation/reportFormatter.js +59 -58
  86. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  87. package/dist/tools/reconciliation/types.d.ts +16 -16
  88. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  89. package/dist/tools/schemas/common.d.ts +1 -1
  90. package/dist/tools/schemas/common.js +1 -1
  91. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  93. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  94. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  95. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  96. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  97. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  98. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  99. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  100. package/dist/tools/schemas/outputs/index.js +14 -14
  101. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  102. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  103. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  104. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  105. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  107. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  109. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  110. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  111. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  112. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  113. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  114. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  115. package/dist/tools/transactionReadTools.d.ts +11 -0
  116. package/dist/tools/transactionReadTools.js +202 -0
  117. package/dist/tools/transactionSchemas.d.ts +7 -7
  118. package/dist/tools/transactionSchemas.js +77 -57
  119. package/dist/tools/transactionTools.d.ts +6 -24
  120. package/dist/tools/transactionTools.js +7 -1499
  121. package/dist/tools/transactionUtils.d.ts +6 -6
  122. package/dist/tools/transactionUtils.js +78 -63
  123. package/dist/tools/transactionWriteTools.d.ts +20 -0
  124. package/dist/tools/transactionWriteTools.js +1342 -0
  125. package/dist/tools/utilityTools.d.ts +5 -4
  126. package/dist/tools/utilityTools.js +11 -11
  127. package/dist/types/index.d.ts +7 -7
  128. package/dist/types/index.js +6 -6
  129. package/dist/types/reconciliation.d.ts +1 -1
  130. package/dist/types/toolRegistration.d.ts +14 -12
  131. package/dist/utils/amountUtils.js +1 -1
  132. package/dist/utils/dateUtils.js +4 -4
  133. package/dist/utils/errors.d.ts +3 -3
  134. package/dist/utils/errors.js +4 -4
  135. package/dist/utils/money.d.ts +2 -2
  136. package/dist/utils/money.js +8 -8
  137. package/dist/utils/validationError.d.ts +1 -1
  138. package/dist/utils/validationError.js +1 -1
  139. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  140. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  141. package/esbuild.config.mjs +53 -50
  142. package/meta.json +12548 -12548
  143. package/package.json +98 -111
  144. package/scripts/analyze-bundle.mjs +33 -30
  145. package/scripts/create-pr-description.js +169 -120
  146. package/scripts/run-all-tests.js +178 -169
  147. package/scripts/run-domain-integration-tests.js +28 -18
  148. package/scripts/run-generate-mcpb.js +19 -17
  149. package/scripts/run-throttled-integration-tests.js +92 -83
  150. package/scripts/test-delta-params.mjs +149 -120
  151. package/scripts/test-recommendations.ts +36 -32
  152. package/scripts/tmpTransaction.ts +80 -43
  153. package/scripts/validate-env.js +98 -91
  154. package/scripts/verify-build.js +78 -76
  155. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  156. package/src/__tests__/performance.test.ts +723 -671
  157. package/src/__tests__/setup.ts +442 -395
  158. package/src/__tests__/smoke.e2e.test.ts +41 -39
  159. package/src/__tests__/testRunner.ts +314 -295
  160. package/src/__tests__/testUtils.ts +456 -364
  161. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  162. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  163. package/src/index.ts +68 -59
  164. package/src/server/CLAUDE.md +480 -0
  165. package/src/server/YNABMCPServer.ts +821 -794
  166. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  167. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  168. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  169. package/src/server/__tests__/cacheManager.test.ts +891 -874
  170. package/src/server/__tests__/completions.integration.test.ts +115 -106
  171. package/src/server/__tests__/completions.test.ts +334 -313
  172. package/src/server/__tests__/config.test.ts +98 -86
  173. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  174. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  175. package/src/server/__tests__/deltaCache.test.ts +946 -759
  176. package/src/server/__tests__/diagnostics.test.ts +825 -792
  177. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  178. package/src/server/__tests__/errorHandler.test.ts +402 -397
  179. package/src/server/__tests__/prompts.test.ts +424 -347
  180. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  181. package/src/server/__tests__/requestLogger.test.ts +443 -403
  182. package/src/server/__tests__/resources.template.test.ts +196 -185
  183. package/src/server/__tests__/resources.test.ts +294 -288
  184. package/src/server/__tests__/security.integration.test.ts +487 -421
  185. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  186. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  187. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  188. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  189. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  190. package/src/server/budgetResolver.ts +221 -181
  191. package/src/server/cacheKeys.ts +6 -6
  192. package/src/server/cacheManager.ts +498 -484
  193. package/src/server/completions.ts +267 -243
  194. package/src/server/config.ts +35 -14
  195. package/src/server/deltaCache.merge.ts +146 -128
  196. package/src/server/deltaCache.ts +352 -309
  197. package/src/server/diagnostics.ts +257 -242
  198. package/src/server/errorHandler.ts +747 -744
  199. package/src/server/prompts.ts +181 -176
  200. package/src/server/rateLimiter.ts +131 -129
  201. package/src/server/requestLogger.ts +350 -322
  202. package/src/server/resources.ts +442 -374
  203. package/src/server/responseFormatter.ts +41 -37
  204. package/src/server/securityMiddleware.ts +223 -205
  205. package/src/server/serverKnowledgeStore.ts +67 -67
  206. package/src/server/toolRegistry.ts +508 -474
  207. package/src/tools/CLAUDE.md +604 -0
  208. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  209. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  210. package/src/tools/__tests__/accountTools.test.ts +685 -638
  211. package/src/tools/__tests__/adapters.test.ts +142 -108
  212. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  213. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  214. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  215. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  216. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  217. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  218. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  219. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  220. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  221. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  222. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  223. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  224. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  225. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  226. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  227. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  228. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  229. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  230. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  231. package/src/tools/__tests__/monthTools.test.ts +561 -512
  232. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  234. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  235. package/src/tools/__tests__/transactionSchemas.test.ts +1202 -1186
  236. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  237. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  238. package/src/tools/__tests__/transactionUtils.test.ts +1004 -977
  239. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  240. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  241. package/src/tools/accountTools.ts +293 -271
  242. package/src/tools/adapters.ts +120 -63
  243. package/src/tools/budgetTools.ts +121 -116
  244. package/src/tools/categoryTools.ts +379 -339
  245. package/src/tools/compareTransactions/formatter.ts +131 -119
  246. package/src/tools/compareTransactions/index.ts +249 -214
  247. package/src/tools/compareTransactions/matcher.ts +259 -209
  248. package/src/tools/compareTransactions/parser.ts +517 -487
  249. package/src/tools/compareTransactions/types.ts +38 -38
  250. package/src/tools/compareTransactions.ts +1 -1
  251. package/src/tools/deltaFetcher.ts +281 -260
  252. package/src/tools/deltaSupport.ts +264 -259
  253. package/src/tools/exportTransactions.ts +230 -218
  254. package/src/tools/monthTools.ts +180 -165
  255. package/src/tools/payeeTools.ts +152 -140
  256. package/src/tools/reconcileAdapter.ts +297 -252
  257. package/src/tools/reconciliation/CLAUDE.md +506 -0
  258. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +133 -124
  259. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -230
  260. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -400
  261. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  262. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  263. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  264. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  265. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  266. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  267. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  268. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -989
  269. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  270. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -533
  271. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -74
  272. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -62
  273. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  274. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +56 -55
  275. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  276. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  277. package/src/tools/reconciliation/analyzer.ts +564 -504
  278. package/src/tools/reconciliation/csvParser.ts +656 -609
  279. package/src/tools/reconciliation/executor.ts +1290 -1128
  280. package/src/tools/reconciliation/index.ts +580 -528
  281. package/src/tools/reconciliation/matcher.ts +256 -240
  282. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  283. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  284. package/src/tools/reconciliation/reportFormatter.ts +343 -307
  285. package/src/tools/reconciliation/signDetector.ts +89 -83
  286. package/src/tools/reconciliation/types.ts +164 -159
  287. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  288. package/src/tools/schemas/CLAUDE.md +546 -0
  289. package/src/tools/schemas/common.ts +1 -1
  290. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  291. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  292. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  293. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  294. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  295. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  296. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  297. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  298. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  299. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  300. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  301. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  302. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  303. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  304. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  305. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  306. package/src/tools/schemas/outputs/index.ts +163 -163
  307. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  308. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  309. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  310. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  311. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  312. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  313. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  314. package/src/tools/toolCategories.ts +114 -114
  315. package/src/tools/transactionReadTools.ts +327 -0
  316. package/src/tools/transactionSchemas.ts +322 -291
  317. package/src/tools/transactionTools.ts +84 -2246
  318. package/src/tools/transactionUtils.ts +507 -422
  319. package/src/tools/transactionWriteTools.ts +2110 -0
  320. package/src/tools/utilityTools.ts +46 -41
  321. package/src/types/CLAUDE.md +477 -0
  322. package/src/types/__tests__/index.test.ts +51 -51
  323. package/src/types/index.ts +43 -39
  324. package/src/types/integration-tests.d.ts +26 -26
  325. package/src/types/reconciliation.ts +29 -29
  326. package/src/types/toolAnnotations.ts +30 -30
  327. package/src/types/toolRegistration.ts +43 -32
  328. package/src/utils/CLAUDE.md +508 -0
  329. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  330. package/src/utils/__tests__/money.test.ts +193 -187
  331. package/src/utils/amountUtils.ts +5 -5
  332. package/src/utils/baseError.ts +5 -5
  333. package/src/utils/dateUtils.ts +29 -26
  334. package/src/utils/errors.ts +14 -14
  335. package/src/utils/money.ts +66 -52
  336. package/src/utils/validationError.ts +1 -1
  337. package/tsconfig.json +29 -29
  338. package/tsconfig.prod.json +16 -16
  339. package/vitest-reporters/split-json-reporter.ts +247 -204
  340. package/vitest.config.ts +99 -95
  341. package/.prettierignore +0 -10
  342. package/.prettierrc.json +0 -10
  343. package/eslint.config.js +0 -49
@@ -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
  }