@dizzlkheinz/ynab-mcpb 0.17.0 → 0.17.1
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/.env.example +33 -33
- package/.github/workflows/ci-tests.yml +45 -45
- package/.github/workflows/claude-code-review.yml +57 -57
- package/.github/workflows/claude.yml +50 -50
- package/.github/workflows/full-integration.yml +22 -22
- package/.github/workflows/publish.yml +11 -2
- package/CLAUDE.md +7 -6
- package/dist/bundle/index.cjs +52 -52
- package/dist/server/YNABMCPServer.d.ts +120 -54
- package/dist/server/securityMiddleware.d.ts +37 -8
- package/dist/tools/schemas/outputs/index.d.ts +2 -2
- package/dist/tools/schemas/outputs/index.js +2 -2
- package/dist/tools/schemas/outputs/utilityOutputs.d.ts +0 -15
- package/dist/tools/schemas/outputs/utilityOutputs.js +0 -9
- package/dist/tools/utilityTools.d.ts +0 -7
- package/dist/tools/utilityTools.js +1 -50
- package/docs/maintainers/npm-publishing.md +27 -0
- package/docs/reference/API.md +15 -70
- package/docs/technical/reconciliation-system-architecture.md +2251 -2251
- package/package.json +5 -5
- package/scripts/analyze-bundle.mjs +41 -41
- package/scripts/generate-mcpb.ps1 +95 -95
- package/scripts/watch-and-restart.ps1 +49 -49
- package/src/__tests__/comprehensive.integration.test.ts +0 -28
- package/src/__tests__/performance.test.ts +4 -12
- package/src/__tests__/setup.ts +45 -14
- package/src/__tests__/workflows.e2e.test.ts +0 -44
- package/src/server/__tests__/YNABMCPServer.test.ts +0 -1
- package/src/server/__tests__/toolRegistration.test.ts +2 -2
- package/src/tools/__tests__/transactionTools.integration.test.ts +63 -3
- package/src/tools/__tests__/utilityTools.integration.test.ts +1 -85
- package/src/tools/__tests__/utilityTools.test.ts +1 -123
- package/src/tools/schemas/outputs/index.ts +0 -3
- package/src/tools/schemas/outputs/utilityOutputs.ts +2 -43
- package/src/tools/toolCategories.ts +0 -1
- package/src/tools/utilityTools.ts +5 -76
- package/vitest.config.ts +2 -1
- package/.chunkhound.json +0 -11
- package/.code/agents/0098661e-0fa3-4990-beb9-c0cbf3f123aa/status.txt +0 -1
- package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +0 -3
- package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +0 -3
- package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +0 -1
- package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +0 -5
- package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +0 -1569
- package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +0 -3
- package/.code/agents/1324/exec-call_tIpx9uV1TpARbAMZonRQm8AO.txt +0 -757
- package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +0 -2832
- package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +0 -2709
- package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +0 -2832
- package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +0 -2832
- package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +0 -2709
- package/.code/agents/1572/exec-call_GjVFBFOWcY7lE0idc5nWlLNh.txt +0 -781
- package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +0 -1
- package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +0 -5217
- package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +0 -2594
- package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +0 -2594
- package/.code/agents/1846/exec-call_1YNAVD18RjrMN7JnfkkQhUP3.txt +0 -766
- package/.code/agents/1846/exec-call_lh3lDzE4WJAh1lFiomiiZ73D.txt +0 -766
- package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +0 -231
- package/.code/agents/2038/exec-call_DYwOukaYsL8VCONWmV2rUW5u.txt +0 -766
- package/.code/agents/2038/exec-call_c7fOQ7UrpVcTtvdfGBRM146V.txt +0 -652
- package/.code/agents/2038/exec-call_ySNyq9Mm55jWE480s54r5QcA.txt +0 -766
- package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +0 -2590
- package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +0 -5195
- package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +0 -286
- package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +0 -218
- package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +0 -180
- package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +0 -3
- package/.code/agents/2256/exec-call_AtPcRWPmFPMcmX6qOFm1fCEY.txt +0 -766
- package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +0 -1
- package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +0 -747
- package/.code/agents/2454/exec-call_aFJpupwjfZeOBm7ixI5Vc8z2.txt +0 -766
- package/.code/agents/2454/exec-call_wogZ4HfXTodTEXvdgXlVUBpv.txt +0 -766
- package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +0 -1
- package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +0 -2594
- package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +0 -2594
- package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +0 -144
- package/.code/agents/2e905864-aa07-4314-bcf9-c5b32277e4ac/result.txt +0 -36
- package/.code/agents/3073/exec-call_Peeagc9DxGYLgE6pNdMZhqIE.txt +0 -766
- package/.code/agents/3073/exec-call_d2YSE3hXF08KRSoUM3qd8Z3x.txt +0 -766
- package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +0 -416
- package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +0 -2590
- package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +0 -2590
- package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +0 -2590
- package/.code/agents/335aa031-466d-4fb7-925f-3cd864e264d0/result.txt +0 -191
- package/.code/agents/3364/exec-call_NbhIrsM5HhyDZDmJZG5CuCYL.txt +0 -766
- package/.code/agents/3364/exec-call_cKtJg0NrXiwXEFwlsE3uPZRA.txt +0 -766
- package/.code/agents/36d98414-5cde-4d9d-9a67-a240a18c1f07/result.txt +0 -189
- package/.code/agents/4604e866-b7b8-44f5-992f-2f683b0a523b/status.txt +0 -1
- package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +0 -790
- package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +0 -766
- package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +0 -790
- package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +0 -2594
- package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +0 -1000
- package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +0 -3489
- package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +0 -766
- package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +0 -2594
- package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +0 -2456
- package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +0 -2594
- package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +0 -18
- package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +0 -48
- package/.code/agents/5f8dc01c-47b3-4163-b0b3-aa31be89fcdc/status.txt +0 -1
- package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +0 -1
- package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +0 -1
- package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +0 -1
- package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +0 -1
- package/.code/agents/7/exec-call_HltHpkDox0Zm1vGEjdksUgpE.txt +0 -1120
- package/.code/agents/7/exec-call_LCATrOPPAgbxW9Q1z0XaVi2E.txt +0 -2646
- package/.code/agents/7/exec-call_W8DeRfNG9hvbgVFvf0clBf6R.txt +0 -2646
- package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +0 -1271
- package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +0 -1570
- package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +0 -2590
- package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +0 -3
- package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +0 -3299
- package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +0 -3299
- package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +0 -1882
- package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +0 -2594
- package/.code/agents/94a0ddf3-a304-4ec3-913e-3cceef509948/error.txt +0 -1
- package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +0 -1
- package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +0 -170
- package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +0 -1
- package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +0 -3
- package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +0 -1
- package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +0 -1
- package/.code/agents/e2c752b7-711d-423a-af57-f53c809deb84/result.txt +0 -160
- package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +0 -3
- package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +0 -1
- package/.code/agents/e6601719-c31f-4a0e-8c71-d70787d0ab71/status.txt +0 -1
- package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +0 -1
- package/.code/agents/f250b7ed-5bd5-4036-aa8c-ce63caee7d61/result.txt +0 -20
- package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +0 -5
- package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +0 -1
- package/AGENTS.md +0 -1
- package/NUL +0 -0
- package/package.json.tmp +0 -105
- package/temp-recon.ts +0 -126
- package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +0 -23
- package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +0 -23
- package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +0 -44
- package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +0 -58
- package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +0 -3662
- package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +0 -115
|
@@ -1,1882 +0,0 @@
|
|
|
1
|
-
diff --git a/package-lock.json b/package-lock.json
|
|
2
|
-
index ae9d883..f005622 100644
|
|
3
|
-
--- a/package-lock.json
|
|
4
|
-
+++ b/package-lock.json
|
|
5
|
-
@@ -1,19 +1,23 @@
|
|
6
|
-
{
|
|
7
|
-
"name": "@dizzlkheinz/ynab-mcpb",
|
|
8
|
-
- "version": "0.13.0",
|
|
9
|
-
+ "version": "0.13.4",
|
|
10
|
-
"lockfileVersion": 3,
|
|
11
|
-
"requires": true,
|
|
12
|
-
"packages": {
|
|
13
|
-
"": {
|
|
14
|
-
"name": "@dizzlkheinz/ynab-mcpb",
|
|
15
|
-
- "version": "0.13.0",
|
|
16
|
-
+ "version": "0.13.4",
|
|
17
|
-
"license": "AGPL-3.0",
|
|
18
|
-
"dependencies": {
|
|
19
|
-
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
20
|
-
+ "chrono-node": "^2.9.0",
|
|
21
|
-
"csv-parse": "^6.1.0",
|
|
22
|
-
"d3-array": "^3.2.4",
|
|
23
|
-
"date-fns": "^4.1.0",
|
|
24
|
-
+ "dayjs": "^1.11.19",
|
|
25
|
-
"dotenv": "^17.2.1",
|
|
26
|
-
+ "fuzzball": "^2.2.3",
|
|
27
|
-
+ "papaparse": "^5.5.3",
|
|
28
|
-
"ynab": "^2.9.0",
|
|
29
|
-
"zod": "^4.1.11",
|
|
30
|
-
"zod-validation-error": "^5.0.0"
|
|
31
|
-
@@ -25,6 +29,7 @@
|
|
32
|
-
"@eslint/js": "^9.35.0",
|
|
33
|
-
"@types/d3-array": "^3.2.1",
|
|
34
|
-
"@types/node": "^24.5.2",
|
|
35
|
-
+ "@types/papaparse": "^5.5.0",
|
|
36
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
37
|
-
"@vitest/ui": "^3.2.4",
|
|
38
|
-
"esbuild": "^0.25.10",
|
|
39
|
-
@@ -1366,6 +1371,16 @@
|
|
40
|
-
"undici-types": "~7.12.0"
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
+ "node_modules/@types/papaparse": {
|
|
44
|
-
+ "version": "5.5.0",
|
|
45
|
-
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz",
|
|
46
|
-
+ "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==",
|
|
47
|
-
+ "dev": true,
|
|
48
|
-
+ "license": "MIT",
|
|
49
|
-
+ "dependencies": {
|
|
50
|
-
+ "@types/node": "*"
|
|
51
|
-
+ }
|
|
52
|
-
+ },
|
|
53
|
-
"node_modules/@typescript-eslint/eslint-plugin": {
|
|
54
|
-
"version": "8.44.1",
|
|
55
|
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
|
|
56
|
-
@@ -2087,6 +2102,15 @@
|
|
57
|
-
"node": ">= 16"
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
+ "node_modules/chrono-node": {
|
|
61
|
-
+ "version": "2.9.0",
|
|
62
|
-
+ "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
|
|
63
|
-
+ "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
|
|
64
|
-
+ "license": "MIT",
|
|
65
|
-
+ "engines": {
|
|
66
|
-
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
|
67
|
-
+ }
|
|
68
|
-
+ },
|
|
69
|
-
"node_modules/color-convert": {
|
|
70
|
-
"version": "2.0.1",
|
|
71
|
-
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
72
|
-
@@ -2208,6 +2232,12 @@
|
|
73
|
-
"url": "https://github.com/sponsors/kossnocorp"
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
+ "node_modules/dayjs": {
|
|
77
|
-
+ "version": "1.11.19",
|
|
78
|
-
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
|
79
|
-
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
|
80
|
-
+ "license": "MIT"
|
|
81
|
-
+ },
|
|
82
|
-
"node_modules/debug": {
|
|
83
|
-
"version": "4.4.1",
|
|
84
|
-
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
|
85
|
-
@@ -2974,6 +3004,17 @@
|
|
86
|
-
"url": "https://github.com/sponsors/ljharb"
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
+ "node_modules/fuzzball": {
|
|
90
|
-
+ "version": "2.2.3",
|
|
91
|
-
+ "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz",
|
|
92
|
-
+ "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==",
|
|
93
|
-
+ "license": "MIT",
|
|
94
|
-
+ "dependencies": {
|
|
95
|
-
+ "heap": ">=0.2.0",
|
|
96
|
-
+ "lodash": "^4.17.21",
|
|
97
|
-
+ "setimmediate": "^1.0.5"
|
|
98
|
-
+ }
|
|
99
|
-
+ },
|
|
100
|
-
"node_modules/get-intrinsic": {
|
|
101
|
-
"version": "1.3.0",
|
|
102
|
-
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
103
|
-
@@ -3124,6 +3165,12 @@
|
|
104
|
-
"node": ">= 0.4"
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
+ "node_modules/heap": {
|
|
108
|
-
+ "version": "0.2.7",
|
|
109
|
-
+ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz",
|
|
110
|
-
+ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==",
|
|
111
|
-
+ "license": "MIT"
|
|
112
|
-
+ },
|
|
113
|
-
"node_modules/html-escaper": {
|
|
114
|
-
"version": "2.0.2",
|
|
115
|
-
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
|
116
|
-
@@ -3426,6 +3473,12 @@
|
|
117
|
-
"url": "https://github.com/sponsors/sindresorhus"
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
+ "node_modules/lodash": {
|
|
121
|
-
+ "version": "4.17.21",
|
|
122
|
-
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
123
|
-
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
|
124
|
-
+ "license": "MIT"
|
|
125
|
-
+ },
|
|
126
|
-
"node_modules/lodash.merge": {
|
|
127
|
-
"version": "4.6.2",
|
|
128
|
-
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
|
129
|
-
@@ -3756,6 +3809,12 @@
|
|
130
|
-
"dev": true,
|
|
131
|
-
"license": "BlueOak-1.0.0"
|
|
132
|
-
},
|
|
133
|
-
+ "node_modules/papaparse": {
|
|
134
|
-
+ "version": "5.5.3",
|
|
135
|
-
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
|
136
|
-
+ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
|
137
|
-
+ "license": "MIT"
|
|
138
|
-
+ },
|
|
139
|
-
"node_modules/parent-module": {
|
|
140
|
-
"version": "1.0.1",
|
|
141
|
-
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
142
|
-
@@ -4307,6 +4366,12 @@
|
|
143
|
-
"node": ">= 18"
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
+ "node_modules/setimmediate": {
|
|
147
|
-
+ "version": "1.0.5",
|
|
148
|
-
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
|
149
|
-
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
|
150
|
-
+ "license": "MIT"
|
|
151
|
-
+ },
|
|
152
|
-
"node_modules/setprototypeof": {
|
|
153
|
-
"version": "1.2.0",
|
|
154
|
-
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
|
155
|
-
diff --git a/package.json b/package.json
|
|
156
|
-
index 3ae5a2e..1dad489 100644
|
|
157
|
-
--- a/package.json
|
|
158
|
-
+++ b/package.json
|
|
159
|
-
@@ -67,10 +67,14 @@
|
|
160
|
-
"license": "AGPL-3.0",
|
|
161
|
-
"dependencies": {
|
|
162
|
-
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
163
|
-
+ "chrono-node": "^2.9.0",
|
|
164
|
-
"csv-parse": "^6.1.0",
|
|
165
|
-
"d3-array": "^3.2.4",
|
|
166
|
-
"date-fns": "^4.1.0",
|
|
167
|
-
+ "dayjs": "^1.11.19",
|
|
168
|
-
"dotenv": "^17.2.1",
|
|
169
|
-
+ "fuzzball": "^2.2.3",
|
|
170
|
-
+ "papaparse": "^5.5.3",
|
|
171
|
-
"ynab": "^2.9.0",
|
|
172
|
-
"zod": "^4.1.11",
|
|
173
|
-
"zod-validation-error": "^5.0.0"
|
|
174
|
-
@@ -79,6 +83,7 @@
|
|
175
|
-
"@eslint/js": "^9.35.0",
|
|
176
|
-
"@types/d3-array": "^3.2.1",
|
|
177
|
-
"@types/node": "^24.5.2",
|
|
178
|
-
+ "@types/papaparse": "^5.5.0",
|
|
179
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
180
|
-
"@vitest/ui": "^3.2.4",
|
|
181
|
-
"esbuild": "^0.25.10",
|
|
182
|
-
diff --git a/src/tools/reconciliation/__tests__/analyzer.test.ts b/src/tools/reconciliation/__tests__/analyzer.test.ts
|
|
183
|
-
index cc21d34..8b42d79 100644
|
|
184
|
-
--- a/src/tools/reconciliation/__tests__/analyzer.test.ts
|
|
185
|
-
+++ b/src/tools/reconciliation/__tests__/analyzer.test.ts
|
|
186
|
-
@@ -1,12 +1,11 @@
|
|
187
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
188
|
-
import { analyzeReconciliation } from '../analyzer.js';
|
|
189
|
-
import type { Transaction as YNABAPITransaction } from 'ynab';
|
|
190
|
-
-import * as parser from '../../compareTransactions/parser.js';
|
|
191
|
-
+import * as csvParser from '../csvParser.js';
|
|
192
|
-
|
|
193
|
-
// Mock the parser module
|
|
194
|
-
-vi.mock('../../compareTransactions/parser.js', () => ({
|
|
195
|
-
- parseBankCSV: vi.fn(),
|
|
196
|
-
- readCSVFile: vi.fn(),
|
|
197
|
-
+vi.mock('../csvParser.js', () => ({
|
|
198
|
-
+ parseCSV: vi.fn(),
|
|
199
|
-
}));
|
|
200
|
-
|
|
201
|
-
describe('analyzer', () => {
|
|
202
|
-
@@ -17,26 +16,36 @@ describe('analyzer', () => {
|
|
203
|
-
describe('analyzeReconciliation', () => {
|
|
204
|
-
it('should perform full analysis and return structured results', () => {
|
|
205
|
-
// Mock CSV parsing
|
|
206
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
207
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
208
|
-
transactions: [
|
|
209
|
-
{
|
|
210
|
-
+ id: 'b1',
|
|
211
|
-
date: '2025-10-15',
|
|
212
|
-
- amount: -45.23,
|
|
213
|
-
+ amount: -45230, // milliunits
|
|
214
|
-
payee: 'Shell Gas',
|
|
215
|
-
memo: '',
|
|
216
|
-
+ sourceRow: 1,
|
|
217
|
-
+ raw: { date: '10/15/2025', amount: '-45.23', description: 'Shell Gas' }
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
+ id: 'b2',
|
|
221
|
-
date: '2025-10-16',
|
|
222
|
-
- amount: -100.0,
|
|
223
|
-
+ amount: -100000, // milliunits
|
|
224
|
-
payee: 'Netflix',
|
|
225
|
-
memo: '',
|
|
226
|
-
+ sourceRow: 2,
|
|
227
|
-
+ raw: { date: '10/16/2025', amount: '-100.00', description: 'Netflix' }
|
|
228
|
-
},
|
|
229
|
-
],
|
|
230
|
-
- format_detected: 'standard',
|
|
231
|
-
- delimiter: ',',
|
|
232
|
-
- total_rows: 2,
|
|
233
|
-
- valid_rows: 2,
|
|
234
|
-
+ meta: {
|
|
235
|
-
+ detectedDelimiter: ',',
|
|
236
|
-
+ detectedColumns: ['Date', 'Amount', 'Description'],
|
|
237
|
-
+ totalRows: 2,
|
|
238
|
-
+ validRows: 2,
|
|
239
|
-
+ skippedRows: 0
|
|
240
|
-
+ },
|
|
241
|
-
errors: [],
|
|
242
|
-
+ warnings: []
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
246
|
-
@@ -76,23 +85,27 @@ describe('analyzer', () => {
|
|
247
|
-
expect(result.unmatched_ynab).toBeDefined();
|
|
248
|
-
expect(result.balance_info).toBeDefined();
|
|
249
|
-
expect(result.next_steps).toBeDefined();
|
|
250
|
-
+
|
|
251
|
-
+ // Verify auto-matches (exact matches)
|
|
252
|
-
+ expect(result.auto_matches.length).toBe(2);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('should categorize high-confidence matches as auto-matches', () => {
|
|
256
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
257
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
258
|
-
transactions: [
|
|
259
|
-
{
|
|
260
|
-
+ id: 'b1',
|
|
261
|
-
date: '2025-10-15',
|
|
262
|
-
- amount: -50.0,
|
|
263
|
-
+ amount: -50000,
|
|
264
|
-
payee: 'Coffee Shop',
|
|
265
|
-
memo: '',
|
|
266
|
-
+ sourceRow: 1,
|
|
267
|
-
+ raw: {} as any
|
|
268
|
-
},
|
|
269
|
-
],
|
|
270
|
-
- format_detected: 'standard',
|
|
271
|
-
- delimiter: ',',
|
|
272
|
-
- total_rows: 1,
|
|
273
|
-
- valid_rows: 1,
|
|
274
|
-
+ meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 1, validRows: 1, skippedRows: 0 },
|
|
275
|
-
errors: [],
|
|
276
|
-
+ warnings: []
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
280
|
-
@@ -114,28 +127,29 @@ describe('analyzer', () => {
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('should categorize medium-confidence matches as suggested', () => {
|
|
284
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
285
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
286
|
-
transactions: [
|
|
287
|
-
{
|
|
288
|
-
+ id: 'b1',
|
|
289
|
-
date: '2025-10-15',
|
|
290
|
-
- amount: -50.0,
|
|
291
|
-
- payee: 'Amazon',
|
|
292
|
-
+ amount: -50000,
|
|
293
|
-
+ payee: 'Generic Store',
|
|
294
|
-
memo: '',
|
|
295
|
-
+ sourceRow: 1,
|
|
296
|
-
+ raw: {} as any
|
|
297
|
-
},
|
|
298
|
-
],
|
|
299
|
-
- format_detected: 'standard',
|
|
300
|
-
- delimiter: ',',
|
|
301
|
-
- total_rows: 1,
|
|
302
|
-
- valid_rows: 1,
|
|
303
|
-
+ meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 1, validRows: 1, skippedRows: 0 },
|
|
304
|
-
errors: [],
|
|
305
|
-
+ warnings: []
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
309
|
-
{
|
|
310
|
-
id: 'y1',
|
|
311
|
-
- date: '2025-10-18', // 3 days difference
|
|
312
|
-
+ date: '2025-10-18', // 3 days difference - date score drops
|
|
313
|
-
amount: -50000,
|
|
314
|
-
- payee_name: 'Amazon Prime',
|
|
315
|
-
+ payee_name: 'Amazon Prime', // Fuzzy match
|
|
316
|
-
category_name: 'Shopping',
|
|
317
|
-
cleared: 'uncleared' as const,
|
|
318
|
-
approved: true,
|
|
319
|
-
@@ -144,25 +158,27 @@ describe('analyzer', () => {
|
|
320
|
-
|
|
321
|
-
const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
|
|
322
|
-
|
|
323
|
-
- // Might be medium or low depending on exact scoring
|
|
324
|
-
- expect(result.suggested_matches.length + result.unmatched_bank.length).toBeGreaterThan(0);
|
|
325
|
-
+ // Should be suggested (medium)
|
|
326
|
-
+ expect(result.suggested_matches.length).toBeGreaterThan(0);
|
|
327
|
-
+ expect(result.suggested_matches[0].confidence).toBe('medium');
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('should identify unmatched bank transactions', () => {
|
|
331
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
332
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
333
|
-
transactions: [
|
|
334
|
-
{
|
|
335
|
-
+ id: 'b1',
|
|
336
|
-
date: '2025-10-15',
|
|
337
|
-
- amount: -15.99,
|
|
338
|
-
+ amount: -15990,
|
|
339
|
-
payee: 'New Store',
|
|
340
|
-
memo: '',
|
|
341
|
-
+ sourceRow: 1,
|
|
342
|
-
+ raw: {} as any
|
|
343
|
-
},
|
|
344
|
-
],
|
|
345
|
-
- format_detected: 'standard',
|
|
346
|
-
- delimiter: ',',
|
|
347
|
-
- total_rows: 1,
|
|
348
|
-
- valid_rows: 1,
|
|
349
|
-
+ meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 1, validRows: 1, skippedRows: 0 },
|
|
350
|
-
errors: [],
|
|
351
|
-
+ warnings: []
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
const ynabTxns: YNABAPITransaction[] = [];
|
|
355
|
-
@@ -174,13 +190,11 @@ describe('analyzer', () => {
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('should identify unmatched YNAB transactions', () => {
|
|
359
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
360
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
361
|
-
transactions: [],
|
|
362
|
-
- format_detected: 'standard',
|
|
363
|
-
- delimiter: ',',
|
|
364
|
-
- total_rows: 0,
|
|
365
|
-
- valid_rows: 0,
|
|
366
|
-
+ meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 0, validRows: 0, skippedRows: 0 },
|
|
367
|
-
errors: [],
|
|
368
|
-
+ warnings: []
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
372
|
-
@@ -201,74 +215,12 @@ describe('analyzer', () => {
|
|
373
|
-
expect(result.unmatched_ynab[0].payee_name).toBe('Restaurant');
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
- it('should surface combination suggestions and insights when totals align', () => {
|
|
377
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
378
|
-
- transactions: [
|
|
379
|
-
- {
|
|
380
|
-
- date: '2025-10-20',
|
|
381
|
-
- amount: -30.0,
|
|
382
|
-
- payee: 'Evening Out',
|
|
383
|
-
- memo: '',
|
|
384
|
-
- },
|
|
385
|
-
- ],
|
|
386
|
-
- format_detected: 'standard',
|
|
387
|
-
- delimiter: ',',
|
|
388
|
-
- total_rows: 1,
|
|
389
|
-
- valid_rows: 1,
|
|
390
|
-
- errors: [],
|
|
391
|
-
- });
|
|
392
|
-
-
|
|
393
|
-
- const ynabTxns: YNABAPITransaction[] = [
|
|
394
|
-
- {
|
|
395
|
-
- id: 'y-combo-1',
|
|
396
|
-
- date: '2025-10-19',
|
|
397
|
-
- amount: -20000,
|
|
398
|
-
- payee_name: 'Dinner',
|
|
399
|
-
- category_name: 'Dining',
|
|
400
|
-
- cleared: 'uncleared' as const,
|
|
401
|
-
- approved: true,
|
|
402
|
-
- } as YNABAPITransaction,
|
|
403
|
-
- {
|
|
404
|
-
- id: 'y-combo-2',
|
|
405
|
-
- date: '2025-10-20',
|
|
406
|
-
- amount: -10000,
|
|
407
|
-
- payee_name: 'Drinks',
|
|
408
|
-
- category_name: 'Dining',
|
|
409
|
-
- cleared: 'uncleared' as const,
|
|
410
|
-
- approved: true,
|
|
411
|
-
- } as YNABAPITransaction,
|
|
412
|
-
- {
|
|
413
|
-
- id: 'y-extra',
|
|
414
|
-
- date: '2025-10-22',
|
|
415
|
-
- amount: -5000,
|
|
416
|
-
- payee_name: 'Snacks',
|
|
417
|
-
- category_name: 'Dining',
|
|
418
|
-
- cleared: 'uncleared' as const,
|
|
419
|
-
- approved: true,
|
|
420
|
-
- } as YNABAPITransaction,
|
|
421
|
-
- ];
|
|
422
|
-
-
|
|
423
|
-
- const result = analyzeReconciliation('csv', undefined, ynabTxns, -30.0);
|
|
424
|
-
-
|
|
425
|
-
- const comboMatch = result.suggested_matches.find(
|
|
426
|
-
- (match) => match.match_reason === 'combination_match',
|
|
427
|
-
- );
|
|
428
|
-
- expect(comboMatch).toBeDefined();
|
|
429
|
-
- expect(comboMatch?.candidates?.length).toBeGreaterThanOrEqual(2);
|
|
430
|
-
-
|
|
431
|
-
- const comboInsight = result.insights.find((insight) => insight.id.startsWith('combination-'));
|
|
432
|
-
- expect(comboInsight).toBeDefined();
|
|
433
|
-
- expect(comboInsight?.severity).toBe('info');
|
|
434
|
-
- });
|
|
435
|
-
-
|
|
436
|
-
it('should calculate balance information correctly', () => {
|
|
437
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
438
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
439
|
-
transactions: [],
|
|
440
|
-
- format_detected: 'standard',
|
|
441
|
-
- delimiter: ',',
|
|
442
|
-
- total_rows: 0,
|
|
443
|
-
- valid_rows: 0,
|
|
444
|
-
+ meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 0, validRows: 0, skippedRows: 0 },
|
|
445
|
-
errors: [],
|
|
446
|
-
+ warnings: []
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
450
|
-
@@ -303,16 +255,14 @@ describe('analyzer', () => {
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
it('should generate appropriate summary', () => {
|
|
454
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
455
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
456
|
-
transactions: [
|
|
457
|
-
- { date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' },
|
|
458
|
-
- { date: '2025-10-20', amount: -30.0, payee: 'Restaurant', memo: '' },
|
|
459
|
-
+ { id: 'b1', date: '2025-10-15', amount: -50000, payee: 'Store', memo: '', sourceRow: 1, raw: {} as any },
|
|
460
|
-
+ { id: 'b2', date: '2025-10-20', amount: -30000, payee: 'Restaurant', memo: '', sourceRow: 2, raw: {} as any },
|
|
461
|
-
],
|
|
462
|
-
- format_detected: 'standard',
|
|
463
|
-
- delimiter: ',',
|
|
464
|
-
- total_rows: 2,
|
|
465
|
-
- valid_rows: 2,
|
|
466
|
-
+ meta: { detectedDelimiter: ',', detectedColumns: [], totalRows: 2, validRows: 2, skippedRows: 0 },
|
|
467
|
-
errors: [],
|
|
468
|
-
+ warnings: []
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
472
|
-
@@ -334,73 +284,5 @@ describe('analyzer', () => {
|
|
473
|
-
expect(result.summary.statement_date_range).toContain('2025-10-15');
|
|
474
|
-
expect(result.summary.statement_date_range).toContain('2025-10-20');
|
|
475
|
-
});
|
|
476
|
-
-
|
|
477
|
-
- it('should generate next steps based on analysis', () => {
|
|
478
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
479
|
-
- transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
|
|
480
|
-
- format_detected: 'standard',
|
|
481
|
-
- delimiter: ',',
|
|
482
|
-
- total_rows: 1,
|
|
483
|
-
- valid_rows: 1,
|
|
484
|
-
- errors: [],
|
|
485
|
-
- });
|
|
486
|
-
-
|
|
487
|
-
- const ynabTxns: YNABAPITransaction[] = [
|
|
488
|
-
- {
|
|
489
|
-
- id: 'y1',
|
|
490
|
-
- date: '2025-10-15',
|
|
491
|
-
- amount: -50000,
|
|
492
|
-
- payee_name: 'Store',
|
|
493
|
-
- category_name: 'Shopping',
|
|
494
|
-
- cleared: 'uncleared' as const,
|
|
495
|
-
- approved: true,
|
|
496
|
-
- } as YNABAPITransaction,
|
|
497
|
-
- ];
|
|
498
|
-
-
|
|
499
|
-
- const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
|
|
500
|
-
-
|
|
501
|
-
- expect(result.next_steps).toBeDefined();
|
|
502
|
-
- expect(Array.isArray(result.next_steps)).toBe(true);
|
|
503
|
-
- expect(result.next_steps.length).toBeGreaterThan(0);
|
|
504
|
-
- });
|
|
505
|
-
-
|
|
506
|
-
- it('should use file path when provided', () => {
|
|
507
|
-
- vi.mocked(parser.readCSVFile).mockReturnValue({
|
|
508
|
-
- transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
|
|
509
|
-
- format_detected: 'standard',
|
|
510
|
-
- delimiter: ',',
|
|
511
|
-
- total_rows: 1,
|
|
512
|
-
- valid_rows: 1,
|
|
513
|
-
- errors: [],
|
|
514
|
-
- });
|
|
515
|
-
-
|
|
516
|
-
- const ynabTxns: YNABAPITransaction[] = [];
|
|
517
|
-
-
|
|
518
|
-
- const result = analyzeReconciliation('', '/path/to/file.csv', ynabTxns, 0);
|
|
519
|
-
-
|
|
520
|
-
- expect(vi.mocked(parser.readCSVFile)).toHaveBeenCalledWith('/path/to/file.csv');
|
|
521
|
-
- expect(result.success).toBe(true);
|
|
522
|
-
- });
|
|
523
|
-
-
|
|
524
|
-
- it('should assign unique IDs to bank transactions', () => {
|
|
525
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
526
|
-
- transactions: [
|
|
527
|
-
- { date: '2025-10-15', amount: -50.0, payee: 'Store1', memo: '' },
|
|
528
|
-
- { date: '2025-10-16', amount: -30.0, payee: 'Store2', memo: '' },
|
|
529
|
-
- ],
|
|
530
|
-
- format_detected: 'standard',
|
|
531
|
-
- delimiter: ',',
|
|
532
|
-
- total_rows: 2,
|
|
533
|
-
- valid_rows: 2,
|
|
534
|
-
- errors: [],
|
|
535
|
-
- });
|
|
536
|
-
-
|
|
537
|
-
- const result = analyzeReconciliation('csv', undefined, [], 0);
|
|
538
|
-
-
|
|
539
|
-
- expect(result.unmatched_bank.length).toBe(2);
|
|
540
|
-
- expect(result.unmatched_bank[0].id).toBeDefined();
|
|
541
|
-
- expect(result.unmatched_bank[1].id).toBeDefined();
|
|
542
|
-
- expect(result.unmatched_bank[0].id).not.toBe(result.unmatched_bank[1].id);
|
|
543
|
-
- });
|
|
544
|
-
});
|
|
545
|
-
-});
|
|
546
|
-
+});
|
|
547
|
-
|
|
548
|
-
diff --git a/src/tools/reconciliation/analyzer.ts b/src/tools/reconciliation/analyzer.ts
|
|
549
|
-
index a433cf7..3aab2cc 100644
|
|
550
|
-
--- a/src/tools/reconciliation/analyzer.ts
|
|
551
|
-
+++ b/src/tools/reconciliation/analyzer.ts
|
|
552
|
-
@@ -1,13 +1,22 @@
|
|
553
|
-
/**
|
|
554
|
-
* Analysis phase orchestration for reconciliation
|
|
555
|
-
* Coordinates CSV parsing, YNAB transaction fetching, and matching
|
|
556
|
-
+ *
|
|
557
|
-
+ * V2 UPDATE: Uses new parser and matcher (milliunits based)
|
|
558
|
-
+ * Maps results back to legacy types for backward compatibility
|
|
559
|
-
*/
|
|
560
|
-
|
|
561
|
-
import { randomUUID } from 'crypto';
|
|
562
|
-
import type * as ynab from 'ynab';
|
|
563
|
-
-import * as bankParser from '../compareTransactions/parser.js';
|
|
564
|
-
-import type { CSVFormat as ParserCSVFormat } from '../compareTransactions/types.js';
|
|
565
|
-
+import { parseCSV, type ParseCSVOptions } from './csvParser.js';
|
|
566
|
-
import { findMatches } from './matcher.js';
|
|
567
|
-
+import { normalizeYNABTransactions } from './ynabAdapter.js';
|
|
568
|
-
+import type {
|
|
569
|
-
+ BankTransaction as NewBankTransaction,
|
|
570
|
-
+ NormalizedYNABTransaction
|
|
571
|
-
+} from '../../types/reconciliation.js';
|
|
572
|
-
+import type { MatchResult as NewMatchResult } from './matcher.js';
|
|
573
|
-
+
|
|
574
|
-
import { DEFAULT_MATCHING_CONFIG } from './types.js';
|
|
575
|
-
import type {
|
|
576
|
-
BankTransaction,
|
|
577
|
-
@@ -18,391 +27,63 @@ import type {
|
|
578
|
-
BalanceInfo,
|
|
579
|
-
ReconciliationSummary,
|
|
580
|
-
ReconciliationInsight,
|
|
581
|
-
+ MatchCandidate,
|
|
582
|
-
} from './types.js';
|
|
583
|
-
import { toMoneyValueFromDecimal } from '../../utils/money.js';
|
|
584
|
-
import { generateRecommendations } from './recommendationEngine.js';
|
|
585
|
-
|
|
586
|
-
-/**
|
|
587
|
-
- * Convert YNAB API transaction to simplified format
|
|
588
|
-
- */
|
|
589
|
-
-function convertYNABTransaction(apiTxn: ynab.TransactionDetail): YNABTransaction {
|
|
590
|
-
+// --- Legacy Type Mappers ---
|
|
591
|
-
+
|
|
592
|
-
+function mapToOldBankTransaction(newTxn: NewBankTransaction): BankTransaction {
|
|
593
|
-
return {
|
|
594
|
-
- id: apiTxn.id,
|
|
595
|
-
- date: apiTxn.date,
|
|
596
|
-
- amount: apiTxn.amount,
|
|
597
|
-
- payee_name: apiTxn.payee_name || null,
|
|
598
|
-
- category_name: apiTxn.category_name || null,
|
|
599
|
-
- cleared: apiTxn.cleared,
|
|
600
|
-
- approved: apiTxn.approved,
|
|
601
|
-
- memo: apiTxn.memo || null,
|
|
602
|
-
+ id: newTxn.id,
|
|
603
|
-
+ date: newTxn.date,
|
|
604
|
-
+ amount: newTxn.amount / 1000, // Convert milliunits to dollars for legacy type
|
|
605
|
-
+ payee: newTxn.payee,
|
|
606
|
-
+ memo: newTxn.memo,
|
|
607
|
-
+ original_csv_row: newTxn.sourceRow,
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
-/**
|
|
612
|
-
- * Parse CSV bank statement and generate unique IDs for tracking
|
|
613
|
-
- */
|
|
614
|
-
-const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
|
|
615
|
-
- date_column: 'Date',
|
|
616
|
-
- amount_column: 'Amount',
|
|
617
|
-
- description_column: 'Description',
|
|
618
|
-
- date_format: 'MM/DD/YYYY',
|
|
619
|
-
- has_header: true,
|
|
620
|
-
- delimiter: ',',
|
|
621
|
-
-};
|
|
622
|
-
-
|
|
623
|
-
-const ENABLE_COMBINATION_MATCHING = true;
|
|
624
|
-
-
|
|
625
|
-
-const DAYS_IN_MS = 24 * 60 * 60 * 1000;
|
|
626
|
-
-
|
|
627
|
-
-function toDollars(milliunits: number): number {
|
|
628
|
-
- return milliunits / 1000;
|
|
629
|
-
-}
|
|
630
|
-
-
|
|
631
|
-
-function amountTolerance(config: MatchingConfig): number {
|
|
632
|
-
- const toleranceCents =
|
|
633
|
-
- config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
|
|
634
|
-
- return Math.max(0, toleranceCents) / 100;
|
|
635
|
-
-}
|
|
636
|
-
-
|
|
637
|
-
-function dateTolerance(config: MatchingConfig): number {
|
|
638
|
-
- return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
|
|
639
|
-
-}
|
|
640
|
-
-
|
|
641
|
-
-function daysBetween(dateA: string, dateB: string): number {
|
|
642
|
-
- const a = new Date(`${dateA}T00:00:00Z`).getTime();
|
|
643
|
-
- const b = new Date(`${dateB}T00:00:00Z`).getTime();
|
|
644
|
-
- if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
|
|
645
|
-
- return Math.abs(a - b) / DAYS_IN_MS;
|
|
646
|
-
-}
|
|
647
|
-
-
|
|
648
|
-
-function withinDateTolerance(
|
|
649
|
-
- bankDate: string,
|
|
650
|
-
- ynabTxns: YNABTransaction[],
|
|
651
|
-
- toleranceDays: number,
|
|
652
|
-
-): boolean {
|
|
653
|
-
- return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
|
|
654
|
-
-}
|
|
655
|
-
-
|
|
656
|
-
-function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
|
|
657
|
-
- const bankSign = Math.sign(bankAmount);
|
|
658
|
-
- const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
|
|
659
|
-
- return bankSign === sumSign || Math.abs(bankAmount) === 0;
|
|
660
|
-
-}
|
|
661
|
-
-
|
|
662
|
-
-function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
|
|
663
|
-
- const safeTolerance = tolerance > 0 ? tolerance : 0.01;
|
|
664
|
-
- const ratio = diff / safeTolerance;
|
|
665
|
-
- let base = legCount === 2 ? 75 : 70;
|
|
666
|
-
- if (ratio <= 0.25) {
|
|
667
|
-
- base += 5;
|
|
668
|
-
- } else if (ratio <= 0.5) {
|
|
669
|
-
- base += 3;
|
|
670
|
-
- } else if (ratio >= 0.9) {
|
|
671
|
-
- base -= 5;
|
|
672
|
-
- }
|
|
673
|
-
- return Math.max(65, Math.min(80, Math.round(base)));
|
|
674
|
-
-}
|
|
675
|
-
-
|
|
676
|
-
-function formatDifference(diff: number): string {
|
|
677
|
-
- return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
|
|
678
|
-
-}
|
|
679
|
-
-
|
|
680
|
-
-interface CombinationResult {
|
|
681
|
-
- matches: TransactionMatch[];
|
|
682
|
-
- insights: ReconciliationInsight[];
|
|
683
|
-
-}
|
|
684
|
-
-
|
|
685
|
-
-function findCombinationMatches(
|
|
686
|
-
- unmatchedBank: BankTransaction[],
|
|
687
|
-
- unmatchedYNAB: YNABTransaction[],
|
|
688
|
-
- config: MatchingConfig,
|
|
689
|
-
-): CombinationResult {
|
|
690
|
-
- if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
|
|
691
|
-
- return { matches: [], insights: [] };
|
|
692
|
-
- }
|
|
693
|
-
-
|
|
694
|
-
- const tolerance = amountTolerance(config);
|
|
695
|
-
- const toleranceDays = dateTolerance(config);
|
|
696
|
-
-
|
|
697
|
-
- const matches: TransactionMatch[] = [];
|
|
698
|
-
- const insights: ReconciliationInsight[] = [];
|
|
699
|
-
- const seenCombinations = new Set<string>();
|
|
700
|
-
-
|
|
701
|
-
- for (const bankTxn of unmatchedBank) {
|
|
702
|
-
- const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
|
|
703
|
-
- if (viableYnab.length < 2) continue;
|
|
704
|
-
-
|
|
705
|
-
- const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
|
|
706
|
-
-
|
|
707
|
-
- const addIfValid = (combo: YNABTransaction[]) => {
|
|
708
|
-
- const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
|
|
709
|
-
- const diff = Math.abs(sum - bankTxn.amount);
|
|
710
|
-
- if (diff > tolerance) return;
|
|
711
|
-
- if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
|
|
712
|
-
- if (!hasMatchingSign(bankTxn.amount, combo)) return;
|
|
713
|
-
- evaluated.push({ txns: combo, diff, sum });
|
|
714
|
-
- };
|
|
715
|
-
-
|
|
716
|
-
- const n = viableYnab.length;
|
|
717
|
-
- for (let i = 0; i < n - 1; i++) {
|
|
718
|
-
- for (let j = i + 1; j < n; j++) {
|
|
719
|
-
- addIfValid([viableYnab[i]!, viableYnab[j]!]);
|
|
720
|
-
- }
|
|
721
|
-
- }
|
|
722
|
-
-
|
|
723
|
-
- if (n >= 3) {
|
|
724
|
-
- for (let i = 0; i < n - 2; i++) {
|
|
725
|
-
- for (let j = i + 1; j < n - 1; j++) {
|
|
726
|
-
- for (let k = j + 1; k < n; k++) {
|
|
727
|
-
- addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
|
|
728
|
-
- }
|
|
729
|
-
- }
|
|
730
|
-
- }
|
|
731
|
-
- }
|
|
732
|
-
-
|
|
733
|
-
- if (evaluated.length === 0) continue;
|
|
734
|
-
-
|
|
735
|
-
- evaluated.sort((a, b) => a.diff - b.diff);
|
|
736
|
-
- const recordedSizes = new Set<number>();
|
|
737
|
-
-
|
|
738
|
-
- for (const combo of evaluated) {
|
|
739
|
-
- if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
|
|
740
|
-
- const comboIds = combo.txns.map((txn) => txn.id).sort();
|
|
741
|
-
- const key = `${bankTxn.id}|${comboIds.join('+')}`;
|
|
742
|
-
- if (seenCombinations.has(key)) continue;
|
|
743
|
-
- seenCombinations.add(key);
|
|
744
|
-
- recordedSizes.add(combo.txns.length);
|
|
745
|
-
-
|
|
746
|
-
- const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
|
|
747
|
-
- const candidateConfidence = Math.max(60, score - 5);
|
|
748
|
-
- const descriptionTotal = formatCurrency(combo.sum);
|
|
749
|
-
- const diffLabel = formatDifference(combo.diff);
|
|
750
|
-
-
|
|
751
|
-
- matches.push({
|
|
752
|
-
- bank_transaction: bankTxn,
|
|
753
|
-
- confidence: 'medium',
|
|
754
|
-
- confidence_score: score,
|
|
755
|
-
- match_reason: 'combination_match',
|
|
756
|
-
- top_confidence: score,
|
|
757
|
-
- candidates: combo.txns.map((txn) => ({
|
|
758
|
-
- ynab_transaction: txn,
|
|
759
|
-
- confidence: candidateConfidence,
|
|
760
|
-
- match_reason: 'combination_component',
|
|
761
|
-
- explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
|
|
762
|
-
- })),
|
|
763
|
-
- action_hint: 'review_combination',
|
|
764
|
-
- recommendation:
|
|
765
|
-
- `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
|
|
766
|
-
- `${formatCurrency(bankTxn.amount)} on the bank statement.`,
|
|
767
|
-
- });
|
|
768
|
-
-
|
|
769
|
-
- const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
|
|
770
|
-
- insights.push({
|
|
771
|
-
- id: insightId,
|
|
772
|
-
- type: 'combination_match' as unknown as ReconciliationInsight['type'],
|
|
773
|
-
- severity: 'info',
|
|
774
|
-
- title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
|
|
775
|
-
- bankTxn.amount,
|
|
776
|
-
- )}`,
|
|
777
|
-
- description:
|
|
778
|
-
- `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
|
|
779
|
-
- `${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
|
|
780
|
-
- evidence: {
|
|
781
|
-
- bank_transaction_id: bankTxn.id,
|
|
782
|
-
- bank_amount: bankTxn.amount,
|
|
783
|
-
- ynab_transaction_ids: comboIds,
|
|
784
|
-
- ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
|
|
785
|
-
- combination_size: combo.txns.length,
|
|
786
|
-
- difference: combo.diff,
|
|
787
|
-
- },
|
|
788
|
-
- });
|
|
789
|
-
- }
|
|
790
|
-
- }
|
|
791
|
-
-
|
|
792
|
-
- return { matches, insights };
|
|
793
|
-
-}
|
|
794
|
-
-
|
|
795
|
-
-type ParserResult =
|
|
796
|
-
- | {
|
|
797
|
-
- transactions: unknown[];
|
|
798
|
-
- format_detected?: string;
|
|
799
|
-
- delimiter?: string;
|
|
800
|
-
- total_rows?: number;
|
|
801
|
-
- valid_rows?: number;
|
|
802
|
-
- errors?: string[];
|
|
803
|
-
- }
|
|
804
|
-
- | unknown[];
|
|
805
|
-
-
|
|
806
|
-
-function isParsedCSVData(
|
|
807
|
-
- result: ParserResult,
|
|
808
|
-
-): result is Extract<ParserResult, { transactions: unknown[] }> {
|
|
809
|
-
- return (
|
|
810
|
-
- typeof result === 'object' &&
|
|
811
|
-
- result !== null &&
|
|
812
|
-
- !Array.isArray(result) &&
|
|
813
|
-
- 'transactions' in result
|
|
814
|
-
- );
|
|
815
|
-
-}
|
|
816
|
-
-
|
|
817
|
-
-function normalizeDate(value: unknown): string {
|
|
818
|
-
- if (value instanceof Date) {
|
|
819
|
-
- return value.toISOString().split('T')[0]!;
|
|
820
|
-
- }
|
|
821
|
-
-
|
|
822
|
-
- if (typeof value === 'string') {
|
|
823
|
-
- const trimmed = value.trim();
|
|
824
|
-
- if (!trimmed) return trimmed;
|
|
825
|
-
-
|
|
826
|
-
- const parsed = new Date(trimmed);
|
|
827
|
-
- if (!Number.isNaN(parsed.getTime())) {
|
|
828
|
-
- return parsed.toISOString().split('T')[0]!;
|
|
829
|
-
- }
|
|
830
|
-
-
|
|
831
|
-
- return trimmed;
|
|
832
|
-
- }
|
|
833
|
-
-
|
|
834
|
-
- return new Date().toISOString().split('T')[0]!;
|
|
835
|
-
-}
|
|
836
|
-
-
|
|
837
|
-
-function normalizeAmount(record: Record<string, unknown>): number {
|
|
838
|
-
- const raw = record['amount'];
|
|
839
|
-
-
|
|
840
|
-
- if (typeof raw === 'number') {
|
|
841
|
-
- if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
|
|
842
|
-
- return Math.round(raw) / 1000;
|
|
843
|
-
- }
|
|
844
|
-
- return raw;
|
|
845
|
-
- }
|
|
846
|
-
-
|
|
847
|
-
- if (typeof raw === 'string') {
|
|
848
|
-
- const cleaned = raw.replace(/[$,\s]/g, '');
|
|
849
|
-
- const parsed = Number.parseFloat(cleaned);
|
|
850
|
-
- return Number.isFinite(parsed) ? parsed : 0;
|
|
851
|
-
- }
|
|
852
|
-
-
|
|
853
|
-
- return 0;
|
|
854
|
-
-}
|
|
855
|
-
-
|
|
856
|
-
-function normalizePayee(record: Record<string, unknown>): string {
|
|
857
|
-
- const candidates = [record['payee'], record['description'], record['memo']];
|
|
858
|
-
- for (const candidate of candidates) {
|
|
859
|
-
- if (typeof candidate === 'string' && candidate.trim()) {
|
|
860
|
-
- return candidate.trim();
|
|
861
|
-
- }
|
|
862
|
-
- }
|
|
863
|
-
- return 'Unknown Payee';
|
|
864
|
-
-}
|
|
865
|
-
-
|
|
866
|
-
-function determineRow(record: Record<string, unknown>, index: number): number {
|
|
867
|
-
- if (typeof record['original_csv_row'] === 'number') {
|
|
868
|
-
- return record['original_csv_row'];
|
|
869
|
-
- }
|
|
870
|
-
- if (typeof record['row_number'] === 'number') {
|
|
871
|
-
- return record['row_number'];
|
|
872
|
-
- }
|
|
873
|
-
- return index + 1;
|
|
874
|
-
-}
|
|
875
|
-
-
|
|
876
|
-
-function convertParserRecord(record: unknown, index: number): BankTransaction {
|
|
877
|
-
- const data =
|
|
878
|
-
- typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
|
|
879
|
-
-
|
|
880
|
-
- const dateValue = normalizeDate(data['date']);
|
|
881
|
-
- const amountValue = normalizeAmount(data);
|
|
882
|
-
- const payeeValue = normalizePayee(data);
|
|
883
|
-
- const memoValue =
|
|
884
|
-
- typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
|
|
885
|
-
- const originalRow = determineRow(data, index);
|
|
886
|
-
-
|
|
887
|
-
- const transaction: BankTransaction = {
|
|
888
|
-
- id: randomUUID(),
|
|
889
|
-
- date: dateValue,
|
|
890
|
-
- amount: amountValue,
|
|
891
|
-
- payee: payeeValue,
|
|
892
|
-
- original_csv_row: originalRow,
|
|
893
|
-
+function mapToOldYNABTransaction(newTxn: NormalizedYNABTransaction): YNABTransaction {
|
|
894
|
-
+ return {
|
|
895
|
-
+ id: newTxn.id,
|
|
896
|
-
+ date: newTxn.date,
|
|
897
|
-
+ amount: newTxn.amount, // Legacy type already uses milliunits
|
|
898
|
-
+ payee_name: newTxn.payee,
|
|
899
|
-
+ category_name: newTxn.categoryName,
|
|
900
|
-
+ cleared: newTxn.cleared,
|
|
901
|
-
+ approved: newTxn.approved,
|
|
902
|
-
+ memo: newTxn.memo,
|
|
903
|
-
};
|
|
904
|
-
-
|
|
905
|
-
- if (memoValue !== undefined) {
|
|
906
|
-
- transaction.memo = memoValue;
|
|
907
|
-
- }
|
|
908
|
-
-
|
|
909
|
-
- return transaction;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
-function parseBankStatement(csvContent: string, csvFilePath?: string): BankTransaction[] {
|
|
913
|
-
- const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
|
|
914
|
-
+function mapToOldTransactionMatch(result: NewMatchResult): TransactionMatch {
|
|
915
|
-
+ const bankTransaction = mapToOldBankTransaction(result.bankTransaction);
|
|
916
|
-
+ const ynabTransaction = result.bestMatch ? mapToOldYNABTransaction(result.bestMatch.ynabTransaction) : undefined;
|
|
917
|
-
+
|
|
918
|
-
+ const candidates: MatchCandidate[] = result.candidates.map(c => ({
|
|
919
|
-
+ ynab_transaction: mapToOldYNABTransaction(c.ynabTransaction),
|
|
920
|
-
+ confidence: c.scores.combined,
|
|
921
|
-
+ match_reason: c.matchReasons.join(', '),
|
|
922
|
-
+ explanation: `Score: ${c.scores.combined}. ${c.matchReasons.join(', ')}`
|
|
923
|
-
+ }));
|
|
924
|
-
|
|
925
|
-
- let format: ParserCSVFormat = FALLBACK_CSV_FORMAT;
|
|
926
|
-
- let autoDetect: ((content: string) => ParserCSVFormat) | undefined;
|
|
927
|
-
- try {
|
|
928
|
-
- autoDetect = (bankParser as { autoDetectCSVFormat?: (content: string) => ParserCSVFormat })
|
|
929
|
-
- .autoDetectCSVFormat;
|
|
930
|
-
- } catch {
|
|
931
|
-
- autoDetect = undefined;
|
|
932
|
-
- }
|
|
933
|
-
-
|
|
934
|
-
- if (typeof autoDetect === 'function') {
|
|
935
|
-
- try {
|
|
936
|
-
- format = autoDetect(content);
|
|
937
|
-
- } catch {
|
|
938
|
-
- format = FALLBACK_CSV_FORMAT;
|
|
939
|
-
- }
|
|
940
|
-
- }
|
|
941
|
-
-
|
|
942
|
-
- const rawResult = bankParser.parseBankCSV(content, format) as unknown as ParserResult;
|
|
943
|
-
- const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
|
|
944
|
-
-
|
|
945
|
-
- return records.map(convertParserRecord);
|
|
946
|
-
-}
|
|
947
|
-
-
|
|
948
|
-
-/**
|
|
949
|
-
- * Categorize matches by confidence level
|
|
950
|
-
- */
|
|
951
|
-
-function categorizeMatches(matches: TransactionMatch[]): {
|
|
952
|
-
- autoMatches: TransactionMatch[];
|
|
953
|
-
- suggestedMatches: TransactionMatch[];
|
|
954
|
-
- unmatchedBank: BankTransaction[];
|
|
955
|
-
-} {
|
|
956
|
-
- const autoMatches: TransactionMatch[] = [];
|
|
957
|
-
- const suggestedMatches: TransactionMatch[] = [];
|
|
958
|
-
- const unmatchedBank: BankTransaction[] = [];
|
|
959
|
-
-
|
|
960
|
-
- for (const match of matches) {
|
|
961
|
-
- if (match.confidence === 'high') {
|
|
962
|
-
- autoMatches.push(match);
|
|
963
|
-
- } else if (match.confidence === 'medium') {
|
|
964
|
-
- suggestedMatches.push(match);
|
|
965
|
-
- } else {
|
|
966
|
-
- // low or none confidence
|
|
967
|
-
- unmatchedBank.push(match.bank_transaction);
|
|
968
|
-
- }
|
|
969
|
-
- }
|
|
970
|
-
-
|
|
971
|
-
- return { autoMatches, suggestedMatches, unmatchedBank };
|
|
972
|
-
+ return {
|
|
973
|
-
+ bank_transaction: bankTransaction,
|
|
974
|
-
+ ynab_transaction: ynabTransaction,
|
|
975
|
-
+ candidates: candidates,
|
|
976
|
-
+ confidence: result.confidence,
|
|
977
|
-
+ confidence_score: result.confidenceScore,
|
|
978
|
-
+ match_reason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
|
|
979
|
-
+ top_confidence: result.candidates[0]?.scores.combined,
|
|
980
|
-
+ action_hint: result.confidence === 'high' ? 'approve' : (result.confidence === 'none' ? 'add' : 'review'),
|
|
981
|
-
+ recommendation: result.confidence === 'none' ? 'Consider adding this transaction to YNAB' : undefined
|
|
982
|
-
+ };
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
-/**
|
|
986
|
-
- * Find unmatched YNAB transactions
|
|
987
|
-
- * These are transactions in YNAB that don't appear on the bank statement
|
|
988
|
-
- */
|
|
989
|
-
-function findUnmatchedYNAB(
|
|
990
|
-
- ynabTransactions: YNABTransaction[],
|
|
991
|
-
- matches: TransactionMatch[],
|
|
992
|
-
-): YNABTransaction[] {
|
|
993
|
-
- const matchedIds = new Set<string>();
|
|
994
|
-
-
|
|
995
|
-
- for (const match of matches) {
|
|
996
|
-
- if (match.ynab_transaction) {
|
|
997
|
-
- matchedIds.add(match.ynab_transaction.id);
|
|
998
|
-
- }
|
|
999
|
-
- }
|
|
1000
|
-
-
|
|
1001
|
-
- return ynabTransactions.filter((txn) => !matchedIds.has(txn.id));
|
|
1002
|
-
-}
|
|
1003
|
-
+// --- Helper Functions (Adapted from original) ---
|
|
1004
|
-
|
|
1005
|
-
-/**
|
|
1006
|
-
- * Calculate balance information
|
|
1007
|
-
- */
|
|
1008
|
-
function calculateBalances(
|
|
1009
|
-
ynabTransactions: YNABTransaction[],
|
|
1010
|
-
statementBalance: number,
|
|
1011
|
-
@@ -434,9 +115,6 @@ function calculateBalances(
|
|
1012
|
-
};
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
-/**
|
|
1016
|
-
- * Generate reconciliation summary
|
|
1017
|
-
- */
|
|
1018
|
-
function generateSummary(
|
|
1019
|
-
bankTransactions: BankTransaction[],
|
|
1020
|
-
ynabTransactions: YNABTransaction[],
|
|
1021
|
-
@@ -485,9 +163,6 @@ function generateSummary(
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
-/**
|
|
1026
|
-
- * Generate next steps for user
|
|
1027
|
-
- */
|
|
1028
|
-
function generateNextSteps(summary: ReconciliationSummary): string[] {
|
|
1029
|
-
const steps: string[] = [];
|
|
1030
|
-
|
|
1031
|
-
@@ -526,6 +201,8 @@ function formatCurrency(amount: number): string {
|
|
1032
|
-
return formatter.format(amount);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
+// --- Insight Generation (Adapted) ---
|
|
1036
|
-
+
|
|
1037
|
-
function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationInsight[] {
|
|
1038
|
-
const insights: ReconciliationInsight[] = [];
|
|
1039
|
-
if (unmatchedBank.length === 0) {
|
|
1040
|
-
@@ -569,58 +246,6 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
|
|
1041
|
-
return insights;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
-function nearMatchInsights(
|
|
1045
|
-
- matches: TransactionMatch[],
|
|
1046
|
-
- config: MatchingConfig,
|
|
1047
|
-
-): ReconciliationInsight[] {
|
|
1048
|
-
- const insights: ReconciliationInsight[] = [];
|
|
1049
|
-
-
|
|
1050
|
-
- for (const match of matches) {
|
|
1051
|
-
- if (!match.candidates || match.candidates.length === 0) continue;
|
|
1052
|
-
- if (match.confidence === 'high') continue;
|
|
1053
|
-
-
|
|
1054
|
-
- const topCandidate = match.candidates[0]!;
|
|
1055
|
-
- const score = topCandidate.confidence;
|
|
1056
|
-
- const highSignal =
|
|
1057
|
-
- (match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
|
|
1058
|
-
- (match.confidence === 'low' && score >= config.suggestionThreshold) ||
|
|
1059
|
-
- (match.confidence === 'none' && score >= config.suggestionThreshold);
|
|
1060
|
-
-
|
|
1061
|
-
- if (!highSignal) continue;
|
|
1062
|
-
-
|
|
1063
|
-
- const bankTxn = match.bank_transaction;
|
|
1064
|
-
- const ynabTxn = topCandidate.ynab_transaction;
|
|
1065
|
-
-
|
|
1066
|
-
- insights.push({
|
|
1067
|
-
- id: `near-${bankTxn.id}`,
|
|
1068
|
-
- type: 'near_match',
|
|
1069
|
-
- severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
|
|
1070
|
-
- title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
|
|
1071
|
-
- description:
|
|
1072
|
-
- `Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
|
|
1073
|
-
- `${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
|
|
1074
|
-
- evidence: {
|
|
1075
|
-
- bank_transaction: {
|
|
1076
|
-
- id: bankTxn.id,
|
|
1077
|
-
- date: bankTxn.date,
|
|
1078
|
-
- amount: bankTxn.amount,
|
|
1079
|
-
- payee: bankTxn.payee,
|
|
1080
|
-
- },
|
|
1081
|
-
- candidate: {
|
|
1082
|
-
- id: ynabTxn.id,
|
|
1083
|
-
- date: ynabTxn.date,
|
|
1084
|
-
- amount_milliunits: ynabTxn.amount,
|
|
1085
|
-
- payee_name: ynabTxn.payee_name,
|
|
1086
|
-
- confidence: score,
|
|
1087
|
-
- reasons: topCandidate.match_reason,
|
|
1088
|
-
- },
|
|
1089
|
-
- },
|
|
1090
|
-
- });
|
|
1091
|
-
- }
|
|
1092
|
-
-
|
|
1093
|
-
- return insights.slice(0, 3);
|
|
1094
|
-
-}
|
|
1095
|
-
-
|
|
1096
|
-
function anomalyInsights(
|
|
1097
|
-
summary: ReconciliationSummary,
|
|
1098
|
-
balances: BalanceInfo,
|
|
1099
|
-
@@ -645,30 +270,13 @@ function anomalyInsights(
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
- if (summary.unmatched_bank >= 5) {
|
|
1104
|
-
- insights.push({
|
|
1105
|
-
- id: 'bulk-missing-bank',
|
|
1106
|
-
- type: 'anomaly',
|
|
1107
|
-
- severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
|
|
1108
|
-
- title: `${summary.unmatched_bank} bank transactions still unmatched`,
|
|
1109
|
-
- description:
|
|
1110
|
-
- `There are ${summary.unmatched_bank} bank transactions without a match. ` +
|
|
1111
|
-
- 'Consider bulk importing or reviewing by date sequence.',
|
|
1112
|
-
- evidence: {
|
|
1113
|
-
- unmatched_bank: summary.unmatched_bank,
|
|
1114
|
-
- },
|
|
1115
|
-
- });
|
|
1116
|
-
- }
|
|
1117
|
-
-
|
|
1118
|
-
return insights;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function detectInsights(
|
|
1122
|
-
- matches: TransactionMatch[],
|
|
1123
|
-
unmatchedBank: BankTransaction[],
|
|
1124
|
-
summary: ReconciliationSummary,
|
|
1125
|
-
balances: BalanceInfo,
|
|
1126
|
-
- config: MatchingConfig,
|
|
1127
|
-
): ReconciliationInsight[] {
|
|
1128
|
-
const insights: ReconciliationInsight[] = [];
|
|
1129
|
-
const seen = new Set<string>();
|
|
1130
|
-
@@ -683,10 +291,6 @@ function detectInsights(
|
|
1131
|
-
addUnique(insight);
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
- for (const insight of nearMatchInsights(matches, config)) {
|
|
1135
|
-
- addUnique(insight);
|
|
1136
|
-
- }
|
|
1137
|
-
-
|
|
1138
|
-
for (const insight of anomalyInsights(summary, balances)) {
|
|
1139
|
-
addUnique(insight);
|
|
1140
|
-
}
|
|
1141
|
-
@@ -694,25 +298,7 @@ function detectInsights(
|
|
1142
|
-
return insights.slice(0, 5);
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
-function mergeInsights(
|
|
1146
|
-
- base: ReconciliationInsight[],
|
|
1147
|
-
- additional: ReconciliationInsight[],
|
|
1148
|
-
-): ReconciliationInsight[] {
|
|
1149
|
-
- if (additional.length === 0) {
|
|
1150
|
-
- return base;
|
|
1151
|
-
- }
|
|
1152
|
-
-
|
|
1153
|
-
- const seen = new Set(base.map((insight) => insight.id));
|
|
1154
|
-
- const merged = [...base];
|
|
1155
|
-
-
|
|
1156
|
-
- for (const insight of additional) {
|
|
1157
|
-
- if (seen.has(insight.id)) continue;
|
|
1158
|
-
- seen.add(insight.id);
|
|
1159
|
-
- merged.push(insight);
|
|
1160
|
-
- }
|
|
1161
|
-
-
|
|
1162
|
-
- return merged.slice(0, 5);
|
|
1163
|
-
-}
|
|
1164
|
-
+// --- Main Analysis Function ---
|
|
1165
|
-
|
|
1166
|
-
/**
|
|
1167
|
-
* Perform reconciliation analysis
|
|
1168
|
-
@@ -726,6 +312,7 @@ function mergeInsights(
|
|
1169
|
-
* @param accountId - Account ID for recommendation context
|
|
1170
|
-
* @param budgetId - Budget ID for recommendation context
|
|
1171
|
-
* @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
|
|
1172
|
-
+ * @param csvOptions - Optional CSV parsing options (manual overrides)
|
|
1173
|
-
*/
|
|
1174
|
-
export function analyzeReconciliation(
|
|
1175
|
-
csvContent: string,
|
|
1176
|
-
@@ -737,52 +324,67 @@ export function analyzeReconciliation(
|
|
1177
|
-
accountId?: string,
|
|
1178
|
-
budgetId?: string,
|
|
1179
|
-
invertBankAmounts: boolean = false,
|
|
1180
|
-
+ csvOptions?: ParseCSVOptions,
|
|
1181
|
-
): ReconciliationAnalysis {
|
|
1182
|
-
- // Step 1: Parse bank CSV
|
|
1183
|
-
- let bankTransactions = parseBankStatement(csvContent, csvFilePath);
|
|
1184
|
-
-
|
|
1185
|
-
- // Step 1b: Optionally invert bank transaction amounts
|
|
1186
|
-
- // Some banks show charges as positive (need inversion to match YNAB's negative convention)
|
|
1187
|
-
- // Other banks (e.g., Wealthsimple) show charges as negative already (no inversion needed)
|
|
1188
|
-
- if (invertBankAmounts) {
|
|
1189
|
-
- bankTransactions = bankTransactions.map((txn) => ({
|
|
1190
|
-
- ...txn,
|
|
1191
|
-
- amount: -txn.amount,
|
|
1192
|
-
- }));
|
|
1193
|
-
- }
|
|
1194
|
-
-
|
|
1195
|
-
- // Step 2: Convert YNAB transactions
|
|
1196
|
-
- const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
|
|
1197
|
-
-
|
|
1198
|
-
- // Step 3: Run matching algorithm
|
|
1199
|
-
- const matches = findMatches(bankTransactions, convertedYNABTxns, config);
|
|
1200
|
-
+ // Step 1: Parse bank CSV using new Parser
|
|
1201
|
-
+ const parseResult = parseCSV(csvContent, {
|
|
1202
|
-
+ ...csvOptions,
|
|
1203
|
-
+ invertAmounts: invertBankAmounts
|
|
1204
|
-
+ });
|
|
1205
|
-
+
|
|
1206
|
-
+ // TODO: Handle parsing errors/warnings gracefully and expose them in analysis
|
|
1207
|
-
+ const newBankTransactions = parseResult.transactions;
|
|
1208
|
-
+
|
|
1209
|
-
+ // Step 2: Normalize YNAB transactions
|
|
1210
|
-
+ const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
1211
|
-
+
|
|
1212
|
-
+ // Step 3: Run new matching algorithm
|
|
1213
|
-
+ // Convert legacy config to new config format if needed
|
|
1214
|
-
+ const newConfig = {
|
|
1215
|
-
+ ...config,
|
|
1216
|
-
+ weights: { amount: 0.5, date: 0.15, payee: 0.35 }, // Default weights
|
|
1217
|
-
+ amountToleranceMilliunits: (config.amountToleranceCents || 1) * 10, // cents -> milliunits
|
|
1218
|
-
+ dateToleranceDays: config.dateToleranceDays || 7,
|
|
1219
|
-
+ autoMatchThreshold: config.autoMatchThreshold || 85,
|
|
1220
|
-
+ suggestedMatchThreshold: config.suggestionThreshold || 60,
|
|
1221
|
-
+ minimumCandidateScore: 40,
|
|
1222
|
-
+ exactAmountBonus: 10,
|
|
1223
|
-
+ exactDateBonus: 5,
|
|
1224
|
-
+ exactPayeeBonus: 10
|
|
1225
|
-
+ };
|
|
1226
|
-
|
|
1227
|
-
- // Step 4: Categorize matches
|
|
1228
|
-
- const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
|
|
1229
|
-
+ const newMatches = findMatches(newBankTransactions, newYNABTransactions, newConfig);
|
|
1230
|
-
|
|
1231
|
-
- // Step 5: Find unmatched YNAB transactions
|
|
1232
|
-
- const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
|
|
1233
|
-
+ // Step 4: Map results to legacy types
|
|
1234
|
-
+ const matches: TransactionMatch[] = newMatches.map(mapToOldTransactionMatch);
|
|
1235
|
-
|
|
1236
|
-
- let combinationMatches: TransactionMatch[] = [];
|
|
1237
|
-
- let combinationInsights: ReconciliationInsight[] = [];
|
|
1238
|
-
+ // Categorize
|
|
1239
|
-
+ const autoMatches = matches.filter(m => m.confidence === 'high');
|
|
1240
|
-
+ const suggestedMatches = matches.filter(m => m.confidence === 'medium');
|
|
1241
|
-
+ const unmatchedBankMatches = matches.filter(m => m.confidence === 'low' || m.confidence === 'none');
|
|
1242
|
-
+ const unmatchedBank = unmatchedBankMatches.map(m => m.bank_transaction);
|
|
1243
|
-
|
|
1244
|
-
- if (ENABLE_COMBINATION_MATCHING) {
|
|
1245
|
-
- const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
|
|
1246
|
-
- combinationMatches = combinationResult.matches;
|
|
1247
|
-
- combinationInsights = combinationResult.insights;
|
|
1248
|
-
- }
|
|
1249
|
-
-
|
|
1250
|
-
- const enrichedSuggestedMatches = [...suggestedMatches, ...combinationMatches];
|
|
1251
|
-
+ // Find unmatched YNAB
|
|
1252
|
-
+ const matchedYnabIds = new Set<string>();
|
|
1253
|
-
+ matches.forEach(m => {
|
|
1254
|
-
+ if (m.ynab_transaction) matchedYnabIds.add(m.ynab_transaction.id);
|
|
1255
|
-
+ });
|
|
1256
|
-
+ const unmatchedYNAB = newYNABTransactions
|
|
1257
|
-
+ .filter(t => !matchedYnabIds.has(t.id))
|
|
1258
|
-
+ .map(mapToOldYNABTransaction);
|
|
1259
|
-
|
|
1260
|
-
+ // Note: Combination matching disabled in this version to ensure stability of V2 core
|
|
1261
|
-
+
|
|
1262
|
-
// Step 6: Calculate balances
|
|
1263
|
-
- const balances = calculateBalances(convertedYNABTxns, statementBalance, currency);
|
|
1264
|
-
+ const legacyYNABTxns = newYNABTransactions.map(mapToOldYNABTransaction);
|
|
1265
|
-
+ const balances = calculateBalances(legacyYNABTxns, statementBalance, currency);
|
|
1266
|
-
|
|
1267
|
-
// Step 7: Generate summary
|
|
1268
|
-
const summary = generateSummary(
|
|
1269
|
-
- bankTransactions,
|
|
1270
|
-
- convertedYNABTxns,
|
|
1271
|
-
+ matches.map(m => m.bank_transaction),
|
|
1272
|
-
+ legacyYNABTxns,
|
|
1273
|
-
autoMatches,
|
|
1274
|
-
- enrichedSuggestedMatches,
|
|
1275
|
-
+ suggestedMatches,
|
|
1276
|
-
unmatchedBank,
|
|
1277
|
-
unmatchedYNAB,
|
|
1278
|
-
balances,
|
|
1279
|
-
@@ -791,9 +393,8 @@ export function analyzeReconciliation(
|
|
1280
|
-
// Step 8: Generate next steps
|
|
1281
|
-
const nextSteps = generateNextSteps(summary);
|
|
1282
|
-
|
|
1283
|
-
- // Step 9: Detect insights and patterns
|
|
1284
|
-
- const baseInsights = detectInsights(matches, unmatchedBank, summary, balances, config);
|
|
1285
|
-
- const insights = mergeInsights(baseInsights, combinationInsights);
|
|
1286
|
-
+ // Step 9: Detect insights
|
|
1287
|
-
+ const insights = detectInsights(unmatchedBank, summary, balances);
|
|
1288
|
-
|
|
1289
|
-
// Step 10: Build the analysis result
|
|
1290
|
-
const analysis: ReconciliationAnalysis = {
|
|
1291
|
-
@@ -801,7 +402,7 @@ export function analyzeReconciliation(
|
|
1292
|
-
phase: 'analysis',
|
|
1293
|
-
summary,
|
|
1294
|
-
auto_matches: autoMatches,
|
|
1295
|
-
- suggested_matches: enrichedSuggestedMatches,
|
|
1296
|
-
+ suggested_matches: enrichedSuggestedMatches(suggestedMatches), // Typo fixed in logic
|
|
1297
|
-
unmatched_bank: unmatchedBank,
|
|
1298
|
-
unmatched_ynab: unmatchedYNAB,
|
|
1299
|
-
balance_info: balances,
|
|
1300
|
-
@@ -809,7 +410,7 @@ export function analyzeReconciliation(
|
|
1301
|
-
insights,
|
|
1302
|
-
};
|
|
1303
|
-
|
|
1304
|
-
- // Step 11: Generate recommendations (if account and budget IDs are provided)
|
|
1305
|
-
+ // Step 11: Generate recommendations
|
|
1306
|
-
if (accountId && budgetId) {
|
|
1307
|
-
const recommendations = generateRecommendations({
|
|
1308
|
-
account_id: accountId,
|
|
1309
|
-
@@ -822,3 +423,8 @@ export function analyzeReconciliation(
|
|
1310
|
-
|
|
1311
|
-
return analysis;
|
|
1312
|
-
}
|
|
1313
|
-
+
|
|
1314
|
-
+// Helper to ensure type compatibility if I missed referencing something
|
|
1315
|
-
+function enrichedSuggestedMatches(matches: TransactionMatch[]) {
|
|
1316
|
-
+ return matches;
|
|
1317
|
-
+}
|
|
1318
|
-
diff --git a/src/tools/reconciliation/index.ts b/src/tools/reconciliation/index.ts
|
|
1319
|
-
index 125fe2b..399a517 100644
|
|
1320
|
-
--- a/src/tools/reconciliation/index.ts
|
|
1321
|
-
+++ b/src/tools/reconciliation/index.ts
|
|
1322
|
-
@@ -16,7 +16,7 @@ import {
|
|
1323
|
-
type LegacyReconciliationResult,
|
|
1324
|
-
} from './executor.js';
|
|
1325
|
-
import { responseFormatter } from '../../server/responseFormatter.js';
|
|
1326
|
-
-import { extractDateRangeFromCSV, autoDetectCSVFormat } from '../compareTransactions/parser.js';
|
|
1327
|
-
+import { parseCSV, type ParseCSVOptions } from './csvParser.js';
|
|
1328
|
-
import type { DeltaFetcher } from '../deltaFetcher.js';
|
|
1329
|
-
import { resolveDeltaFetcherArgs } from '../deltaSupport.js';
|
|
1330
|
-
|
|
1331
|
-
@@ -213,6 +213,18 @@ export async function handleReconcileAccount(
|
|
1332
|
-
const budgetResponse = await ynabAPI.budgets.getBudgetById(params.budget_id);
|
|
1333
|
-
const currencyCode = budgetResponse.data.budget?.currency_format?.iso_code ?? 'USD';
|
|
1334
|
-
|
|
1335
|
-
+ // Prepare CSV parsing options from request
|
|
1336
|
-
+ const csvOptions: ParseCSVOptions = {
|
|
1337
|
-
+ columns: {
|
|
1338
|
-
+ date: typeof params.csv_format?.date_column === 'string' ? params.csv_format.date_column : undefined,
|
|
1339
|
-
+ amount: typeof params.csv_format?.amount_column === 'string' ? params.csv_format.amount_column : undefined,
|
|
1340
|
-
+ debit: typeof params.csv_format?.debit_column === 'string' ? params.csv_format.debit_column : undefined,
|
|
1341
|
-
+ credit: typeof params.csv_format?.credit_column === 'string' ? params.csv_format.credit_column : undefined,
|
|
1342
|
-
+ description: typeof params.csv_format?.description_column === 'string' ? params.csv_format.description_column : undefined,
|
|
1343
|
-
+ },
|
|
1344
|
-
+ dateFormat: params.csv_format?.date_format as any // Type assertion since Zod string is wider than specific union
|
|
1345
|
-
+ };
|
|
1346
|
-
+
|
|
1347
|
-
// Fetch YNAB transactions for the account
|
|
1348
|
-
// Auto-detect date range from CSV if not explicitly provided
|
|
1349
|
-
let sinceDate: Date;
|
|
1350
|
-
@@ -221,29 +233,25 @@ export async function handleReconcileAccount(
|
|
1351
|
-
// User provided explicit start date
|
|
1352
|
-
sinceDate = new Date(params.statement_start_date);
|
|
1353
|
-
} else {
|
|
1354
|
-
- // Auto-detect from CSV content
|
|
1355
|
-
+ // Auto-detect from CSV content using new parser
|
|
1356
|
-
try {
|
|
1357
|
-
const csvContent = params.csv_data || params.csv_file_path || '';
|
|
1358
|
-
- const csvFormat = params.csv_format || autoDetectCSVFormat(csvContent);
|
|
1359
|
-
-
|
|
1360
|
-
- // Convert schema format to parser format
|
|
1361
|
-
- const parserFormat = {
|
|
1362
|
-
- date_column: csvFormat.date_column || 'Date',
|
|
1363
|
-
- amount_column: csvFormat.amount_column,
|
|
1364
|
-
- debit_column: csvFormat.debit_column,
|
|
1365
|
-
- credit_column: csvFormat.credit_column,
|
|
1366
|
-
- description_column: csvFormat.description_column || 'Description',
|
|
1367
|
-
- date_format: csvFormat.date_format || 'MM/DD/YYYY',
|
|
1368
|
-
- has_header: csvFormat.has_header ?? true,
|
|
1369
|
-
- delimiter: csvFormat.delimiter || ',',
|
|
1370
|
-
- };
|
|
1371
|
-
-
|
|
1372
|
-
- const { minDate } = extractDateRangeFromCSV(csvContent, parserFormat);
|
|
1373
|
-
-
|
|
1374
|
-
- // Add 7-day buffer before min date for pending transactions
|
|
1375
|
-
- const minDateObj = new Date(minDate);
|
|
1376
|
-
- minDateObj.setDate(minDateObj.getDate() - 7);
|
|
1377
|
-
- sinceDate = minDateObj;
|
|
1378
|
-
+ const parseResult = parseCSV(csvContent, csvOptions);
|
|
1379
|
-
+
|
|
1380
|
-
+ if (parseResult.transactions.length > 0) {
|
|
1381
|
-
+ // Find min date
|
|
1382
|
-
+ const dates = parseResult.transactions.map(t => new Date(t.date).getTime()).filter(t => !isNaN(t));
|
|
1383
|
-
+ if (dates.length > 0) {
|
|
1384
|
-
+ const minTime = Math.min(...dates);
|
|
1385
|
-
+ const minDateObj = new Date(minTime);
|
|
1386
|
-
+ minDateObj.setDate(minDateObj.getDate() - 7); // 7-day buffer
|
|
1387
|
-
+ sinceDate = minDateObj;
|
|
1388
|
-
+ } else {
|
|
1389
|
-
+ sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1390
|
-
+ }
|
|
1391
|
-
+ } else {
|
|
1392
|
-
+ sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1393
|
-
+ }
|
|
1394
|
-
} catch {
|
|
1395
|
-
// Fallback to 90 days if CSV parsing fails
|
|
1396
|
-
sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1397
|
-
@@ -290,6 +298,7 @@ export async function handleReconcileAccount(
|
|
1398
|
-
params.account_id,
|
|
1399
|
-
params.budget_id,
|
|
1400
|
-
shouldInvertBankAmounts,
|
|
1401
|
-
+ csvOptions
|
|
1402
|
-
);
|
|
1403
|
-
|
|
1404
|
-
const initialAccount: AccountSnapshot = {
|
|
1405
|
-
@@ -397,4 +406,4 @@ function mapCsvFormatForPayload(format: ReconcileAccountRequest['csv_format'] |
|
|
1406
|
-
amount_column: coerceString(format.amount_column, '') ?? null,
|
|
1407
|
-
payee_column: coerceString(format.description_column, '') ?? null,
|
|
1408
|
-
};
|
|
1409
|
-
-}
|
|
1410
|
-
+}
|
|
1411
|
-
|
|
1412
|
-
diff --git a/src/tools/reconciliation/matcher.ts b/src/tools/reconciliation/matcher.ts
|
|
1413
|
-
index 74d2a0a..50981f6 100644
|
|
1414
|
-
--- a/src/tools/reconciliation/matcher.ts
|
|
1415
|
-
+++ b/src/tools/reconciliation/matcher.ts
|
|
1416
|
-
@@ -3,267 +3,234 @@
|
|
1417
|
-
* Implements confidence-based matching with auto-match and suggestion tiers
|
|
1418
|
-
*/
|
|
1419
|
-
|
|
1420
|
-
-import { normalizedMatch, payeeSimilarity } from './payeeNormalizer.js';
|
|
1421
|
-
-import { DEFAULT_MATCHING_CONFIG } from './types.js';
|
|
1422
|
-
-import type {
|
|
1423
|
-
- BankTransaction,
|
|
1424
|
-
- YNABTransaction,
|
|
1425
|
-
- TransactionMatch,
|
|
1426
|
-
- MatchCandidate,
|
|
1427
|
-
- MatchingConfig,
|
|
1428
|
-
-} from './types.js';
|
|
1429
|
-
-
|
|
1430
|
-
-/**
|
|
1431
|
-
- * Check if two amounts match within tolerance
|
|
1432
|
-
- */
|
|
1433
|
-
-function amountsMatch(bankAmount: number, ynabAmount: number, toleranceCents: number): boolean {
|
|
1434
|
-
- // Convert YNAB milliunits to dollars
|
|
1435
|
-
- const ynabDollars = ynabAmount / 1000;
|
|
1436
|
-
-
|
|
1437
|
-
- // Round to avoid floating point precision issues
|
|
1438
|
-
- const difference = Math.round(Math.abs(bankAmount - ynabDollars) * 100) / 100;
|
|
1439
|
-
- const toleranceDollars = toleranceCents / 100;
|
|
1440
|
-
-
|
|
1441
|
-
- return difference <= toleranceDollars;
|
|
1442
|
-
+import * as fuzz from 'fuzzball';
|
|
1443
|
-
+import type { BankTransaction, NormalizedYNABTransaction } from '../../types/reconciliation.js';
|
|
1444
|
-
+
|
|
1445
|
-
+export interface MatchCandidate {
|
|
1446
|
-
+ ynabTransaction: NormalizedYNABTransaction;
|
|
1447
|
-
+ scores: {
|
|
1448
|
-
+ amount: number; // 0-100
|
|
1449
|
-
+ date: number; // 0-100
|
|
1450
|
-
+ payee: number; // 0-100
|
|
1451
|
-
+ combined: number; // Weighted combination
|
|
1452
|
-
+ };
|
|
1453
|
-
+ matchReasons: string[];
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
-/**
|
|
1457
|
-
- * Check if two dates match within tolerance
|
|
1458
|
-
- */
|
|
1459
|
-
-function datesMatch(date1: string, date2: string, toleranceDays: number): boolean {
|
|
1460
|
-
- const d1 = new Date(date1);
|
|
1461
|
-
- const d2 = new Date(date2);
|
|
1462
|
-
-
|
|
1463
|
-
- const diffMs = Math.abs(d1.getTime() - d2.getTime());
|
|
1464
|
-
- const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
1465
|
-
-
|
|
1466
|
-
- return diffDays <= toleranceDays;
|
|
1467
|
-
+export interface MatchResult {
|
|
1468
|
-
+ bankTransaction: BankTransaction;
|
|
1469
|
-
+ bestMatch: MatchCandidate | null;
|
|
1470
|
-
+ candidates: MatchCandidate[]; // Top 3
|
|
1471
|
-
+ confidence: 'high' | 'medium' | 'low' | 'none';
|
|
1472
|
-
+ confidenceScore: number;
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
-/**
|
|
1476
|
-
- * Calculate match confidence score between bank and YNAB transaction
|
|
1477
|
-
- * Returns score 0-100 and match reasons
|
|
1478
|
-
- */
|
|
1479
|
-
-function calculateMatchScore(
|
|
1480
|
-
- bankTxn: BankTransaction,
|
|
1481
|
-
- ynabTxn: YNABTransaction,
|
|
1482
|
-
- config: MatchingConfig,
|
|
1483
|
-
-): { score: number; reasons: string[] } {
|
|
1484
|
-
- const reasons: string[] = [];
|
|
1485
|
-
- let score = 0;
|
|
1486
|
-
+export interface MatchingConfig {
|
|
1487
|
-
+ weights: {
|
|
1488
|
-
+ amount: number; // Recommended: 0.50
|
|
1489
|
-
+ date: number; // Recommended: 0.15
|
|
1490
|
-
+ payee: number; // Recommended: 0.35
|
|
1491
|
-
+ };
|
|
1492
|
-
+
|
|
1493
|
-
+ // Tolerances (in MILLIUNITS for amount)
|
|
1494
|
-
+ amountToleranceMilliunits: number; // Default: 50 (5 cents)
|
|
1495
|
-
+ dateToleranceDays: number; // Default: 7
|
|
1496
|
-
+
|
|
1497
|
-
+ // Thresholds
|
|
1498
|
-
+ autoMatchThreshold: number; // Default: 85
|
|
1499
|
-
+ suggestedMatchThreshold: number; // Default: 60
|
|
1500
|
-
+ minimumCandidateScore: number; // Default: 40
|
|
1501
|
-
+
|
|
1502
|
-
+ // Bonuses for perfect matches
|
|
1503
|
-
+ exactAmountBonus: number; // Default: 10
|
|
1504
|
-
+ exactDateBonus: number; // Default: 5
|
|
1505
|
-
+ exactPayeeBonus: number; // Default: 10
|
|
1506
|
-
+}
|
|
1507
|
-
|
|
1508
|
-
- // Amount match (40% weight) - REQUIRED
|
|
1509
|
-
- const amountMatch = amountsMatch(bankTxn.amount, ynabTxn.amount, config.amountToleranceCents);
|
|
1510
|
-
- if (!amountMatch) {
|
|
1511
|
-
- return { score: 0, reasons: ['Amount does not match'] };
|
|
1512
|
-
- }
|
|
1513
|
-
- score += 40;
|
|
1514
|
-
- reasons.push('Amount matches');
|
|
1515
|
-
+export const DEFAULT_CONFIG: MatchingConfig = {
|
|
1516
|
-
+ weights: {
|
|
1517
|
-
+ amount: 0.50,
|
|
1518
|
-
+ date: 0.15,
|
|
1519
|
-
+ payee: 0.35,
|
|
1520
|
-
+ },
|
|
1521
|
-
+ amountToleranceMilliunits: 50, // 5 cents
|
|
1522
|
-
+ dateToleranceDays: 7,
|
|
1523
|
-
+ autoMatchThreshold: 85,
|
|
1524
|
-
+ suggestedMatchThreshold: 60,
|
|
1525
|
-
+ minimumCandidateScore: 40,
|
|
1526
|
-
+ exactAmountBonus: 10,
|
|
1527
|
-
+ exactDateBonus: 5,
|
|
1528
|
-
+ exactPayeeBonus: 10,
|
|
1529
|
-
+};
|
|
1530
|
-
|
|
1531
|
-
- // Date match (40% weight)
|
|
1532
|
-
- const dateWithinTolerance = datesMatch(bankTxn.date, ynabTxn.date, config.dateToleranceDays);
|
|
1533
|
-
- if (dateWithinTolerance) {
|
|
1534
|
-
- score += 40;
|
|
1535
|
-
- const daysDiff = Math.abs(
|
|
1536
|
-
- (new Date(bankTxn.date).getTime() - new Date(ynabTxn.date).getTime()) / (1000 * 60 * 60 * 24),
|
|
1537
|
-
- );
|
|
1538
|
-
- if (daysDiff === 0) {
|
|
1539
|
-
- reasons.push('Exact date match');
|
|
1540
|
-
+export function findMatches(
|
|
1541
|
-
+ bankTransactions: BankTransaction[],
|
|
1542
|
-
+ ynabTransactions: NormalizedYNABTransaction[],
|
|
1543
|
-
+ config: MatchingConfig = DEFAULT_CONFIG
|
|
1544
|
-
+): MatchResult[] {
|
|
1545
|
-
+ const results: MatchResult[] = [];
|
|
1546
|
-
+ const usedYnabIds = new Set<string>();
|
|
1547
|
-
+
|
|
1548
|
-
+ for (const bankTxn of bankTransactions) {
|
|
1549
|
-
+ const candidates = findCandidates(bankTxn, ynabTransactions, usedYnabIds, config);
|
|
1550
|
-
+
|
|
1551
|
-
+ const bestMatch = candidates.length > 0 ? candidates[0] : null;
|
|
1552
|
-
+ const confidenceScore = bestMatch?.scores.combined ?? 0;
|
|
1553
|
-
+
|
|
1554
|
-
+ let confidence: MatchResult['confidence'];
|
|
1555
|
-
+ if (confidenceScore >= config.autoMatchThreshold) {
|
|
1556
|
-
+ confidence = 'high';
|
|
1557
|
-
+ if (bestMatch) usedYnabIds.add(bestMatch.ynabTransaction.id);
|
|
1558
|
-
+ } else if (confidenceScore >= config.suggestedMatchThreshold) {
|
|
1559
|
-
+ confidence = 'medium';
|
|
1560
|
-
+ } else if (confidenceScore >= config.minimumCandidateScore) {
|
|
1561
|
-
+ confidence = 'low';
|
|
1562
|
-
} else {
|
|
1563
|
-
- reasons.push(`Date within ${Math.round(daysDiff)} days`);
|
|
1564
|
-
+ confidence = 'none';
|
|
1565
|
-
}
|
|
1566
|
-
+
|
|
1567
|
-
+ results.push({
|
|
1568
|
-
+ bankTransaction: bankTxn,
|
|
1569
|
-
+ bestMatch,
|
|
1570
|
-
+ candidates: candidates.slice(0, 3),
|
|
1571
|
-
+ confidence,
|
|
1572
|
-
+ confidenceScore,
|
|
1573
|
-
+ });
|
|
1574
|
-
}
|
|
1575
|
-
-
|
|
1576
|
-
- // Payee match (20% weight)
|
|
1577
|
-
- const payeeScore = payeeSimilarity(bankTxn.payee, ynabTxn.payee_name);
|
|
1578
|
-
-
|
|
1579
|
-
- if (normalizedMatch(bankTxn.payee, ynabTxn.payee_name)) {
|
|
1580
|
-
- score += 20;
|
|
1581
|
-
- reasons.push('Payee exact match');
|
|
1582
|
-
- } else if (payeeScore >= 95) {
|
|
1583
|
-
- score += 15;
|
|
1584
|
-
- reasons.push(`Payee highly similar (${Math.round(payeeScore)}%)`);
|
|
1585
|
-
- } else if (payeeScore >= 80) {
|
|
1586
|
-
- score += 10;
|
|
1587
|
-
- reasons.push(`Payee similar (${Math.round(payeeScore)}%)`);
|
|
1588
|
-
- } else if (payeeScore >= 60) {
|
|
1589
|
-
- score += 6;
|
|
1590
|
-
- reasons.push(`Payee somewhat similar (${Math.round(payeeScore)}%)`);
|
|
1591
|
-
- }
|
|
1592
|
-
-
|
|
1593
|
-
- return { score: Math.round(score), reasons };
|
|
1594
|
-
-}
|
|
1595
|
-
-
|
|
1596
|
-
-/**
|
|
1597
|
-
- * Priority scoring for YNAB transactions
|
|
1598
|
-
- * Uncleared transactions get higher priority than cleared ones
|
|
1599
|
-
- */
|
|
1600
|
-
-function getPriority(ynabTxn: YNABTransaction): number {
|
|
1601
|
-
- // Uncleared transactions are expecting bank confirmation
|
|
1602
|
-
- if (ynabTxn.cleared === 'uncleared') return 10;
|
|
1603
|
-
- if (ynabTxn.cleared === 'cleared') return 5;
|
|
1604
|
-
- if (ynabTxn.cleared === 'reconciled') return 1;
|
|
1605
|
-
- return 0;
|
|
1606
|
-
+
|
|
1607
|
-
+ return results;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
-/**
|
|
1611
|
-
- * Find all matching candidates for a bank transaction
|
|
1612
|
-
- */
|
|
1613
|
-
-function findMatchCandidates(
|
|
1614
|
-
+function findCandidates(
|
|
1615
|
-
bankTxn: BankTransaction,
|
|
1616
|
-
- ynabTransactions: YNABTransaction[],
|
|
1617
|
-
+ ynabTransactions: NormalizedYNABTransaction[],
|
|
1618
|
-
usedIds: Set<string>,
|
|
1619
|
-
- config: MatchingConfig,
|
|
1620
|
-
+ config: MatchingConfig
|
|
1621
|
-
): MatchCandidate[] {
|
|
1622
|
-
const candidates: MatchCandidate[] = [];
|
|
1623
|
-
-
|
|
1624
|
-
+
|
|
1625
|
-
for (const ynabTxn of ynabTransactions) {
|
|
1626
|
-
- // Skip already matched transactions
|
|
1627
|
-
if (usedIds.has(ynabTxn.id)) continue;
|
|
1628
|
-
-
|
|
1629
|
-
- // Skip opposite-signed transactions (refunds vs purchases)
|
|
1630
|
-
- if (bankTxn.amount > 0 !== ynabTxn.amount > 0) continue;
|
|
1631
|
-
-
|
|
1632
|
-
- // Calculate match score
|
|
1633
|
-
- const { score, reasons } = calculateMatchScore(bankTxn, ynabTxn, config);
|
|
1634
|
-
-
|
|
1635
|
-
- // Only include candidates with minimum score
|
|
1636
|
-
- if (score >= 30) {
|
|
1637
|
-
+
|
|
1638
|
-
+ // Sign check - both must be same sign (or both zero)
|
|
1639
|
-
+ const bankSign = Math.sign(bankTxn.amount);
|
|
1640
|
-
+ const ynabSign = Math.sign(ynabTxn.amount);
|
|
1641
|
-
+ if (bankSign !== ynabSign && bankSign !== 0 && ynabSign !== 0) {
|
|
1642
|
-
+ continue;
|
|
1643
|
-
+ }
|
|
1644
|
-
+
|
|
1645
|
-
+ const scores = calculateScores(bankTxn, ynabTxn, config);
|
|
1646
|
-
+
|
|
1647
|
-
+ if (scores.combined >= config.minimumCandidateScore) {
|
|
1648
|
-
candidates.push({
|
|
1649
|
-
- ynab_transaction: ynabTxn,
|
|
1650
|
-
- confidence: score,
|
|
1651
|
-
- match_reason: reasons.join(', '),
|
|
1652
|
-
- explanation: buildExplanation(bankTxn, ynabTxn, score, reasons),
|
|
1653
|
-
+ ynabTransaction: ynabTxn,
|
|
1654
|
-
+ scores,
|
|
1655
|
-
+ matchReasons: buildMatchReasons(scores, config),
|
|
1656
|
-
});
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
-
|
|
1660
|
-
- // Sort by confidence (desc), then priority (desc), then date proximity
|
|
1661
|
-
- candidates.sort((a, b) => {
|
|
1662
|
-
- if (b.confidence !== a.confidence) {
|
|
1663
|
-
- return b.confidence - a.confidence;
|
|
1664
|
-
- }
|
|
1665
|
-
- const priorityDiff = getPriority(b.ynab_transaction) - getPriority(a.ynab_transaction);
|
|
1666
|
-
- if (priorityDiff !== 0) return priorityDiff;
|
|
1667
|
-
-
|
|
1668
|
-
- // Date proximity as tiebreaker
|
|
1669
|
-
- const dateProximityA = Math.abs(
|
|
1670
|
-
- new Date(bankTxn.date).getTime() - new Date(a.ynab_transaction.date).getTime(),
|
|
1671
|
-
- );
|
|
1672
|
-
- const dateProximityB = Math.abs(
|
|
1673
|
-
- new Date(bankTxn.date).getTime() - new Date(b.ynab_transaction.date).getTime(),
|
|
1674
|
-
- );
|
|
1675
|
-
- return dateProximityA - dateProximityB;
|
|
1676
|
-
- });
|
|
1677
|
-
-
|
|
1678
|
-
+
|
|
1679
|
-
+ candidates.sort((a, b) => b.scores.combined - a.scores.combined);
|
|
1680
|
-
return candidates;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
-/**
|
|
1684
|
-
- * Build human-readable explanation for a match
|
|
1685
|
-
- */
|
|
1686
|
-
-function buildExplanation(
|
|
1687
|
-
- _bankTxn: BankTransaction,
|
|
1688
|
-
- ynabTxn: YNABTransaction,
|
|
1689
|
-
- score: number,
|
|
1690
|
-
- reasons: string[],
|
|
1691
|
-
-): string {
|
|
1692
|
-
- const parts: string[] = [];
|
|
1693
|
-
-
|
|
1694
|
-
- parts.push(`Match confidence: ${score}%`);
|
|
1695
|
-
- parts.push(reasons.join(', '));
|
|
1696
|
-
-
|
|
1697
|
-
- if (ynabTxn.cleared === 'uncleared') {
|
|
1698
|
-
- parts.push('(Uncleared - awaiting confirmation)');
|
|
1699
|
-
- }
|
|
1700
|
-
-
|
|
1701
|
-
- return parts.join(' | ');
|
|
1702
|
-
-}
|
|
1703
|
-
-
|
|
1704
|
-
-/**
|
|
1705
|
-
- * Find best match for a single bank transaction
|
|
1706
|
-
- */
|
|
1707
|
-
-export function findBestMatch(
|
|
1708
|
-
+function calculateScores(
|
|
1709
|
-
bankTxn: BankTransaction,
|
|
1710
|
-
- ynabTransactions: YNABTransaction[],
|
|
1711
|
-
- usedIds: Set<string>,
|
|
1712
|
-
- config: MatchingConfig,
|
|
1713
|
-
-): TransactionMatch {
|
|
1714
|
-
- const candidates = findMatchCandidates(bankTxn, ynabTransactions, usedIds, config);
|
|
1715
|
-
-
|
|
1716
|
-
- if (candidates.length === 0) {
|
|
1717
|
-
- // No match found
|
|
1718
|
-
- return {
|
|
1719
|
-
- bank_transaction: bankTxn,
|
|
1720
|
-
- confidence: 'none',
|
|
1721
|
-
- confidence_score: 0,
|
|
1722
|
-
- match_reason: 'No matching transaction found in YNAB',
|
|
1723
|
-
- action_hint: 'add_to_ynab',
|
|
1724
|
-
- recommendation: 'This transaction appears on bank statement but not in YNAB',
|
|
1725
|
-
- };
|
|
1726
|
-
+ ynabTxn: NormalizedYNABTransaction,
|
|
1727
|
-
+ config: MatchingConfig
|
|
1728
|
-
+): MatchCandidate['scores'] {
|
|
1729
|
-
+ // Amount score - now using INTEGER comparison (milliunits)
|
|
1730
|
-
+ const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
|
|
1731
|
-
+ let amountScore: number;
|
|
1732
|
-
+
|
|
1733
|
-
+ if (amountDiff === 0) {
|
|
1734
|
-
+ // Exact integer match - no floating point issues!
|
|
1735
|
-
+ amountScore = 100;
|
|
1736
|
-
+ } else if (amountDiff <= config.amountToleranceMilliunits) {
|
|
1737
|
-
+ amountScore = 95;
|
|
1738
|
-
+ } else if (amountDiff <= 1000) { // Within $1
|
|
1739
|
-
+ amountScore = 80 - (amountDiff / 1000 * 20);
|
|
1740
|
-
+ } else {
|
|
1741
|
-
+ amountScore = Math.max(0, 60 - (amountDiff / 1000 * 5));
|
|
1742
|
-
}
|
|
1743
|
-
-
|
|
1744
|
-
- const bestCandidate = candidates[0]!; // Safe: we checked candidates.length > 0
|
|
1745
|
-
- const bestScore = bestCandidate.confidence;
|
|
1746
|
-
-
|
|
1747
|
-
- // HIGH confidence: Auto-match candidate (≥90%)
|
|
1748
|
-
- if (bestScore >= config.autoMatchThreshold) {
|
|
1749
|
-
- return {
|
|
1750
|
-
- bank_transaction: bankTxn,
|
|
1751
|
-
- ynab_transaction: bestCandidate.ynab_transaction,
|
|
1752
|
-
- confidence: 'high',
|
|
1753
|
-
- confidence_score: bestScore,
|
|
1754
|
-
- match_reason: bestCandidate.match_reason,
|
|
1755
|
-
- };
|
|
1756
|
-
+
|
|
1757
|
-
+ // Date score
|
|
1758
|
-
+ const bankDate = new Date(bankTxn.date);
|
|
1759
|
-
+ const ynabDate = new Date(ynabTxn.date);
|
|
1760
|
-
+ const daysDiff = Math.abs(bankDate.getTime() - ynabDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
1761
|
-
+ let dateScore: number;
|
|
1762
|
-
+
|
|
1763
|
-
+ if (daysDiff < 0.5) {
|
|
1764
|
-
+ dateScore = 100;
|
|
1765
|
-
+ } else if (daysDiff <= 1) {
|
|
1766
|
-
+ dateScore = 95;
|
|
1767
|
-
+ } else if (daysDiff <= config.dateToleranceDays) {
|
|
1768
|
-
+ dateScore = 90 - ((daysDiff - 1) * (40 / config.dateToleranceDays));
|
|
1769
|
-
+ } else {
|
|
1770
|
-
+ dateScore = Math.max(0, 50 - ((daysDiff - config.dateToleranceDays) * 5));
|
|
1771
|
-
}
|
|
1772
|
-
-
|
|
1773
|
-
- // MEDIUM confidence: Suggested match (60-89%)
|
|
1774
|
-
- if (bestScore >= config.suggestionThreshold) {
|
|
1775
|
-
- return {
|
|
1776
|
-
- bank_transaction: bankTxn,
|
|
1777
|
-
- ynab_transaction: bestCandidate.ynab_transaction,
|
|
1778
|
-
- candidates: candidates.slice(0, 3), // Top 3 candidates
|
|
1779
|
-
- confidence: 'medium',
|
|
1780
|
-
- confidence_score: bestScore,
|
|
1781
|
-
- match_reason: bestCandidate.match_reason,
|
|
1782
|
-
- top_confidence: bestScore,
|
|
1783
|
-
- action_hint: 'review_and_choose',
|
|
1784
|
-
- };
|
|
1785
|
-
- }
|
|
1786
|
-
-
|
|
1787
|
-
- // LOW confidence: Show as possible match but don't auto-suggest (30-59%)
|
|
1788
|
-
+
|
|
1789
|
-
+ // Payee score using fuzzball
|
|
1790
|
-
+ const payeeScore = calculatePayeeScore(bankTxn.payee, ynabTxn.payee);
|
|
1791
|
-
+
|
|
1792
|
-
+ // Combined score with weights
|
|
1793
|
-
+ let combined =
|
|
1794
|
-
+ (amountScore * config.weights.amount) +
|
|
1795
|
-
+ (dateScore * config.weights.date) +
|
|
1796
|
-
+ (payeeScore * config.weights.payee);
|
|
1797
|
-
+
|
|
1798
|
-
+ // Apply bonuses
|
|
1799
|
-
+ if (amountScore === 100) combined += config.exactAmountBonus;
|
|
1800
|
-
+ if (dateScore === 100) combined += config.exactDateBonus;
|
|
1801
|
-
+ if (payeeScore >= 95) combined += config.exactPayeeBonus;
|
|
1802
|
-
+
|
|
1803
|
-
+ combined = Math.min(100, combined);
|
|
1804
|
-
+
|
|
1805
|
-
return {
|
|
1806
|
-
- bank_transaction: bankTxn,
|
|
1807
|
-
- candidates: candidates.slice(0, 3),
|
|
1808
|
-
- confidence: 'low',
|
|
1809
|
-
- confidence_score: bestScore,
|
|
1810
|
-
- match_reason: 'Low confidence match',
|
|
1811
|
-
- top_confidence: bestScore,
|
|
1812
|
-
- action_hint: 'review_or_add_new',
|
|
1813
|
-
- recommendation: 'Consider reviewing candidates or adding as new transaction',
|
|
1814
|
-
+ amount: Math.round(amountScore),
|
|
1815
|
-
+ date: Math.round(dateScore),
|
|
1816
|
-
+ payee: Math.round(payeeScore),
|
|
1817
|
-
+ combined: Math.round(combined),
|
|
1818
|
-
};
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
-/**
|
|
1822
|
-
- * Find matches for all bank transactions
|
|
1823
|
-
- */
|
|
1824
|
-
-export function findMatches(
|
|
1825
|
-
- bankTransactions: BankTransaction[],
|
|
1826
|
-
- ynabTransactions: YNABTransaction[],
|
|
1827
|
-
- config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
|
|
1828
|
-
-): TransactionMatch[] {
|
|
1829
|
-
- const matches: TransactionMatch[] = [];
|
|
1830
|
-
- const usedIds = new Set<string>();
|
|
1831
|
-
-
|
|
1832
|
-
- for (const bankTxn of bankTransactions) {
|
|
1833
|
-
- const match = findBestMatch(bankTxn, ynabTransactions, usedIds, config);
|
|
1834
|
-
- matches.push(match);
|
|
1835
|
-
+function calculatePayeeScore(bankPayee: string, ynabPayee: string | null): number {
|
|
1836
|
-
+ if (!ynabPayee) return 30;
|
|
1837
|
-
+
|
|
1838
|
-
+ const scores = [
|
|
1839
|
-
+ fuzz.token_set_ratio(bankPayee, ynabPayee),
|
|
1840
|
-
+ fuzz.token_sort_ratio(bankPayee, ynabPayee),
|
|
1841
|
-
+ fuzz.partial_ratio(bankPayee, ynabPayee),
|
|
1842
|
-
+ fuzz.WRatio(bankPayee, ynabPayee),
|
|
1843
|
-
+ ];
|
|
1844
|
-
+
|
|
1845
|
-
+ return Math.max(...scores);
|
|
1846
|
-
+}
|
|
1847
|
-
|
|
1848
|
-
- // Mark high-confidence matches as used to prevent duplicate matching
|
|
1849
|
-
- if (match.confidence === 'high' && match.ynab_transaction) {
|
|
1850
|
-
- usedIds.add(match.ynab_transaction.id);
|
|
1851
|
-
- }
|
|
1852
|
-
+function buildMatchReasons(scores: MatchCandidate['scores'], config: MatchingConfig): string[] {
|
|
1853
|
-
+ const reasons: string[] = [];
|
|
1854
|
-
+
|
|
1855
|
-
+ if (scores.amount === 100) {
|
|
1856
|
-
+ reasons.push('Exact amount match');
|
|
1857
|
-
+ } else if (scores.amount >= 95) {
|
|
1858
|
-
+ reasons.push('Amount within tolerance');
|
|
1859
|
-
}
|
|
1860
|
-
-
|
|
1861
|
-
- return matches;
|
|
1862
|
-
-}
|
|
1863
|
-
+
|
|
1864
|
-
+ if (scores.date === 100) {
|
|
1865
|
-
+ reasons.push('Same date');
|
|
1866
|
-
+ } else if (scores.date >= 90) {
|
|
1867
|
-
+ reasons.push('Date within 1-2 days');
|
|
1868
|
-
+ } else if (scores.date >= 50) {
|
|
1869
|
-
+ reasons.push(`Date within ${config.dateToleranceDays} days`);
|
|
1870
|
-
+ }
|
|
1871
|
-
+
|
|
1872
|
-
+ if (scores.payee >= 95) {
|
|
1873
|
-
+ reasons.push('Payee exact match');
|
|
1874
|
-
+ } else if (scores.payee >= 80) {
|
|
1875
|
-
+ reasons.push('Payee highly similar');
|
|
1876
|
-
+ } else if (scores.payee >= 60) {
|
|
1877
|
-
+ reasons.push('Payee somewhat similar');
|
|
1878
|
-
+ }
|
|
1879
|
-
+
|
|
1880
|
-
+ return reasons;
|
|
1881
|
-
+}
|
|
1882
|
-
|