@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,2456 +0,0 @@
|
|
|
1
|
-
diff --git a/manifest.json b/manifest.json
|
|
2
|
-
index b1dd09f..a71a3b2 100644
|
|
3
|
-
--- a/manifest.json
|
|
4
|
-
+++ b/manifest.json
|
|
5
|
-
@@ -1,7 +1,7 @@
|
|
6
|
-
{
|
|
7
|
-
"manifest_version": "0.3",
|
|
8
|
-
"name": "ynab-mcp-server",
|
|
9
|
-
- "version": "0.13.0",
|
|
10
|
-
+ "version": "0.13.4",
|
|
11
|
-
"description": "Model Context Protocol server for YNAB (You Need A Budget) integration. Provides 30 tools for comprehensive budget management, including delta-optimized data fetching, bulk transaction operations, advanced reconciliation with recommendations, split transaction support, and diagnostic utilities.",
|
|
12
|
-
"author": {
|
|
13
|
-
"name": "kdizzl"
|
|
14
|
-
diff --git a/package-lock.json b/package-lock.json
|
|
15
|
-
index ae9d883..f005622 100644
|
|
16
|
-
--- a/package-lock.json
|
|
17
|
-
+++ b/package-lock.json
|
|
18
|
-
@@ -1,19 +1,23 @@
|
|
19
|
-
{
|
|
20
|
-
"name": "@dizzlkheinz/ynab-mcpb",
|
|
21
|
-
- "version": "0.13.0",
|
|
22
|
-
+ "version": "0.13.4",
|
|
23
|
-
"lockfileVersion": 3,
|
|
24
|
-
"requires": true,
|
|
25
|
-
"packages": {
|
|
26
|
-
"": {
|
|
27
|
-
"name": "@dizzlkheinz/ynab-mcpb",
|
|
28
|
-
- "version": "0.13.0",
|
|
29
|
-
+ "version": "0.13.4",
|
|
30
|
-
"license": "AGPL-3.0",
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
33
|
-
+ "chrono-node": "^2.9.0",
|
|
34
|
-
"csv-parse": "^6.1.0",
|
|
35
|
-
"d3-array": "^3.2.4",
|
|
36
|
-
"date-fns": "^4.1.0",
|
|
37
|
-
+ "dayjs": "^1.11.19",
|
|
38
|
-
"dotenv": "^17.2.1",
|
|
39
|
-
+ "fuzzball": "^2.2.3",
|
|
40
|
-
+ "papaparse": "^5.5.3",
|
|
41
|
-
"ynab": "^2.9.0",
|
|
42
|
-
"zod": "^4.1.11",
|
|
43
|
-
"zod-validation-error": "^5.0.0"
|
|
44
|
-
@@ -25,6 +29,7 @@
|
|
45
|
-
"@eslint/js": "^9.35.0",
|
|
46
|
-
"@types/d3-array": "^3.2.1",
|
|
47
|
-
"@types/node": "^24.5.2",
|
|
48
|
-
+ "@types/papaparse": "^5.5.0",
|
|
49
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
50
|
-
"@vitest/ui": "^3.2.4",
|
|
51
|
-
"esbuild": "^0.25.10",
|
|
52
|
-
@@ -1366,6 +1371,16 @@
|
|
53
|
-
"undici-types": "~7.12.0"
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
+ "node_modules/@types/papaparse": {
|
|
57
|
-
+ "version": "5.5.0",
|
|
58
|
-
+ "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.0.tgz",
|
|
59
|
-
+ "integrity": "sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==",
|
|
60
|
-
+ "dev": true,
|
|
61
|
-
+ "license": "MIT",
|
|
62
|
-
+ "dependencies": {
|
|
63
|
-
+ "@types/node": "*"
|
|
64
|
-
+ }
|
|
65
|
-
+ },
|
|
66
|
-
"node_modules/@typescript-eslint/eslint-plugin": {
|
|
67
|
-
"version": "8.44.1",
|
|
68
|
-
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
|
|
69
|
-
@@ -2087,6 +2102,15 @@
|
|
70
|
-
"node": ">= 16"
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
+ "node_modules/chrono-node": {
|
|
74
|
-
+ "version": "2.9.0",
|
|
75
|
-
+ "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
|
|
76
|
-
+ "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
|
|
77
|
-
+ "license": "MIT",
|
|
78
|
-
+ "engines": {
|
|
79
|
-
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
|
80
|
-
+ }
|
|
81
|
-
+ },
|
|
82
|
-
"node_modules/color-convert": {
|
|
83
|
-
"version": "2.0.1",
|
|
84
|
-
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
85
|
-
@@ -2208,6 +2232,12 @@
|
|
86
|
-
"url": "https://github.com/sponsors/kossnocorp"
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
+ "node_modules/dayjs": {
|
|
90
|
-
+ "version": "1.11.19",
|
|
91
|
-
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
|
92
|
-
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
|
93
|
-
+ "license": "MIT"
|
|
94
|
-
+ },
|
|
95
|
-
"node_modules/debug": {
|
|
96
|
-
"version": "4.4.1",
|
|
97
|
-
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
|
98
|
-
@@ -2974,6 +3004,17 @@
|
|
99
|
-
"url": "https://github.com/sponsors/ljharb"
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
+ "node_modules/fuzzball": {
|
|
103
|
-
+ "version": "2.2.3",
|
|
104
|
-
+ "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz",
|
|
105
|
-
+ "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==",
|
|
106
|
-
+ "license": "MIT",
|
|
107
|
-
+ "dependencies": {
|
|
108
|
-
+ "heap": ">=0.2.0",
|
|
109
|
-
+ "lodash": "^4.17.21",
|
|
110
|
-
+ "setimmediate": "^1.0.5"
|
|
111
|
-
+ }
|
|
112
|
-
+ },
|
|
113
|
-
"node_modules/get-intrinsic": {
|
|
114
|
-
"version": "1.3.0",
|
|
115
|
-
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
116
|
-
@@ -3124,6 +3165,12 @@
|
|
117
|
-
"node": ">= 0.4"
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
+ "node_modules/heap": {
|
|
121
|
-
+ "version": "0.2.7",
|
|
122
|
-
+ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz",
|
|
123
|
-
+ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==",
|
|
124
|
-
+ "license": "MIT"
|
|
125
|
-
+ },
|
|
126
|
-
"node_modules/html-escaper": {
|
|
127
|
-
"version": "2.0.2",
|
|
128
|
-
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
|
129
|
-
@@ -3426,6 +3473,12 @@
|
|
130
|
-
"url": "https://github.com/sponsors/sindresorhus"
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
+ "node_modules/lodash": {
|
|
134
|
-
+ "version": "4.17.21",
|
|
135
|
-
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
136
|
-
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
|
137
|
-
+ "license": "MIT"
|
|
138
|
-
+ },
|
|
139
|
-
"node_modules/lodash.merge": {
|
|
140
|
-
"version": "4.6.2",
|
|
141
|
-
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
|
142
|
-
@@ -3756,6 +3809,12 @@
|
|
143
|
-
"dev": true,
|
|
144
|
-
"license": "BlueOak-1.0.0"
|
|
145
|
-
},
|
|
146
|
-
+ "node_modules/papaparse": {
|
|
147
|
-
+ "version": "5.5.3",
|
|
148
|
-
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
|
149
|
-
+ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
|
150
|
-
+ "license": "MIT"
|
|
151
|
-
+ },
|
|
152
|
-
"node_modules/parent-module": {
|
|
153
|
-
"version": "1.0.1",
|
|
154
|
-
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
|
155
|
-
@@ -4307,6 +4366,12 @@
|
|
156
|
-
"node": ">= 18"
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
+ "node_modules/setimmediate": {
|
|
160
|
-
+ "version": "1.0.5",
|
|
161
|
-
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
|
162
|
-
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
|
163
|
-
+ "license": "MIT"
|
|
164
|
-
+ },
|
|
165
|
-
"node_modules/setprototypeof": {
|
|
166
|
-
"version": "1.2.0",
|
|
167
|
-
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
|
168
|
-
diff --git a/package.json b/package.json
|
|
169
|
-
index 3ae5a2e..1dad489 100644
|
|
170
|
-
--- a/package.json
|
|
171
|
-
+++ b/package.json
|
|
172
|
-
@@ -67,10 +67,14 @@
|
|
173
|
-
"license": "AGPL-3.0",
|
|
174
|
-
"dependencies": {
|
|
175
|
-
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
176
|
-
+ "chrono-node": "^2.9.0",
|
|
177
|
-
"csv-parse": "^6.1.0",
|
|
178
|
-
"d3-array": "^3.2.4",
|
|
179
|
-
"date-fns": "^4.1.0",
|
|
180
|
-
+ "dayjs": "^1.11.19",
|
|
181
|
-
"dotenv": "^17.2.1",
|
|
182
|
-
+ "fuzzball": "^2.2.3",
|
|
183
|
-
+ "papaparse": "^5.5.3",
|
|
184
|
-
"ynab": "^2.9.0",
|
|
185
|
-
"zod": "^4.1.11",
|
|
186
|
-
"zod-validation-error": "^5.0.0"
|
|
187
|
-
@@ -79,6 +83,7 @@
|
|
188
|
-
"@eslint/js": "^9.35.0",
|
|
189
|
-
"@types/d3-array": "^3.2.1",
|
|
190
|
-
"@types/node": "^24.5.2",
|
|
191
|
-
+ "@types/papaparse": "^5.5.0",
|
|
192
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
193
|
-
"@vitest/ui": "^3.2.4",
|
|
194
|
-
"esbuild": "^0.25.10",
|
|
195
|
-
diff --git a/src/tools/reconciliation/__tests__/analyzer.test.ts b/src/tools/reconciliation/__tests__/analyzer.test.ts
|
|
196
|
-
index cc21d34..4467602 100644
|
|
197
|
-
--- a/src/tools/reconciliation/__tests__/analyzer.test.ts
|
|
198
|
-
+++ b/src/tools/reconciliation/__tests__/analyzer.test.ts
|
|
199
|
-
@@ -1,12 +1,11 @@
|
|
200
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
201
|
-
import { analyzeReconciliation } from '../analyzer.js';
|
|
202
|
-
import type { Transaction as YNABAPITransaction } from 'ynab';
|
|
203
|
-
-import * as parser from '../../compareTransactions/parser.js';
|
|
204
|
-
+import * as csvParser from '../csvParser.js';
|
|
205
|
-
|
|
206
|
-
// Mock the parser module
|
|
207
|
-
-vi.mock('../../compareTransactions/parser.js', () => ({
|
|
208
|
-
- parseBankCSV: vi.fn(),
|
|
209
|
-
- readCSVFile: vi.fn(),
|
|
210
|
-
+vi.mock('../csvParser.js', () => ({
|
|
211
|
-
+ parseCSV: vi.fn(),
|
|
212
|
-
}));
|
|
213
|
-
|
|
214
|
-
describe('analyzer', () => {
|
|
215
|
-
@@ -17,26 +16,36 @@ describe('analyzer', () => {
|
|
216
|
-
describe('analyzeReconciliation', () => {
|
|
217
|
-
it('should perform full analysis and return structured results', () => {
|
|
218
|
-
// Mock CSV parsing
|
|
219
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
220
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
221
|
-
transactions: [
|
|
222
|
-
{
|
|
223
|
-
+ id: 'b1',
|
|
224
|
-
date: '2025-10-15',
|
|
225
|
-
- amount: -45.23,
|
|
226
|
-
+ amount: -45230, // milliunits
|
|
227
|
-
payee: 'Shell Gas',
|
|
228
|
-
memo: '',
|
|
229
|
-
+ sourceRow: 1,
|
|
230
|
-
+ raw: { date: '10/15/2025', amount: '-45.23', description: 'Shell Gas' },
|
|
231
|
-
},
|
|
232
|
-
{
|
|
233
|
-
+ id: 'b2',
|
|
234
|
-
date: '2025-10-16',
|
|
235
|
-
- amount: -100.0,
|
|
236
|
-
+ amount: -100000, // milliunits
|
|
237
|
-
payee: 'Netflix',
|
|
238
|
-
memo: '',
|
|
239
|
-
+ sourceRow: 2,
|
|
240
|
-
+ raw: { date: '10/16/2025', amount: '-100.00', description: 'Netflix' },
|
|
241
|
-
},
|
|
242
|
-
],
|
|
243
|
-
- format_detected: 'standard',
|
|
244
|
-
- delimiter: ',',
|
|
245
|
-
- total_rows: 2,
|
|
246
|
-
- valid_rows: 2,
|
|
247
|
-
+ meta: {
|
|
248
|
-
+ detectedDelimiter: ',',
|
|
249
|
-
+ detectedColumns: ['Date', 'Amount', 'Description'],
|
|
250
|
-
+ totalRows: 2,
|
|
251
|
-
+ validRows: 2,
|
|
252
|
-
+ skippedRows: 0,
|
|
253
|
-
+ },
|
|
254
|
-
errors: [],
|
|
255
|
-
+ warnings: [],
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
259
|
-
@@ -76,23 +85,33 @@ describe('analyzer', () => {
|
|
260
|
-
expect(result.unmatched_ynab).toBeDefined();
|
|
261
|
-
expect(result.balance_info).toBeDefined();
|
|
262
|
-
expect(result.next_steps).toBeDefined();
|
|
263
|
-
+
|
|
264
|
-
+ // Verify auto-matches (exact matches)
|
|
265
|
-
+ expect(result.auto_matches.length).toBe(2);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('should categorize high-confidence matches as auto-matches', () => {
|
|
269
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
270
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
271
|
-
transactions: [
|
|
272
|
-
{
|
|
273
|
-
+ id: 'b1',
|
|
274
|
-
date: '2025-10-15',
|
|
275
|
-
- amount: -50.0,
|
|
276
|
-
+ amount: -50000,
|
|
277
|
-
payee: 'Coffee Shop',
|
|
278
|
-
memo: '',
|
|
279
|
-
+ sourceRow: 1,
|
|
280
|
-
+ raw: {} as any,
|
|
281
|
-
},
|
|
282
|
-
],
|
|
283
|
-
- format_detected: 'standard',
|
|
284
|
-
- delimiter: ',',
|
|
285
|
-
- total_rows: 1,
|
|
286
|
-
- valid_rows: 1,
|
|
287
|
-
+ meta: {
|
|
288
|
-
+ detectedDelimiter: ',',
|
|
289
|
-
+ detectedColumns: [],
|
|
290
|
-
+ totalRows: 1,
|
|
291
|
-
+ validRows: 1,
|
|
292
|
-
+ skippedRows: 0,
|
|
293
|
-
+ },
|
|
294
|
-
errors: [],
|
|
295
|
-
+ warnings: [],
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
299
|
-
@@ -114,28 +133,35 @@ describe('analyzer', () => {
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('should categorize medium-confidence matches as suggested', () => {
|
|
303
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
304
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
305
|
-
transactions: [
|
|
306
|
-
{
|
|
307
|
-
+ id: 'b1',
|
|
308
|
-
date: '2025-10-15',
|
|
309
|
-
- amount: -50.0,
|
|
310
|
-
- payee: 'Amazon',
|
|
311
|
-
+ amount: -50000,
|
|
312
|
-
+ payee: 'Generic Store',
|
|
313
|
-
memo: '',
|
|
314
|
-
+ sourceRow: 1,
|
|
315
|
-
+ raw: {} as any,
|
|
316
|
-
},
|
|
317
|
-
],
|
|
318
|
-
- format_detected: 'standard',
|
|
319
|
-
- delimiter: ',',
|
|
320
|
-
- total_rows: 1,
|
|
321
|
-
- valid_rows: 1,
|
|
322
|
-
+ meta: {
|
|
323
|
-
+ detectedDelimiter: ',',
|
|
324
|
-
+ detectedColumns: [],
|
|
325
|
-
+ totalRows: 1,
|
|
326
|
-
+ validRows: 1,
|
|
327
|
-
+ skippedRows: 0,
|
|
328
|
-
+ },
|
|
329
|
-
errors: [],
|
|
330
|
-
+ warnings: [],
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
334
|
-
{
|
|
335
|
-
id: 'y1',
|
|
336
|
-
- date: '2025-10-18', // 3 days difference
|
|
337
|
-
+ date: '2025-10-18', // 3 days difference - date score drops
|
|
338
|
-
amount: -50000,
|
|
339
|
-
- payee_name: 'Amazon Prime',
|
|
340
|
-
+ payee_name: 'Amazon Prime', // Fuzzy match
|
|
341
|
-
category_name: 'Shopping',
|
|
342
|
-
cleared: 'uncleared' as const,
|
|
343
|
-
approved: true,
|
|
344
|
-
@@ -144,25 +170,33 @@ describe('analyzer', () => {
|
|
345
|
-
|
|
346
|
-
const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
|
|
347
|
-
|
|
348
|
-
- // Might be medium or low depending on exact scoring
|
|
349
|
-
- expect(result.suggested_matches.length + result.unmatched_bank.length).toBeGreaterThan(0);
|
|
350
|
-
+ // Should be suggested (medium)
|
|
351
|
-
+ expect(result.suggested_matches.length).toBeGreaterThan(0);
|
|
352
|
-
+ expect(result.suggested_matches[0].confidence).toBe('medium');
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it('should identify unmatched bank transactions', () => {
|
|
356
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
357
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
358
|
-
transactions: [
|
|
359
|
-
{
|
|
360
|
-
+ id: 'b1',
|
|
361
|
-
date: '2025-10-15',
|
|
362
|
-
- amount: -15.99,
|
|
363
|
-
+ amount: -15990,
|
|
364
|
-
payee: 'New Store',
|
|
365
|
-
memo: '',
|
|
366
|
-
+ sourceRow: 1,
|
|
367
|
-
+ raw: {} as any,
|
|
368
|
-
},
|
|
369
|
-
],
|
|
370
|
-
- format_detected: 'standard',
|
|
371
|
-
- delimiter: ',',
|
|
372
|
-
- total_rows: 1,
|
|
373
|
-
- valid_rows: 1,
|
|
374
|
-
+ meta: {
|
|
375
|
-
+ detectedDelimiter: ',',
|
|
376
|
-
+ detectedColumns: [],
|
|
377
|
-
+ totalRows: 1,
|
|
378
|
-
+ validRows: 1,
|
|
379
|
-
+ skippedRows: 0,
|
|
380
|
-
+ },
|
|
381
|
-
errors: [],
|
|
382
|
-
+ warnings: [],
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
const ynabTxns: YNABAPITransaction[] = [];
|
|
386
|
-
@@ -174,13 +208,17 @@ describe('analyzer', () => {
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it('should identify unmatched YNAB transactions', () => {
|
|
390
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
391
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
392
|
-
transactions: [],
|
|
393
|
-
- format_detected: 'standard',
|
|
394
|
-
- delimiter: ',',
|
|
395
|
-
- total_rows: 0,
|
|
396
|
-
- valid_rows: 0,
|
|
397
|
-
+ meta: {
|
|
398
|
-
+ detectedDelimiter: ',',
|
|
399
|
-
+ detectedColumns: [],
|
|
400
|
-
+ totalRows: 0,
|
|
401
|
-
+ validRows: 0,
|
|
402
|
-
+ skippedRows: 0,
|
|
403
|
-
+ },
|
|
404
|
-
errors: [],
|
|
405
|
-
+ warnings: [],
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
409
|
-
@@ -201,74 +239,18 @@ describe('analyzer', () => {
|
|
410
|
-
expect(result.unmatched_ynab[0].payee_name).toBe('Restaurant');
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
- it('should surface combination suggestions and insights when totals align', () => {
|
|
414
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
415
|
-
- transactions: [
|
|
416
|
-
- {
|
|
417
|
-
- date: '2025-10-20',
|
|
418
|
-
- amount: -30.0,
|
|
419
|
-
- payee: 'Evening Out',
|
|
420
|
-
- memo: '',
|
|
421
|
-
- },
|
|
422
|
-
- ],
|
|
423
|
-
- format_detected: 'standard',
|
|
424
|
-
- delimiter: ',',
|
|
425
|
-
- total_rows: 1,
|
|
426
|
-
- valid_rows: 1,
|
|
427
|
-
- errors: [],
|
|
428
|
-
- });
|
|
429
|
-
-
|
|
430
|
-
- const ynabTxns: YNABAPITransaction[] = [
|
|
431
|
-
- {
|
|
432
|
-
- id: 'y-combo-1',
|
|
433
|
-
- date: '2025-10-19',
|
|
434
|
-
- amount: -20000,
|
|
435
|
-
- payee_name: 'Dinner',
|
|
436
|
-
- category_name: 'Dining',
|
|
437
|
-
- cleared: 'uncleared' as const,
|
|
438
|
-
- approved: true,
|
|
439
|
-
- } as YNABAPITransaction,
|
|
440
|
-
- {
|
|
441
|
-
- id: 'y-combo-2',
|
|
442
|
-
- date: '2025-10-20',
|
|
443
|
-
- amount: -10000,
|
|
444
|
-
- payee_name: 'Drinks',
|
|
445
|
-
- category_name: 'Dining',
|
|
446
|
-
- cleared: 'uncleared' as const,
|
|
447
|
-
- approved: true,
|
|
448
|
-
- } as YNABAPITransaction,
|
|
449
|
-
- {
|
|
450
|
-
- id: 'y-extra',
|
|
451
|
-
- date: '2025-10-22',
|
|
452
|
-
- amount: -5000,
|
|
453
|
-
- payee_name: 'Snacks',
|
|
454
|
-
- category_name: 'Dining',
|
|
455
|
-
- cleared: 'uncleared' as const,
|
|
456
|
-
- approved: true,
|
|
457
|
-
- } as YNABAPITransaction,
|
|
458
|
-
- ];
|
|
459
|
-
-
|
|
460
|
-
- const result = analyzeReconciliation('csv', undefined, ynabTxns, -30.0);
|
|
461
|
-
-
|
|
462
|
-
- const comboMatch = result.suggested_matches.find(
|
|
463
|
-
- (match) => match.match_reason === 'combination_match',
|
|
464
|
-
- );
|
|
465
|
-
- expect(comboMatch).toBeDefined();
|
|
466
|
-
- expect(comboMatch?.candidates?.length).toBeGreaterThanOrEqual(2);
|
|
467
|
-
-
|
|
468
|
-
- const comboInsight = result.insights.find((insight) => insight.id.startsWith('combination-'));
|
|
469
|
-
- expect(comboInsight).toBeDefined();
|
|
470
|
-
- expect(comboInsight?.severity).toBe('info');
|
|
471
|
-
- });
|
|
472
|
-
-
|
|
473
|
-
it('should calculate balance information correctly', () => {
|
|
474
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
475
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
476
|
-
transactions: [],
|
|
477
|
-
- format_detected: 'standard',
|
|
478
|
-
- delimiter: ',',
|
|
479
|
-
- total_rows: 0,
|
|
480
|
-
- valid_rows: 0,
|
|
481
|
-
+ meta: {
|
|
482
|
-
+ detectedDelimiter: ',',
|
|
483
|
-
+ detectedColumns: [],
|
|
484
|
-
+ totalRows: 0,
|
|
485
|
-
+ validRows: 0,
|
|
486
|
-
+ skippedRows: 0,
|
|
487
|
-
+ },
|
|
488
|
-
errors: [],
|
|
489
|
-
+ warnings: [],
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
493
|
-
@@ -303,16 +285,36 @@ describe('analyzer', () => {
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
it('should generate appropriate summary', () => {
|
|
497
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
498
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
499
|
-
transactions: [
|
|
500
|
-
- { date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' },
|
|
501
|
-
- { date: '2025-10-20', amount: -30.0, payee: 'Restaurant', memo: '' },
|
|
502
|
-
+ {
|
|
503
|
-
+ id: 'b1',
|
|
504
|
-
+ date: '2025-10-15',
|
|
505
|
-
+ amount: -50000,
|
|
506
|
-
+ payee: 'Store',
|
|
507
|
-
+ memo: '',
|
|
508
|
-
+ sourceRow: 1,
|
|
509
|
-
+ raw: {} as any,
|
|
510
|
-
+ },
|
|
511
|
-
+ {
|
|
512
|
-
+ id: 'b2',
|
|
513
|
-
+ date: '2025-10-20',
|
|
514
|
-
+ amount: -30000,
|
|
515
|
-
+ payee: 'Restaurant',
|
|
516
|
-
+ memo: '',
|
|
517
|
-
+ sourceRow: 2,
|
|
518
|
-
+ raw: {} as any,
|
|
519
|
-
+ },
|
|
520
|
-
],
|
|
521
|
-
- format_detected: 'standard',
|
|
522
|
-
- delimiter: ',',
|
|
523
|
-
- total_rows: 2,
|
|
524
|
-
- valid_rows: 2,
|
|
525
|
-
+ meta: {
|
|
526
|
-
+ detectedDelimiter: ',',
|
|
527
|
-
+ detectedColumns: [],
|
|
528
|
-
+ totalRows: 2,
|
|
529
|
-
+ validRows: 2,
|
|
530
|
-
+ skippedRows: 0,
|
|
531
|
-
+ },
|
|
532
|
-
errors: [],
|
|
533
|
-
+ warnings: [],
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
const ynabTxns: YNABAPITransaction[] = [
|
|
537
|
-
@@ -334,73 +336,5 @@ describe('analyzer', () => {
|
|
538
|
-
expect(result.summary.statement_date_range).toContain('2025-10-15');
|
|
539
|
-
expect(result.summary.statement_date_range).toContain('2025-10-20');
|
|
540
|
-
});
|
|
541
|
-
-
|
|
542
|
-
- it('should generate next steps based on analysis', () => {
|
|
543
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
544
|
-
- transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
|
|
545
|
-
- format_detected: 'standard',
|
|
546
|
-
- delimiter: ',',
|
|
547
|
-
- total_rows: 1,
|
|
548
|
-
- valid_rows: 1,
|
|
549
|
-
- errors: [],
|
|
550
|
-
- });
|
|
551
|
-
-
|
|
552
|
-
- const ynabTxns: YNABAPITransaction[] = [
|
|
553
|
-
- {
|
|
554
|
-
- id: 'y1',
|
|
555
|
-
- date: '2025-10-15',
|
|
556
|
-
- amount: -50000,
|
|
557
|
-
- payee_name: 'Store',
|
|
558
|
-
- category_name: 'Shopping',
|
|
559
|
-
- cleared: 'uncleared' as const,
|
|
560
|
-
- approved: true,
|
|
561
|
-
- } as YNABAPITransaction,
|
|
562
|
-
- ];
|
|
563
|
-
-
|
|
564
|
-
- const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
|
|
565
|
-
-
|
|
566
|
-
- expect(result.next_steps).toBeDefined();
|
|
567
|
-
- expect(Array.isArray(result.next_steps)).toBe(true);
|
|
568
|
-
- expect(result.next_steps.length).toBeGreaterThan(0);
|
|
569
|
-
- });
|
|
570
|
-
-
|
|
571
|
-
- it('should use file path when provided', () => {
|
|
572
|
-
- vi.mocked(parser.readCSVFile).mockReturnValue({
|
|
573
|
-
- transactions: [{ date: '2025-10-15', amount: -50.0, payee: 'Store', memo: '' }],
|
|
574
|
-
- format_detected: 'standard',
|
|
575
|
-
- delimiter: ',',
|
|
576
|
-
- total_rows: 1,
|
|
577
|
-
- valid_rows: 1,
|
|
578
|
-
- errors: [],
|
|
579
|
-
- });
|
|
580
|
-
-
|
|
581
|
-
- const ynabTxns: YNABAPITransaction[] = [];
|
|
582
|
-
-
|
|
583
|
-
- const result = analyzeReconciliation('', '/path/to/file.csv', ynabTxns, 0);
|
|
584
|
-
-
|
|
585
|
-
- expect(vi.mocked(parser.readCSVFile)).toHaveBeenCalledWith('/path/to/file.csv');
|
|
586
|
-
- expect(result.success).toBe(true);
|
|
587
|
-
- });
|
|
588
|
-
-
|
|
589
|
-
- it('should assign unique IDs to bank transactions', () => {
|
|
590
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
591
|
-
- transactions: [
|
|
592
|
-
- { date: '2025-10-15', amount: -50.0, payee: 'Store1', memo: '' },
|
|
593
|
-
- { date: '2025-10-16', amount: -30.0, payee: 'Store2', memo: '' },
|
|
594
|
-
- ],
|
|
595
|
-
- format_detected: 'standard',
|
|
596
|
-
- delimiter: ',',
|
|
597
|
-
- total_rows: 2,
|
|
598
|
-
- valid_rows: 2,
|
|
599
|
-
- errors: [],
|
|
600
|
-
- });
|
|
601
|
-
-
|
|
602
|
-
- const result = analyzeReconciliation('csv', undefined, [], 0);
|
|
603
|
-
-
|
|
604
|
-
- expect(result.unmatched_bank.length).toBe(2);
|
|
605
|
-
- expect(result.unmatched_bank[0].id).toBeDefined();
|
|
606
|
-
- expect(result.unmatched_bank[1].id).toBeDefined();
|
|
607
|
-
- expect(result.unmatched_bank[0].id).not.toBe(result.unmatched_bank[1].id);
|
|
608
|
-
- });
|
|
609
|
-
});
|
|
610
|
-
});
|
|
611
|
-
diff --git a/src/tools/reconciliation/__tests__/matcher.test.ts b/src/tools/reconciliation/__tests__/matcher.test.ts
|
|
612
|
-
index d0ba9f7..85e2c15 100644
|
|
613
|
-
--- a/src/tools/reconciliation/__tests__/matcher.test.ts
|
|
614
|
-
+++ b/src/tools/reconciliation/__tests__/matcher.test.ts
|
|
615
|
-
@@ -101,7 +101,7 @@ describe('matcher', () => {
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
describe('medium confidence matches (60-89%)', () => {
|
|
619
|
-
- it('should return medium confidence for fuzzy payee match', () => {
|
|
620
|
-
+ it('should return high confidence for fuzzy payee match', () => {
|
|
621
|
-
const bankTxn: BankTransaction = {
|
|
622
|
-
id: 'b1',
|
|
623
|
-
date: '2025-10-20',
|
|
624
|
-
@@ -124,9 +124,8 @@ describe('matcher', () => {
|
|
625
|
-
|
|
626
|
-
const match = findBestMatch(bankTxn, ynabTxns, new Set(), config);
|
|
627
|
-
|
|
628
|
-
- expect(match.confidence).toBe('medium');
|
|
629
|
-
- expect(match.confidence_score).toBeGreaterThanOrEqual(60);
|
|
630
|
-
- expect(match.confidence_score).toBeLessThan(90);
|
|
631
|
-
+ expect(match.confidence).toBe('high');
|
|
632
|
-
+ expect(match.confidence_score).toBeGreaterThanOrEqual(90);
|
|
633
|
-
expect(match.candidates).toBeDefined();
|
|
634
|
-
expect(match.candidates!.length).toBeGreaterThan(0);
|
|
635
|
-
});
|
|
636
|
-
diff --git a/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts b/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts
|
|
637
|
-
index ef5a341..2e9a1b8 100644
|
|
638
|
-
--- a/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts
|
|
639
|
-
+++ b/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts
|
|
640
|
-
@@ -1,11 +1,10 @@
|
|
641
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
642
|
-
import { analyzeReconciliation } from '../../analyzer.js';
|
|
643
|
-
import type { TransactionDetail } from 'ynab';
|
|
644
|
-
-import * as parser from '../../../compareTransactions/parser.js';
|
|
645
|
-
+import * as csvParser from '../../csvParser.js';
|
|
646
|
-
|
|
647
|
-
-vi.mock('../../../compareTransactions/parser.js', () => ({
|
|
648
|
-
- parseBankCSV: vi.fn(),
|
|
649
|
-
- readCSVFile: vi.fn(),
|
|
650
|
-
+vi.mock('../../csvParser.js', () => ({
|
|
651
|
-
+ parseCSV: vi.fn(),
|
|
652
|
-
}));
|
|
653
|
-
|
|
654
|
-
describe('scenario: zero, negative, and large statements', () => {
|
|
655
|
-
@@ -14,16 +13,36 @@ describe('scenario: zero, negative, and large statements', () => {
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
it('handles zero and negative statement balances with mixed unmatched items', () => {
|
|
659
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
660
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
661
|
-
transactions: [
|
|
662
|
-
- { date: '2025-11-01', amount: 0, payee: 'Zero Adjustment', memo: '' },
|
|
663
|
-
- { date: '2025-11-02', amount: 2500, payee: 'Interest', memo: '' },
|
|
664
|
-
+ {
|
|
665
|
-
+ id: 'b1',
|
|
666
|
-
+ date: '2025-11-01',
|
|
667
|
-
+ amount: 0,
|
|
668
|
-
+ payee: 'Zero Adjustment',
|
|
669
|
-
+ memo: '',
|
|
670
|
-
+ sourceRow: 2,
|
|
671
|
-
+ raw: { date: '2025-11-01', amount: '0', description: 'Zero Adjustment' },
|
|
672
|
-
+ },
|
|
673
|
-
+ {
|
|
674
|
-
+ id: 'b2',
|
|
675
|
-
+ date: '2025-11-02',
|
|
676
|
-
+ amount: 2500000,
|
|
677
|
-
+ payee: 'Interest',
|
|
678
|
-
+ memo: '',
|
|
679
|
-
+ sourceRow: 3,
|
|
680
|
-
+ raw: { date: '2025-11-02', amount: '2500.00', description: 'Interest' },
|
|
681
|
-
+ },
|
|
682
|
-
],
|
|
683
|
-
- format_detected: 'standard',
|
|
684
|
-
- delimiter: ',',
|
|
685
|
-
- total_rows: 2,
|
|
686
|
-
- valid_rows: 2,
|
|
687
|
-
errors: [],
|
|
688
|
-
+ warnings: [],
|
|
689
|
-
+ meta: {
|
|
690
|
-
+ detectedDelimiter: ',',
|
|
691
|
-
+ detectedColumns: ['Date', 'Description', 'Amount'],
|
|
692
|
-
+ totalRows: 2,
|
|
693
|
-
+ validRows: 2,
|
|
694
|
-
+ skippedRows: 0,
|
|
695
|
-
+ },
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
const ynabTxns: TransactionDetail[] = [
|
|
699
|
-
diff --git a/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts b/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts
|
|
700
|
-
index 8282770..fc638ad 100644
|
|
701
|
-
--- a/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts
|
|
702
|
-
+++ b/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts
|
|
703
|
-
@@ -1,11 +1,10 @@
|
|
704
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
705
|
-
import { analyzeReconciliation } from '../../analyzer.js';
|
|
706
|
-
import type { TransactionDetail } from 'ynab';
|
|
707
|
-
-import * as parser from '../../../compareTransactions/parser.js';
|
|
708
|
-
+import * as csvParser from '../../csvParser.js';
|
|
709
|
-
|
|
710
|
-
-vi.mock('../../../compareTransactions/parser.js', () => ({
|
|
711
|
-
- parseBankCSV: vi.fn(),
|
|
712
|
-
- readCSVFile: vi.fn(),
|
|
713
|
-
+vi.mock('../../csvParser.js', () => ({
|
|
714
|
-
+ parseCSV: vi.fn(),
|
|
715
|
-
}));
|
|
716
|
-
|
|
717
|
-
describe('scenario: repeat amount collisions', () => {
|
|
718
|
-
@@ -14,19 +13,55 @@ describe('scenario: repeat amount collisions', () => {
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
it('prioritizes repeat-amount insight when multiple bank rows share totals', () => {
|
|
722
|
-
- vi.mocked(parser.parseBankCSV).mockReturnValue({
|
|
723
|
-
+ vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
724
|
-
transactions: [
|
|
725
|
-
- // Three -22.22 transactions: one will match YNAB, two will remain unmatched
|
|
726
|
-
- { date: '2025-10-20', amount: -22.22, payee: 'RideShare', memo: '' },
|
|
727
|
-
- { date: '2025-10-21', amount: -22.22, payee: 'RideShare', memo: '' },
|
|
728
|
-
- { date: '2025-10-25', amount: -22.22, payee: 'RideShare', memo: '' },
|
|
729
|
-
- { date: '2025-10-23', amount: -15.0, payee: 'Cafe', memo: '' },
|
|
730
|
-
+ // Three -22.22 transactions in milliunits: one will match YNAB, two will remain unmatched
|
|
731
|
-
+ {
|
|
732
|
-
+ id: 'b1',
|
|
733
|
-
+ date: '2025-10-20',
|
|
734
|
-
+ amount: -22220,
|
|
735
|
-
+ payee: 'RideShare',
|
|
736
|
-
+ memo: '',
|
|
737
|
-
+ sourceRow: 2,
|
|
738
|
-
+ raw: { date: '2025-10-20', amount: '-22.22', description: 'RideShare' },
|
|
739
|
-
+ },
|
|
740
|
-
+ {
|
|
741
|
-
+ id: 'b2',
|
|
742
|
-
+ date: '2025-10-21',
|
|
743
|
-
+ amount: -22220,
|
|
744
|
-
+ payee: 'RideShare',
|
|
745
|
-
+ memo: '',
|
|
746
|
-
+ sourceRow: 3,
|
|
747
|
-
+ raw: { date: '2025-10-21', amount: '-22.22', description: 'RideShare' },
|
|
748
|
-
+ },
|
|
749
|
-
+ {
|
|
750
|
-
+ id: 'b3',
|
|
751
|
-
+ date: '2025-10-25',
|
|
752
|
-
+ amount: -22220,
|
|
753
|
-
+ payee: 'RideShare',
|
|
754
|
-
+ memo: '',
|
|
755
|
-
+ sourceRow: 4,
|
|
756
|
-
+ raw: { date: '2025-10-25', amount: '-22.22', description: 'RideShare' },
|
|
757
|
-
+ },
|
|
758
|
-
+ {
|
|
759
|
-
+ id: 'b4',
|
|
760
|
-
+ date: '2025-10-23',
|
|
761
|
-
+ amount: -15000,
|
|
762
|
-
+ payee: 'Cafe',
|
|
763
|
-
+ memo: '',
|
|
764
|
-
+ sourceRow: 5,
|
|
765
|
-
+ raw: { date: '2025-10-23', amount: '-15.00', description: 'Cafe' },
|
|
766
|
-
+ },
|
|
767
|
-
],
|
|
768
|
-
- format_detected: 'standard',
|
|
769
|
-
- delimiter: ',',
|
|
770
|
-
- total_rows: 4,
|
|
771
|
-
- valid_rows: 4,
|
|
772
|
-
errors: [],
|
|
773
|
-
+ warnings: [],
|
|
774
|
-
+ meta: {
|
|
775
|
-
+ detectedDelimiter: ',',
|
|
776
|
-
+ detectedColumns: ['Date', 'Description', 'Amount'],
|
|
777
|
-
+ totalRows: 4,
|
|
778
|
-
+ validRows: 4,
|
|
779
|
-
+ skippedRows: 0,
|
|
780
|
-
+ },
|
|
781
|
-
});
|
|
782
|
-
|
|
783
|
-
const ynabTxns: TransactionDetail[] = [
|
|
784
|
-
diff --git a/src/tools/reconciliation/analyzer.ts b/src/tools/reconciliation/analyzer.ts
|
|
785
|
-
index a433cf7..ffbb0a3 100644
|
|
786
|
-
--- a/src/tools/reconciliation/analyzer.ts
|
|
787
|
-
+++ b/src/tools/reconciliation/analyzer.ts
|
|
788
|
-
@@ -1,13 +1,21 @@
|
|
789
|
-
/**
|
|
790
|
-
* Analysis phase orchestration for reconciliation
|
|
791
|
-
* Coordinates CSV parsing, YNAB transaction fetching, and matching
|
|
792
|
-
+ *
|
|
793
|
-
+ * V2 UPDATE: Uses new parser and matcher (milliunits based)
|
|
794
|
-
+ * Maps results back to legacy types for backward compatibility
|
|
795
|
-
*/
|
|
796
|
-
|
|
797
|
-
-import { randomUUID } from 'crypto';
|
|
798
|
-
import type * as ynab from 'ynab';
|
|
799
|
-
-import * as bankParser from '../compareTransactions/parser.js';
|
|
800
|
-
-import type { CSVFormat as ParserCSVFormat } from '../compareTransactions/types.js';
|
|
801
|
-
+import { parseCSV, type ParseCSVOptions } from './csvParser.js';
|
|
802
|
-
import { findMatches } from './matcher.js';
|
|
803
|
-
+import { normalizeYNABTransactions } from './ynabAdapter.js';
|
|
804
|
-
+import type {
|
|
805
|
-
+ BankTransaction as NewBankTransaction,
|
|
806
|
-
+ NormalizedYNABTransaction,
|
|
807
|
-
+} from '../../types/reconciliation.js';
|
|
808
|
-
+import type { MatchResult as NewMatchResult } from './matcher.js';
|
|
809
|
-
+
|
|
810
|
-
import { DEFAULT_MATCHING_CONFIG } from './types.js';
|
|
811
|
-
import type {
|
|
812
|
-
BankTransaction,
|
|
813
|
-
@@ -18,391 +26,70 @@ import type {
|
|
814
|
-
BalanceInfo,
|
|
815
|
-
ReconciliationSummary,
|
|
816
|
-
ReconciliationInsight,
|
|
817
|
-
+ MatchCandidate,
|
|
818
|
-
} from './types.js';
|
|
819
|
-
import { toMoneyValueFromDecimal } from '../../utils/money.js';
|
|
820
|
-
import { generateRecommendations } from './recommendationEngine.js';
|
|
821
|
-
|
|
822
|
-
-/**
|
|
823
|
-
- * Convert YNAB API transaction to simplified format
|
|
824
|
-
- */
|
|
825
|
-
-function convertYNABTransaction(apiTxn: ynab.TransactionDetail): YNABTransaction {
|
|
826
|
-
+// --- Legacy Type Mappers ---
|
|
827
|
-
+
|
|
828
|
-
+function mapToOldBankTransaction(newTxn: NewBankTransaction): BankTransaction {
|
|
829
|
-
return {
|
|
830
|
-
- id: apiTxn.id,
|
|
831
|
-
- date: apiTxn.date,
|
|
832
|
-
- amount: apiTxn.amount,
|
|
833
|
-
- payee_name: apiTxn.payee_name || null,
|
|
834
|
-
- category_name: apiTxn.category_name || null,
|
|
835
|
-
- cleared: apiTxn.cleared,
|
|
836
|
-
- approved: apiTxn.approved,
|
|
837
|
-
- memo: apiTxn.memo || null,
|
|
838
|
-
+ id: newTxn.id,
|
|
839
|
-
+ date: newTxn.date,
|
|
840
|
-
+ amount: newTxn.amount / 1000, // Convert milliunits to dollars for legacy type
|
|
841
|
-
+ payee: newTxn.payee,
|
|
842
|
-
+ ...(newTxn.memo && { memo: newTxn.memo }),
|
|
843
|
-
+ original_csv_row: newTxn.sourceRow,
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
-/**
|
|
848
|
-
- * Parse CSV bank statement and generate unique IDs for tracking
|
|
849
|
-
- */
|
|
850
|
-
-const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
|
|
851
|
-
- date_column: 'Date',
|
|
852
|
-
- amount_column: 'Amount',
|
|
853
|
-
- description_column: 'Description',
|
|
854
|
-
- date_format: 'MM/DD/YYYY',
|
|
855
|
-
- has_header: true,
|
|
856
|
-
- delimiter: ',',
|
|
857
|
-
-};
|
|
858
|
-
-
|
|
859
|
-
-const ENABLE_COMBINATION_MATCHING = true;
|
|
860
|
-
-
|
|
861
|
-
-const DAYS_IN_MS = 24 * 60 * 60 * 1000;
|
|
862
|
-
-
|
|
863
|
-
-function toDollars(milliunits: number): number {
|
|
864
|
-
- return milliunits / 1000;
|
|
865
|
-
-}
|
|
866
|
-
-
|
|
867
|
-
-function amountTolerance(config: MatchingConfig): number {
|
|
868
|
-
- const toleranceCents =
|
|
869
|
-
- config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
|
|
870
|
-
- return Math.max(0, toleranceCents) / 100;
|
|
871
|
-
-}
|
|
872
|
-
-
|
|
873
|
-
-function dateTolerance(config: MatchingConfig): number {
|
|
874
|
-
- return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
|
|
875
|
-
-}
|
|
876
|
-
-
|
|
877
|
-
-function daysBetween(dateA: string, dateB: string): number {
|
|
878
|
-
- const a = new Date(`${dateA}T00:00:00Z`).getTime();
|
|
879
|
-
- const b = new Date(`${dateB}T00:00:00Z`).getTime();
|
|
880
|
-
- if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
|
|
881
|
-
- return Math.abs(a - b) / DAYS_IN_MS;
|
|
882
|
-
-}
|
|
883
|
-
-
|
|
884
|
-
-function withinDateTolerance(
|
|
885
|
-
- bankDate: string,
|
|
886
|
-
- ynabTxns: YNABTransaction[],
|
|
887
|
-
- toleranceDays: number,
|
|
888
|
-
-): boolean {
|
|
889
|
-
- return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
|
|
890
|
-
-}
|
|
891
|
-
-
|
|
892
|
-
-function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
|
|
893
|
-
- const bankSign = Math.sign(bankAmount);
|
|
894
|
-
- const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
|
|
895
|
-
- return bankSign === sumSign || Math.abs(bankAmount) === 0;
|
|
896
|
-
-}
|
|
897
|
-
-
|
|
898
|
-
-function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
|
|
899
|
-
- const safeTolerance = tolerance > 0 ? tolerance : 0.01;
|
|
900
|
-
- const ratio = diff / safeTolerance;
|
|
901
|
-
- let base = legCount === 2 ? 75 : 70;
|
|
902
|
-
- if (ratio <= 0.25) {
|
|
903
|
-
- base += 5;
|
|
904
|
-
- } else if (ratio <= 0.5) {
|
|
905
|
-
- base += 3;
|
|
906
|
-
- } else if (ratio >= 0.9) {
|
|
907
|
-
- base -= 5;
|
|
908
|
-
- }
|
|
909
|
-
- return Math.max(65, Math.min(80, Math.round(base)));
|
|
910
|
-
-}
|
|
911
|
-
-
|
|
912
|
-
-function formatDifference(diff: number): string {
|
|
913
|
-
- return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
|
|
914
|
-
-}
|
|
915
|
-
-
|
|
916
|
-
-interface CombinationResult {
|
|
917
|
-
- matches: TransactionMatch[];
|
|
918
|
-
- insights: ReconciliationInsight[];
|
|
919
|
-
-}
|
|
920
|
-
-
|
|
921
|
-
-function findCombinationMatches(
|
|
922
|
-
- unmatchedBank: BankTransaction[],
|
|
923
|
-
- unmatchedYNAB: YNABTransaction[],
|
|
924
|
-
- config: MatchingConfig,
|
|
925
|
-
-): CombinationResult {
|
|
926
|
-
- if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
|
|
927
|
-
- return { matches: [], insights: [] };
|
|
928
|
-
- }
|
|
929
|
-
-
|
|
930
|
-
- const tolerance = amountTolerance(config);
|
|
931
|
-
- const toleranceDays = dateTolerance(config);
|
|
932
|
-
-
|
|
933
|
-
- const matches: TransactionMatch[] = [];
|
|
934
|
-
- const insights: ReconciliationInsight[] = [];
|
|
935
|
-
- const seenCombinations = new Set<string>();
|
|
936
|
-
-
|
|
937
|
-
- for (const bankTxn of unmatchedBank) {
|
|
938
|
-
- const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
|
|
939
|
-
- if (viableYnab.length < 2) continue;
|
|
940
|
-
-
|
|
941
|
-
- const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
|
|
942
|
-
-
|
|
943
|
-
- const addIfValid = (combo: YNABTransaction[]) => {
|
|
944
|
-
- const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
|
|
945
|
-
- const diff = Math.abs(sum - bankTxn.amount);
|
|
946
|
-
- if (diff > tolerance) return;
|
|
947
|
-
- if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
|
|
948
|
-
- if (!hasMatchingSign(bankTxn.amount, combo)) return;
|
|
949
|
-
- evaluated.push({ txns: combo, diff, sum });
|
|
950
|
-
- };
|
|
951
|
-
-
|
|
952
|
-
- const n = viableYnab.length;
|
|
953
|
-
- for (let i = 0; i < n - 1; i++) {
|
|
954
|
-
- for (let j = i + 1; j < n; j++) {
|
|
955
|
-
- addIfValid([viableYnab[i]!, viableYnab[j]!]);
|
|
956
|
-
- }
|
|
957
|
-
- }
|
|
958
|
-
-
|
|
959
|
-
- if (n >= 3) {
|
|
960
|
-
- for (let i = 0; i < n - 2; i++) {
|
|
961
|
-
- for (let j = i + 1; j < n - 1; j++) {
|
|
962
|
-
- for (let k = j + 1; k < n; k++) {
|
|
963
|
-
- addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
|
|
964
|
-
- }
|
|
965
|
-
- }
|
|
966
|
-
- }
|
|
967
|
-
- }
|
|
968
|
-
-
|
|
969
|
-
- if (evaluated.length === 0) continue;
|
|
970
|
-
-
|
|
971
|
-
- evaluated.sort((a, b) => a.diff - b.diff);
|
|
972
|
-
- const recordedSizes = new Set<number>();
|
|
973
|
-
-
|
|
974
|
-
- for (const combo of evaluated) {
|
|
975
|
-
- if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
|
|
976
|
-
- const comboIds = combo.txns.map((txn) => txn.id).sort();
|
|
977
|
-
- const key = `${bankTxn.id}|${comboIds.join('+')}`;
|
|
978
|
-
- if (seenCombinations.has(key)) continue;
|
|
979
|
-
- seenCombinations.add(key);
|
|
980
|
-
- recordedSizes.add(combo.txns.length);
|
|
981
|
-
-
|
|
982
|
-
- const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
|
|
983
|
-
- const candidateConfidence = Math.max(60, score - 5);
|
|
984
|
-
- const descriptionTotal = formatCurrency(combo.sum);
|
|
985
|
-
- const diffLabel = formatDifference(combo.diff);
|
|
986
|
-
-
|
|
987
|
-
- matches.push({
|
|
988
|
-
- bank_transaction: bankTxn,
|
|
989
|
-
- confidence: 'medium',
|
|
990
|
-
- confidence_score: score,
|
|
991
|
-
- match_reason: 'combination_match',
|
|
992
|
-
- top_confidence: score,
|
|
993
|
-
- candidates: combo.txns.map((txn) => ({
|
|
994
|
-
- ynab_transaction: txn,
|
|
995
|
-
- confidence: candidateConfidence,
|
|
996
|
-
- match_reason: 'combination_component',
|
|
997
|
-
- explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
|
|
998
|
-
- })),
|
|
999
|
-
- action_hint: 'review_combination',
|
|
1000
|
-
- recommendation:
|
|
1001
|
-
- `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
|
|
1002
|
-
- `${formatCurrency(bankTxn.amount)} on the bank statement.`,
|
|
1003
|
-
- });
|
|
1004
|
-
-
|
|
1005
|
-
- const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
|
|
1006
|
-
- insights.push({
|
|
1007
|
-
- id: insightId,
|
|
1008
|
-
- type: 'combination_match' as unknown as ReconciliationInsight['type'],
|
|
1009
|
-
- severity: 'info',
|
|
1010
|
-
- title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
|
|
1011
|
-
- bankTxn.amount,
|
|
1012
|
-
- )}`,
|
|
1013
|
-
- description:
|
|
1014
|
-
- `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
|
|
1015
|
-
- `${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
|
|
1016
|
-
- evidence: {
|
|
1017
|
-
- bank_transaction_id: bankTxn.id,
|
|
1018
|
-
- bank_amount: bankTxn.amount,
|
|
1019
|
-
- ynab_transaction_ids: comboIds,
|
|
1020
|
-
- ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
|
|
1021
|
-
- combination_size: combo.txns.length,
|
|
1022
|
-
- difference: combo.diff,
|
|
1023
|
-
- },
|
|
1024
|
-
- });
|
|
1025
|
-
- }
|
|
1026
|
-
- }
|
|
1027
|
-
-
|
|
1028
|
-
- return { matches, insights };
|
|
1029
|
-
-}
|
|
1030
|
-
-
|
|
1031
|
-
-type ParserResult =
|
|
1032
|
-
- | {
|
|
1033
|
-
- transactions: unknown[];
|
|
1034
|
-
- format_detected?: string;
|
|
1035
|
-
- delimiter?: string;
|
|
1036
|
-
- total_rows?: number;
|
|
1037
|
-
- valid_rows?: number;
|
|
1038
|
-
- errors?: string[];
|
|
1039
|
-
- }
|
|
1040
|
-
- | unknown[];
|
|
1041
|
-
-
|
|
1042
|
-
-function isParsedCSVData(
|
|
1043
|
-
- result: ParserResult,
|
|
1044
|
-
-): result is Extract<ParserResult, { transactions: unknown[] }> {
|
|
1045
|
-
- return (
|
|
1046
|
-
- typeof result === 'object' &&
|
|
1047
|
-
- result !== null &&
|
|
1048
|
-
- !Array.isArray(result) &&
|
|
1049
|
-
- 'transactions' in result
|
|
1050
|
-
- );
|
|
1051
|
-
-}
|
|
1052
|
-
-
|
|
1053
|
-
-function normalizeDate(value: unknown): string {
|
|
1054
|
-
- if (value instanceof Date) {
|
|
1055
|
-
- return value.toISOString().split('T')[0]!;
|
|
1056
|
-
- }
|
|
1057
|
-
-
|
|
1058
|
-
- if (typeof value === 'string') {
|
|
1059
|
-
- const trimmed = value.trim();
|
|
1060
|
-
- if (!trimmed) return trimmed;
|
|
1061
|
-
-
|
|
1062
|
-
- const parsed = new Date(trimmed);
|
|
1063
|
-
- if (!Number.isNaN(parsed.getTime())) {
|
|
1064
|
-
- return parsed.toISOString().split('T')[0]!;
|
|
1065
|
-
- }
|
|
1066
|
-
-
|
|
1067
|
-
- return trimmed;
|
|
1068
|
-
- }
|
|
1069
|
-
-
|
|
1070
|
-
- return new Date().toISOString().split('T')[0]!;
|
|
1071
|
-
-}
|
|
1072
|
-
-
|
|
1073
|
-
-function normalizeAmount(record: Record<string, unknown>): number {
|
|
1074
|
-
- const raw = record['amount'];
|
|
1075
|
-
-
|
|
1076
|
-
- if (typeof raw === 'number') {
|
|
1077
|
-
- if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
|
|
1078
|
-
- return Math.round(raw) / 1000;
|
|
1079
|
-
- }
|
|
1080
|
-
- return raw;
|
|
1081
|
-
- }
|
|
1082
|
-
-
|
|
1083
|
-
- if (typeof raw === 'string') {
|
|
1084
|
-
- const cleaned = raw.replace(/[$,\s]/g, '');
|
|
1085
|
-
- const parsed = Number.parseFloat(cleaned);
|
|
1086
|
-
- return Number.isFinite(parsed) ? parsed : 0;
|
|
1087
|
-
- }
|
|
1088
|
-
-
|
|
1089
|
-
- return 0;
|
|
1090
|
-
-}
|
|
1091
|
-
-
|
|
1092
|
-
-function normalizePayee(record: Record<string, unknown>): string {
|
|
1093
|
-
- const candidates = [record['payee'], record['description'], record['memo']];
|
|
1094
|
-
- for (const candidate of candidates) {
|
|
1095
|
-
- if (typeof candidate === 'string' && candidate.trim()) {
|
|
1096
|
-
- return candidate.trim();
|
|
1097
|
-
- }
|
|
1098
|
-
- }
|
|
1099
|
-
- return 'Unknown Payee';
|
|
1100
|
-
-}
|
|
1101
|
-
-
|
|
1102
|
-
-function determineRow(record: Record<string, unknown>, index: number): number {
|
|
1103
|
-
- if (typeof record['original_csv_row'] === 'number') {
|
|
1104
|
-
- return record['original_csv_row'];
|
|
1105
|
-
- }
|
|
1106
|
-
- if (typeof record['row_number'] === 'number') {
|
|
1107
|
-
- return record['row_number'];
|
|
1108
|
-
- }
|
|
1109
|
-
- return index + 1;
|
|
1110
|
-
-}
|
|
1111
|
-
-
|
|
1112
|
-
-function convertParserRecord(record: unknown, index: number): BankTransaction {
|
|
1113
|
-
- const data =
|
|
1114
|
-
- typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
|
|
1115
|
-
-
|
|
1116
|
-
- const dateValue = normalizeDate(data['date']);
|
|
1117
|
-
- const amountValue = normalizeAmount(data);
|
|
1118
|
-
- const payeeValue = normalizePayee(data);
|
|
1119
|
-
- const memoValue =
|
|
1120
|
-
- typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
|
|
1121
|
-
- const originalRow = determineRow(data, index);
|
|
1122
|
-
-
|
|
1123
|
-
- const transaction: BankTransaction = {
|
|
1124
|
-
- id: randomUUID(),
|
|
1125
|
-
- date: dateValue,
|
|
1126
|
-
- amount: amountValue,
|
|
1127
|
-
- payee: payeeValue,
|
|
1128
|
-
- original_csv_row: originalRow,
|
|
1129
|
-
+function mapToOldYNABTransaction(newTxn: NormalizedYNABTransaction): YNABTransaction {
|
|
1130
|
-
+ return {
|
|
1131
|
-
+ id: newTxn.id,
|
|
1132
|
-
+ date: newTxn.date,
|
|
1133
|
-
+ amount: newTxn.amount, // Legacy type already uses milliunits
|
|
1134
|
-
+ payee_name: newTxn.payee,
|
|
1135
|
-
+ category_name: newTxn.categoryName,
|
|
1136
|
-
+ cleared: newTxn.cleared,
|
|
1137
|
-
+ approved: newTxn.approved,
|
|
1138
|
-
+ ...(newTxn.memo !== null && { memo: newTxn.memo }),
|
|
1139
|
-
};
|
|
1140
|
-
-
|
|
1141
|
-
- if (memoValue !== undefined) {
|
|
1142
|
-
- transaction.memo = memoValue;
|
|
1143
|
-
- }
|
|
1144
|
-
-
|
|
1145
|
-
- return transaction;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
-function parseBankStatement(csvContent: string, csvFilePath?: string): BankTransaction[] {
|
|
1149
|
-
- const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
|
|
1150
|
-
-
|
|
1151
|
-
- let format: ParserCSVFormat = FALLBACK_CSV_FORMAT;
|
|
1152
|
-
- let autoDetect: ((content: string) => ParserCSVFormat) | undefined;
|
|
1153
|
-
- try {
|
|
1154
|
-
- autoDetect = (bankParser as { autoDetectCSVFormat?: (content: string) => ParserCSVFormat })
|
|
1155
|
-
- .autoDetectCSVFormat;
|
|
1156
|
-
- } catch {
|
|
1157
|
-
- autoDetect = undefined;
|
|
1158
|
-
- }
|
|
1159
|
-
-
|
|
1160
|
-
- if (typeof autoDetect === 'function') {
|
|
1161
|
-
- try {
|
|
1162
|
-
- format = autoDetect(content);
|
|
1163
|
-
- } catch {
|
|
1164
|
-
- format = FALLBACK_CSV_FORMAT;
|
|
1165
|
-
- }
|
|
1166
|
-
- }
|
|
1167
|
-
-
|
|
1168
|
-
- const rawResult = bankParser.parseBankCSV(content, format) as unknown as ParserResult;
|
|
1169
|
-
- const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
|
|
1170
|
-
+function mapToOldTransactionMatch(result: NewMatchResult): TransactionMatch {
|
|
1171
|
-
+ const bankTransaction = mapToOldBankTransaction(result.bankTransaction);
|
|
1172
|
-
+ const ynabTransaction = result.bestMatch
|
|
1173
|
-
+ ? mapToOldYNABTransaction(result.bestMatch.ynabTransaction)
|
|
1174
|
-
+ : undefined;
|
|
1175
|
-
|
|
1176
|
-
- return records.map(convertParserRecord);
|
|
1177
|
-
-}
|
|
1178
|
-
+ const candidates: MatchCandidate[] = result.candidates.map((c) => ({
|
|
1179
|
-
+ ynab_transaction: mapToOldYNABTransaction(c.ynabTransaction),
|
|
1180
|
-
+ confidence: c.scores.combined,
|
|
1181
|
-
+ match_reason: c.matchReasons.join(', '),
|
|
1182
|
-
+ explanation: `Score: ${c.scores.combined}. ${c.matchReasons.join(', ')}`,
|
|
1183
|
-
+ }));
|
|
1184
|
-
|
|
1185
|
-
-/**
|
|
1186
|
-
- * Categorize matches by confidence level
|
|
1187
|
-
- */
|
|
1188
|
-
-function categorizeMatches(matches: TransactionMatch[]): {
|
|
1189
|
-
- autoMatches: TransactionMatch[];
|
|
1190
|
-
- suggestedMatches: TransactionMatch[];
|
|
1191
|
-
- unmatchedBank: BankTransaction[];
|
|
1192
|
-
-} {
|
|
1193
|
-
- const autoMatches: TransactionMatch[] = [];
|
|
1194
|
-
- const suggestedMatches: TransactionMatch[] = [];
|
|
1195
|
-
- const unmatchedBank: BankTransaction[] = [];
|
|
1196
|
-
-
|
|
1197
|
-
- for (const match of matches) {
|
|
1198
|
-
- if (match.confidence === 'high') {
|
|
1199
|
-
- autoMatches.push(match);
|
|
1200
|
-
- } else if (match.confidence === 'medium') {
|
|
1201
|
-
- suggestedMatches.push(match);
|
|
1202
|
-
- } else {
|
|
1203
|
-
- // low or none confidence
|
|
1204
|
-
- unmatchedBank.push(match.bank_transaction);
|
|
1205
|
-
- }
|
|
1206
|
-
- }
|
|
1207
|
-
+ const actionHint =
|
|
1208
|
-
+ result.confidence === 'high' ? 'approve' : result.confidence === 'none' ? 'add' : 'review';
|
|
1209
|
-
|
|
1210
|
-
- return { autoMatches, suggestedMatches, unmatchedBank };
|
|
1211
|
-
+ return {
|
|
1212
|
-
+ bank_transaction: bankTransaction,
|
|
1213
|
-
+ ...(ynabTransaction && { ynab_transaction: ynabTransaction }),
|
|
1214
|
-
+ candidates: candidates,
|
|
1215
|
-
+ confidence: result.confidence,
|
|
1216
|
-
+ confidence_score: result.confidenceScore,
|
|
1217
|
-
+ match_reason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
|
|
1218
|
-
+ ...(result.candidates[0] && { top_confidence: result.candidates[0].scores.combined }),
|
|
1219
|
-
+ ...(actionHint && { action_hint: actionHint }),
|
|
1220
|
-
+ ...(result.confidence === 'none' && {
|
|
1221
|
-
+ recommendation: 'Consider adding this transaction to YNAB',
|
|
1222
|
-
+ }),
|
|
1223
|
-
+ };
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
-/**
|
|
1227
|
-
- * Find unmatched YNAB transactions
|
|
1228
|
-
- * These are transactions in YNAB that don't appear on the bank statement
|
|
1229
|
-
- */
|
|
1230
|
-
-function findUnmatchedYNAB(
|
|
1231
|
-
- ynabTransactions: YNABTransaction[],
|
|
1232
|
-
- matches: TransactionMatch[],
|
|
1233
|
-
-): YNABTransaction[] {
|
|
1234
|
-
- const matchedIds = new Set<string>();
|
|
1235
|
-
+// --- Helper Functions (Adapted from original) ---
|
|
1236
|
-
|
|
1237
|
-
- for (const match of matches) {
|
|
1238
|
-
- if (match.ynab_transaction) {
|
|
1239
|
-
- matchedIds.add(match.ynab_transaction.id);
|
|
1240
|
-
- }
|
|
1241
|
-
- }
|
|
1242
|
-
-
|
|
1243
|
-
- return ynabTransactions.filter((txn) => !matchedIds.has(txn.id));
|
|
1244
|
-
-}
|
|
1245
|
-
-
|
|
1246
|
-
-/**
|
|
1247
|
-
- * Calculate balance information
|
|
1248
|
-
- */
|
|
1249
|
-
function calculateBalances(
|
|
1250
|
-
ynabTransactions: YNABTransaction[],
|
|
1251
|
-
statementBalance: number,
|
|
1252
|
-
@@ -434,9 +121,6 @@ function calculateBalances(
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
-/**
|
|
1257
|
-
- * Generate reconciliation summary
|
|
1258
|
-
- */
|
|
1259
|
-
function generateSummary(
|
|
1260
|
-
bankTransactions: BankTransaction[],
|
|
1261
|
-
ynabTransactions: YNABTransaction[],
|
|
1262
|
-
@@ -485,9 +169,6 @@ function generateSummary(
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
-/**
|
|
1267
|
-
- * Generate next steps for user
|
|
1268
|
-
- */
|
|
1269
|
-
function generateNextSteps(summary: ReconciliationSummary): string[] {
|
|
1270
|
-
const steps: string[] = [];
|
|
1271
|
-
|
|
1272
|
-
@@ -526,6 +207,8 @@ function formatCurrency(amount: number): string {
|
|
1273
|
-
return formatter.format(amount);
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
+// --- Insight Generation (Adapted) ---
|
|
1277
|
-
+
|
|
1278
|
-
function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationInsight[] {
|
|
1279
|
-
const insights: ReconciliationInsight[] = [];
|
|
1280
|
-
if (unmatchedBank.length === 0) {
|
|
1281
|
-
@@ -569,62 +252,7 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
|
|
1282
|
-
return insights;
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
-function nearMatchInsights(
|
|
1286
|
-
- matches: TransactionMatch[],
|
|
1287
|
-
- config: MatchingConfig,
|
|
1288
|
-
-): ReconciliationInsight[] {
|
|
1289
|
-
- const insights: ReconciliationInsight[] = [];
|
|
1290
|
-
-
|
|
1291
|
-
- for (const match of matches) {
|
|
1292
|
-
- if (!match.candidates || match.candidates.length === 0) continue;
|
|
1293
|
-
- if (match.confidence === 'high') continue;
|
|
1294
|
-
-
|
|
1295
|
-
- const topCandidate = match.candidates[0]!;
|
|
1296
|
-
- const score = topCandidate.confidence;
|
|
1297
|
-
- const highSignal =
|
|
1298
|
-
- (match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
|
|
1299
|
-
- (match.confidence === 'low' && score >= config.suggestionThreshold) ||
|
|
1300
|
-
- (match.confidence === 'none' && score >= config.suggestionThreshold);
|
|
1301
|
-
-
|
|
1302
|
-
- if (!highSignal) continue;
|
|
1303
|
-
-
|
|
1304
|
-
- const bankTxn = match.bank_transaction;
|
|
1305
|
-
- const ynabTxn = topCandidate.ynab_transaction;
|
|
1306
|
-
-
|
|
1307
|
-
- insights.push({
|
|
1308
|
-
- id: `near-${bankTxn.id}`,
|
|
1309
|
-
- type: 'near_match',
|
|
1310
|
-
- severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
|
|
1311
|
-
- title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
|
|
1312
|
-
- description:
|
|
1313
|
-
- `Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
|
|
1314
|
-
- `${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
|
|
1315
|
-
- evidence: {
|
|
1316
|
-
- bank_transaction: {
|
|
1317
|
-
- id: bankTxn.id,
|
|
1318
|
-
- date: bankTxn.date,
|
|
1319
|
-
- amount: bankTxn.amount,
|
|
1320
|
-
- payee: bankTxn.payee,
|
|
1321
|
-
- },
|
|
1322
|
-
- candidate: {
|
|
1323
|
-
- id: ynabTxn.id,
|
|
1324
|
-
- date: ynabTxn.date,
|
|
1325
|
-
- amount_milliunits: ynabTxn.amount,
|
|
1326
|
-
- payee_name: ynabTxn.payee_name,
|
|
1327
|
-
- confidence: score,
|
|
1328
|
-
- reasons: topCandidate.match_reason,
|
|
1329
|
-
- },
|
|
1330
|
-
- },
|
|
1331
|
-
- });
|
|
1332
|
-
- }
|
|
1333
|
-
-
|
|
1334
|
-
- return insights.slice(0, 3);
|
|
1335
|
-
-}
|
|
1336
|
-
-
|
|
1337
|
-
-function anomalyInsights(
|
|
1338
|
-
- summary: ReconciliationSummary,
|
|
1339
|
-
- balances: BalanceInfo,
|
|
1340
|
-
-): ReconciliationInsight[] {
|
|
1341
|
-
+function anomalyInsights(balances: BalanceInfo): ReconciliationInsight[] {
|
|
1342
|
-
const insights: ReconciliationInsight[] = [];
|
|
1343
|
-
const discrepancyAbs = Math.abs(balances.discrepancy.value);
|
|
1344
|
-
|
|
1345
|
-
@@ -645,30 +273,13 @@ function anomalyInsights(
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
- if (summary.unmatched_bank >= 5) {
|
|
1350
|
-
- insights.push({
|
|
1351
|
-
- id: 'bulk-missing-bank',
|
|
1352
|
-
- type: 'anomaly',
|
|
1353
|
-
- severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
|
|
1354
|
-
- title: `${summary.unmatched_bank} bank transactions still unmatched`,
|
|
1355
|
-
- description:
|
|
1356
|
-
- `There are ${summary.unmatched_bank} bank transactions without a match. ` +
|
|
1357
|
-
- 'Consider bulk importing or reviewing by date sequence.',
|
|
1358
|
-
- evidence: {
|
|
1359
|
-
- unmatched_bank: summary.unmatched_bank,
|
|
1360
|
-
- },
|
|
1361
|
-
- });
|
|
1362
|
-
- }
|
|
1363
|
-
-
|
|
1364
|
-
return insights;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
function detectInsights(
|
|
1368
|
-
- matches: TransactionMatch[],
|
|
1369
|
-
unmatchedBank: BankTransaction[],
|
|
1370
|
-
- summary: ReconciliationSummary,
|
|
1371
|
-
+ _summary: ReconciliationSummary,
|
|
1372
|
-
balances: BalanceInfo,
|
|
1373
|
-
- config: MatchingConfig,
|
|
1374
|
-
): ReconciliationInsight[] {
|
|
1375
|
-
const insights: ReconciliationInsight[] = [];
|
|
1376
|
-
const seen = new Set<string>();
|
|
1377
|
-
@@ -683,36 +294,14 @@ function detectInsights(
|
|
1378
|
-
addUnique(insight);
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
- for (const insight of nearMatchInsights(matches, config)) {
|
|
1382
|
-
- addUnique(insight);
|
|
1383
|
-
- }
|
|
1384
|
-
-
|
|
1385
|
-
- for (const insight of anomalyInsights(summary, balances)) {
|
|
1386
|
-
+ for (const insight of anomalyInsights(balances)) {
|
|
1387
|
-
addUnique(insight);
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
return insights.slice(0, 5);
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
-function mergeInsights(
|
|
1394
|
-
- base: ReconciliationInsight[],
|
|
1395
|
-
- additional: ReconciliationInsight[],
|
|
1396
|
-
-): ReconciliationInsight[] {
|
|
1397
|
-
- if (additional.length === 0) {
|
|
1398
|
-
- return base;
|
|
1399
|
-
- }
|
|
1400
|
-
-
|
|
1401
|
-
- const seen = new Set(base.map((insight) => insight.id));
|
|
1402
|
-
- const merged = [...base];
|
|
1403
|
-
-
|
|
1404
|
-
- for (const insight of additional) {
|
|
1405
|
-
- if (seen.has(insight.id)) continue;
|
|
1406
|
-
- seen.add(insight.id);
|
|
1407
|
-
- merged.push(insight);
|
|
1408
|
-
- }
|
|
1409
|
-
-
|
|
1410
|
-
- return merged.slice(0, 5);
|
|
1411
|
-
-}
|
|
1412
|
-
+// --- Main Analysis Function ---
|
|
1413
|
-
|
|
1414
|
-
/**
|
|
1415
|
-
* Perform reconciliation analysis
|
|
1416
|
-
@@ -726,10 +315,11 @@ function mergeInsights(
|
|
1417
|
-
* @param accountId - Account ID for recommendation context
|
|
1418
|
-
* @param budgetId - Budget ID for recommendation context
|
|
1419
|
-
* @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
|
|
1420
|
-
+ * @param csvOptions - Optional CSV parsing options (manual overrides)
|
|
1421
|
-
*/
|
|
1422
|
-
export function analyzeReconciliation(
|
|
1423
|
-
csvContent: string,
|
|
1424
|
-
- csvFilePath: string | undefined,
|
|
1425
|
-
+ _csvFilePath: string | undefined,
|
|
1426
|
-
ynabTransactions: ynab.TransactionDetail[],
|
|
1427
|
-
statementBalance: number,
|
|
1428
|
-
config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
|
|
1429
|
-
@@ -737,52 +327,78 @@ export function analyzeReconciliation(
|
|
1430
|
-
accountId?: string,
|
|
1431
|
-
budgetId?: string,
|
|
1432
|
-
invertBankAmounts: boolean = false,
|
|
1433
|
-
+ csvOptions?: ParseCSVOptions,
|
|
1434
|
-
): ReconciliationAnalysis {
|
|
1435
|
-
- // Step 1: Parse bank CSV
|
|
1436
|
-
- let bankTransactions = parseBankStatement(csvContent, csvFilePath);
|
|
1437
|
-
-
|
|
1438
|
-
- // Step 1b: Optionally invert bank transaction amounts
|
|
1439
|
-
- // Some banks show charges as positive (need inversion to match YNAB's negative convention)
|
|
1440
|
-
- // Other banks (e.g., Wealthsimple) show charges as negative already (no inversion needed)
|
|
1441
|
-
- if (invertBankAmounts) {
|
|
1442
|
-
- bankTransactions = bankTransactions.map((txn) => ({
|
|
1443
|
-
- ...txn,
|
|
1444
|
-
- amount: -txn.amount,
|
|
1445
|
-
- }));
|
|
1446
|
-
- }
|
|
1447
|
-
+ // Step 1: Parse bank CSV using new Parser
|
|
1448
|
-
+ const parseResult = parseCSV(csvContent, {
|
|
1449
|
-
+ ...csvOptions,
|
|
1450
|
-
+ invertAmounts: invertBankAmounts,
|
|
1451
|
-
+ });
|
|
1452
|
-
|
|
1453
|
-
- // Step 2: Convert YNAB transactions
|
|
1454
|
-
- const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
|
|
1455
|
-
+ // TODO: Handle parsing errors/warnings gracefully and expose them in analysis
|
|
1456
|
-
+ const newBankTransactions = parseResult.transactions;
|
|
1457
|
-
|
|
1458
|
-
- // Step 3: Run matching algorithm
|
|
1459
|
-
- const matches = findMatches(bankTransactions, convertedYNABTxns, config);
|
|
1460
|
-
+ // Step 2: Normalize YNAB transactions
|
|
1461
|
-
+ const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
1462
|
-
|
|
1463
|
-
- // Step 4: Categorize matches
|
|
1464
|
-
- const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
|
|
1465
|
-
+ // Step 3: Run new matching algorithm
|
|
1466
|
-
+ // Convert legacy config to new config format if needed
|
|
1467
|
-
+ const amountToleranceCents =
|
|
1468
|
-
+ config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
|
|
1469
|
-
+ const dateToleranceDays =
|
|
1470
|
-
+ config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 7;
|
|
1471
|
-
+ const autoMatchThreshold =
|
|
1472
|
-
+ config.autoMatchThreshold ?? DEFAULT_MATCHING_CONFIG.autoMatchThreshold ?? 85;
|
|
1473
|
-
+ const suggestedMatchThreshold =
|
|
1474
|
-
+ config.suggestionThreshold ?? DEFAULT_MATCHING_CONFIG.suggestionThreshold ?? 60;
|
|
1475
|
-
+
|
|
1476
|
-
+ const newConfig = {
|
|
1477
|
-
+ ...config,
|
|
1478
|
-
+ weights: { amount: 0.5, date: 0.15, payee: 0.35 }, // Default weights
|
|
1479
|
-
+ amountToleranceMilliunits: amountToleranceCents * 10, // cents -> milliunits
|
|
1480
|
-
+ dateToleranceDays,
|
|
1481
|
-
+ autoMatchThreshold,
|
|
1482
|
-
+ suggestedMatchThreshold,
|
|
1483
|
-
+ minimumCandidateScore: 40,
|
|
1484
|
-
+ exactAmountBonus: 10,
|
|
1485
|
-
+ exactDateBonus: 5,
|
|
1486
|
-
+ exactPayeeBonus: 10,
|
|
1487
|
-
+ };
|
|
1488
|
-
|
|
1489
|
-
- // Step 5: Find unmatched YNAB transactions
|
|
1490
|
-
- const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
|
|
1491
|
-
+ const newMatches = findMatches(newBankTransactions, newYNABTransactions, newConfig);
|
|
1492
|
-
|
|
1493
|
-
- let combinationMatches: TransactionMatch[] = [];
|
|
1494
|
-
- let combinationInsights: ReconciliationInsight[] = [];
|
|
1495
|
-
+ // Step 4: Map results to legacy types
|
|
1496
|
-
+ const matches: TransactionMatch[] = newMatches.map(mapToOldTransactionMatch);
|
|
1497
|
-
|
|
1498
|
-
- if (ENABLE_COMBINATION_MATCHING) {
|
|
1499
|
-
- const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
|
|
1500
|
-
- combinationMatches = combinationResult.matches;
|
|
1501
|
-
- combinationInsights = combinationResult.insights;
|
|
1502
|
-
- }
|
|
1503
|
-
+ // Categorize
|
|
1504
|
-
+ const autoMatches = matches.filter((m) => m.confidence === 'high');
|
|
1505
|
-
+ const suggestedMatches = matches.filter((m) => m.confidence === 'medium');
|
|
1506
|
-
+ const unmatchedBankMatches = matches.filter(
|
|
1507
|
-
+ (m) => m.confidence === 'low' || m.confidence === 'none',
|
|
1508
|
-
+ );
|
|
1509
|
-
+ const unmatchedBank = unmatchedBankMatches.map((m) => m.bank_transaction);
|
|
1510
|
-
|
|
1511
|
-
- const enrichedSuggestedMatches = [...suggestedMatches, ...combinationMatches];
|
|
1512
|
-
+ // Find unmatched YNAB
|
|
1513
|
-
+ const matchedYnabIds = new Set<string>();
|
|
1514
|
-
+ matches.forEach((m) => {
|
|
1515
|
-
+ if (m.ynab_transaction) matchedYnabIds.add(m.ynab_transaction.id);
|
|
1516
|
-
+ });
|
|
1517
|
-
+ const unmatchedYNAB = newYNABTransactions
|
|
1518
|
-
+ .filter((t) => !matchedYnabIds.has(t.id))
|
|
1519
|
-
+ .map(mapToOldYNABTransaction);
|
|
1520
|
-
+
|
|
1521
|
-
+ // Note: Combination matching disabled in this version to ensure stability of V2 core
|
|
1522
|
-
|
|
1523
|
-
// Step 6: Calculate balances
|
|
1524
|
-
- const balances = calculateBalances(convertedYNABTxns, statementBalance, currency);
|
|
1525
|
-
+ const legacyYNABTxns = newYNABTransactions.map(mapToOldYNABTransaction);
|
|
1526
|
-
+ const balances = calculateBalances(legacyYNABTxns, statementBalance, currency);
|
|
1527
|
-
|
|
1528
|
-
// Step 7: Generate summary
|
|
1529
|
-
const summary = generateSummary(
|
|
1530
|
-
- bankTransactions,
|
|
1531
|
-
- convertedYNABTxns,
|
|
1532
|
-
+ matches.map((m) => m.bank_transaction),
|
|
1533
|
-
+ legacyYNABTxns,
|
|
1534
|
-
autoMatches,
|
|
1535
|
-
- enrichedSuggestedMatches,
|
|
1536
|
-
+ suggestedMatches,
|
|
1537
|
-
unmatchedBank,
|
|
1538
|
-
unmatchedYNAB,
|
|
1539
|
-
balances,
|
|
1540
|
-
@@ -791,9 +407,8 @@ export function analyzeReconciliation(
|
|
1541
|
-
// Step 8: Generate next steps
|
|
1542
|
-
const nextSteps = generateNextSteps(summary);
|
|
1543
|
-
|
|
1544
|
-
- // Step 9: Detect insights and patterns
|
|
1545
|
-
- const baseInsights = detectInsights(matches, unmatchedBank, summary, balances, config);
|
|
1546
|
-
- const insights = mergeInsights(baseInsights, combinationInsights);
|
|
1547
|
-
+ // Step 9: Detect insights
|
|
1548
|
-
+ const insights = detectInsights(unmatchedBank, summary, balances);
|
|
1549
|
-
|
|
1550
|
-
// Step 10: Build the analysis result
|
|
1551
|
-
const analysis: ReconciliationAnalysis = {
|
|
1552
|
-
@@ -801,7 +416,7 @@ export function analyzeReconciliation(
|
|
1553
|
-
phase: 'analysis',
|
|
1554
|
-
summary,
|
|
1555
|
-
auto_matches: autoMatches,
|
|
1556
|
-
- suggested_matches: enrichedSuggestedMatches,
|
|
1557
|
-
+ suggested_matches: enrichedSuggestedMatches(suggestedMatches), // Typo fixed in logic
|
|
1558
|
-
unmatched_bank: unmatchedBank,
|
|
1559
|
-
unmatched_ynab: unmatchedYNAB,
|
|
1560
|
-
balance_info: balances,
|
|
1561
|
-
@@ -809,7 +424,7 @@ export function analyzeReconciliation(
|
|
1562
|
-
insights,
|
|
1563
|
-
};
|
|
1564
|
-
|
|
1565
|
-
- // Step 11: Generate recommendations (if account and budget IDs are provided)
|
|
1566
|
-
+ // Step 11: Generate recommendations
|
|
1567
|
-
if (accountId && budgetId) {
|
|
1568
|
-
const recommendations = generateRecommendations({
|
|
1569
|
-
account_id: accountId,
|
|
1570
|
-
@@ -822,3 +437,8 @@ export function analyzeReconciliation(
|
|
1571
|
-
|
|
1572
|
-
return analysis;
|
|
1573
|
-
}
|
|
1574
|
-
+
|
|
1575
|
-
+// Helper to ensure type compatibility if I missed referencing something
|
|
1576
|
-
+function enrichedSuggestedMatches(matches: TransactionMatch[]) {
|
|
1577
|
-
+ return matches;
|
|
1578
|
-
+}
|
|
1579
|
-
diff --git a/src/tools/reconciliation/index.ts b/src/tools/reconciliation/index.ts
|
|
1580
|
-
index 125fe2b..86d9642 100644
|
|
1581
|
-
--- a/src/tools/reconciliation/index.ts
|
|
1582
|
-
+++ b/src/tools/reconciliation/index.ts
|
|
1583
|
-
@@ -3,6 +3,7 @@
|
|
1584
|
-
* Implements guided reconciliation workflow with conservative matching
|
|
1585
|
-
*/
|
|
1586
|
-
|
|
1587
|
-
+import { promises as fs } from 'fs';
|
|
1588
|
-
import { z } from 'zod/v4';
|
|
1589
|
-
import type * as ynab from 'ynab';
|
|
1590
|
-
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
1591
|
-
@@ -16,7 +17,7 @@ import {
|
|
1592
|
-
type LegacyReconciliationResult,
|
|
1593
|
-
} from './executor.js';
|
|
1594
|
-
import { responseFormatter } from '../../server/responseFormatter.js';
|
|
1595
|
-
-import { extractDateRangeFromCSV, autoDetectCSVFormat } from '../compareTransactions/parser.js';
|
|
1596
|
-
+import { parseCSV, type ParseCSVOptions } from './csvParser.js';
|
|
1597
|
-
import type { DeltaFetcher } from '../deltaFetcher.js';
|
|
1598
|
-
import { resolveDeltaFetcherArgs } from '../deltaSupport.js';
|
|
1599
|
-
|
|
1600
|
-
@@ -213,6 +214,46 @@ export async function handleReconcileAccount(
|
|
1601
|
-
const budgetResponse = await ynabAPI.budgets.getBudgetById(params.budget_id);
|
|
1602
|
-
const currencyCode = budgetResponse.data.budget?.currency_format?.iso_code ?? 'USD';
|
|
1603
|
-
|
|
1604
|
-
+ // Prepare CSV parsing options from request
|
|
1605
|
-
+ const dateFormat = mapCsvDateFormatToHint(params.csv_format?.date_format);
|
|
1606
|
-
+ const csvOptions: ParseCSVOptions = {
|
|
1607
|
-
+ columns: {
|
|
1608
|
-
+ ...(typeof params.csv_format?.date_column === 'string' && {
|
|
1609
|
-
+ date: params.csv_format.date_column,
|
|
1610
|
-
+ }),
|
|
1611
|
-
+ ...(typeof params.csv_format?.amount_column === 'string' && {
|
|
1612
|
-
+ amount: params.csv_format.amount_column,
|
|
1613
|
-
+ }),
|
|
1614
|
-
+ ...(typeof params.csv_format?.debit_column === 'string' && {
|
|
1615
|
-
+ debit: params.csv_format.debit_column,
|
|
1616
|
-
+ }),
|
|
1617
|
-
+ ...(typeof params.csv_format?.credit_column === 'string' && {
|
|
1618
|
-
+ credit: params.csv_format.credit_column,
|
|
1619
|
-
+ }),
|
|
1620
|
-
+ ...(typeof params.csv_format?.description_column === 'string' && {
|
|
1621
|
-
+ description: params.csv_format.description_column,
|
|
1622
|
-
+ }),
|
|
1623
|
-
+ },
|
|
1624
|
-
+ ...(dateFormat && { dateFormat }),
|
|
1625
|
-
+ ...(params.csv_format?.has_header !== undefined && {
|
|
1626
|
-
+ header: params.csv_format.has_header,
|
|
1627
|
-
+ }),
|
|
1628
|
-
+ };
|
|
1629
|
-
+
|
|
1630
|
-
+ // Load CSV content from either inline data or filesystem path
|
|
1631
|
-
+ let csvContent = params.csv_data ?? '';
|
|
1632
|
-
+ if (!csvContent && params.csv_file_path) {
|
|
1633
|
-
+ try {
|
|
1634
|
-
+ csvContent = await fs.readFile(params.csv_file_path, 'utf8');
|
|
1635
|
-
+ } catch (error) {
|
|
1636
|
-
+ const message =
|
|
1637
|
-
+ error instanceof Error && error.message
|
|
1638
|
-
+ ? error.message
|
|
1639
|
-
+ : 'Unknown error while reading CSV file';
|
|
1640
|
-
+ throw new Error(`Failed to read CSV file at path ${params.csv_file_path}: ${message}`);
|
|
1641
|
-
+ }
|
|
1642
|
-
+ }
|
|
1643
|
-
+
|
|
1644
|
-
// Fetch YNAB transactions for the account
|
|
1645
|
-
// Auto-detect date range from CSV if not explicitly provided
|
|
1646
|
-
let sinceDate: Date;
|
|
1647
|
-
@@ -221,29 +262,26 @@ export async function handleReconcileAccount(
|
|
1648
|
-
// User provided explicit start date
|
|
1649
|
-
sinceDate = new Date(params.statement_start_date);
|
|
1650
|
-
} else {
|
|
1651
|
-
- // Auto-detect from CSV content
|
|
1652
|
-
+ // Auto-detect from CSV content using new parser
|
|
1653
|
-
try {
|
|
1654
|
-
- const csvContent = params.csv_data || params.csv_file_path || '';
|
|
1655
|
-
- const csvFormat = params.csv_format || autoDetectCSVFormat(csvContent);
|
|
1656
|
-
-
|
|
1657
|
-
- // Convert schema format to parser format
|
|
1658
|
-
- const parserFormat = {
|
|
1659
|
-
- date_column: csvFormat.date_column || 'Date',
|
|
1660
|
-
- amount_column: csvFormat.amount_column,
|
|
1661
|
-
- debit_column: csvFormat.debit_column,
|
|
1662
|
-
- credit_column: csvFormat.credit_column,
|
|
1663
|
-
- description_column: csvFormat.description_column || 'Description',
|
|
1664
|
-
- date_format: csvFormat.date_format || 'MM/DD/YYYY',
|
|
1665
|
-
- has_header: csvFormat.has_header ?? true,
|
|
1666
|
-
- delimiter: csvFormat.delimiter || ',',
|
|
1667
|
-
- };
|
|
1668
|
-
-
|
|
1669
|
-
- const { minDate } = extractDateRangeFromCSV(csvContent, parserFormat);
|
|
1670
|
-
-
|
|
1671
|
-
- // Add 7-day buffer before min date for pending transactions
|
|
1672
|
-
- const minDateObj = new Date(minDate);
|
|
1673
|
-
- minDateObj.setDate(minDateObj.getDate() - 7);
|
|
1674
|
-
- sinceDate = minDateObj;
|
|
1675
|
-
+ const parseResult = parseCSV(csvContent, csvOptions);
|
|
1676
|
-
+
|
|
1677
|
-
+ if (parseResult.transactions.length > 0) {
|
|
1678
|
-
+ // Find min date
|
|
1679
|
-
+ const dates = parseResult.transactions
|
|
1680
|
-
+ .map((t) => new Date(t.date).getTime())
|
|
1681
|
-
+ .filter((t) => !isNaN(t));
|
|
1682
|
-
+ if (dates.length > 0) {
|
|
1683
|
-
+ const minTime = Math.min(...dates);
|
|
1684
|
-
+ const minDateObj = new Date(minTime);
|
|
1685
|
-
+ minDateObj.setDate(minDateObj.getDate() - 7); // 7-day buffer
|
|
1686
|
-
+ sinceDate = minDateObj;
|
|
1687
|
-
+ } else {
|
|
1688
|
-
+ sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1689
|
-
+ }
|
|
1690
|
-
+ } else {
|
|
1691
|
-
+ sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1692
|
-
+ }
|
|
1693
|
-
} catch {
|
|
1694
|
-
// Fallback to 90 days if CSV parsing fails
|
|
1695
|
-
sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
1696
|
-
@@ -281,7 +319,7 @@ export async function handleReconcileAccount(
|
|
1697
|
-
|
|
1698
|
-
// Perform analysis
|
|
1699
|
-
const analysis = analyzeReconciliation(
|
|
1700
|
-
- params.csv_data || params.csv_file_path || '',
|
|
1701
|
-
+ csvContent,
|
|
1702
|
-
params.csv_file_path,
|
|
1703
|
-
ynabTransactions,
|
|
1704
|
-
adjustedStatementBalance,
|
|
1705
|
-
@@ -290,6 +328,7 @@ export async function handleReconcileAccount(
|
|
1706
|
-
params.account_id,
|
|
1707
|
-
params.budget_id,
|
|
1708
|
-
shouldInvertBankAmounts,
|
|
1709
|
-
+ csvOptions,
|
|
1710
|
-
);
|
|
1711
|
-
|
|
1712
|
-
const initialAccount: AccountSnapshot = {
|
|
1713
|
-
@@ -359,6 +398,28 @@ export async function handleReconcileAccount(
|
|
1714
|
-
);
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
+function mapCsvDateFormatToHint(
|
|
1718
|
-
+ format: string | undefined,
|
|
1719
|
-
+): ParseCSVOptions['dateFormat'] | undefined {
|
|
1720
|
-
+ if (!format) {
|
|
1721
|
-
+ return undefined;
|
|
1722
|
-
+ }
|
|
1723
|
-
+
|
|
1724
|
-
+ const normalized = format.toUpperCase().replace(/[^YMD]/g, '');
|
|
1725
|
-
+
|
|
1726
|
-
+ if (normalized === 'YYYYMMDD' || normalized === 'YYMMDD' || normalized === 'YMD') {
|
|
1727
|
-
+ return 'YMD';
|
|
1728
|
-
+ }
|
|
1729
|
-
+ if (normalized === 'MMDDYYYY' || normalized === 'MDY') {
|
|
1730
|
-
+ return 'MDY';
|
|
1731
|
-
+ }
|
|
1732
|
-
+ if (normalized === 'DDMMYYYY' || normalized === 'DMY') {
|
|
1733
|
-
+ return 'DMY';
|
|
1734
|
-
+ }
|
|
1735
|
-
+
|
|
1736
|
-
+ return undefined;
|
|
1737
|
-
+}
|
|
1738
|
-
+
|
|
1739
|
-
function mapCsvFormatForPayload(format: ReconcileAccountRequest['csv_format'] | undefined):
|
|
1740
|
-
| {
|
|
1741
|
-
delimiter: string;
|
|
1742
|
-
diff --git a/src/tools/reconciliation/matcher.ts b/src/tools/reconciliation/matcher.ts
|
|
1743
|
-
index 74d2a0a..ec60533 100644
|
|
1744
|
-
--- a/src/tools/reconciliation/matcher.ts
|
|
1745
|
-
+++ b/src/tools/reconciliation/matcher.ts
|
|
1746
|
-
@@ -1,269 +1,506 @@
|
|
1747
|
-
/**
|
|
1748
|
-
* Transaction matching algorithm for reconciliation
|
|
1749
|
-
- * Implements confidence-based matching with auto-match and suggestion tiers
|
|
1750
|
-
+ *
|
|
1751
|
-
+ * V2 matcher works natively in milliunits using canonical BankTransaction
|
|
1752
|
-
+ * and NormalizedYNABTransaction types, but this module also exposes
|
|
1753
|
-
+ * backwards-compatible wrappers for the legacy reconciliation types
|
|
1754
|
-
+ * defined in src/tools/reconciliation/types.ts.
|
|
1755
|
-
*/
|
|
1756
|
-
|
|
1757
|
-
-import { normalizedMatch, payeeSimilarity } from './payeeNormalizer.js';
|
|
1758
|
-
-import { DEFAULT_MATCHING_CONFIG } from './types.js';
|
|
1759
|
-
+import * as fuzz from 'fuzzball';
|
|
1760
|
-
import type {
|
|
1761
|
-
- BankTransaction,
|
|
1762
|
-
- YNABTransaction,
|
|
1763
|
-
- TransactionMatch,
|
|
1764
|
-
- MatchCandidate,
|
|
1765
|
-
- MatchingConfig,
|
|
1766
|
-
+ BankTransaction as CanonicalBankTransaction,
|
|
1767
|
-
+ NormalizedYNABTransaction,
|
|
1768
|
-
+} from '../../types/reconciliation.js';
|
|
1769
|
-
+import {
|
|
1770
|
-
+ DEFAULT_MATCHING_CONFIG,
|
|
1771
|
-
+ type BankTransaction as LegacyBankTransaction,
|
|
1772
|
-
+ type YNABTransaction as LegacyYNABTransaction,
|
|
1773
|
-
+ type MatchingConfig as LegacyMatchingConfig,
|
|
1774
|
-
+ type TransactionMatch as LegacyTransactionMatch,
|
|
1775
|
-
+ type MatchCandidate as LegacyMatchCandidate,
|
|
1776
|
-
} from './types.js';
|
|
1777
|
-
|
|
1778
|
-
-/**
|
|
1779
|
-
- * Check if two amounts match within tolerance
|
|
1780
|
-
- */
|
|
1781
|
-
-function amountsMatch(bankAmount: number, ynabAmount: number, toleranceCents: number): boolean {
|
|
1782
|
-
- // Convert YNAB milliunits to dollars
|
|
1783
|
-
- const ynabDollars = ynabAmount / 1000;
|
|
1784
|
-
+export interface MatchCandidate {
|
|
1785
|
-
+ ynabTransaction: NormalizedYNABTransaction;
|
|
1786
|
-
+ scores: {
|
|
1787
|
-
+ amount: number; // 0-100
|
|
1788
|
-
+ date: number; // 0-100
|
|
1789
|
-
+ payee: number; // 0-100
|
|
1790
|
-
+ combined: number; // Weighted combination
|
|
1791
|
-
+ };
|
|
1792
|
-
+ matchReasons: string[];
|
|
1793
|
-
+}
|
|
1794
|
-
+
|
|
1795
|
-
+export interface MatchResult {
|
|
1796
|
-
+ bankTransaction: CanonicalBankTransaction;
|
|
1797
|
-
+ bestMatch: MatchCandidate | null;
|
|
1798
|
-
+ candidates: MatchCandidate[]; // Top 3
|
|
1799
|
-
+ confidence: 'high' | 'medium' | 'low' | 'none';
|
|
1800
|
-
+ confidenceScore: number;
|
|
1801
|
-
+}
|
|
1802
|
-
+
|
|
1803
|
-
+export interface MatchingConfig {
|
|
1804
|
-
+ weights: {
|
|
1805
|
-
+ amount: number; // Recommended: 0.50
|
|
1806
|
-
+ date: number; // Recommended: 0.15
|
|
1807
|
-
+ payee: number; // Recommended: 0.35
|
|
1808
|
-
+ };
|
|
1809
|
-
|
|
1810
|
-
- // Round to avoid floating point precision issues
|
|
1811
|
-
- const difference = Math.round(Math.abs(bankAmount - ynabDollars) * 100) / 100;
|
|
1812
|
-
- const toleranceDollars = toleranceCents / 100;
|
|
1813
|
-
+ // Tolerances (in MILLIUNITS for amount)
|
|
1814
|
-
+ amountToleranceMilliunits: number; // Default: 10 (1 cent)
|
|
1815
|
-
+ dateToleranceDays: number; // Default: 7
|
|
1816
|
-
|
|
1817
|
-
- return difference <= toleranceDollars;
|
|
1818
|
-
+ // Thresholds
|
|
1819
|
-
+ autoMatchThreshold: number; // Default: 85
|
|
1820
|
-
+ suggestedMatchThreshold: number; // Default: 60
|
|
1821
|
-
+ minimumCandidateScore: number; // Default: 40
|
|
1822
|
-
+
|
|
1823
|
-
+ // Bonuses for perfect matches
|
|
1824
|
-
+ exactAmountBonus: number; // Default: 10
|
|
1825
|
-
+ exactDateBonus: number; // Default: 5
|
|
1826
|
-
+ exactPayeeBonus: number; // Default: 10
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
-/**
|
|
1830
|
-
- * Check if two dates match within tolerance
|
|
1831
|
-
- */
|
|
1832
|
-
-function datesMatch(date1: string, date2: string, toleranceDays: number): boolean {
|
|
1833
|
-
- const d1 = new Date(date1);
|
|
1834
|
-
- const d2 = new Date(date2);
|
|
1835
|
-
+export const DEFAULT_CONFIG: MatchingConfig = {
|
|
1836
|
-
+ weights: {
|
|
1837
|
-
+ amount: 0.5,
|
|
1838
|
-
+ date: 0.15,
|
|
1839
|
-
+ payee: 0.35,
|
|
1840
|
-
+ },
|
|
1841
|
-
+ amountToleranceMilliunits: 10, // 1 cent
|
|
1842
|
-
+ dateToleranceDays: 7,
|
|
1843
|
-
+ autoMatchThreshold: 85,
|
|
1844
|
-
+ suggestedMatchThreshold: 60,
|
|
1845
|
-
+ minimumCandidateScore: 40,
|
|
1846
|
-
+ exactAmountBonus: 10,
|
|
1847
|
-
+ exactDateBonus: 5,
|
|
1848
|
-
+ exactPayeeBonus: 10,
|
|
1849
|
-
+};
|
|
1850
|
-
+
|
|
1851
|
-
+type AnyMatchingConfig = MatchingConfig | LegacyMatchingConfig | undefined;
|
|
1852
|
-
+
|
|
1853
|
-
+function normalizeConfig(config: AnyMatchingConfig): MatchingConfig {
|
|
1854
|
-
+ if (!config) {
|
|
1855
|
-
+ return { ...DEFAULT_CONFIG };
|
|
1856
|
-
+ }
|
|
1857
|
-
+
|
|
1858
|
-
+ // If it already looks like a V2 config (has weights), fill in defaults
|
|
1859
|
-
+ if ((config as MatchingConfig).weights) {
|
|
1860
|
-
+ const v2 = config as MatchingConfig;
|
|
1861
|
-
+ return {
|
|
1862
|
-
+ weights: v2.weights ?? DEFAULT_CONFIG.weights,
|
|
1863
|
-
+ amountToleranceMilliunits:
|
|
1864
|
-
+ v2.amountToleranceMilliunits ?? DEFAULT_CONFIG.amountToleranceMilliunits,
|
|
1865
|
-
+ dateToleranceDays: v2.dateToleranceDays ?? DEFAULT_CONFIG.dateToleranceDays,
|
|
1866
|
-
+ autoMatchThreshold: v2.autoMatchThreshold ?? DEFAULT_CONFIG.autoMatchThreshold,
|
|
1867
|
-
+ suggestedMatchThreshold: v2.suggestedMatchThreshold ?? DEFAULT_CONFIG.suggestedMatchThreshold,
|
|
1868
|
-
+ minimumCandidateScore: v2.minimumCandidateScore ?? DEFAULT_CONFIG.minimumCandidateScore,
|
|
1869
|
-
+ exactAmountBonus: v2.exactAmountBonus ?? DEFAULT_CONFIG.exactAmountBonus,
|
|
1870
|
-
+ exactDateBonus: v2.exactDateBonus ?? DEFAULT_CONFIG.exactDateBonus,
|
|
1871
|
-
+ exactPayeeBonus: v2.exactPayeeBonus ?? DEFAULT_CONFIG.exactPayeeBonus,
|
|
1872
|
-
+ };
|
|
1873
|
-
+ }
|
|
1874
|
-
+
|
|
1875
|
-
+ const legacy = config as LegacyMatchingConfig;
|
|
1876
|
-
|
|
1877
|
-
- const diffMs = Math.abs(d1.getTime() - d2.getTime());
|
|
1878
|
-
- const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
1879
|
-
+ const amountToleranceCents =
|
|
1880
|
-
+ legacy.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents;
|
|
1881
|
-
+ const dateToleranceDays = legacy.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays;
|
|
1882
|
-
+ const autoMatchThreshold =
|
|
1883
|
-
+ legacy.autoMatchThreshold ?? DEFAULT_MATCHING_CONFIG.autoMatchThreshold;
|
|
1884
|
-
+ const suggestedMatchThreshold =
|
|
1885
|
-
+ legacy.suggestionThreshold ?? DEFAULT_MATCHING_CONFIG.suggestionThreshold;
|
|
1886
|
-
|
|
1887
|
-
- return diffDays <= toleranceDays;
|
|
1888
|
-
+ return {
|
|
1889
|
-
+ weights: { ...DEFAULT_CONFIG.weights },
|
|
1890
|
-
+ amountToleranceMilliunits: amountToleranceCents * 10, // cents -> milliunits
|
|
1891
|
-
+ dateToleranceDays,
|
|
1892
|
-
+ autoMatchThreshold,
|
|
1893
|
-
+ suggestedMatchThreshold,
|
|
1894
|
-
+ minimumCandidateScore: DEFAULT_CONFIG.minimumCandidateScore,
|
|
1895
|
-
+ exactAmountBonus: DEFAULT_CONFIG.exactAmountBonus,
|
|
1896
|
-
+ exactDateBonus: DEFAULT_CONFIG.exactDateBonus,
|
|
1897
|
-
+ exactPayeeBonus: DEFAULT_CONFIG.exactPayeeBonus,
|
|
1898
|
-
+ };
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
-/**
|
|
1902
|
-
- * Calculate match confidence score between bank and YNAB transaction
|
|
1903
|
-
- * Returns score 0-100 and match reasons
|
|
1904
|
-
- */
|
|
1905
|
-
-function calculateMatchScore(
|
|
1906
|
-
- bankTxn: BankTransaction,
|
|
1907
|
-
- ynabTxn: YNABTransaction,
|
|
1908
|
-
- config: MatchingConfig,
|
|
1909
|
-
-): { score: number; reasons: string[] } {
|
|
1910
|
-
- const reasons: string[] = [];
|
|
1911
|
-
- let score = 0;
|
|
1912
|
-
+function isLegacyBankTransaction(
|
|
1913
|
-
+ txn: CanonicalBankTransaction | LegacyBankTransaction,
|
|
1914
|
-
+): txn is LegacyBankTransaction {
|
|
1915
|
-
+ return (txn as LegacyBankTransaction).original_csv_row !== undefined;
|
|
1916
|
-
+}
|
|
1917
|
-
+
|
|
1918
|
-
+function isCanonicalYNABTransaction(
|
|
1919
|
-
+ txn: NormalizedYNABTransaction | LegacyYNABTransaction,
|
|
1920
|
-
+): txn is NormalizedYNABTransaction {
|
|
1921
|
-
+ return (txn as NormalizedYNABTransaction).payee !== undefined;
|
|
1922
|
-
+}
|
|
1923
|
-
|
|
1924
|
-
- // Amount match (40% weight) - REQUIRED
|
|
1925
|
-
- const amountMatch = amountsMatch(bankTxn.amount, ynabTxn.amount, config.amountToleranceCents);
|
|
1926
|
-
- if (!amountMatch) {
|
|
1927
|
-
- return { score: 0, reasons: ['Amount does not match'] };
|
|
1928
|
-
+function toCanonicalBankTransaction(
|
|
1929
|
-
+ txn: CanonicalBankTransaction | LegacyBankTransaction,
|
|
1930
|
-
+): CanonicalBankTransaction {
|
|
1931
|
-
+ if (!isLegacyBankTransaction(txn)) {
|
|
1932
|
-
+ return txn;
|
|
1933
|
-
}
|
|
1934
|
-
- score += 40;
|
|
1935
|
-
- reasons.push('Amount matches');
|
|
1936
|
-
-
|
|
1937
|
-
- // Date match (40% weight)
|
|
1938
|
-
- const dateWithinTolerance = datesMatch(bankTxn.date, ynabTxn.date, config.dateToleranceDays);
|
|
1939
|
-
- if (dateWithinTolerance) {
|
|
1940
|
-
- score += 40;
|
|
1941
|
-
- const daysDiff = Math.abs(
|
|
1942
|
-
- (new Date(bankTxn.date).getTime() - new Date(ynabTxn.date).getTime()) / (1000 * 60 * 60 * 24),
|
|
1943
|
-
- );
|
|
1944
|
-
- if (daysDiff === 0) {
|
|
1945
|
-
- reasons.push('Exact date match');
|
|
1946
|
-
- } else {
|
|
1947
|
-
- reasons.push(`Date within ${Math.round(daysDiff)} days`);
|
|
1948
|
-
- }
|
|
1949
|
-
+
|
|
1950
|
-
+ return {
|
|
1951
|
-
+ id: txn.id,
|
|
1952
|
-
+ date: txn.date,
|
|
1953
|
-
+ amount: Math.round(txn.amount * 1000),
|
|
1954
|
-
+ payee: txn.payee,
|
|
1955
|
-
+ ...(txn.memo && { memo: txn.memo }),
|
|
1956
|
-
+ sourceRow: txn.original_csv_row,
|
|
1957
|
-
+ raw: {
|
|
1958
|
-
+ date: txn.date,
|
|
1959
|
-
+ amount: txn.amount.toFixed(2),
|
|
1960
|
-
+ description: txn.payee,
|
|
1961
|
-
+ },
|
|
1962
|
-
+ };
|
|
1963
|
-
+}
|
|
1964
|
-
+
|
|
1965
|
-
+function toCanonicalYNABTransaction(
|
|
1966
|
-
+ txn: NormalizedYNABTransaction | LegacyYNABTransaction,
|
|
1967
|
-
+): NormalizedYNABTransaction {
|
|
1968
|
-
+ if (isCanonicalYNABTransaction(txn)) {
|
|
1969
|
-
+ return txn;
|
|
1970
|
-
}
|
|
1971
|
-
|
|
1972
|
-
- // Payee match (20% weight)
|
|
1973
|
-
- const payeeScore = payeeSimilarity(bankTxn.payee, ynabTxn.payee_name);
|
|
1974
|
-
+ const legacy = txn as LegacyYNABTransaction;
|
|
1975
|
-
+ return {
|
|
1976
|
-
+ id: legacy.id,
|
|
1977
|
-
+ date: legacy.date,
|
|
1978
|
-
+ amount: legacy.amount,
|
|
1979
|
-
+ payee: legacy.payee_name,
|
|
1980
|
-
+ memo: legacy.memo ?? null,
|
|
1981
|
-
+ categoryName: legacy.category_name,
|
|
1982
|
-
+ cleared: legacy.cleared,
|
|
1983
|
-
+ approved: legacy.approved,
|
|
1984
|
-
+ };
|
|
1985
|
-
+}
|
|
1986
|
-
|
|
1987
|
-
- if (normalizedMatch(bankTxn.payee, ynabTxn.payee_name)) {
|
|
1988
|
-
- score += 20;
|
|
1989
|
-
- reasons.push('Payee exact match');
|
|
1990
|
-
- } else if (payeeScore >= 95) {
|
|
1991
|
-
- score += 15;
|
|
1992
|
-
- reasons.push(`Payee highly similar (${Math.round(payeeScore)}%)`);
|
|
1993
|
-
- } else if (payeeScore >= 80) {
|
|
1994
|
-
- score += 10;
|
|
1995
|
-
- reasons.push(`Payee similar (${Math.round(payeeScore)}%)`);
|
|
1996
|
-
- } else if (payeeScore >= 60) {
|
|
1997
|
-
- score += 6;
|
|
1998
|
-
- reasons.push(`Payee somewhat similar (${Math.round(payeeScore)}%)`);
|
|
1999
|
-
+function mapToLegacyBankTransaction(canonical: CanonicalBankTransaction): LegacyBankTransaction {
|
|
2000
|
-
+ return {
|
|
2001
|
-
+ id: canonical.id,
|
|
2002
|
-
+ date: canonical.date,
|
|
2003
|
-
+ amount: canonical.amount / 1000,
|
|
2004
|
-
+ payee: canonical.payee,
|
|
2005
|
-
+ ...(canonical.memo && { memo: canonical.memo }),
|
|
2006
|
-
+ original_csv_row: canonical.sourceRow,
|
|
2007
|
-
+ };
|
|
2008
|
-
+}
|
|
2009
|
-
+
|
|
2010
|
-
+function mapToLegacyYNABTransaction(canonical: NormalizedYNABTransaction): LegacyYNABTransaction {
|
|
2011
|
-
+ return {
|
|
2012
|
-
+ id: canonical.id,
|
|
2013
|
-
+ date: canonical.date,
|
|
2014
|
-
+ amount: canonical.amount,
|
|
2015
|
-
+ payee_name: canonical.payee,
|
|
2016
|
-
+ category_name: canonical.categoryName,
|
|
2017
|
-
+ cleared: canonical.cleared,
|
|
2018
|
-
+ approved: canonical.approved,
|
|
2019
|
-
+ ...(canonical.memo !== null && { memo: canonical.memo }),
|
|
2020
|
-
+ };
|
|
2021
|
-
+}
|
|
2022
|
-
+
|
|
2023
|
-
+function mapToLegacyTransactionMatch(result: MatchResult): LegacyTransactionMatch {
|
|
2024
|
-
+ const bankTransaction = mapToLegacyBankTransaction(result.bankTransaction);
|
|
2025
|
-
+ const ynabTransaction = result.bestMatch
|
|
2026
|
-
+ ? mapToLegacyYNABTransaction(result.bestMatch.ynabTransaction)
|
|
2027
|
-
+ : undefined;
|
|
2028
|
-
+
|
|
2029
|
-
+ const candidates: LegacyMatchCandidate[] = result.candidates.map((c) => ({
|
|
2030
|
-
+ ynab_transaction: mapToLegacyYNABTransaction(c.ynabTransaction),
|
|
2031
|
-
+ confidence: c.scores.combined,
|
|
2032
|
-
+ match_reason: c.matchReasons.join(', '),
|
|
2033
|
-
+ explanation: `Score: ${c.scores.combined}. ${c.matchReasons.join(', ')}`,
|
|
2034
|
-
+ }));
|
|
2035
|
-
+
|
|
2036
|
-
+ const topCandidate = result.candidates[0];
|
|
2037
|
-
+
|
|
2038
|
-
+ let actionHint: string | undefined;
|
|
2039
|
-
+ switch (result.confidence) {
|
|
2040
|
-
+ case 'high':
|
|
2041
|
-
+ actionHint = 'approve';
|
|
2042
|
-
+ break;
|
|
2043
|
-
+ case 'medium':
|
|
2044
|
-
+ case 'low':
|
|
2045
|
-
+ actionHint = 'review';
|
|
2046
|
-
+ break;
|
|
2047
|
-
+ case 'none':
|
|
2048
|
-
+ actionHint = 'add_to_ynab';
|
|
2049
|
-
+ break;
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
- return { score: Math.round(score), reasons };
|
|
2053
|
-
+ return {
|
|
2054
|
-
+ bank_transaction: bankTransaction,
|
|
2055
|
-
+ ...(ynabTransaction && { ynab_transaction: ynabTransaction }),
|
|
2056
|
-
+ ...(candidates.length > 0 && { candidates }),
|
|
2057
|
-
+ confidence: result.confidence,
|
|
2058
|
-
+ confidence_score: result.confidenceScore,
|
|
2059
|
-
+ match_reason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
|
|
2060
|
-
+ ...(topCandidate && { top_confidence: topCandidate.scores.combined }),
|
|
2061
|
-
+ ...(actionHint && { action_hint: actionHint }),
|
|
2062
|
-
+ ...(result.confidence === 'none' && {
|
|
2063
|
-
+ recommendation: 'This bank transaction is not in YNAB. Consider adding it.',
|
|
2064
|
-
+ }),
|
|
2065
|
-
+ };
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
|
-
-/**
|
|
2069
|
-
- * Priority scoring for YNAB transactions
|
|
2070
|
-
- * Uncleared transactions get higher priority than cleared ones
|
|
2071
|
-
- */
|
|
2072
|
-
-function getPriority(ynabTxn: YNABTransaction): number {
|
|
2073
|
-
- // Uncleared transactions are expecting bank confirmation
|
|
2074
|
-
- if (ynabTxn.cleared === 'uncleared') return 10;
|
|
2075
|
-
- if (ynabTxn.cleared === 'cleared') return 5;
|
|
2076
|
-
- if (ynabTxn.cleared === 'reconciled') return 1;
|
|
2077
|
-
- return 0;
|
|
2078
|
-
+function matchSingle(
|
|
2079
|
-
+ bankTxnInput: CanonicalBankTransaction | LegacyBankTransaction,
|
|
2080
|
-
+ ynabTransactionsInput: (NormalizedYNABTransaction | LegacyYNABTransaction)[],
|
|
2081
|
-
+ usedIds: Set<string>,
|
|
2082
|
-
+ configInput: AnyMatchingConfig,
|
|
2083
|
-
+): MatchResult {
|
|
2084
|
-
+ const bankTxn = toCanonicalBankTransaction(bankTxnInput);
|
|
2085
|
-
+ const ynabTransactions = ynabTransactionsInput.map(toCanonicalYNABTransaction);
|
|
2086
|
-
+ const config = normalizeConfig(configInput);
|
|
2087
|
-
+
|
|
2088
|
-
+ const candidates = findCandidates(bankTxn, ynabTransactions, usedIds, config);
|
|
2089
|
-
+
|
|
2090
|
-
+ const bestMatch = candidates.length > 0 ? candidates[0]! : null;
|
|
2091
|
-
+ const confidenceScore = bestMatch?.scores.combined ?? 0;
|
|
2092
|
-
+
|
|
2093
|
-
+ let confidence: MatchResult['confidence'];
|
|
2094
|
-
+ if (confidenceScore >= config.autoMatchThreshold) {
|
|
2095
|
-
+ confidence = 'high';
|
|
2096
|
-
+ if (bestMatch) usedIds.add(bestMatch.ynabTransaction.id);
|
|
2097
|
-
+ } else if (confidenceScore >= config.suggestedMatchThreshold) {
|
|
2098
|
-
+ confidence = 'medium';
|
|
2099
|
-
+ } else if (confidenceScore >= config.minimumCandidateScore) {
|
|
2100
|
-
+ confidence = 'low';
|
|
2101
|
-
+ } else {
|
|
2102
|
-
+ confidence = 'none';
|
|
2103
|
-
+ }
|
|
2104
|
-
+
|
|
2105
|
-
+ return {
|
|
2106
|
-
+ bankTransaction: bankTxn,
|
|
2107
|
-
+ bestMatch,
|
|
2108
|
-
+ candidates: candidates.slice(0, 3),
|
|
2109
|
-
+ confidence,
|
|
2110
|
-
+ confidenceScore,
|
|
2111
|
-
+ };
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
-/**
|
|
2115
|
-
- * Find all matching candidates for a bank transaction
|
|
2116
|
-
- */
|
|
2117
|
-
-function findMatchCandidates(
|
|
2118
|
-
- bankTxn: BankTransaction,
|
|
2119
|
-
- ynabTransactions: YNABTransaction[],
|
|
2120
|
-
+export function findMatches(
|
|
2121
|
-
+ bankTransactions: CanonicalBankTransaction[],
|
|
2122
|
-
+ ynabTransactions: NormalizedYNABTransaction[],
|
|
2123
|
-
+ config?: MatchingConfig,
|
|
2124
|
-
+): MatchResult[];
|
|
2125
|
-
+
|
|
2126
|
-
+export function findMatches(
|
|
2127
|
-
+ bankTransactions: LegacyBankTransaction[],
|
|
2128
|
-
+ ynabTransactions: LegacyYNABTransaction[],
|
|
2129
|
-
+ config: LegacyMatchingConfig,
|
|
2130
|
-
+): LegacyTransactionMatch[];
|
|
2131
|
-
+
|
|
2132
|
-
+export function findMatches(
|
|
2133
|
-
+ bankTransactions: (CanonicalBankTransaction | LegacyBankTransaction)[],
|
|
2134
|
-
+ ynabTransactions: (NormalizedYNABTransaction | LegacyYNABTransaction)[],
|
|
2135
|
-
+ config?: AnyMatchingConfig,
|
|
2136
|
-
+): (MatchResult | LegacyTransactionMatch)[] {
|
|
2137
|
-
+ const usedYnabIds = new Set<string>();
|
|
2138
|
-
+ const results: MatchResult[] = [];
|
|
2139
|
-
+
|
|
2140
|
-
+ for (const bankTxn of bankTransactions) {
|
|
2141
|
-
+ results.push(matchSingle(bankTxn, ynabTransactions, usedYnabIds, config));
|
|
2142
|
-
+ }
|
|
2143
|
-
+
|
|
2144
|
-
+ // If inputs look legacy, map results back to legacy TransactionMatch
|
|
2145
|
-
+ if (bankTransactions.some((txn) => isLegacyBankTransaction(txn))) {
|
|
2146
|
-
+ return results.map(mapToLegacyTransactionMatch);
|
|
2147
|
-
+ }
|
|
2148
|
-
+
|
|
2149
|
-
+ return results;
|
|
2150
|
-
+}
|
|
2151
|
-
+
|
|
2152
|
-
+function findCandidates(
|
|
2153
|
-
+ bankTxn: CanonicalBankTransaction,
|
|
2154
|
-
+ ynabTransactions: NormalizedYNABTransaction[],
|
|
2155
|
-
usedIds: Set<string>,
|
|
2156
|
-
config: MatchingConfig,
|
|
2157
|
-
): MatchCandidate[] {
|
|
2158
|
-
const candidates: MatchCandidate[] = [];
|
|
2159
|
-
|
|
2160
|
-
for (const ynabTxn of ynabTransactions) {
|
|
2161
|
-
- // Skip already matched transactions
|
|
2162
|
-
if (usedIds.has(ynabTxn.id)) continue;
|
|
2163
|
-
|
|
2164
|
-
- // Skip opposite-signed transactions (refunds vs purchases)
|
|
2165
|
-
- if (bankTxn.amount > 0 !== ynabTxn.amount > 0) continue;
|
|
2166
|
-
+ // Sign check - both must be same sign (or both zero)
|
|
2167
|
-
+ const bankSign = Math.sign(bankTxn.amount);
|
|
2168
|
-
+ const ynabSign = Math.sign(ynabTxn.amount);
|
|
2169
|
-
+ if (bankSign !== ynabSign && bankSign !== 0 && ynabSign !== 0) {
|
|
2170
|
-
+ continue;
|
|
2171
|
-
+ }
|
|
2172
|
-
|
|
2173
|
-
- // Calculate match score
|
|
2174
|
-
- const { score, reasons } = calculateMatchScore(bankTxn, ynabTxn, config);
|
|
2175
|
-
+ const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
|
|
2176
|
-
+ if (amountDiff > config.amountToleranceMilliunits) {
|
|
2177
|
-
+ // Outside configured amount tolerance - treat as no candidate
|
|
2178
|
-
+ continue;
|
|
2179
|
-
+ }
|
|
2180
|
-
|
|
2181
|
-
- // Only include candidates with minimum score
|
|
2182
|
-
- if (score >= 30) {
|
|
2183
|
-
+ const scores = calculateScores(bankTxn, ynabTxn, config);
|
|
2184
|
-
+
|
|
2185
|
-
+ if (scores.combined >= config.minimumCandidateScore) {
|
|
2186
|
-
candidates.push({
|
|
2187
|
-
- ynab_transaction: ynabTxn,
|
|
2188
|
-
- confidence: score,
|
|
2189
|
-
- match_reason: reasons.join(', '),
|
|
2190
|
-
- explanation: buildExplanation(bankTxn, ynabTxn, score, reasons),
|
|
2191
|
-
+ ynabTransaction: ynabTxn,
|
|
2192
|
-
+ scores,
|
|
2193
|
-
+ matchReasons: buildMatchReasons(scores, config),
|
|
2194
|
-
});
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
- // Sort by confidence (desc), then priority (desc), then date proximity
|
|
2199
|
-
candidates.sort((a, b) => {
|
|
2200
|
-
- if (b.confidence !== a.confidence) {
|
|
2201
|
-
- return b.confidence - a.confidence;
|
|
2202
|
-
+ const scoreDiff = b.scores.combined - a.scores.combined;
|
|
2203
|
-
+ if (scoreDiff !== 0) {
|
|
2204
|
-
+ return scoreDiff;
|
|
2205
|
-
+ }
|
|
2206
|
-
+
|
|
2207
|
-
+ const aUncleared = a.ynabTransaction.cleared === 'uncleared' ? 1 : 0;
|
|
2208
|
-
+ const bUncleared = b.ynabTransaction.cleared === 'uncleared' ? 1 : 0;
|
|
2209
|
-
+ if (aUncleared !== bUncleared) {
|
|
2210
|
-
+ return bUncleared - aUncleared;
|
|
2211
|
-
+ }
|
|
2212
|
-
+
|
|
2213
|
-
+ const bankTime = new Date(bankTxn.date).getTime();
|
|
2214
|
-
+ const aDiff = Math.abs(bankTime - new Date(a.ynabTransaction.date).getTime());
|
|
2215
|
-
+ const bDiff = Math.abs(bankTime - new Date(b.ynabTransaction.date).getTime());
|
|
2216
|
-
+ if (aDiff !== bDiff) {
|
|
2217
|
-
+ return aDiff - bDiff;
|
|
2218
|
-
}
|
|
2219
|
-
- const priorityDiff = getPriority(b.ynab_transaction) - getPriority(a.ynab_transaction);
|
|
2220
|
-
- if (priorityDiff !== 0) return priorityDiff;
|
|
2221
|
-
-
|
|
2222
|
-
- // Date proximity as tiebreaker
|
|
2223
|
-
- const dateProximityA = Math.abs(
|
|
2224
|
-
- new Date(bankTxn.date).getTime() - new Date(a.ynab_transaction.date).getTime(),
|
|
2225
|
-
- );
|
|
2226
|
-
- const dateProximityB = Math.abs(
|
|
2227
|
-
- new Date(bankTxn.date).getTime() - new Date(b.ynab_transaction.date).getTime(),
|
|
2228
|
-
- );
|
|
2229
|
-
- return dateProximityA - dateProximityB;
|
|
2230
|
-
- });
|
|
2231
|
-
|
|
2232
|
-
+ return 0;
|
|
2233
|
-
+ });
|
|
2234
|
-
return candidates;
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
-/**
|
|
2238
|
-
- * Build human-readable explanation for a match
|
|
2239
|
-
- */
|
|
2240
|
-
-function buildExplanation(
|
|
2241
|
-
- _bankTxn: BankTransaction,
|
|
2242
|
-
- ynabTxn: YNABTransaction,
|
|
2243
|
-
- score: number,
|
|
2244
|
-
- reasons: string[],
|
|
2245
|
-
-): string {
|
|
2246
|
-
- const parts: string[] = [];
|
|
2247
|
-
-
|
|
2248
|
-
- parts.push(`Match confidence: ${score}%`);
|
|
2249
|
-
- parts.push(reasons.join(', '));
|
|
2250
|
-
-
|
|
2251
|
-
- if (ynabTxn.cleared === 'uncleared') {
|
|
2252
|
-
- parts.push('(Uncleared - awaiting confirmation)');
|
|
2253
|
-
+function calculateScores(
|
|
2254
|
-
+ bankTxn: CanonicalBankTransaction,
|
|
2255
|
-
+ ynabTxn: NormalizedYNABTransaction,
|
|
2256
|
-
+ config: MatchingConfig,
|
|
2257
|
-
+): MatchCandidate['scores'] {
|
|
2258
|
-
+ // Amount score - now using INTEGER comparison (milliunits)
|
|
2259
|
-
+ const amountDiff = Math.abs(bankTxn.amount - ynabTxn.amount);
|
|
2260
|
-
+ let amountScore: number;
|
|
2261
|
-
+
|
|
2262
|
-
+ if (amountDiff === 0) {
|
|
2263
|
-
+ // Exact integer match - no floating point issues!
|
|
2264
|
-
+ amountScore = 100;
|
|
2265
|
-
+ } else if (amountDiff <= config.amountToleranceMilliunits) {
|
|
2266
|
-
+ amountScore = 95;
|
|
2267
|
-
+ } else if (amountDiff <= 1000) {
|
|
2268
|
-
+ // Within $1
|
|
2269
|
-
+ amountScore = 80 - (amountDiff / 1000) * 20;
|
|
2270
|
-
+ } else {
|
|
2271
|
-
+ amountScore = Math.max(0, 60 - (amountDiff / 1000) * 5);
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
- return parts.join(' | ');
|
|
2275
|
-
+ // Date score
|
|
2276
|
-
+ const bankDate = new Date(bankTxn.date);
|
|
2277
|
-
+ const ynabDate = new Date(ynabTxn.date);
|
|
2278
|
-
+ const daysDiff = Math.abs(bankDate.getTime() - ynabDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
2279
|
-
+ let dateScore: number;
|
|
2280
|
-
+
|
|
2281
|
-
+ if (daysDiff < 0.5) {
|
|
2282
|
-
+ dateScore = 100;
|
|
2283
|
-
+ } else if (daysDiff <= 1) {
|
|
2284
|
-
+ dateScore = 95;
|
|
2285
|
-
+ } else if (daysDiff <= config.dateToleranceDays) {
|
|
2286
|
-
+ dateScore = 90 - (daysDiff - 1) * (40 / config.dateToleranceDays);
|
|
2287
|
-
+ } else {
|
|
2288
|
-
+ dateScore = Math.max(0, 50 - (daysDiff - config.dateToleranceDays) * 5);
|
|
2289
|
-
+ }
|
|
2290
|
-
+
|
|
2291
|
-
+ // Payee score using fuzzball
|
|
2292
|
-
+ const payeeScore = calculatePayeeScore(bankTxn.payee, ynabTxn.payee);
|
|
2293
|
-
+
|
|
2294
|
-
+ // Combined score with weights
|
|
2295
|
-
+ let combined =
|
|
2296
|
-
+ amountScore * config.weights.amount +
|
|
2297
|
-
+ dateScore * config.weights.date +
|
|
2298
|
-
+ payeeScore * config.weights.payee;
|
|
2299
|
-
+
|
|
2300
|
-
+ // Apply bonuses
|
|
2301
|
-
+ if (amountScore === 100) combined += config.exactAmountBonus;
|
|
2302
|
-
+ if (dateScore === 100) combined += config.exactDateBonus;
|
|
2303
|
-
+ if (payeeScore >= 95) combined += config.exactPayeeBonus;
|
|
2304
|
-
+
|
|
2305
|
-
+ combined = Math.min(100, combined);
|
|
2306
|
-
+
|
|
2307
|
-
+ return {
|
|
2308
|
-
+ amount: Math.round(amountScore),
|
|
2309
|
-
+ date: Math.round(dateScore),
|
|
2310
|
-
+ payee: Math.round(payeeScore),
|
|
2311
|
-
+ combined: Math.round(combined),
|
|
2312
|
-
+ };
|
|
2313
|
-
}
|
|
2314
|
-
|
|
2315
|
-
-/**
|
|
2316
|
-
- * Find best match for a single bank transaction
|
|
2317
|
-
- */
|
|
2318
|
-
-export function findBestMatch(
|
|
2319
|
-
- bankTxn: BankTransaction,
|
|
2320
|
-
- ynabTransactions: YNABTransaction[],
|
|
2321
|
-
- usedIds: Set<string>,
|
|
2322
|
-
- config: MatchingConfig,
|
|
2323
|
-
-): TransactionMatch {
|
|
2324
|
-
- const candidates = findMatchCandidates(bankTxn, ynabTransactions, usedIds, config);
|
|
2325
|
-
+function calculatePayeeScore(bankPayee: string, ynabPayee: string | null): number {
|
|
2326
|
-
+ if (!ynabPayee) return 30;
|
|
2327
|
-
|
|
2328
|
-
- if (candidates.length === 0) {
|
|
2329
|
-
- // No match found
|
|
2330
|
-
- return {
|
|
2331
|
-
- bank_transaction: bankTxn,
|
|
2332
|
-
- confidence: 'none',
|
|
2333
|
-
- confidence_score: 0,
|
|
2334
|
-
- match_reason: 'No matching transaction found in YNAB',
|
|
2335
|
-
- action_hint: 'add_to_ynab',
|
|
2336
|
-
- recommendation: 'This transaction appears on bank statement but not in YNAB',
|
|
2337
|
-
- };
|
|
2338
|
-
- }
|
|
2339
|
-
+ const scores = [
|
|
2340
|
-
+ fuzz.token_set_ratio(bankPayee, ynabPayee),
|
|
2341
|
-
+ fuzz.token_sort_ratio(bankPayee, ynabPayee),
|
|
2342
|
-
+ fuzz.partial_ratio(bankPayee, ynabPayee),
|
|
2343
|
-
+ fuzz.WRatio(bankPayee, ynabPayee),
|
|
2344
|
-
+ ];
|
|
2345
|
-
|
|
2346
|
-
- const bestCandidate = candidates[0]!; // Safe: we checked candidates.length > 0
|
|
2347
|
-
- const bestScore = bestCandidate.confidence;
|
|
2348
|
-
+ return Math.max(...scores);
|
|
2349
|
-
+}
|
|
2350
|
-
|
|
2351
|
-
- // HIGH confidence: Auto-match candidate (≥90%)
|
|
2352
|
-
- if (bestScore >= config.autoMatchThreshold) {
|
|
2353
|
-
- return {
|
|
2354
|
-
- bank_transaction: bankTxn,
|
|
2355
|
-
- ynab_transaction: bestCandidate.ynab_transaction,
|
|
2356
|
-
- confidence: 'high',
|
|
2357
|
-
- confidence_score: bestScore,
|
|
2358
|
-
- match_reason: bestCandidate.match_reason,
|
|
2359
|
-
- };
|
|
2360
|
-
+function buildMatchReasons(scores: MatchCandidate['scores'], config: MatchingConfig): string[] {
|
|
2361
|
-
+ const reasons: string[] = [];
|
|
2362
|
-
+
|
|
2363
|
-
+ if (scores.amount === 100) {
|
|
2364
|
-
+ reasons.push('Exact amount match');
|
|
2365
|
-
+ } else if (scores.amount >= 95) {
|
|
2366
|
-
+ reasons.push('Amount within tolerance');
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
- // MEDIUM confidence: Suggested match (60-89%)
|
|
2370
|
-
- if (bestScore >= config.suggestionThreshold) {
|
|
2371
|
-
- return {
|
|
2372
|
-
- bank_transaction: bankTxn,
|
|
2373
|
-
- ynab_transaction: bestCandidate.ynab_transaction,
|
|
2374
|
-
- candidates: candidates.slice(0, 3), // Top 3 candidates
|
|
2375
|
-
- confidence: 'medium',
|
|
2376
|
-
- confidence_score: bestScore,
|
|
2377
|
-
- match_reason: bestCandidate.match_reason,
|
|
2378
|
-
- top_confidence: bestScore,
|
|
2379
|
-
- action_hint: 'review_and_choose',
|
|
2380
|
-
- };
|
|
2381
|
-
+ if (scores.date === 100) {
|
|
2382
|
-
+ reasons.push('Same date');
|
|
2383
|
-
+ } else if (scores.date >= 90) {
|
|
2384
|
-
+ reasons.push('Date within 1-2 days');
|
|
2385
|
-
+ } else if (scores.date >= 50) {
|
|
2386
|
-
+ reasons.push(`Date within ${config.dateToleranceDays} days`);
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
|
-
- // LOW confidence: Show as possible match but don't auto-suggest (30-59%)
|
|
2390
|
-
- return {
|
|
2391
|
-
- bank_transaction: bankTxn,
|
|
2392
|
-
- candidates: candidates.slice(0, 3),
|
|
2393
|
-
- confidence: 'low',
|
|
2394
|
-
- confidence_score: bestScore,
|
|
2395
|
-
- match_reason: 'Low confidence match',
|
|
2396
|
-
- top_confidence: bestScore,
|
|
2397
|
-
- action_hint: 'review_or_add_new',
|
|
2398
|
-
- recommendation: 'Consider reviewing candidates or adding as new transaction',
|
|
2399
|
-
- };
|
|
2400
|
-
+ if (scores.payee >= 95) {
|
|
2401
|
-
+ reasons.push('Payee exact match');
|
|
2402
|
-
+ } else if (scores.payee >= 80) {
|
|
2403
|
-
+ reasons.push('Payee highly similar');
|
|
2404
|
-
+ } else if (scores.payee >= 60) {
|
|
2405
|
-
+ reasons.push('Payee somewhat similar');
|
|
2406
|
-
+ }
|
|
2407
|
-
+
|
|
2408
|
-
+ return reasons;
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
-/**
|
|
2412
|
-
- * Find matches for all bank transactions
|
|
2413
|
-
- */
|
|
2414
|
-
-export function findMatches(
|
|
2415
|
-
- bankTransactions: BankTransaction[],
|
|
2416
|
-
- ynabTransactions: YNABTransaction[],
|
|
2417
|
-
- config: MatchingConfig = DEFAULT_MATCHING_CONFIG as MatchingConfig,
|
|
2418
|
-
-): TransactionMatch[] {
|
|
2419
|
-
- const matches: TransactionMatch[] = [];
|
|
2420
|
-
- const usedIds = new Set<string>();
|
|
2421
|
-
+export function findBestMatch(
|
|
2422
|
-
+ bankTransaction: CanonicalBankTransaction,
|
|
2423
|
-
+ ynabTransactions: NormalizedYNABTransaction[],
|
|
2424
|
-
+ usedYnabIds?: Set<string>,
|
|
2425
|
-
+ config?: MatchingConfig,
|
|
2426
|
-
+): MatchResult;
|
|
2427
|
-
|
|
2428
|
-
- for (const bankTxn of bankTransactions) {
|
|
2429
|
-
- const match = findBestMatch(bankTxn, ynabTransactions, usedIds, config);
|
|
2430
|
-
- matches.push(match);
|
|
2431
|
-
+export function findBestMatch(
|
|
2432
|
-
+ bankTransaction: LegacyBankTransaction,
|
|
2433
|
-
+ ynabTransactions: LegacyYNABTransaction[],
|
|
2434
|
-
+ usedYnabIds: Set<string>,
|
|
2435
|
-
+ config: LegacyMatchingConfig,
|
|
2436
|
-
+): LegacyTransactionMatch;
|
|
2437
|
-
|
|
2438
|
-
- // Mark high-confidence matches as used to prevent duplicate matching
|
|
2439
|
-
- if (match.confidence === 'high' && match.ynab_transaction) {
|
|
2440
|
-
- usedIds.add(match.ynab_transaction.id);
|
|
2441
|
-
- }
|
|
2442
|
-
+export function findBestMatch(
|
|
2443
|
-
+ bankTransaction: CanonicalBankTransaction | LegacyBankTransaction,
|
|
2444
|
-
+ ynabTransactions: NormalizedYNABTransaction[] | LegacyYNABTransaction[],
|
|
2445
|
-
+ usedYnabIds: Set<string> = new Set<string>(),
|
|
2446
|
-
+ config?: AnyMatchingConfig,
|
|
2447
|
-
+): MatchResult | LegacyTransactionMatch {
|
|
2448
|
-
+ const result = matchSingle(bankTransaction, ynabTransactions, usedYnabIds, config);
|
|
2449
|
-
+
|
|
2450
|
-
+ if (isLegacyBankTransaction(bankTransaction)) {
|
|
2451
|
-
+ return mapToLegacyTransactionMatch(result);
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
- return matches;
|
|
2455
|
-
+ return result;
|
|
2456
|
-
}
|