@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.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/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
- package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
- package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
- package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
- package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
- package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
- package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
- package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
- package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
- package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
- package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
- package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
- package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
- package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
- package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
- package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
- package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
- package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
- package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
- package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
- package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
- package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
- package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
- package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
- package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
- package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
- package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
- package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
- package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
- package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
- package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
- package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
- package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
- package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
- package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
- package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
- package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
- package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
- package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
- package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
- package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
- package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
- package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
- package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
- package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
- package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
- package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
- package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
- package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
- package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
- package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
- package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
- package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
- package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
- package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
- package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
- package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
- package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
- package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
- package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
- package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
- package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
- package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
- package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
- package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
- package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
- package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/release.yml +4 -0
- package/CHANGELOG.md +75 -0
- package/NUL +1 -0
- package/dist/bundle/index.cjs +65 -42
- package/dist/server/errorHandler.d.ts +2 -0
- package/dist/server/errorHandler.js +49 -5
- package/dist/tools/reconcileAdapter.js +10 -5
- package/dist/tools/reconciliation/analyzer.d.ts +8 -2
- package/dist/tools/reconciliation/analyzer.js +127 -409
- package/dist/tools/reconciliation/csvParser.d.ts +51 -0
- package/dist/tools/reconciliation/csvParser.js +413 -0
- package/dist/tools/reconciliation/executor.d.ts +8 -0
- package/dist/tools/reconciliation/executor.js +204 -58
- package/dist/tools/reconciliation/index.d.ts +7 -7
- package/dist/tools/reconciliation/index.js +115 -39
- package/dist/tools/reconciliation/matcher.d.ts +24 -3
- package/dist/tools/reconciliation/matcher.js +175 -133
- package/dist/tools/reconciliation/recommendationEngine.js +22 -18
- package/dist/tools/reconciliation/reportFormatter.js +9 -8
- package/dist/tools/reconciliation/signDetector.d.ts +2 -0
- package/dist/tools/reconciliation/signDetector.js +54 -0
- package/dist/tools/reconciliation/types.d.ts +20 -34
- package/dist/tools/reconciliation/types.js +1 -7
- package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
- package/dist/tools/reconciliation/ynabAdapter.js +15 -0
- package/dist/types/reconciliation.d.ts +24 -0
- package/dist/types/reconciliation.js +1 -0
- package/docs/guides/ARCHITECTURE.md +12 -129
- package/docs/plans/2025-11-21-v014-hardening.md +153 -0
- package/docs/plans/reconciliation-v2-redesign.md +1571 -0
- package/package.json +6 -1
- package/scripts/test-recommendations.ts +1 -1
- package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
- package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
- package/src/server/errorHandler.ts +52 -5
- package/src/tools/reconcileAdapter.ts +10 -5
- package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
- package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +1 -1
- package/src/tools/reconciliation/__tests__/executor.test.ts +88 -61
- package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
- package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
- package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
- package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
- package/src/tools/reconciliation/analyzer.ts +191 -550
- package/src/tools/reconciliation/csvParser.ts +617 -0
- package/src/tools/reconciliation/executor.ts +249 -66
- package/src/tools/reconciliation/index.ts +148 -54
- package/src/tools/reconciliation/matcher.ts +234 -214
- package/src/tools/reconciliation/recommendationEngine.ts +23 -19
- package/src/tools/reconciliation/reportFormatter.ts +16 -11
- package/src/tools/reconciliation/signDetector.ts +117 -0
- package/src/tools/reconciliation/types.ts +39 -61
- package/src/tools/reconciliation/ynabAdapter.ts +33 -0
- package/src/types/reconciliation.ts +49 -0
- package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
- package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
- package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
- package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
- package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
- package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
- package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
- package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
- package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
- package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
- package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
- package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
- package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
- package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
- package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
- package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
- package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
- package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
- package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
- package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
- package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
- package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
- package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
- package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
- package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
- package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
- package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
- package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
- package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
- package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
- package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
- package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
- package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
- package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
- package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
- package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
- package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
- package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
- package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
- package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
- package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
- package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
- package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
- package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
- package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
- package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
- package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
- package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
- package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
- package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
- package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
- package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
- package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
- package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
- package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
- package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
- package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
- package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
- package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
- package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
- package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
- package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
- package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
- package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
- package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
- package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
- package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
- package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
- package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
- package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
- package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
- package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
- package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
- package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
- package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
- package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
- package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
- package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
- package/.gemini/settings.json +0 -8
- package/WARP.md +0 -245
|
@@ -1,442 +1,100 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Analysis phase orchestration for reconciliation
|
|
3
3
|
* Coordinates CSV parsing, YNAB transaction fetching, and matching
|
|
4
|
+
*
|
|
5
|
+
* V2 UPDATE: Uses new parser and matcher (milliunits based)
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
|
-
import { randomUUID } from 'crypto';
|
|
7
8
|
import type * as ynab from 'ynab';
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
9
|
+
import { parseCSV, type ParseCSVOptions, type CSVParseResult } from './csvParser.js';
|
|
10
|
+
import { findMatches, normalizeConfig, type MatchingConfig, DEFAULT_CONFIG } from './matcher.js';
|
|
11
|
+
import { normalizeYNABTransactions } from './ynabAdapter.js';
|
|
12
|
+
|
|
12
13
|
import type {
|
|
13
14
|
BankTransaction,
|
|
14
15
|
YNABTransaction,
|
|
15
16
|
ReconciliationAnalysis,
|
|
16
17
|
TransactionMatch,
|
|
17
|
-
MatchingConfig,
|
|
18
18
|
BalanceInfo,
|
|
19
19
|
ReconciliationSummary,
|
|
20
20
|
ReconciliationInsight,
|
|
21
21
|
} from './types.js';
|
|
22
|
-
import {
|
|
22
|
+
import type { MatchResult } from './matcher.js'; // Import MatchResult
|
|
23
|
+
import { toMoneyValue } from '../../utils/money.js';
|
|
23
24
|
import { generateRecommendations } from './recommendationEngine.js';
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
// --- Helper Functions ---
|
|
27
|
+
|
|
28
|
+
function mapToTransactionMatch(result: MatchResult): TransactionMatch {
|
|
29
|
+
const candidates = result.candidates.map((c) => ({
|
|
30
|
+
ynab_transaction: c.ynabTransaction,
|
|
31
|
+
confidence: c.scores.combined,
|
|
32
|
+
match_reason: c.matchReasons.join(', '),
|
|
33
|
+
explanation: c.matchReasons.join(', '),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const match: TransactionMatch = {
|
|
37
|
+
bankTransaction: result.bankTransaction,
|
|
38
|
+
candidates,
|
|
39
|
+
confidence: result.confidence,
|
|
40
|
+
confidenceScore: result.confidenceScore,
|
|
41
|
+
matchReason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
|
|
42
|
+
actionHint: result.confidence === 'high' ? 'approve' : 'review',
|
|
38
43
|
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Parse CSV bank statement and generate unique IDs for tracking
|
|
43
|
-
*/
|
|
44
|
-
const FALLBACK_CSV_FORMAT: ParserCSVFormat = {
|
|
45
|
-
date_column: 'Date',
|
|
46
|
-
amount_column: 'Amount',
|
|
47
|
-
description_column: 'Description',
|
|
48
|
-
date_format: 'MM/DD/YYYY',
|
|
49
|
-
has_header: true,
|
|
50
|
-
delimiter: ',',
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const ENABLE_COMBINATION_MATCHING = true;
|
|
54
|
-
|
|
55
|
-
const DAYS_IN_MS = 24 * 60 * 60 * 1000;
|
|
56
|
-
|
|
57
|
-
function toDollars(milliunits: number): number {
|
|
58
|
-
return milliunits / 1000;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function amountTolerance(config: MatchingConfig): number {
|
|
62
|
-
const toleranceCents =
|
|
63
|
-
config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
|
|
64
|
-
return Math.max(0, toleranceCents) / 100;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function dateTolerance(config: MatchingConfig): number {
|
|
68
|
-
return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function daysBetween(dateA: string, dateB: string): number {
|
|
72
|
-
const a = new Date(`${dateA}T00:00:00Z`).getTime();
|
|
73
|
-
const b = new Date(`${dateB}T00:00:00Z`).getTime();
|
|
74
|
-
if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
|
|
75
|
-
return Math.abs(a - b) / DAYS_IN_MS;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function withinDateTolerance(
|
|
79
|
-
bankDate: string,
|
|
80
|
-
ynabTxns: YNABTransaction[],
|
|
81
|
-
toleranceDays: number,
|
|
82
|
-
): boolean {
|
|
83
|
-
return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function hasMatchingSign(bankAmount: number, ynabTxns: YNABTransaction[]): boolean {
|
|
87
|
-
const bankSign = Math.sign(bankAmount);
|
|
88
|
-
const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
|
|
89
|
-
return bankSign === sumSign || Math.abs(bankAmount) === 0;
|
|
90
|
-
}
|
|
91
44
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const ratio = diff / safeTolerance;
|
|
95
|
-
let base = legCount === 2 ? 75 : 70;
|
|
96
|
-
if (ratio <= 0.25) {
|
|
97
|
-
base += 5;
|
|
98
|
-
} else if (ratio <= 0.5) {
|
|
99
|
-
base += 3;
|
|
100
|
-
} else if (ratio >= 0.9) {
|
|
101
|
-
base -= 5;
|
|
45
|
+
if (result.bestMatch) {
|
|
46
|
+
match.ynabTransaction = result.bestMatch.ynabTransaction;
|
|
102
47
|
}
|
|
103
|
-
return Math.max(65, Math.min(80, Math.round(base)));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function formatDifference(diff: number): string {
|
|
107
|
-
return formatCurrency(diff); // diff already absolute; formatCurrency handles sign
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
interface CombinationResult {
|
|
111
|
-
matches: TransactionMatch[];
|
|
112
|
-
insights: ReconciliationInsight[];
|
|
113
|
-
}
|
|
114
48
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
unmatchedYNAB: YNABTransaction[],
|
|
118
|
-
config: MatchingConfig,
|
|
119
|
-
): CombinationResult {
|
|
120
|
-
if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
|
|
121
|
-
return { matches: [], insights: [] };
|
|
49
|
+
if (result.candidates[0]) {
|
|
50
|
+
match.topConfidence = result.candidates[0].scores.combined;
|
|
122
51
|
}
|
|
123
52
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const matches: TransactionMatch[] = [];
|
|
128
|
-
const insights: ReconciliationInsight[] = [];
|
|
129
|
-
const seenCombinations = new Set<string>();
|
|
130
|
-
|
|
131
|
-
for (const bankTxn of unmatchedBank) {
|
|
132
|
-
const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
|
|
133
|
-
if (viableYnab.length < 2) continue;
|
|
134
|
-
|
|
135
|
-
const evaluated: { txns: YNABTransaction[]; diff: number; sum: number }[] = [];
|
|
136
|
-
|
|
137
|
-
const addIfValid = (combo: YNABTransaction[]) => {
|
|
138
|
-
const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
|
|
139
|
-
const diff = Math.abs(sum - bankTxn.amount);
|
|
140
|
-
if (diff > tolerance) return;
|
|
141
|
-
if (!withinDateTolerance(bankTxn.date, combo, toleranceDays)) return;
|
|
142
|
-
if (!hasMatchingSign(bankTxn.amount, combo)) return;
|
|
143
|
-
evaluated.push({ txns: combo, diff, sum });
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const n = viableYnab.length;
|
|
147
|
-
for (let i = 0; i < n - 1; i++) {
|
|
148
|
-
for (let j = i + 1; j < n; j++) {
|
|
149
|
-
addIfValid([viableYnab[i]!, viableYnab[j]!]);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (n >= 3) {
|
|
154
|
-
for (let i = 0; i < n - 2; i++) {
|
|
155
|
-
for (let j = i + 1; j < n - 1; j++) {
|
|
156
|
-
for (let k = j + 1; k < n; k++) {
|
|
157
|
-
addIfValid([viableYnab[i]!, viableYnab[j]!, viableYnab[k]!]);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (evaluated.length === 0) continue;
|
|
164
|
-
|
|
165
|
-
evaluated.sort((a, b) => a.diff - b.diff);
|
|
166
|
-
const recordedSizes = new Set<number>();
|
|
167
|
-
|
|
168
|
-
for (const combo of evaluated) {
|
|
169
|
-
if (recordedSizes.has(combo.txns.length)) continue; // surface best per size
|
|
170
|
-
const comboIds = combo.txns.map((txn) => txn.id).sort();
|
|
171
|
-
const key = `${bankTxn.id}|${comboIds.join('+')}`;
|
|
172
|
-
if (seenCombinations.has(key)) continue;
|
|
173
|
-
seenCombinations.add(key);
|
|
174
|
-
recordedSizes.add(combo.txns.length);
|
|
175
|
-
|
|
176
|
-
const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
|
|
177
|
-
const candidateConfidence = Math.max(60, score - 5);
|
|
178
|
-
const descriptionTotal = formatCurrency(combo.sum);
|
|
179
|
-
const diffLabel = formatDifference(combo.diff);
|
|
180
|
-
|
|
181
|
-
matches.push({
|
|
182
|
-
bank_transaction: bankTxn,
|
|
183
|
-
confidence: 'medium',
|
|
184
|
-
confidence_score: score,
|
|
185
|
-
match_reason: 'combination_match',
|
|
186
|
-
top_confidence: score,
|
|
187
|
-
candidates: combo.txns.map((txn) => ({
|
|
188
|
-
ynab_transaction: txn,
|
|
189
|
-
confidence: candidateConfidence,
|
|
190
|
-
match_reason: 'combination_component',
|
|
191
|
-
explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
|
|
192
|
-
})),
|
|
193
|
-
action_hint: 'review_combination',
|
|
194
|
-
recommendation:
|
|
195
|
-
`Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
|
|
196
|
-
`${formatCurrency(bankTxn.amount)} on the bank statement.`,
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
|
|
200
|
-
insights.push({
|
|
201
|
-
id: insightId,
|
|
202
|
-
type: 'combination_match' as unknown as ReconciliationInsight['type'],
|
|
203
|
-
severity: 'info',
|
|
204
|
-
title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(
|
|
205
|
-
bankTxn.amount,
|
|
206
|
-
)}`,
|
|
207
|
-
description:
|
|
208
|
-
`${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
|
|
209
|
-
`${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
|
|
210
|
-
evidence: {
|
|
211
|
-
bank_transaction_id: bankTxn.id,
|
|
212
|
-
bank_amount: bankTxn.amount,
|
|
213
|
-
ynab_transaction_ids: comboIds,
|
|
214
|
-
ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
|
|
215
|
-
combination_size: combo.txns.length,
|
|
216
|
-
difference: combo.diff,
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
}
|
|
53
|
+
if (result.confidence === 'none') {
|
|
54
|
+
match.recommendation = 'This bank transaction is not in YNAB. Consider adding it.';
|
|
220
55
|
}
|
|
221
56
|
|
|
222
|
-
return
|
|
57
|
+
return match;
|
|
223
58
|
}
|
|
224
59
|
|
|
225
|
-
type ParserResult =
|
|
226
|
-
| {
|
|
227
|
-
transactions: unknown[];
|
|
228
|
-
format_detected?: string;
|
|
229
|
-
delimiter?: string;
|
|
230
|
-
total_rows?: number;
|
|
231
|
-
valid_rows?: number;
|
|
232
|
-
errors?: string[];
|
|
233
|
-
}
|
|
234
|
-
| unknown[];
|
|
235
|
-
|
|
236
|
-
function isParsedCSVData(
|
|
237
|
-
result: ParserResult,
|
|
238
|
-
): result is Extract<ParserResult, { transactions: unknown[] }> {
|
|
239
|
-
return (
|
|
240
|
-
typeof result === 'object' &&
|
|
241
|
-
result !== null &&
|
|
242
|
-
!Array.isArray(result) &&
|
|
243
|
-
'transactions' in result
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function normalizeDate(value: unknown): string {
|
|
248
|
-
if (value instanceof Date) {
|
|
249
|
-
return value.toISOString().split('T')[0]!;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (typeof value === 'string') {
|
|
253
|
-
const trimmed = value.trim();
|
|
254
|
-
if (!trimmed) return trimmed;
|
|
255
|
-
|
|
256
|
-
const parsed = new Date(trimmed);
|
|
257
|
-
if (!Number.isNaN(parsed.getTime())) {
|
|
258
|
-
return parsed.toISOString().split('T')[0]!;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return trimmed;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return new Date().toISOString().split('T')[0]!;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function normalizeAmount(record: Record<string, unknown>): number {
|
|
268
|
-
const raw = record['amount'];
|
|
269
|
-
|
|
270
|
-
if (typeof raw === 'number') {
|
|
271
|
-
if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
|
|
272
|
-
return Math.round(raw) / 1000;
|
|
273
|
-
}
|
|
274
|
-
return raw;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (typeof raw === 'string') {
|
|
278
|
-
const cleaned = raw.replace(/[$,\s]/g, '');
|
|
279
|
-
const parsed = Number.parseFloat(cleaned);
|
|
280
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return 0;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function normalizePayee(record: Record<string, unknown>): string {
|
|
287
|
-
const candidates = [record['payee'], record['description'], record['memo']];
|
|
288
|
-
for (const candidate of candidates) {
|
|
289
|
-
if (typeof candidate === 'string' && candidate.trim()) {
|
|
290
|
-
return candidate.trim();
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return 'Unknown Payee';
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function determineRow(record: Record<string, unknown>, index: number): number {
|
|
297
|
-
if (typeof record['original_csv_row'] === 'number') {
|
|
298
|
-
return record['original_csv_row'];
|
|
299
|
-
}
|
|
300
|
-
if (typeof record['row_number'] === 'number') {
|
|
301
|
-
return record['row_number'];
|
|
302
|
-
}
|
|
303
|
-
return index + 1;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function convertParserRecord(record: unknown, index: number): BankTransaction {
|
|
307
|
-
const data =
|
|
308
|
-
typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {};
|
|
309
|
-
|
|
310
|
-
const dateValue = normalizeDate(data['date']);
|
|
311
|
-
const amountValue = normalizeAmount(data);
|
|
312
|
-
const payeeValue = normalizePayee(data);
|
|
313
|
-
const memoValue =
|
|
314
|
-
typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
|
|
315
|
-
const originalRow = determineRow(data, index);
|
|
316
|
-
|
|
317
|
-
const transaction: BankTransaction = {
|
|
318
|
-
id: randomUUID(),
|
|
319
|
-
date: dateValue,
|
|
320
|
-
amount: amountValue,
|
|
321
|
-
payee: payeeValue,
|
|
322
|
-
original_csv_row: originalRow,
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
if (memoValue !== undefined) {
|
|
326
|
-
transaction.memo = memoValue;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return transaction;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function parseBankStatement(csvContent: string, csvFilePath?: string): BankTransaction[] {
|
|
333
|
-
const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
|
|
334
|
-
|
|
335
|
-
let format: ParserCSVFormat = FALLBACK_CSV_FORMAT;
|
|
336
|
-
let autoDetect: ((content: string) => ParserCSVFormat) | undefined;
|
|
337
|
-
try {
|
|
338
|
-
autoDetect = (bankParser as { autoDetectCSVFormat?: (content: string) => ParserCSVFormat })
|
|
339
|
-
.autoDetectCSVFormat;
|
|
340
|
-
} catch {
|
|
341
|
-
autoDetect = undefined;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (typeof autoDetect === 'function') {
|
|
345
|
-
try {
|
|
346
|
-
format = autoDetect(content);
|
|
347
|
-
} catch {
|
|
348
|
-
format = FALLBACK_CSV_FORMAT;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const rawResult = bankParser.parseBankCSV(content, format) as unknown as ParserResult;
|
|
353
|
-
const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
|
|
354
|
-
|
|
355
|
-
return records.map(convertParserRecord);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Categorize matches by confidence level
|
|
360
|
-
*/
|
|
361
|
-
function categorizeMatches(matches: TransactionMatch[]): {
|
|
362
|
-
autoMatches: TransactionMatch[];
|
|
363
|
-
suggestedMatches: TransactionMatch[];
|
|
364
|
-
unmatchedBank: BankTransaction[];
|
|
365
|
-
} {
|
|
366
|
-
const autoMatches: TransactionMatch[] = [];
|
|
367
|
-
const suggestedMatches: TransactionMatch[] = [];
|
|
368
|
-
const unmatchedBank: BankTransaction[] = [];
|
|
369
|
-
|
|
370
|
-
for (const match of matches) {
|
|
371
|
-
if (match.confidence === 'high') {
|
|
372
|
-
autoMatches.push(match);
|
|
373
|
-
} else if (match.confidence === 'medium') {
|
|
374
|
-
suggestedMatches.push(match);
|
|
375
|
-
} else {
|
|
376
|
-
// low or none confidence
|
|
377
|
-
unmatchedBank.push(match.bank_transaction);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
return { autoMatches, suggestedMatches, unmatchedBank };
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Find unmatched YNAB transactions
|
|
386
|
-
* These are transactions in YNAB that don't appear on the bank statement
|
|
387
|
-
*/
|
|
388
|
-
function findUnmatchedYNAB(
|
|
389
|
-
ynabTransactions: YNABTransaction[],
|
|
390
|
-
matches: TransactionMatch[],
|
|
391
|
-
): YNABTransaction[] {
|
|
392
|
-
const matchedIds = new Set<string>();
|
|
393
|
-
|
|
394
|
-
for (const match of matches) {
|
|
395
|
-
if (match.ynab_transaction) {
|
|
396
|
-
matchedIds.add(match.ynab_transaction.id);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
return ynabTransactions.filter((txn) => !matchedIds.has(txn.id));
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Calculate balance information
|
|
405
|
-
*/
|
|
406
60
|
function calculateBalances(
|
|
407
61
|
ynabTransactions: YNABTransaction[],
|
|
408
|
-
|
|
62
|
+
statementBalanceDecimal: number,
|
|
409
63
|
currency: string,
|
|
64
|
+
accountSnapshot?: { balance?: number; cleared_balance?: number; uncleared_balance?: number },
|
|
410
65
|
): BalanceInfo {
|
|
411
|
-
|
|
412
|
-
|
|
66
|
+
// Compute from the fetched transactions, but prefer the authoritative account snapshot
|
|
67
|
+
// because we usually fetch a limited date window.
|
|
68
|
+
let computedCleared = 0;
|
|
69
|
+
let computedUncleared = 0;
|
|
413
70
|
|
|
414
71
|
for (const txn of ynabTransactions) {
|
|
415
|
-
const amount = txn.amount
|
|
72
|
+
const amount = txn.amount; // Milliunits
|
|
416
73
|
|
|
417
74
|
if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
|
|
418
|
-
|
|
75
|
+
computedCleared += amount;
|
|
419
76
|
} else {
|
|
420
|
-
|
|
77
|
+
computedUncleared += amount;
|
|
421
78
|
}
|
|
422
79
|
}
|
|
423
80
|
|
|
424
|
-
const
|
|
425
|
-
const
|
|
81
|
+
const clearedBalance = accountSnapshot?.cleared_balance ?? computedCleared;
|
|
82
|
+
const unclearedBalance = accountSnapshot?.uncleared_balance ?? computedUncleared;
|
|
83
|
+
const totalBalance = accountSnapshot?.balance ?? clearedBalance + unclearedBalance;
|
|
84
|
+
|
|
85
|
+
const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
|
|
86
|
+
const discrepancy = clearedBalance - statementBalanceMilli;
|
|
426
87
|
|
|
427
88
|
return {
|
|
428
|
-
current_cleared:
|
|
429
|
-
current_uncleared:
|
|
430
|
-
current_total:
|
|
431
|
-
target_statement:
|
|
432
|
-
discrepancy:
|
|
433
|
-
on_track: Math.abs(discrepancy) <
|
|
89
|
+
current_cleared: toMoneyValue(clearedBalance, currency),
|
|
90
|
+
current_uncleared: toMoneyValue(unclearedBalance, currency),
|
|
91
|
+
current_total: toMoneyValue(totalBalance, currency),
|
|
92
|
+
target_statement: toMoneyValue(statementBalanceMilli, currency),
|
|
93
|
+
discrepancy: toMoneyValue(discrepancy, currency),
|
|
94
|
+
on_track: Math.abs(discrepancy) < 10, // Within 1 cent (10 milliunits)
|
|
434
95
|
};
|
|
435
96
|
}
|
|
436
97
|
|
|
437
|
-
/**
|
|
438
|
-
* Generate reconciliation summary
|
|
439
|
-
*/
|
|
440
98
|
function generateSummary(
|
|
441
99
|
bankTransactions: BankTransaction[],
|
|
442
100
|
ynabTransactions: YNABTransaction[],
|
|
@@ -485,9 +143,6 @@ function generateSummary(
|
|
|
485
143
|
};
|
|
486
144
|
}
|
|
487
145
|
|
|
488
|
-
/**
|
|
489
|
-
* Generate next steps for user
|
|
490
|
-
*/
|
|
491
146
|
function generateNextSteps(summary: ReconciliationSummary): string[] {
|
|
492
147
|
const steps: string[] = [];
|
|
493
148
|
|
|
@@ -516,26 +171,32 @@ function generateNextSteps(summary: ReconciliationSummary): string[] {
|
|
|
516
171
|
return steps;
|
|
517
172
|
}
|
|
518
173
|
|
|
519
|
-
function formatCurrency(
|
|
174
|
+
function formatCurrency(amountMilli: number, currency: string = 'USD'): string {
|
|
520
175
|
const formatter = new Intl.NumberFormat('en-US', {
|
|
521
176
|
style: 'currency',
|
|
522
|
-
currency:
|
|
177
|
+
currency: currency,
|
|
523
178
|
minimumFractionDigits: 2,
|
|
524
179
|
maximumFractionDigits: 2,
|
|
525
180
|
});
|
|
526
|
-
return formatter.format(
|
|
181
|
+
return formatter.format(amountMilli / 1000);
|
|
527
182
|
}
|
|
528
183
|
|
|
529
|
-
|
|
184
|
+
// --- Insight Generation ---
|
|
185
|
+
|
|
186
|
+
function repeatAmountInsights(
|
|
187
|
+
unmatchedBank: BankTransaction[],
|
|
188
|
+
currency: string = 'USD',
|
|
189
|
+
): ReconciliationInsight[] {
|
|
530
190
|
const insights: ReconciliationInsight[] = [];
|
|
531
191
|
if (unmatchedBank.length === 0) {
|
|
532
192
|
return insights;
|
|
533
193
|
}
|
|
534
194
|
|
|
535
|
-
|
|
195
|
+
// Group by milliunits amount
|
|
196
|
+
const frequency = new Map<number, { amount: number; txns: BankTransaction[] }>();
|
|
536
197
|
|
|
537
198
|
for (const txn of unmatchedBank) {
|
|
538
|
-
const key = txn.amount
|
|
199
|
+
const key = txn.amount;
|
|
539
200
|
const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
|
|
540
201
|
entry.txns.push(txn);
|
|
541
202
|
frequency.set(key, entry);
|
|
@@ -551,88 +212,34 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
|
|
|
551
212
|
|
|
552
213
|
const top = repeated[0]!;
|
|
553
214
|
insights.push({
|
|
554
|
-
id: `repeat-${top.amount
|
|
215
|
+
id: `repeat-${top.amount}`,
|
|
555
216
|
type: 'repeat_amount',
|
|
556
217
|
severity: top.txns.length >= 4 ? 'critical' : 'warning',
|
|
557
|
-
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount)}`,
|
|
218
|
+
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
|
|
558
219
|
description:
|
|
559
|
-
`The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount)}. ` +
|
|
220
|
+
`The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. ` +
|
|
560
221
|
'Repeated amounts are usually the quickest wins — reconcile these first.',
|
|
561
222
|
evidence: {
|
|
562
|
-
amount: top.amount,
|
|
223
|
+
amount: top.amount, // Milliunits
|
|
563
224
|
occurrences: top.txns.length,
|
|
564
225
|
dates: top.txns.map((txn) => txn.date),
|
|
565
|
-
csv_rows: top.txns.map((txn) => txn.
|
|
226
|
+
csv_rows: top.txns.map((txn) => txn.sourceRow),
|
|
566
227
|
},
|
|
567
228
|
});
|
|
568
229
|
|
|
569
230
|
return insights;
|
|
570
231
|
}
|
|
571
232
|
|
|
572
|
-
function
|
|
573
|
-
matches: TransactionMatch[],
|
|
574
|
-
config: MatchingConfig,
|
|
575
|
-
): ReconciliationInsight[] {
|
|
233
|
+
function anomalyInsights(balances: BalanceInfo): ReconciliationInsight[] {
|
|
576
234
|
const insights: ReconciliationInsight[] = [];
|
|
235
|
+
const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
|
|
577
236
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (match.confidence === 'high') continue;
|
|
581
|
-
|
|
582
|
-
const topCandidate = match.candidates[0]!;
|
|
583
|
-
const score = topCandidate.confidence;
|
|
584
|
-
const highSignal =
|
|
585
|
-
(match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
|
|
586
|
-
(match.confidence === 'low' && score >= config.suggestionThreshold) ||
|
|
587
|
-
(match.confidence === 'none' && score >= config.suggestionThreshold);
|
|
588
|
-
|
|
589
|
-
if (!highSignal) continue;
|
|
590
|
-
|
|
591
|
-
const bankTxn = match.bank_transaction;
|
|
592
|
-
const ynabTxn = topCandidate.ynab_transaction;
|
|
593
|
-
|
|
594
|
-
insights.push({
|
|
595
|
-
id: `near-${bankTxn.id}`,
|
|
596
|
-
type: 'near_match',
|
|
597
|
-
severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
|
|
598
|
-
title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
|
|
599
|
-
description:
|
|
600
|
-
`Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
|
|
601
|
-
`${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
|
|
602
|
-
evidence: {
|
|
603
|
-
bank_transaction: {
|
|
604
|
-
id: bankTxn.id,
|
|
605
|
-
date: bankTxn.date,
|
|
606
|
-
amount: bankTxn.amount,
|
|
607
|
-
payee: bankTxn.payee,
|
|
608
|
-
},
|
|
609
|
-
candidate: {
|
|
610
|
-
id: ynabTxn.id,
|
|
611
|
-
date: ynabTxn.date,
|
|
612
|
-
amount_milliunits: ynabTxn.amount,
|
|
613
|
-
payee_name: ynabTxn.payee_name,
|
|
614
|
-
confidence: score,
|
|
615
|
-
reasons: topCandidate.match_reason,
|
|
616
|
-
},
|
|
617
|
-
},
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
return insights.slice(0, 3);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function anomalyInsights(
|
|
625
|
-
summary: ReconciliationSummary,
|
|
626
|
-
balances: BalanceInfo,
|
|
627
|
-
): ReconciliationInsight[] {
|
|
628
|
-
const insights: ReconciliationInsight[] = [];
|
|
629
|
-
const discrepancyAbs = Math.abs(balances.discrepancy.value);
|
|
630
|
-
|
|
631
|
-
if (discrepancyAbs >= 1) {
|
|
237
|
+
if (discrepancyAbs >= 1000) {
|
|
238
|
+
// 1 dollar
|
|
632
239
|
insights.push({
|
|
633
240
|
id: 'balance-gap',
|
|
634
241
|
type: 'anomaly',
|
|
635
|
-
severity: discrepancyAbs >=
|
|
242
|
+
severity: discrepancyAbs >= 100000 ? 'critical' : 'warning', // 100 dollars
|
|
636
243
|
title: `Cleared balance off by ${balances.discrepancy.value_display}`,
|
|
637
244
|
description:
|
|
638
245
|
`YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
|
|
@@ -645,30 +252,16 @@ function anomalyInsights(
|
|
|
645
252
|
});
|
|
646
253
|
}
|
|
647
254
|
|
|
648
|
-
if (summary.unmatched_bank >= 5) {
|
|
649
|
-
insights.push({
|
|
650
|
-
id: 'bulk-missing-bank',
|
|
651
|
-
type: 'anomaly',
|
|
652
|
-
severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
|
|
653
|
-
title: `${summary.unmatched_bank} bank transactions still unmatched`,
|
|
654
|
-
description:
|
|
655
|
-
`There are ${summary.unmatched_bank} bank transactions without a match. ` +
|
|
656
|
-
'Consider bulk importing or reviewing by date sequence.',
|
|
657
|
-
evidence: {
|
|
658
|
-
unmatched_bank: summary.unmatched_bank,
|
|
659
|
-
},
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
|
|
663
255
|
return insights;
|
|
664
256
|
}
|
|
665
257
|
|
|
666
258
|
function detectInsights(
|
|
667
|
-
matches: TransactionMatch[],
|
|
668
259
|
unmatchedBank: BankTransaction[],
|
|
669
|
-
|
|
260
|
+
_summary: ReconciliationSummary,
|
|
670
261
|
balances: BalanceInfo,
|
|
671
|
-
|
|
262
|
+
currency: string,
|
|
263
|
+
csvErrors: { row: number; field: string; message: string }[] = [],
|
|
264
|
+
csvWarnings: { row: number; message: string }[] = [],
|
|
672
265
|
): ReconciliationInsight[] {
|
|
673
266
|
const insights: ReconciliationInsight[] = [];
|
|
674
267
|
const seen = new Set<string>();
|
|
@@ -679,45 +272,61 @@ function detectInsights(
|
|
|
679
272
|
insights.push(insight);
|
|
680
273
|
};
|
|
681
274
|
|
|
682
|
-
|
|
683
|
-
|
|
275
|
+
// Surface CSV parsing errors
|
|
276
|
+
if (csvErrors.length > 0) {
|
|
277
|
+
addUnique({
|
|
278
|
+
id: 'csv-parse-errors',
|
|
279
|
+
type: 'anomaly',
|
|
280
|
+
severity: csvErrors.length >= 5 ? 'critical' : 'warning',
|
|
281
|
+
title: `${csvErrors.length} CSV parsing error(s)`,
|
|
282
|
+
description:
|
|
283
|
+
csvErrors
|
|
284
|
+
.slice(0, 3)
|
|
285
|
+
.map((e) => `Row ${e.row}: ${e.message}`)
|
|
286
|
+
.join('; ') + (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ''),
|
|
287
|
+
evidence: {
|
|
288
|
+
error_count: csvErrors.length,
|
|
289
|
+
errors: csvErrors.slice(0, 5),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
684
292
|
}
|
|
685
293
|
|
|
686
|
-
|
|
294
|
+
// Surface CSV parsing warnings
|
|
295
|
+
if (csvWarnings.length > 0) {
|
|
296
|
+
addUnique({
|
|
297
|
+
id: 'csv-parse-warnings',
|
|
298
|
+
type: 'anomaly',
|
|
299
|
+
severity: 'info',
|
|
300
|
+
title: `${csvWarnings.length} CSV parsing warning(s)`,
|
|
301
|
+
description:
|
|
302
|
+
csvWarnings
|
|
303
|
+
.slice(0, 3)
|
|
304
|
+
.map((w) => `Row ${w.row}: ${w.message}`)
|
|
305
|
+
.join('; ') + (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ''),
|
|
306
|
+
evidence: {
|
|
307
|
+
warning_count: csvWarnings.length,
|
|
308
|
+
warnings: csvWarnings.slice(0, 5),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
|
|
687
314
|
addUnique(insight);
|
|
688
315
|
}
|
|
689
316
|
|
|
690
|
-
for (const insight of anomalyInsights(
|
|
317
|
+
for (const insight of anomalyInsights(balances)) {
|
|
691
318
|
addUnique(insight);
|
|
692
319
|
}
|
|
693
320
|
|
|
694
321
|
return insights.slice(0, 5);
|
|
695
322
|
}
|
|
696
323
|
|
|
697
|
-
|
|
698
|
-
base: ReconciliationInsight[],
|
|
699
|
-
additional: ReconciliationInsight[],
|
|
700
|
-
): ReconciliationInsight[] {
|
|
701
|
-
if (additional.length === 0) {
|
|
702
|
-
return base;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
const seen = new Set(base.map((insight) => insight.id));
|
|
706
|
-
const merged = [...base];
|
|
707
|
-
|
|
708
|
-
for (const insight of additional) {
|
|
709
|
-
if (seen.has(insight.id)) continue;
|
|
710
|
-
seen.add(insight.id);
|
|
711
|
-
merged.push(insight);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
return merged.slice(0, 5);
|
|
715
|
-
}
|
|
324
|
+
// --- Main Analysis Function ---
|
|
716
325
|
|
|
717
326
|
/**
|
|
718
327
|
* Perform reconciliation analysis
|
|
719
328
|
*
|
|
720
|
-
* @param
|
|
329
|
+
* @param csvContentOrParsed - CSV file content or pre-parsed result
|
|
721
330
|
* @param csvFilePath - Optional file path (if csvContent is a path)
|
|
722
331
|
* @param ynabTransactions - YNAB transactions from API
|
|
723
332
|
* @param statementBalance - Expected cleared balance from statement
|
|
@@ -726,63 +335,89 @@ function mergeInsights(
|
|
|
726
335
|
* @param accountId - Account ID for recommendation context
|
|
727
336
|
* @param budgetId - Budget ID for recommendation context
|
|
728
337
|
* @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
|
|
338
|
+
* @param csvOptions - Optional CSV parsing options (manual overrides)
|
|
729
339
|
*/
|
|
730
340
|
export function analyzeReconciliation(
|
|
731
|
-
|
|
732
|
-
|
|
341
|
+
csvContentOrParsed: string | CSVParseResult,
|
|
342
|
+
_csvFilePath: string | undefined,
|
|
733
343
|
ynabTransactions: ynab.TransactionDetail[],
|
|
734
344
|
statementBalance: number,
|
|
735
|
-
config: MatchingConfig =
|
|
345
|
+
config: MatchingConfig = DEFAULT_CONFIG,
|
|
736
346
|
currency: string = 'USD',
|
|
737
347
|
accountId?: string,
|
|
738
348
|
budgetId?: string,
|
|
739
349
|
invertBankAmounts: boolean = false,
|
|
350
|
+
csvOptions?: ParseCSVOptions,
|
|
351
|
+
accountSnapshot?: { balance?: number; cleared_balance?: number; uncleared_balance?: number },
|
|
740
352
|
): ReconciliationAnalysis {
|
|
741
|
-
// Step 1: Parse bank CSV
|
|
742
|
-
let
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}));
|
|
353
|
+
// Step 1: Parse bank CSV using new Parser (or use provided result)
|
|
354
|
+
let parseResult: CSVParseResult;
|
|
355
|
+
|
|
356
|
+
if (typeof csvContentOrParsed === 'string') {
|
|
357
|
+
parseResult = parseCSV(csvContentOrParsed, {
|
|
358
|
+
...csvOptions,
|
|
359
|
+
invertAmounts: invertBankAmounts,
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
parseResult = csvContentOrParsed;
|
|
752
363
|
}
|
|
753
364
|
|
|
754
|
-
|
|
755
|
-
const
|
|
365
|
+
const newBankTransactions = parseResult.transactions;
|
|
366
|
+
const csvParseErrors = parseResult.errors;
|
|
367
|
+
const csvParseWarnings = parseResult.warnings;
|
|
756
368
|
|
|
757
|
-
// Step
|
|
758
|
-
const
|
|
369
|
+
// Step 2: Normalize YNAB transactions
|
|
370
|
+
const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
759
371
|
|
|
760
|
-
// Step
|
|
761
|
-
|
|
372
|
+
// Step 3: Run new matching algorithm
|
|
373
|
+
// Use normalizeConfig to convert legacy config to V2 format with defaults
|
|
374
|
+
const normalizedConfig = normalizeConfig(config);
|
|
762
375
|
|
|
763
|
-
|
|
764
|
-
const
|
|
376
|
+
const newMatches = findMatches(newBankTransactions, newYNABTransactions, normalizedConfig);
|
|
377
|
+
const matches: TransactionMatch[] = newMatches.map(mapToTransactionMatch);
|
|
765
378
|
|
|
766
|
-
|
|
767
|
-
|
|
379
|
+
// Categorize
|
|
380
|
+
const autoMatches = matches.filter((m) => m.confidence === 'high');
|
|
768
381
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
382
|
+
// Build set of YNAB transaction IDs that are already auto-matched
|
|
383
|
+
const autoMatchedYnabIds = new Set<string>();
|
|
384
|
+
autoMatches.forEach((m) => {
|
|
385
|
+
if (m.ynabTransaction) autoMatchedYnabIds.add(m.ynabTransaction.id);
|
|
386
|
+
});
|
|
774
387
|
|
|
775
|
-
|
|
388
|
+
// Only suggest matches for YNAB transactions NOT already auto-matched
|
|
389
|
+
const suggestedMatches = matches.filter(
|
|
390
|
+
(m) =>
|
|
391
|
+
m.confidence === 'medium' &&
|
|
392
|
+
(!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const unmatchedBankMatches = matches.filter(
|
|
396
|
+
(m) => m.confidence === 'low' || m.confidence === 'none',
|
|
397
|
+
);
|
|
398
|
+
const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
|
|
399
|
+
|
|
400
|
+
// Find unmatched YNAB
|
|
401
|
+
const matchedYnabIds = new Set<string>();
|
|
402
|
+
matches.forEach((m) => {
|
|
403
|
+
if (m.ynabTransaction) matchedYnabIds.add(m.ynabTransaction.id);
|
|
404
|
+
});
|
|
405
|
+
const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
|
|
776
406
|
|
|
777
407
|
// Step 6: Calculate balances
|
|
778
|
-
const balances = calculateBalances(
|
|
408
|
+
const balances = calculateBalances(
|
|
409
|
+
newYNABTransactions,
|
|
410
|
+
statementBalance,
|
|
411
|
+
currency,
|
|
412
|
+
accountSnapshot,
|
|
413
|
+
);
|
|
779
414
|
|
|
780
415
|
// Step 7: Generate summary
|
|
781
416
|
const summary = generateSummary(
|
|
782
|
-
|
|
783
|
-
|
|
417
|
+
matches.map((m) => m.bankTransaction),
|
|
418
|
+
newYNABTransactions,
|
|
784
419
|
autoMatches,
|
|
785
|
-
|
|
420
|
+
suggestedMatches,
|
|
786
421
|
unmatchedBank,
|
|
787
422
|
unmatchedYNAB,
|
|
788
423
|
balances,
|
|
@@ -791,9 +426,15 @@ export function analyzeReconciliation(
|
|
|
791
426
|
// Step 8: Generate next steps
|
|
792
427
|
const nextSteps = generateNextSteps(summary);
|
|
793
428
|
|
|
794
|
-
// Step 9: Detect insights
|
|
795
|
-
const
|
|
796
|
-
|
|
429
|
+
// Step 9: Detect insights (including any CSV parsing issues)
|
|
430
|
+
const insights = detectInsights(
|
|
431
|
+
unmatchedBank,
|
|
432
|
+
summary,
|
|
433
|
+
balances,
|
|
434
|
+
currency,
|
|
435
|
+
csvParseErrors,
|
|
436
|
+
csvParseWarnings,
|
|
437
|
+
);
|
|
797
438
|
|
|
798
439
|
// Step 10: Build the analysis result
|
|
799
440
|
const analysis: ReconciliationAnalysis = {
|
|
@@ -801,7 +442,7 @@ export function analyzeReconciliation(
|
|
|
801
442
|
phase: 'analysis',
|
|
802
443
|
summary,
|
|
803
444
|
auto_matches: autoMatches,
|
|
804
|
-
suggested_matches:
|
|
445
|
+
suggested_matches: suggestedMatches,
|
|
805
446
|
unmatched_bank: unmatchedBank,
|
|
806
447
|
unmatched_ynab: unmatchedYNAB,
|
|
807
448
|
balance_info: balances,
|
|
@@ -809,13 +450,13 @@ export function analyzeReconciliation(
|
|
|
809
450
|
insights,
|
|
810
451
|
};
|
|
811
452
|
|
|
812
|
-
// Step 11: Generate recommendations
|
|
453
|
+
// Step 11: Generate recommendations
|
|
813
454
|
if (accountId && budgetId) {
|
|
814
455
|
const recommendations = generateRecommendations({
|
|
815
456
|
account_id: accountId,
|
|
816
457
|
budget_id: budgetId,
|
|
817
458
|
analysis,
|
|
818
|
-
matching_config:
|
|
459
|
+
matching_config: normalizedConfig,
|
|
819
460
|
});
|
|
820
461
|
analysis.recommendations = recommendations;
|
|
821
462
|
}
|