@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -2
- package/dist/tools/reconciliation/analyzer.js +120 -404
- package/dist/tools/reconciliation/csvParser.d.ts +51 -0
- package/dist/tools/reconciliation/csvParser.js +413 -0
- package/dist/tools/reconciliation/executor.d.ts +8 -0
- package/dist/tools/reconciliation/executor.js +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 +174 -545
- package/src/tools/reconciliation/csvParser.ts +617 -0
- package/src/tools/reconciliation/executor.ts +249 -66
- package/src/tools/reconciliation/index.ts +141 -48
- package/src/tools/reconciliation/matcher.ts +234 -214
- package/src/tools/reconciliation/recommendationEngine.ts +23 -19
- package/src/tools/reconciliation/reportFormatter.ts +16 -11
- package/src/tools/reconciliation/signDetector.ts +117 -0
- package/src/tools/reconciliation/types.ts +39 -61
- package/src/tools/reconciliation/ynabAdapter.ts +33 -0
- package/src/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,418 +1,72 @@
|
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
92
|
-
function computeCombinationConfidence(diff: number, tolerance: number, legCount: number): number {
|
|
93
|
-
const safeTolerance = tolerance > 0 ? tolerance : 0.01;
|
|
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;
|
|
102
|
-
}
|
|
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
|
-
|
|
115
|
-
function findCombinationMatches(
|
|
116
|
-
unmatchedBank: BankTransaction[],
|
|
117
|
-
unmatchedYNAB: YNABTransaction[],
|
|
118
|
-
config: MatchingConfig,
|
|
119
|
-
): CombinationResult {
|
|
120
|
-
if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
|
|
121
|
-
return { matches: [], insights: [] };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const tolerance = amountTolerance(config);
|
|
125
|
-
const toleranceDays = dateTolerance(config);
|
|
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
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return { matches, insights };
|
|
223
|
-
}
|
|
224
|
-
|
|
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,
|
|
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',
|
|
323
43
|
};
|
|
324
44
|
|
|
325
|
-
if (
|
|
326
|
-
|
|
45
|
+
if (result.bestMatch) {
|
|
46
|
+
match.ynabTransaction = result.bestMatch.ynabTransaction;
|
|
327
47
|
}
|
|
328
48
|
|
|
329
|
-
|
|
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;
|
|
49
|
+
if (result.candidates[0]) {
|
|
50
|
+
match.topConfidence = result.candidates[0].scores.combined;
|
|
342
51
|
}
|
|
343
52
|
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
format = autoDetect(content);
|
|
347
|
-
} catch {
|
|
348
|
-
format = FALLBACK_CSV_FORMAT;
|
|
349
|
-
}
|
|
53
|
+
if (result.confidence === 'none') {
|
|
54
|
+
match.recommendation = 'This bank transaction is not in YNAB. Consider adding it.';
|
|
350
55
|
}
|
|
351
56
|
|
|
352
|
-
|
|
353
|
-
const records = isParsedCSVData(rawResult) ? rawResult.transactions : rawResult;
|
|
354
|
-
|
|
355
|
-
return records.map(convertParserRecord);
|
|
57
|
+
return match;
|
|
356
58
|
}
|
|
357
59
|
|
|
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,
|
|
410
64
|
): BalanceInfo {
|
|
411
65
|
let clearedBalance = 0;
|
|
412
66
|
let unclearedBalance = 0;
|
|
413
67
|
|
|
414
68
|
for (const txn of ynabTransactions) {
|
|
415
|
-
const amount = txn.amount
|
|
69
|
+
const amount = txn.amount; // Milliunits
|
|
416
70
|
|
|
417
71
|
if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
|
|
418
72
|
clearedBalance += amount;
|
|
@@ -421,22 +75,20 @@ function calculateBalances(
|
|
|
421
75
|
}
|
|
422
76
|
}
|
|
423
77
|
|
|
78
|
+
const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
|
|
424
79
|
const totalBalance = clearedBalance + unclearedBalance;
|
|
425
|
-
const discrepancy = clearedBalance -
|
|
80
|
+
const discrepancy = clearedBalance - statementBalanceMilli;
|
|
426
81
|
|
|
427
82
|
return {
|
|
428
|
-
current_cleared:
|
|
429
|
-
current_uncleared:
|
|
430
|
-
current_total:
|
|
431
|
-
target_statement:
|
|
432
|
-
discrepancy:
|
|
433
|
-
on_track: Math.abs(discrepancy) <
|
|
83
|
+
current_cleared: toMoneyValue(clearedBalance, currency),
|
|
84
|
+
current_uncleared: toMoneyValue(unclearedBalance, currency),
|
|
85
|
+
current_total: toMoneyValue(totalBalance, currency),
|
|
86
|
+
target_statement: toMoneyValue(statementBalanceMilli, currency),
|
|
87
|
+
discrepancy: toMoneyValue(discrepancy, currency),
|
|
88
|
+
on_track: Math.abs(discrepancy) < 10, // Within 1 cent (10 milliunits)
|
|
434
89
|
};
|
|
435
90
|
}
|
|
436
91
|
|
|
437
|
-
/**
|
|
438
|
-
* Generate reconciliation summary
|
|
439
|
-
*/
|
|
440
92
|
function generateSummary(
|
|
441
93
|
bankTransactions: BankTransaction[],
|
|
442
94
|
ynabTransactions: YNABTransaction[],
|
|
@@ -485,9 +137,6 @@ function generateSummary(
|
|
|
485
137
|
};
|
|
486
138
|
}
|
|
487
139
|
|
|
488
|
-
/**
|
|
489
|
-
* Generate next steps for user
|
|
490
|
-
*/
|
|
491
140
|
function generateNextSteps(summary: ReconciliationSummary): string[] {
|
|
492
141
|
const steps: string[] = [];
|
|
493
142
|
|
|
@@ -516,26 +165,32 @@ function generateNextSteps(summary: ReconciliationSummary): string[] {
|
|
|
516
165
|
return steps;
|
|
517
166
|
}
|
|
518
167
|
|
|
519
|
-
function formatCurrency(
|
|
168
|
+
function formatCurrency(amountMilli: number, currency: string = 'USD'): string {
|
|
520
169
|
const formatter = new Intl.NumberFormat('en-US', {
|
|
521
170
|
style: 'currency',
|
|
522
|
-
currency:
|
|
171
|
+
currency: currency,
|
|
523
172
|
minimumFractionDigits: 2,
|
|
524
173
|
maximumFractionDigits: 2,
|
|
525
174
|
});
|
|
526
|
-
return formatter.format(
|
|
175
|
+
return formatter.format(amountMilli / 1000);
|
|
527
176
|
}
|
|
528
177
|
|
|
529
|
-
|
|
178
|
+
// --- Insight Generation ---
|
|
179
|
+
|
|
180
|
+
function repeatAmountInsights(
|
|
181
|
+
unmatchedBank: BankTransaction[],
|
|
182
|
+
currency: string = 'USD',
|
|
183
|
+
): ReconciliationInsight[] {
|
|
530
184
|
const insights: ReconciliationInsight[] = [];
|
|
531
185
|
if (unmatchedBank.length === 0) {
|
|
532
186
|
return insights;
|
|
533
187
|
}
|
|
534
188
|
|
|
535
|
-
|
|
189
|
+
// Group by milliunits amount
|
|
190
|
+
const frequency = new Map<number, { amount: number; txns: BankTransaction[] }>();
|
|
536
191
|
|
|
537
192
|
for (const txn of unmatchedBank) {
|
|
538
|
-
const key = txn.amount
|
|
193
|
+
const key = txn.amount;
|
|
539
194
|
const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
|
|
540
195
|
entry.txns.push(txn);
|
|
541
196
|
frequency.set(key, entry);
|
|
@@ -551,88 +206,34 @@ function repeatAmountInsights(unmatchedBank: BankTransaction[]): ReconciliationI
|
|
|
551
206
|
|
|
552
207
|
const top = repeated[0]!;
|
|
553
208
|
insights.push({
|
|
554
|
-
id: `repeat-${top.amount
|
|
209
|
+
id: `repeat-${top.amount}`,
|
|
555
210
|
type: 'repeat_amount',
|
|
556
211
|
severity: top.txns.length >= 4 ? 'critical' : 'warning',
|
|
557
|
-
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount)}`,
|
|
212
|
+
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
|
|
558
213
|
description:
|
|
559
|
-
`The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount)}. ` +
|
|
214
|
+
`The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. ` +
|
|
560
215
|
'Repeated amounts are usually the quickest wins — reconcile these first.',
|
|
561
216
|
evidence: {
|
|
562
|
-
amount: top.amount,
|
|
217
|
+
amount: top.amount, // Milliunits
|
|
563
218
|
occurrences: top.txns.length,
|
|
564
219
|
dates: top.txns.map((txn) => txn.date),
|
|
565
|
-
csv_rows: top.txns.map((txn) => txn.
|
|
220
|
+
csv_rows: top.txns.map((txn) => txn.sourceRow),
|
|
566
221
|
},
|
|
567
222
|
});
|
|
568
223
|
|
|
569
224
|
return insights;
|
|
570
225
|
}
|
|
571
226
|
|
|
572
|
-
function
|
|
573
|
-
matches: TransactionMatch[],
|
|
574
|
-
config: MatchingConfig,
|
|
575
|
-
): ReconciliationInsight[] {
|
|
227
|
+
function anomalyInsights(balances: BalanceInfo): ReconciliationInsight[] {
|
|
576
228
|
const insights: ReconciliationInsight[] = [];
|
|
229
|
+
const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
|
|
577
230
|
|
|
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) {
|
|
231
|
+
if (discrepancyAbs >= 1000) {
|
|
232
|
+
// 1 dollar
|
|
632
233
|
insights.push({
|
|
633
234
|
id: 'balance-gap',
|
|
634
235
|
type: 'anomaly',
|
|
635
|
-
severity: discrepancyAbs >=
|
|
236
|
+
severity: discrepancyAbs >= 100000 ? 'critical' : 'warning', // 100 dollars
|
|
636
237
|
title: `Cleared balance off by ${balances.discrepancy.value_display}`,
|
|
637
238
|
description:
|
|
638
239
|
`YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
|
|
@@ -645,30 +246,16 @@ function anomalyInsights(
|
|
|
645
246
|
});
|
|
646
247
|
}
|
|
647
248
|
|
|
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
249
|
return insights;
|
|
664
250
|
}
|
|
665
251
|
|
|
666
252
|
function detectInsights(
|
|
667
|
-
matches: TransactionMatch[],
|
|
668
253
|
unmatchedBank: BankTransaction[],
|
|
669
|
-
|
|
254
|
+
_summary: ReconciliationSummary,
|
|
670
255
|
balances: BalanceInfo,
|
|
671
|
-
|
|
256
|
+
currency: string,
|
|
257
|
+
csvErrors: { row: number; field: string; message: string }[] = [],
|
|
258
|
+
csvWarnings: { row: number; message: string }[] = [],
|
|
672
259
|
): ReconciliationInsight[] {
|
|
673
260
|
const insights: ReconciliationInsight[] = [];
|
|
674
261
|
const seen = new Set<string>();
|
|
@@ -679,45 +266,61 @@ function detectInsights(
|
|
|
679
266
|
insights.push(insight);
|
|
680
267
|
};
|
|
681
268
|
|
|
682
|
-
|
|
683
|
-
|
|
269
|
+
// Surface CSV parsing errors
|
|
270
|
+
if (csvErrors.length > 0) {
|
|
271
|
+
addUnique({
|
|
272
|
+
id: 'csv-parse-errors',
|
|
273
|
+
type: 'anomaly',
|
|
274
|
+
severity: csvErrors.length >= 5 ? 'critical' : 'warning',
|
|
275
|
+
title: `${csvErrors.length} CSV parsing error(s)`,
|
|
276
|
+
description:
|
|
277
|
+
csvErrors
|
|
278
|
+
.slice(0, 3)
|
|
279
|
+
.map((e) => `Row ${e.row}: ${e.message}`)
|
|
280
|
+
.join('; ') + (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ''),
|
|
281
|
+
evidence: {
|
|
282
|
+
error_count: csvErrors.length,
|
|
283
|
+
errors: csvErrors.slice(0, 5),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Surface CSV parsing warnings
|
|
289
|
+
if (csvWarnings.length > 0) {
|
|
290
|
+
addUnique({
|
|
291
|
+
id: 'csv-parse-warnings',
|
|
292
|
+
type: 'anomaly',
|
|
293
|
+
severity: 'info',
|
|
294
|
+
title: `${csvWarnings.length} CSV parsing warning(s)`,
|
|
295
|
+
description:
|
|
296
|
+
csvWarnings
|
|
297
|
+
.slice(0, 3)
|
|
298
|
+
.map((w) => `Row ${w.row}: ${w.message}`)
|
|
299
|
+
.join('; ') + (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ''),
|
|
300
|
+
evidence: {
|
|
301
|
+
warning_count: csvWarnings.length,
|
|
302
|
+
warnings: csvWarnings.slice(0, 5),
|
|
303
|
+
},
|
|
304
|
+
});
|
|
684
305
|
}
|
|
685
306
|
|
|
686
|
-
for (const insight of
|
|
307
|
+
for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
|
|
687
308
|
addUnique(insight);
|
|
688
309
|
}
|
|
689
310
|
|
|
690
|
-
for (const insight of anomalyInsights(
|
|
311
|
+
for (const insight of anomalyInsights(balances)) {
|
|
691
312
|
addUnique(insight);
|
|
692
313
|
}
|
|
693
314
|
|
|
694
315
|
return insights.slice(0, 5);
|
|
695
316
|
}
|
|
696
317
|
|
|
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
|
-
}
|
|
318
|
+
// --- Main Analysis Function ---
|
|
716
319
|
|
|
717
320
|
/**
|
|
718
321
|
* Perform reconciliation analysis
|
|
719
322
|
*
|
|
720
|
-
* @param
|
|
323
|
+
* @param csvContentOrParsed - CSV file content or pre-parsed result
|
|
721
324
|
* @param csvFilePath - Optional file path (if csvContent is a path)
|
|
722
325
|
* @param ynabTransactions - YNAB transactions from API
|
|
723
326
|
* @param statementBalance - Expected cleared balance from statement
|
|
@@ -726,63 +329,83 @@ function mergeInsights(
|
|
|
726
329
|
* @param accountId - Account ID for recommendation context
|
|
727
330
|
* @param budgetId - Budget ID for recommendation context
|
|
728
331
|
* @param invertBankAmounts - Whether to invert bank transaction amounts (for banks that show charges as positive)
|
|
332
|
+
* @param csvOptions - Optional CSV parsing options (manual overrides)
|
|
729
333
|
*/
|
|
730
334
|
export function analyzeReconciliation(
|
|
731
|
-
|
|
732
|
-
|
|
335
|
+
csvContentOrParsed: string | CSVParseResult,
|
|
336
|
+
_csvFilePath: string | undefined,
|
|
733
337
|
ynabTransactions: ynab.TransactionDetail[],
|
|
734
338
|
statementBalance: number,
|
|
735
|
-
config: MatchingConfig =
|
|
339
|
+
config: MatchingConfig = DEFAULT_CONFIG,
|
|
736
340
|
currency: string = 'USD',
|
|
737
341
|
accountId?: string,
|
|
738
342
|
budgetId?: string,
|
|
739
343
|
invertBankAmounts: boolean = false,
|
|
344
|
+
csvOptions?: ParseCSVOptions,
|
|
740
345
|
): ReconciliationAnalysis {
|
|
741
|
-
// Step 1: Parse bank CSV
|
|
742
|
-
let
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}));
|
|
346
|
+
// Step 1: Parse bank CSV using new Parser (or use provided result)
|
|
347
|
+
let parseResult: CSVParseResult;
|
|
348
|
+
|
|
349
|
+
if (typeof csvContentOrParsed === 'string') {
|
|
350
|
+
parseResult = parseCSV(csvContentOrParsed, {
|
|
351
|
+
...csvOptions,
|
|
352
|
+
invertAmounts: invertBankAmounts,
|
|
353
|
+
});
|
|
354
|
+
} else {
|
|
355
|
+
parseResult = csvContentOrParsed;
|
|
752
356
|
}
|
|
753
357
|
|
|
754
|
-
|
|
755
|
-
const
|
|
358
|
+
const newBankTransactions = parseResult.transactions;
|
|
359
|
+
const csvParseErrors = parseResult.errors;
|
|
360
|
+
const csvParseWarnings = parseResult.warnings;
|
|
756
361
|
|
|
757
|
-
// Step
|
|
758
|
-
const
|
|
362
|
+
// Step 2: Normalize YNAB transactions
|
|
363
|
+
const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
759
364
|
|
|
760
|
-
// Step
|
|
761
|
-
|
|
365
|
+
// Step 3: Run new matching algorithm
|
|
366
|
+
// Use normalizeConfig to convert legacy config to V2 format with defaults
|
|
367
|
+
const normalizedConfig = normalizeConfig(config);
|
|
762
368
|
|
|
763
|
-
|
|
764
|
-
const
|
|
369
|
+
const newMatches = findMatches(newBankTransactions, newYNABTransactions, normalizedConfig);
|
|
370
|
+
const matches: TransactionMatch[] = newMatches.map(mapToTransactionMatch);
|
|
765
371
|
|
|
766
|
-
|
|
767
|
-
|
|
372
|
+
// Categorize
|
|
373
|
+
const autoMatches = matches.filter((m) => m.confidence === 'high');
|
|
768
374
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
375
|
+
// Build set of YNAB transaction IDs that are already auto-matched
|
|
376
|
+
const autoMatchedYnabIds = new Set<string>();
|
|
377
|
+
autoMatches.forEach((m) => {
|
|
378
|
+
if (m.ynabTransaction) autoMatchedYnabIds.add(m.ynabTransaction.id);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Only suggest matches for YNAB transactions NOT already auto-matched
|
|
382
|
+
const suggestedMatches = matches.filter(
|
|
383
|
+
(m) =>
|
|
384
|
+
m.confidence === 'medium' &&
|
|
385
|
+
(!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const unmatchedBankMatches = matches.filter(
|
|
389
|
+
(m) => m.confidence === 'low' || m.confidence === 'none',
|
|
390
|
+
);
|
|
391
|
+
const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
|
|
774
392
|
|
|
775
|
-
|
|
393
|
+
// Find unmatched YNAB
|
|
394
|
+
const matchedYnabIds = new Set<string>();
|
|
395
|
+
matches.forEach((m) => {
|
|
396
|
+
if (m.ynabTransaction) matchedYnabIds.add(m.ynabTransaction.id);
|
|
397
|
+
});
|
|
398
|
+
const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
|
|
776
399
|
|
|
777
400
|
// Step 6: Calculate balances
|
|
778
|
-
const balances = calculateBalances(
|
|
401
|
+
const balances = calculateBalances(newYNABTransactions, statementBalance, currency);
|
|
779
402
|
|
|
780
403
|
// Step 7: Generate summary
|
|
781
404
|
const summary = generateSummary(
|
|
782
|
-
|
|
783
|
-
|
|
405
|
+
matches.map((m) => m.bankTransaction),
|
|
406
|
+
newYNABTransactions,
|
|
784
407
|
autoMatches,
|
|
785
|
-
|
|
408
|
+
suggestedMatches,
|
|
786
409
|
unmatchedBank,
|
|
787
410
|
unmatchedYNAB,
|
|
788
411
|
balances,
|
|
@@ -791,9 +414,15 @@ export function analyzeReconciliation(
|
|
|
791
414
|
// Step 8: Generate next steps
|
|
792
415
|
const nextSteps = generateNextSteps(summary);
|
|
793
416
|
|
|
794
|
-
// Step 9: Detect insights
|
|
795
|
-
const
|
|
796
|
-
|
|
417
|
+
// Step 9: Detect insights (including any CSV parsing issues)
|
|
418
|
+
const insights = detectInsights(
|
|
419
|
+
unmatchedBank,
|
|
420
|
+
summary,
|
|
421
|
+
balances,
|
|
422
|
+
currency,
|
|
423
|
+
csvParseErrors,
|
|
424
|
+
csvParseWarnings,
|
|
425
|
+
);
|
|
797
426
|
|
|
798
427
|
// Step 10: Build the analysis result
|
|
799
428
|
const analysis: ReconciliationAnalysis = {
|
|
@@ -801,7 +430,7 @@ export function analyzeReconciliation(
|
|
|
801
430
|
phase: 'analysis',
|
|
802
431
|
summary,
|
|
803
432
|
auto_matches: autoMatches,
|
|
804
|
-
suggested_matches:
|
|
433
|
+
suggested_matches: suggestedMatches,
|
|
805
434
|
unmatched_bank: unmatchedBank,
|
|
806
435
|
unmatched_ynab: unmatchedYNAB,
|
|
807
436
|
balance_info: balances,
|
|
@@ -809,13 +438,13 @@ export function analyzeReconciliation(
|
|
|
809
438
|
insights,
|
|
810
439
|
};
|
|
811
440
|
|
|
812
|
-
// Step 11: Generate recommendations
|
|
441
|
+
// Step 11: Generate recommendations
|
|
813
442
|
if (accountId && budgetId) {
|
|
814
443
|
const recommendations = generateRecommendations({
|
|
815
444
|
account_id: accountId,
|
|
816
445
|
budget_id: budgetId,
|
|
817
446
|
analysis,
|
|
818
|
-
matching_config:
|
|
447
|
+
matching_config: normalizedConfig,
|
|
819
448
|
});
|
|
820
449
|
analysis.recommendations = recommendations;
|
|
821
450
|
}
|