@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.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 (207) hide show
  1. package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
  2. package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
  3. package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
  4. package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
  5. package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
  6. package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
  7. package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
  8. package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
  9. package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
  10. package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
  11. package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
  12. package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
  13. package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
  14. package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
  15. package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
  16. package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
  17. package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
  18. package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
  19. package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
  20. package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
  21. package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
  22. package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
  23. package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
  24. package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
  25. package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
  26. package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
  27. package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
  28. package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
  29. package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
  30. package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
  31. package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
  32. package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
  33. package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
  34. package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
  35. package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
  36. package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
  37. package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
  38. package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
  39. package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
  40. package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
  41. package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
  42. package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
  43. package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
  44. package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
  45. package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
  46. package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
  47. package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
  48. package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
  49. package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
  50. package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
  51. package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
  52. package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
  53. package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
  54. package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
  55. package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
  56. package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
  57. package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
  58. package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
  59. package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
  60. package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
  61. package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
  62. package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
  63. package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
  64. package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
  65. package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
  66. package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
  67. package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
  68. package/.github/workflows/publish.yml +3 -3
  69. package/.github/workflows/release.yml +4 -0
  70. package/CHANGELOG.md +75 -0
  71. package/NUL +1 -0
  72. package/dist/bundle/index.cjs +65 -42
  73. package/dist/server/errorHandler.d.ts +2 -0
  74. package/dist/server/errorHandler.js +49 -5
  75. package/dist/tools/reconcileAdapter.js +10 -5
  76. package/dist/tools/reconciliation/analyzer.d.ts +4 -2
  77. package/dist/tools/reconciliation/analyzer.js +120 -404
  78. package/dist/tools/reconciliation/csvParser.d.ts +51 -0
  79. package/dist/tools/reconciliation/csvParser.js +413 -0
  80. package/dist/tools/reconciliation/executor.d.ts +8 -0
  81. package/dist/tools/reconciliation/executor.js +204 -58
  82. package/dist/tools/reconciliation/index.d.ts +7 -7
  83. package/dist/tools/reconciliation/index.js +115 -39
  84. package/dist/tools/reconciliation/matcher.d.ts +24 -3
  85. package/dist/tools/reconciliation/matcher.js +175 -133
  86. package/dist/tools/reconciliation/recommendationEngine.js +22 -18
  87. package/dist/tools/reconciliation/reportFormatter.js +9 -8
  88. package/dist/tools/reconciliation/signDetector.d.ts +2 -0
  89. package/dist/tools/reconciliation/signDetector.js +54 -0
  90. package/dist/tools/reconciliation/types.d.ts +20 -34
  91. package/dist/tools/reconciliation/types.js +1 -7
  92. package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
  93. package/dist/tools/reconciliation/ynabAdapter.js +15 -0
  94. package/dist/types/reconciliation.d.ts +24 -0
  95. package/dist/types/reconciliation.js +1 -0
  96. package/docs/guides/ARCHITECTURE.md +12 -129
  97. package/docs/plans/2025-11-21-v014-hardening.md +153 -0
  98. package/docs/plans/reconciliation-v2-redesign.md +1571 -0
  99. package/package.json +6 -1
  100. package/scripts/test-recommendations.ts +1 -1
  101. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
  102. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
  103. package/src/server/errorHandler.ts +52 -5
  104. package/src/tools/reconcileAdapter.ts +10 -5
  105. package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
  106. package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
  107. package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
  108. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +1 -1
  109. package/src/tools/reconciliation/__tests__/executor.test.ts +88 -61
  110. package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
  111. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
  112. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
  113. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
  114. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
  115. package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
  116. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
  117. package/src/tools/reconciliation/analyzer.ts +174 -545
  118. package/src/tools/reconciliation/csvParser.ts +617 -0
  119. package/src/tools/reconciliation/executor.ts +249 -66
  120. package/src/tools/reconciliation/index.ts +141 -48
  121. package/src/tools/reconciliation/matcher.ts +234 -214
  122. package/src/tools/reconciliation/recommendationEngine.ts +23 -19
  123. package/src/tools/reconciliation/reportFormatter.ts +16 -11
  124. package/src/tools/reconciliation/signDetector.ts +117 -0
  125. package/src/tools/reconciliation/types.ts +39 -61
  126. package/src/tools/reconciliation/ynabAdapter.ts +33 -0
  127. package/src/types/reconciliation.ts +49 -0
  128. package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
  129. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
  130. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
  131. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
  132. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
  133. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
  134. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
  135. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
  136. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
  137. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
  138. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
  139. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
  140. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
  141. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
  142. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
  143. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
  144. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
  145. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
  146. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
  147. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
  148. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
  149. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
  150. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
  151. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
  152. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
  153. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
  154. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
  155. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
  156. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
  157. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
  158. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
  159. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
  160. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
  161. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
  162. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
  163. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
  164. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
  165. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
  166. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
  167. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
  168. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
  169. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
  170. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
  171. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
  172. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
  173. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
  174. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
  175. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
  176. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
  177. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
  178. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
  179. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
  180. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
  181. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
  182. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
  183. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
  184. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
  185. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
  186. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
  187. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
  188. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
  189. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
  190. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
  191. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
  192. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
  193. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
  194. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
  195. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
  196. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
  197. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
  198. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
  199. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
  200. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
  201. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
  202. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
  203. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
  204. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
  205. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
  206. package/.gemini/settings.json +0 -8
  207. package/WARP.md +0 -245
@@ -0,0 +1,617 @@
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';
5
+
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
+ };
17
+ }
18
+
19
+ export interface ParseError {
20
+ row: number;
21
+ field: string;
22
+ message: string;
23
+ rawValue: string;
24
+ }
25
+
26
+ export interface ParseWarning {
27
+ row: number;
28
+ message: string;
29
+ }
30
+
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;
43
+ }
44
+
45
+ // Presets for Canadian banks
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
+ },
96
+ };
97
+
98
+ export interface ParseCSVOptions {
99
+ /** Bank preset key (e.g., 'td', 'rbc') */
100
+ preset?: string;
101
+ /** Multiply all amounts by -1 */
102
+ invertAmounts?: boolean;
103
+ /** Manual column overrides */
104
+ columns?: {
105
+ date?: string;
106
+ amount?: string;
107
+ debit?: string;
108
+ credit?: string;
109
+ description?: string;
110
+ };
111
+ /** Date format hint */
112
+ dateFormat?: 'YMD' | 'MDY' | 'DMY';
113
+ /**
114
+ * Whether the CSV has a header row.
115
+ * If false, columns must be specified by index (e.g., "0", "1").
116
+ * Defaults to true.
117
+ */
118
+ header?: boolean;
119
+ /** Maximum number of rows to process (default: 10000) */
120
+ maxRows?: number;
121
+ /** Maximum file size in bytes (default: 10MB) */
122
+ maxBytes?: number;
123
+ }
124
+
125
+ /**
126
+ * Attempt to auto-detect the bank format from raw content.
127
+ * Strategies:
128
+ * 1. Parse first 5 lines.
129
+ * 2. Check for header matches (existing logic).
130
+ * 3. Check for headerless patterns (TD specific: date, desc, debit, credit, balance).
131
+ */
132
+ function autoDetectFormat(content: string): { preset?: string; header?: boolean } | undefined {
133
+ const preview = Papa.parse(content, {
134
+ preview: 5,
135
+ header: false, // Parse as array first to inspect structure
136
+ skipEmptyLines: true,
137
+ });
138
+
139
+ if (preview.errors.length > 0 || preview.data.length === 0) return undefined;
140
+
141
+ const rows = preview.data as string[][];
142
+ const firstRow = rows[0];
143
+ if (!firstRow) return undefined;
144
+
145
+ // 1. Check for known headers (RBC, etc.)
146
+ const headerMatch = detectPreset(firstRow);
147
+ if (headerMatch) {
148
+ // Find key in BANK_PRESETS
149
+ const key = Object.keys(BANK_PRESETS).find((k) => BANK_PRESETS[k] === headerMatch);
150
+ if (key) return { preset: key, header: true };
151
+ }
152
+
153
+ // 2. Check for TD Headerless Pattern
154
+ // Typical TD row: [Date, Description, Debit, Credit, Balance]
155
+ // Date: MM/DD/YYYY (e.g., 11/21/2025)
156
+ // Debit/Credit: Numbers or empty
157
+ // Balance: Number
158
+ if (checkTDPattern(rows)) {
159
+ return { preset: 'td', header: false };
160
+ }
161
+
162
+ return undefined;
163
+ }
164
+
165
+ function checkTDPattern(rows: string[][]): boolean {
166
+ // Needs at least one valid row
167
+ // Headerless TD exports typically have at least
168
+ // Date, Description, Debit, Credit columns. Require
169
+ // 4+ columns to avoid misclassifying generic
170
+ // Date/Description/Amount formats as TD.
171
+ const validRows = rows.filter((r) => r.length >= 4);
172
+ if (validRows.length === 0) return false;
173
+
174
+ // Check first few rows for MM/DD/YYYY date in column 0
175
+ // AND numeric values in columns 2, 3, 4 (if present)
176
+ let matchCount = 0;
177
+ for (const row of validRows) {
178
+ // Col 0: Date MM/DD/YYYY
179
+ if (!/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(row[0] || '')) continue;
180
+
181
+ // Col 2 (Debit) or Col 3 (Credit) must be numeric-ish if present
182
+ const isDebitNumeric = !row[2] || /^-?[\d,.]+$/.test(row[2]);
183
+ const isCreditNumeric = !row[3] || /^-?[\d,.]+$/.test(row[3]);
184
+
185
+ if (isDebitNumeric && isCreditNumeric) {
186
+ matchCount++;
187
+ }
188
+ }
189
+
190
+ // If majority of preview rows match, it's likely TD
191
+ return matchCount > validRows.length / 2;
192
+ }
193
+
194
+ /**
195
+ * Parse a bank CSV file into BankTransaction objects.
196
+ *
197
+ * IMPORTANT: Amounts are converted to MILLIUNITS (integers) at this boundary.
198
+ * This is the ONLY place where float-to-milliunit conversion happens.
199
+ */
200
+ export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVParseResult {
201
+ const errors: ParseError[] = [];
202
+ const warnings: ParseWarning[] = [];
203
+
204
+ // Security: Check file size limit
205
+ const MAX_BYTES = options.maxBytes ?? 10 * 1024 * 1024; // 10MB default
206
+ if (content.length > MAX_BYTES) {
207
+ throw new Error(`File size exceeds limit of ${Math.round(MAX_BYTES / 1024 / 1024)}MB`);
208
+ }
209
+
210
+ // Auto-detect format when preset or header are not fully specified
211
+ let detectedPreset: string | undefined = options.preset;
212
+ let detectedHeader: boolean | undefined = options.header;
213
+
214
+ if (!detectedPreset || detectedHeader === undefined) {
215
+ const autoResult = autoDetectFormat(content);
216
+ if (autoResult) {
217
+ if (!detectedPreset) {
218
+ detectedPreset = autoResult.preset;
219
+ }
220
+ if (detectedHeader === undefined && autoResult.header !== undefined) {
221
+ detectedHeader = autoResult.header;
222
+ }
223
+ }
224
+ }
225
+
226
+ // Determine header setting: Explicit > Detected > Preset > Default (true)
227
+ let hasHeader = true;
228
+ if (detectedHeader !== undefined) {
229
+ hasHeader = detectedHeader;
230
+ } else if (detectedPreset) {
231
+ const preset = BANK_PRESETS[detectedPreset];
232
+ if (preset && preset.header !== undefined) {
233
+ hasHeader = preset.header;
234
+ }
235
+ }
236
+
237
+ const maxRows = options.maxRows ?? 10000;
238
+
239
+ // Parse with PapaParse
240
+ // Security: Use preview to limit rows parsed into memory (prevents memory exhaustion)
241
+ const parsed = Papa.parse(content, {
242
+ header: hasHeader,
243
+ preview: maxRows + (hasHeader ? 1 : 0), // +1 for header row if present
244
+ dynamicTyping: false, // We'll handle type conversion ourselves
245
+ skipEmptyLines: true,
246
+ transformHeader: (h) => h.trim(),
247
+ });
248
+
249
+ if (parsed.errors.length > 0) {
250
+ for (const err of parsed.errors) {
251
+ errors.push({
252
+ row: err.row ?? 0,
253
+ field: 'csv',
254
+ message: err.message,
255
+ rawValue: '',
256
+ });
257
+ }
258
+ }
259
+
260
+ const rows = parsed.data as (Record<string, string> | string[])[];
261
+ let columns: string[] = [];
262
+
263
+ if (hasHeader) {
264
+ columns = parsed.meta.fields ?? [];
265
+ } else {
266
+ // If no header, rows are arrays. Create dummy columns based on max length
267
+ const maxLen = rows.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0);
268
+ columns = Array.from({ length: maxLen }, (_, i) => String(i));
269
+ }
270
+
271
+ const preset = detectedPreset
272
+ ? BANK_PRESETS[detectedPreset]
273
+ : hasHeader
274
+ ? detectPreset(columns)
275
+ : undefined;
276
+
277
+ // Determine column names (Priority: Options > Preset > Defaults)
278
+
279
+ const dateCandidates = options.columns?.date
280
+ ? [options.columns.date]
281
+ : (preset?.dateColumn ?? ['Date', 'Transaction Date', 'Posted Date']);
282
+ const descCandidates = options.columns?.description
283
+ ? [options.columns.description]
284
+ : (preset?.descriptionColumn ?? ['Description', 'Payee', 'Merchant', 'Name']);
285
+
286
+ const dateCol = findColumn(columns, dateCandidates, !hasHeader);
287
+ const descCol = findColumn(columns, descCandidates, !hasHeader);
288
+
289
+ let amountCol: string | null = null;
290
+ let debitCol: string | null = null;
291
+ let creditCol: string | null = null;
292
+
293
+ if (options.columns?.debit && options.columns?.credit) {
294
+ debitCol = findColumn(columns, [options.columns.debit], !hasHeader);
295
+ creditCol = findColumn(columns, [options.columns.credit], !hasHeader);
296
+ } else if (
297
+ preset?.debitColumn &&
298
+ preset?.creditColumn &&
299
+ !options.columns?.amount &&
300
+ // If a preset also defines an amount column, prefer that when headers
301
+ // are present. This lets TD support both headerless (debit/credit)
302
+ // and headered (Amount) variants while RBC still uses debit/credit
303
+ // with headers.
304
+ (hasHeader ? !preset?.amountColumn : true)
305
+ ) {
306
+ debitCol = findColumn(columns, [preset.debitColumn], !hasHeader);
307
+ creditCol = findColumn(columns, [preset.creditColumn], !hasHeader);
308
+ } else {
309
+ const amountCandidates = options.columns?.amount
310
+ ? [options.columns.amount]
311
+ : (preset?.amountColumn ?? ['Amount', 'CAD$', 'Value']);
312
+ amountCol = findColumn(columns, amountCandidates, !hasHeader);
313
+ }
314
+
315
+ if (!dateCol) {
316
+ errors.push({
317
+ row: 0,
318
+ field: 'date',
319
+ message: `Could not identify date column from: ${columns.join(', ')}. Try using preset option (td, rbc, scotiabank, etc.) or specify columns manually with columns.date`,
320
+ rawValue: columns.join(', '),
321
+ });
322
+ }
323
+ if (!amountCol && (!debitCol || !creditCol)) {
324
+ if (!debitCol && !creditCol) {
325
+ errors.push({
326
+ row: 0,
327
+ field: 'amount',
328
+ 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)`,
329
+ rawValue: columns.join(', '),
330
+ });
331
+ } else if (!debitCol || !creditCol) {
332
+ errors.push({
333
+ row: 0,
334
+ field: 'amount',
335
+ 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`,
336
+ rawValue: columns.join(', '),
337
+ });
338
+ }
339
+ }
340
+
341
+ const transactions: BankTransaction[] = [];
342
+
343
+ const dateFormat = options.dateFormat ?? preset?.dateFormat;
344
+
345
+ // Papa.parse preview already limited rows, but keep defensive check
346
+ for (let i = 0; i < Math.min(rows.length, maxRows); i++) {
347
+ const row = rows[i];
348
+ if (!row) continue;
349
+
350
+ // Helper to get value
351
+ const getValue = (colName: string | null): string => {
352
+ if (!colName) return '';
353
+ if (Array.isArray(row)) {
354
+ const idx = parseInt(colName, 10);
355
+ return String(row[idx] ?? '');
356
+ }
357
+ return String(row[colName as keyof typeof row] ?? '');
358
+ };
359
+
360
+ const rowNum = i + (hasHeader ? 2 : 1); // 1-indexed. Header consumes line 1.
361
+ const rowWarnings: string[] = [];
362
+
363
+ // Parse date
364
+ const rawDate = getValue(dateCol)?.trim() ?? '';
365
+ const parsedDate = parseDate(rawDate, dateFormat);
366
+ if (!parsedDate) {
367
+ errors.push({
368
+ row: rowNum,
369
+ field: 'date',
370
+ message: `Could not parse date: "${rawDate}"`,
371
+ rawValue: rawDate,
372
+ });
373
+ continue;
374
+ }
375
+ // Use LOCAL date components (now derived from UTC date object)
376
+ const dateStr = formatLocalDate(parsedDate);
377
+
378
+ // Parse amount
379
+ let amountMilliunits: number;
380
+ let rawAmount: string;
381
+
382
+ if (amountCol) {
383
+ rawAmount = getValue(amountCol)?.trim() ?? '';
384
+ amountMilliunits = dollarStringToMilliunits(rawAmount);
385
+ } else if (debitCol && creditCol) {
386
+ const debit = getValue(debitCol)?.trim() ?? '';
387
+ const credit = getValue(creditCol)?.trim() ?? '';
388
+ rawAmount = debit || credit;
389
+
390
+ const debitMilliunits = dollarStringToMilliunits(debit);
391
+ const creditMilliunits = dollarStringToMilliunits(credit);
392
+
393
+ // Warn if both debit and credit have values (ambiguous)
394
+ if (Math.abs(debitMilliunits) > 0 && Math.abs(creditMilliunits) > 0) {
395
+ const warning = `Both Debit (${debit}) and Credit (${credit}) have values - using Debit`;
396
+ rowWarnings.push(warning);
397
+ warnings.push({ row: rowNum, message: warning });
398
+ }
399
+
400
+ if (Math.abs(debitMilliunits) > 0) {
401
+ amountMilliunits = -Math.abs(debitMilliunits); // Debits are outflows (negative)
402
+ } else if (Math.abs(creditMilliunits) > 0) {
403
+ amountMilliunits = Math.abs(creditMilliunits); // Credits are inflows (positive)
404
+ } else {
405
+ amountMilliunits = 0;
406
+ }
407
+
408
+ // Warn if debit column contains negative value (unusual)
409
+ if (debitMilliunits < 0) {
410
+ const warning = `Debit column contains negative value (${debit}) - treating as positive debit`;
411
+ rowWarnings.push(warning);
412
+ warnings.push({ row: rowNum, message: warning });
413
+ }
414
+ } else {
415
+ continue;
416
+ }
417
+
418
+ if (!Number.isFinite(amountMilliunits)) {
419
+ errors.push({
420
+ row: rowNum,
421
+ field: 'amount',
422
+ message: `Invalid amount: "${rawAmount}"`,
423
+ rawValue: rawAmount,
424
+ });
425
+ continue;
426
+ }
427
+
428
+ // Apply amount inversion if needed
429
+ const multiplier = options.invertAmounts ? -1 : (preset?.amountMultiplier ?? 1);
430
+ amountMilliunits *= multiplier;
431
+
432
+ // Parse description & Sanitize
433
+ let rawDesc = getValue(descCol)?.trim() ?? '';
434
+ // Security: Remove potentially malicious/confusing Unicode characters:
435
+ // - ASCII control chars (0x00-0x1F, 0x7F)
436
+ // - C1 control chars (0x80-0x9F)
437
+ // - Bidirectional text overrides (U+202A-202E, U+2066-2069)
438
+ // - Zero-width characters (U+200B-200D, U+FEFF)
439
+ // - Unicode line/paragraph separators (U+2028-2029)
440
+
441
+ rawDesc = rawDesc
442
+ // eslint-disable-next-line no-control-regex
443
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') // ASCII + C1 control chars
444
+ .replace(/[\u202A-\u202E\u2066-\u2069]/g, '') // Bidirectional overrides
445
+ .replace(/[\u200B-\u200D\uFEFF]/g, '') // Zero-width chars
446
+ .replace(/[\u2028-\u2029]/g, '') // Line/paragraph separators
447
+ .substring(0, 500);
448
+
449
+ transactions.push({
450
+ id: randomUUID(),
451
+ date: dateStr,
452
+ amount: amountMilliunits,
453
+ payee: rawDesc || 'Unknown',
454
+ sourceRow: rowNum,
455
+ raw: {
456
+ date: rawDate,
457
+ amount: rawAmount,
458
+ description: rawDesc,
459
+ },
460
+ ...(rowWarnings.length > 0 && { warnings: rowWarnings }),
461
+ });
462
+ }
463
+
464
+ return {
465
+ transactions,
466
+ errors,
467
+ warnings,
468
+ meta: {
469
+ detectedDelimiter: parsed.meta.delimiter || ',',
470
+ detectedColumns: columns,
471
+ totalRows: rows.length,
472
+ validRows: transactions.length,
473
+ skippedRows: rows.length - transactions.length,
474
+ },
475
+ };
476
+ }
477
+
478
+ function parseDate(raw: string, formatHint?: 'YMD' | 'MDY' | 'DMY'): Date | null {
479
+ if (!raw) return null;
480
+
481
+ // 1. Try ISO format first (unambiguous)
482
+ const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
483
+ if (isoMatch) {
484
+ const [, year, month, day] = isoMatch;
485
+ return new Date(Date.UTC(parseInt(year!), parseInt(month!) - 1, parseInt(day!)));
486
+ }
487
+
488
+ // 2. Try explicit format hint for ambiguous numeric dates
489
+ // Pattern: X/X/X or X-X-X where X can be 1-4 digits
490
+ const numericMatch = raw.match(/^(\d{1,4})[/-](\d{1,2})[/-](\d{1,4})$/);
491
+ if (numericMatch && formatHint) {
492
+ const [, a, b, c] = numericMatch;
493
+
494
+ let year: number, month: number, day: number;
495
+ switch (formatHint) {
496
+ case 'YMD': // YYYY/MM/DD or YY/MM/DD
497
+ year = parseInt(a!);
498
+ month = parseInt(b!);
499
+ day = parseInt(c!);
500
+ break;
501
+ case 'MDY': // US format: MM/DD/YYYY or MM/DD/YY
502
+ month = parseInt(a!);
503
+ day = parseInt(b!);
504
+ year = parseInt(c!);
505
+ break;
506
+ case 'DMY': // European/UK format: DD/MM/YYYY or DD/MM/YY
507
+ day = parseInt(a!);
508
+ month = parseInt(b!);
509
+ year = parseInt(c!);
510
+ break;
511
+ }
512
+
513
+ // Handle 2-digit years
514
+ if (year < 100) year += 2000; // 25 -> 2025
515
+
516
+ if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
517
+ return new Date(Date.UTC(year, month - 1, day));
518
+ }
519
+ }
520
+
521
+ // 3. Fallback to chrono-node (handles natural language, many formats)
522
+ // Timezone strategy: chrono-node returns local time, but we extract only date components
523
+ // and reconstruct as UTC to ensure consistent date handling across all parsing paths.
524
+ // This prevents "off-by-one-day" errors from timezone conversions during date comparison.
525
+ const parsed = chrono.parseDate(raw);
526
+ if (parsed) {
527
+ return new Date(Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()));
528
+ }
529
+
530
+ return null;
531
+ }
532
+
533
+ function formatLocalDate(date: Date): string {
534
+ const year = date.getUTCFullYear();
535
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
536
+ const day = String(date.getUTCDate()).padStart(2, '0');
537
+ return `${year}-${month}-${day}`;
538
+ }
539
+
540
+ function findColumn(
541
+ available: string[],
542
+ candidates: string | string[],
543
+ exactIndex: boolean = false,
544
+ ): string | null {
545
+ const candidateList = Array.isArray(candidates) ? candidates : [candidates];
546
+
547
+ for (const candidate of candidateList) {
548
+ if (exactIndex) {
549
+ // If exact index required (no header), check if candidate matches an index
550
+ if (available.includes(candidate)) return candidate;
551
+ } else {
552
+ const lower = candidate.toLowerCase();
553
+ const found = available.find((col) => col.toLowerCase() === lower);
554
+ if (found) return found;
555
+ }
556
+ }
557
+
558
+ if (!exactIndex) {
559
+ // Try partial match
560
+ for (const candidate of candidateList) {
561
+ const lower = candidate.toLowerCase();
562
+ const found = available.find((col) => col.toLowerCase().includes(lower));
563
+ if (found) return found;
564
+ }
565
+ }
566
+
567
+ return null;
568
+ }
569
+
570
+ function detectPreset(columns: string[]): BankPreset | undefined {
571
+ const colSet = new Set(columns.map((c) => c.toLowerCase()));
572
+
573
+ if (colSet.has('description 1') || colSet.has('account type')) {
574
+ return BANK_PRESETS['rbc'];
575
+ }
576
+ if (columns.some((c) => c.toLowerCase().includes('cad$'))) {
577
+ return BANK_PRESETS['td'];
578
+ }
579
+
580
+ // Generic headered TD-style exports: Date, Description, Amount
581
+ if (colSet.has('date') && colSet.has('description') && colSet.has('amount')) {
582
+ return BANK_PRESETS['td'];
583
+ }
584
+
585
+ return undefined;
586
+ }
587
+
588
+ // Currency helpers remain the same
589
+ const CURRENCY_SYMBOLS = /[$€£¥]/g;
590
+ const CURRENCY_CODES = /\b(CAD|USD|EUR|GBP)\b/gi;
591
+
592
+ function dollarStringToMilliunits(str: string): number {
593
+ if (!str) return 0;
594
+
595
+ let cleaned = str.replace(CURRENCY_SYMBOLS, '').replace(CURRENCY_CODES, '').trim();
596
+
597
+ // Handle parentheses as negative: (123.45) → -123.45
598
+ if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
599
+ cleaned = '-' + cleaned.slice(1, -1);
600
+ }
601
+
602
+ // Detect European format: 1.234,56 → 1234.56
603
+ if (/^-?\d{1,3}(\.\d{3})+,\d{2}$/.test(cleaned)) {
604
+ cleaned = cleaned.replace(/\./g, '').replace(',', '.');
605
+ }
606
+
607
+ // Handle thousands separator: 1,234.56 → 1234.56
608
+ if (cleaned.includes('.')) {
609
+ cleaned = cleaned.replace(/,/g, '');
610
+ }
611
+
612
+ const dollars = parseFloat(cleaned);
613
+ if (!Number.isFinite(dollars)) return 0;
614
+
615
+ // Convert to milliunits: $1.00 → 1000
616
+ return Math.round(dollars * 1000);
617
+ }