@dizzlkheinz/ynab-mcpb 0.12.2 → 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.
- package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
- package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
- package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
- package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
- package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
- package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
- package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
- package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
- package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
- package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
- package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
- package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
- package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
- package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
- package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
- package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
- package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
- package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
- package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
- package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
- package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
- package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
- package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
- package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
- package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
- package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
- package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
- package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
- package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
- package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
- package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
- package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
- package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
- package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
- package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
- package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
- package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
- package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
- package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
- package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
- package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
- package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
- package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
- package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
- package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
- package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
- package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
- package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
- package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
- package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
- package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
- package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
- package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
- package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
- package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
- package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
- package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
- package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
- package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
- package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
- package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
- package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
- package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
- package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
- package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
- package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
- package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
- package/.github/workflows/ci-tests.yml +6 -2
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/release.yml +4 -0
- package/CHANGELOG.md +89 -1
- package/NUL +1 -1
- package/README.md +36 -10
- package/dist/bundle/index.cjs +65 -42
- package/dist/index.js +9 -20
- package/dist/server/YNABMCPServer.d.ts +2 -1
- package/dist/server/YNABMCPServer.js +61 -27
- package/dist/server/cacheKeys.d.ts +8 -0
- package/dist/server/cacheKeys.js +8 -0
- package/dist/server/config.d.ts +22 -3
- package/dist/server/config.js +16 -17
- package/dist/server/errorHandler.d.ts +2 -0
- package/dist/server/errorHandler.js +49 -5
- package/dist/server/securityMiddleware.js +3 -6
- package/dist/server/toolRegistry.js +8 -10
- package/dist/tools/accountTools.js +4 -3
- package/dist/tools/categoryTools.js +8 -7
- package/dist/tools/monthTools.js +2 -1
- package/dist/tools/payeeTools.js +2 -1
- package/dist/tools/reconcileAdapter.js +10 -5
- package/dist/tools/reconciliation/analyzer.d.ts +4 -2
- package/dist/tools/reconciliation/analyzer.js +120 -404
- package/dist/tools/reconciliation/csvParser.d.ts +51 -0
- package/dist/tools/reconciliation/csvParser.js +413 -0
- package/dist/tools/reconciliation/executor.d.ts +8 -0
- package/dist/tools/reconciliation/executor.js +277 -50
- package/dist/tools/reconciliation/index.d.ts +7 -7
- package/dist/tools/reconciliation/index.js +115 -39
- package/dist/tools/reconciliation/matcher.d.ts +24 -3
- package/dist/tools/reconciliation/matcher.js +175 -133
- package/dist/tools/reconciliation/recommendationEngine.js +22 -18
- package/dist/tools/reconciliation/reportFormatter.js +9 -8
- package/dist/tools/reconciliation/signDetector.d.ts +2 -0
- package/dist/tools/reconciliation/signDetector.js +54 -0
- package/dist/tools/reconciliation/types.d.ts +20 -34
- package/dist/tools/reconciliation/types.js +1 -7
- package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
- package/dist/tools/reconciliation/ynabAdapter.js +15 -0
- package/dist/tools/transactionTools.d.ts +3 -17
- package/dist/tools/transactionTools.js +5 -17
- package/dist/types/reconciliation.d.ts +24 -0
- package/dist/types/reconciliation.js +1 -0
- package/dist/utils/baseError.d.ts +3 -0
- package/dist/utils/baseError.js +7 -0
- package/dist/utils/errors.d.ts +13 -0
- package/dist/utils/errors.js +15 -0
- package/dist/utils/validationError.d.ts +3 -0
- package/dist/utils/validationError.js +3 -0
- package/docs/guides/ARCHITECTURE.md +12 -129
- package/docs/plans/2025-11-20-reloadable-config-token-validation.md +93 -0
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +362 -0
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +90 -0
- package/docs/plans/2025-11-21-v014-hardening.md +153 -0
- package/docs/plans/reconciliation-v2-redesign.md +1571 -0
- package/package.json +8 -2
- package/scripts/run-throttled-integration-tests.js +9 -3
- package/scripts/test-recommendations.ts +1 -1
- package/src/__tests__/performance.test.ts +12 -5
- package/src/__tests__/testUtils.ts +62 -5
- package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
- package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
- package/src/__tests__/workflows.e2e.test.ts +33 -0
- package/src/index.ts +8 -31
- package/src/server/YNABMCPServer.ts +81 -42
- package/src/server/__tests__/YNABMCPServer.integration.test.ts +10 -12
- package/src/server/__tests__/YNABMCPServer.test.ts +27 -15
- package/src/server/__tests__/config.test.ts +76 -152
- package/src/server/__tests__/server-startup.integration.test.ts +42 -14
- package/src/server/__tests__/toolRegistry.test.ts +1 -1
- package/src/server/cacheKeys.ts +8 -0
- package/src/server/config.ts +20 -38
- package/src/server/errorHandler.ts +52 -5
- package/src/server/securityMiddleware.ts +3 -7
- package/src/server/toolRegistry.ts +14 -10
- package/src/tools/__tests__/categoryTools.test.ts +37 -19
- package/src/tools/__tests__/transactionTools.test.ts +58 -2
- package/src/tools/accountTools.ts +8 -3
- package/src/tools/categoryTools.ts +12 -7
- package/src/tools/monthTools.ts +7 -1
- package/src/tools/payeeTools.ts +7 -1
- package/src/tools/reconcileAdapter.ts +10 -5
- package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
- package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +26 -6
- package/src/tools/reconciliation/__tests__/executor.test.ts +133 -60
- package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
- package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
- package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
- package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
- package/src/tools/reconciliation/analyzer.ts +174 -545
- package/src/tools/reconciliation/csvParser.ts +617 -0
- package/src/tools/reconciliation/executor.ts +344 -58
- package/src/tools/reconciliation/index.ts +141 -48
- package/src/tools/reconciliation/matcher.ts +234 -214
- package/src/tools/reconciliation/recommendationEngine.ts +23 -19
- package/src/tools/reconciliation/reportFormatter.ts +16 -11
- package/src/tools/reconciliation/signDetector.ts +117 -0
- package/src/tools/reconciliation/types.ts +39 -61
- package/src/tools/reconciliation/ynabAdapter.ts +33 -0
- package/src/tools/schemas/outputs/utilityOutputs.ts +1 -1
- package/src/tools/transactionTools.ts +7 -18
- package/src/types/reconciliation.ts +49 -0
- package/src/utils/baseError.ts +7 -0
- package/src/utils/errors.ts +21 -0
- package/src/utils/validationError.ts +3 -0
- package/temp-recon.ts +126 -0
- package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
- package/test_mcp_tools.mjs +75 -0
- package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
- package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
- package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
- package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
- package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
- package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
- package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
- package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
- package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
- package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
- package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
- package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
- package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
- package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
- package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
- package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
- package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
- package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
- package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
- package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
- package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
- package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
- package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
- package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
- package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
- package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
- package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
- package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
- package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
- package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
- package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
- package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
- package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
- package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
- package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
- package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
- package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
- package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
- package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
- package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
- package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
- package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
- package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
- package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
- package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
- package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
- package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
- package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
- package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
- package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
- package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
- package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
- package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
- package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
- package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
- package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
- package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
- package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
- package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
- package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
- package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
- package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
- package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
- package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
- package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
- package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
- package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
- package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
- package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
- package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
- package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
- package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
- package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
- package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
- package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
- package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
- package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
- package/.gemini/settings.json +0 -8
- package/ADOS-2-Module-1-Complete-Manual.md +0 -757
- 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
|
+
}
|