@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,296 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { toMoneyValueFromDecimal } from '../../utils/money.js';
|
|
1
|
+
import { parseCSV } from './csvParser.js';
|
|
2
|
+
import { findMatches, normalizeConfig, DEFAULT_CONFIG } from './matcher.js';
|
|
3
|
+
import { normalizeYNABTransactions } from './ynabAdapter.js';
|
|
4
|
+
import { toMoneyValue } from '../../utils/money.js';
|
|
6
5
|
import { generateRecommendations } from './recommendationEngine.js';
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
function mapToTransactionMatch(result) {
|
|
7
|
+
const candidates = result.candidates.map((c) => ({
|
|
8
|
+
ynab_transaction: c.ynabTransaction,
|
|
9
|
+
confidence: c.scores.combined,
|
|
10
|
+
match_reason: c.matchReasons.join(', '),
|
|
11
|
+
explanation: c.matchReasons.join(', '),
|
|
12
|
+
}));
|
|
13
|
+
const match = {
|
|
14
|
+
bankTransaction: result.bankTransaction,
|
|
15
|
+
candidates,
|
|
16
|
+
confidence: result.confidence,
|
|
17
|
+
confidenceScore: result.confidenceScore,
|
|
18
|
+
matchReason: result.bestMatch?.matchReasons.join(', ') ?? 'No match found',
|
|
19
|
+
actionHint: result.confidence === 'high' ? 'approve' : 'review',
|
|
17
20
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
date_column: 'Date',
|
|
21
|
-
amount_column: 'Amount',
|
|
22
|
-
description_column: 'Description',
|
|
23
|
-
date_format: 'MM/DD/YYYY',
|
|
24
|
-
has_header: true,
|
|
25
|
-
delimiter: ',',
|
|
26
|
-
};
|
|
27
|
-
const ENABLE_COMBINATION_MATCHING = true;
|
|
28
|
-
const DAYS_IN_MS = 24 * 60 * 60 * 1000;
|
|
29
|
-
function toDollars(milliunits) {
|
|
30
|
-
return milliunits / 1000;
|
|
31
|
-
}
|
|
32
|
-
function amountTolerance(config) {
|
|
33
|
-
const toleranceCents = config.amountToleranceCents ?? DEFAULT_MATCHING_CONFIG.amountToleranceCents ?? 1;
|
|
34
|
-
return Math.max(0, toleranceCents) / 100;
|
|
35
|
-
}
|
|
36
|
-
function dateTolerance(config) {
|
|
37
|
-
return config.dateToleranceDays ?? DEFAULT_MATCHING_CONFIG.dateToleranceDays ?? 2;
|
|
38
|
-
}
|
|
39
|
-
function daysBetween(dateA, dateB) {
|
|
40
|
-
const a = new Date(`${dateA}T00:00:00Z`).getTime();
|
|
41
|
-
const b = new Date(`${dateB}T00:00:00Z`).getTime();
|
|
42
|
-
if (Number.isNaN(a) || Number.isNaN(b))
|
|
43
|
-
return Number.POSITIVE_INFINITY;
|
|
44
|
-
return Math.abs(a - b) / DAYS_IN_MS;
|
|
45
|
-
}
|
|
46
|
-
function withinDateTolerance(bankDate, ynabTxns, toleranceDays) {
|
|
47
|
-
return ynabTxns.every((txn) => daysBetween(bankDate, txn.date) <= toleranceDays);
|
|
48
|
-
}
|
|
49
|
-
function hasMatchingSign(bankAmount, ynabTxns) {
|
|
50
|
-
const bankSign = Math.sign(bankAmount);
|
|
51
|
-
const sumSign = Math.sign(ynabTxns.reduce((sum, txn) => sum + toDollars(txn.amount), 0));
|
|
52
|
-
return bankSign === sumSign || Math.abs(bankAmount) === 0;
|
|
53
|
-
}
|
|
54
|
-
function computeCombinationConfidence(diff, tolerance, legCount) {
|
|
55
|
-
const safeTolerance = tolerance > 0 ? tolerance : 0.01;
|
|
56
|
-
const ratio = diff / safeTolerance;
|
|
57
|
-
let base = legCount === 2 ? 75 : 70;
|
|
58
|
-
if (ratio <= 0.25) {
|
|
59
|
-
base += 5;
|
|
60
|
-
}
|
|
61
|
-
else if (ratio <= 0.5) {
|
|
62
|
-
base += 3;
|
|
63
|
-
}
|
|
64
|
-
else if (ratio >= 0.9) {
|
|
65
|
-
base -= 5;
|
|
66
|
-
}
|
|
67
|
-
return Math.max(65, Math.min(80, Math.round(base)));
|
|
68
|
-
}
|
|
69
|
-
function formatDifference(diff) {
|
|
70
|
-
return formatCurrency(diff);
|
|
71
|
-
}
|
|
72
|
-
function findCombinationMatches(unmatchedBank, unmatchedYNAB, config) {
|
|
73
|
-
if (!ENABLE_COMBINATION_MATCHING || unmatchedBank.length === 0 || unmatchedYNAB.length === 0) {
|
|
74
|
-
return { matches: [], insights: [] };
|
|
75
|
-
}
|
|
76
|
-
const tolerance = amountTolerance(config);
|
|
77
|
-
const toleranceDays = dateTolerance(config);
|
|
78
|
-
const matches = [];
|
|
79
|
-
const insights = [];
|
|
80
|
-
const seenCombinations = new Set();
|
|
81
|
-
for (const bankTxn of unmatchedBank) {
|
|
82
|
-
const viableYnab = unmatchedYNAB.filter((txn) => hasMatchingSign(bankTxn.amount, [txn]));
|
|
83
|
-
if (viableYnab.length < 2)
|
|
84
|
-
continue;
|
|
85
|
-
const evaluated = [];
|
|
86
|
-
const addIfValid = (combo) => {
|
|
87
|
-
const sum = combo.reduce((acc, txn) => acc + toDollars(txn.amount), 0);
|
|
88
|
-
const diff = Math.abs(sum - bankTxn.amount);
|
|
89
|
-
if (diff > tolerance)
|
|
90
|
-
return;
|
|
91
|
-
if (!withinDateTolerance(bankTxn.date, combo, toleranceDays))
|
|
92
|
-
return;
|
|
93
|
-
if (!hasMatchingSign(bankTxn.amount, combo))
|
|
94
|
-
return;
|
|
95
|
-
evaluated.push({ txns: combo, diff, sum });
|
|
96
|
-
};
|
|
97
|
-
const n = viableYnab.length;
|
|
98
|
-
for (let i = 0; i < n - 1; i++) {
|
|
99
|
-
for (let j = i + 1; j < n; j++) {
|
|
100
|
-
addIfValid([viableYnab[i], viableYnab[j]]);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (n >= 3) {
|
|
104
|
-
for (let i = 0; i < n - 2; i++) {
|
|
105
|
-
for (let j = i + 1; j < n - 1; j++) {
|
|
106
|
-
for (let k = j + 1; k < n; k++) {
|
|
107
|
-
addIfValid([viableYnab[i], viableYnab[j], viableYnab[k]]);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (evaluated.length === 0)
|
|
113
|
-
continue;
|
|
114
|
-
evaluated.sort((a, b) => a.diff - b.diff);
|
|
115
|
-
const recordedSizes = new Set();
|
|
116
|
-
for (const combo of evaluated) {
|
|
117
|
-
if (recordedSizes.has(combo.txns.length))
|
|
118
|
-
continue;
|
|
119
|
-
const comboIds = combo.txns.map((txn) => txn.id).sort();
|
|
120
|
-
const key = `${bankTxn.id}|${comboIds.join('+')}`;
|
|
121
|
-
if (seenCombinations.has(key))
|
|
122
|
-
continue;
|
|
123
|
-
seenCombinations.add(key);
|
|
124
|
-
recordedSizes.add(combo.txns.length);
|
|
125
|
-
const score = computeCombinationConfidence(combo.diff, tolerance, combo.txns.length);
|
|
126
|
-
const candidateConfidence = Math.max(60, score - 5);
|
|
127
|
-
const descriptionTotal = formatCurrency(combo.sum);
|
|
128
|
-
const diffLabel = formatDifference(combo.diff);
|
|
129
|
-
matches.push({
|
|
130
|
-
bank_transaction: bankTxn,
|
|
131
|
-
confidence: 'medium',
|
|
132
|
-
confidence_score: score,
|
|
133
|
-
match_reason: 'combination_match',
|
|
134
|
-
top_confidence: score,
|
|
135
|
-
candidates: combo.txns.map((txn) => ({
|
|
136
|
-
ynab_transaction: txn,
|
|
137
|
-
confidence: candidateConfidence,
|
|
138
|
-
match_reason: 'combination_component',
|
|
139
|
-
explanation: `Part of combination totaling ${descriptionTotal} (difference ${diffLabel}).`,
|
|
140
|
-
})),
|
|
141
|
-
action_hint: 'review_combination',
|
|
142
|
-
recommendation: `Combination of ${combo.txns.length} YNAB transactions totals ${descriptionTotal} versus ` +
|
|
143
|
-
`${formatCurrency(bankTxn.amount)} on the bank statement.`,
|
|
144
|
-
});
|
|
145
|
-
const insightId = `combination-${bankTxn.id}-${comboIds.join('+')}`;
|
|
146
|
-
insights.push({
|
|
147
|
-
id: insightId,
|
|
148
|
-
type: 'combination_match',
|
|
149
|
-
severity: 'info',
|
|
150
|
-
title: `Combination of ${combo.txns.length} transactions matches ${formatCurrency(bankTxn.amount)}`,
|
|
151
|
-
description: `${combo.txns.length} YNAB transactions totaling ${descriptionTotal} align with ` +
|
|
152
|
-
`${formatCurrency(bankTxn.amount)} from ${bankTxn.payee}. Difference ${diffLabel}.`,
|
|
153
|
-
evidence: {
|
|
154
|
-
bank_transaction_id: bankTxn.id,
|
|
155
|
-
bank_amount: bankTxn.amount,
|
|
156
|
-
ynab_transaction_ids: comboIds,
|
|
157
|
-
ynab_amounts_milliunits: combo.txns.map((txn) => txn.amount),
|
|
158
|
-
combination_size: combo.txns.length,
|
|
159
|
-
difference: combo.diff,
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return { matches, insights };
|
|
165
|
-
}
|
|
166
|
-
function isParsedCSVData(result) {
|
|
167
|
-
return (typeof result === 'object' &&
|
|
168
|
-
result !== null &&
|
|
169
|
-
!Array.isArray(result) &&
|
|
170
|
-
'transactions' in result);
|
|
171
|
-
}
|
|
172
|
-
function normalizeDate(value) {
|
|
173
|
-
if (value instanceof Date) {
|
|
174
|
-
return value.toISOString().split('T')[0];
|
|
175
|
-
}
|
|
176
|
-
if (typeof value === 'string') {
|
|
177
|
-
const trimmed = value.trim();
|
|
178
|
-
if (!trimmed)
|
|
179
|
-
return trimmed;
|
|
180
|
-
const parsed = new Date(trimmed);
|
|
181
|
-
if (!Number.isNaN(parsed.getTime())) {
|
|
182
|
-
return parsed.toISOString().split('T')[0];
|
|
183
|
-
}
|
|
184
|
-
return trimmed;
|
|
185
|
-
}
|
|
186
|
-
return new Date().toISOString().split('T')[0];
|
|
187
|
-
}
|
|
188
|
-
function normalizeAmount(record) {
|
|
189
|
-
const raw = record['amount'];
|
|
190
|
-
if (typeof raw === 'number') {
|
|
191
|
-
if (record['date'] instanceof Date || 'raw_amount' in record || 'raw_date' in record) {
|
|
192
|
-
return Math.round(raw) / 1000;
|
|
193
|
-
}
|
|
194
|
-
return raw;
|
|
195
|
-
}
|
|
196
|
-
if (typeof raw === 'string') {
|
|
197
|
-
const cleaned = raw.replace(/[$,\s]/g, '');
|
|
198
|
-
const parsed = Number.parseFloat(cleaned);
|
|
199
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
200
|
-
}
|
|
201
|
-
return 0;
|
|
202
|
-
}
|
|
203
|
-
function normalizePayee(record) {
|
|
204
|
-
const candidates = [record['payee'], record['description'], record['memo']];
|
|
205
|
-
for (const candidate of candidates) {
|
|
206
|
-
if (typeof candidate === 'string' && candidate.trim()) {
|
|
207
|
-
return candidate.trim();
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return 'Unknown Payee';
|
|
211
|
-
}
|
|
212
|
-
function determineRow(record, index) {
|
|
213
|
-
if (typeof record['original_csv_row'] === 'number') {
|
|
214
|
-
return record['original_csv_row'];
|
|
215
|
-
}
|
|
216
|
-
if (typeof record['row_number'] === 'number') {
|
|
217
|
-
return record['row_number'];
|
|
218
|
-
}
|
|
219
|
-
return index + 1;
|
|
220
|
-
}
|
|
221
|
-
function convertParserRecord(record, index) {
|
|
222
|
-
const data = typeof record === 'object' && record !== null ? record : {};
|
|
223
|
-
const dateValue = normalizeDate(data['date']);
|
|
224
|
-
const amountValue = normalizeAmount(data);
|
|
225
|
-
const payeeValue = normalizePayee(data);
|
|
226
|
-
const memoValue = typeof data['memo'] === 'string' && data['memo'].trim() ? data['memo'].trim() : undefined;
|
|
227
|
-
const originalRow = determineRow(data, index);
|
|
228
|
-
const transaction = {
|
|
229
|
-
id: randomUUID(),
|
|
230
|
-
date: dateValue,
|
|
231
|
-
amount: amountValue,
|
|
232
|
-
payee: payeeValue,
|
|
233
|
-
original_csv_row: originalRow,
|
|
234
|
-
};
|
|
235
|
-
if (memoValue !== undefined) {
|
|
236
|
-
transaction.memo = memoValue;
|
|
237
|
-
}
|
|
238
|
-
return transaction;
|
|
239
|
-
}
|
|
240
|
-
function parseBankStatement(csvContent, csvFilePath) {
|
|
241
|
-
const content = csvFilePath ? bankParser.readCSVFile(csvFilePath) : csvContent;
|
|
242
|
-
let format = FALLBACK_CSV_FORMAT;
|
|
243
|
-
let autoDetect;
|
|
244
|
-
try {
|
|
245
|
-
autoDetect = bankParser
|
|
246
|
-
.autoDetectCSVFormat;
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
autoDetect = undefined;
|
|
250
|
-
}
|
|
251
|
-
if (typeof autoDetect === 'function') {
|
|
252
|
-
try {
|
|
253
|
-
format = autoDetect(content);
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
format = FALLBACK_CSV_FORMAT;
|
|
257
|
-
}
|
|
21
|
+
if (result.bestMatch) {
|
|
22
|
+
match.ynabTransaction = result.bestMatch.ynabTransaction;
|
|
258
23
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return records.map(convertParserRecord);
|
|
262
|
-
}
|
|
263
|
-
function categorizeMatches(matches) {
|
|
264
|
-
const autoMatches = [];
|
|
265
|
-
const suggestedMatches = [];
|
|
266
|
-
const unmatchedBank = [];
|
|
267
|
-
for (const match of matches) {
|
|
268
|
-
if (match.confidence === 'high') {
|
|
269
|
-
autoMatches.push(match);
|
|
270
|
-
}
|
|
271
|
-
else if (match.confidence === 'medium') {
|
|
272
|
-
suggestedMatches.push(match);
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
unmatchedBank.push(match.bank_transaction);
|
|
276
|
-
}
|
|
24
|
+
if (result.candidates[0]) {
|
|
25
|
+
match.topConfidence = result.candidates[0].scores.combined;
|
|
277
26
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
function findUnmatchedYNAB(ynabTransactions, matches) {
|
|
281
|
-
const matchedIds = new Set();
|
|
282
|
-
for (const match of matches) {
|
|
283
|
-
if (match.ynab_transaction) {
|
|
284
|
-
matchedIds.add(match.ynab_transaction.id);
|
|
285
|
-
}
|
|
27
|
+
if (result.confidence === 'none') {
|
|
28
|
+
match.recommendation = 'This bank transaction is not in YNAB. Consider adding it.';
|
|
286
29
|
}
|
|
287
|
-
return
|
|
30
|
+
return match;
|
|
288
31
|
}
|
|
289
|
-
function calculateBalances(ynabTransactions,
|
|
32
|
+
function calculateBalances(ynabTransactions, statementBalanceDecimal, currency) {
|
|
290
33
|
let clearedBalance = 0;
|
|
291
34
|
let unclearedBalance = 0;
|
|
292
35
|
for (const txn of ynabTransactions) {
|
|
293
|
-
const amount = txn.amount
|
|
36
|
+
const amount = txn.amount;
|
|
294
37
|
if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
|
|
295
38
|
clearedBalance += amount;
|
|
296
39
|
}
|
|
@@ -298,15 +41,16 @@ function calculateBalances(ynabTransactions, statementBalance, currency) {
|
|
|
298
41
|
unclearedBalance += amount;
|
|
299
42
|
}
|
|
300
43
|
}
|
|
44
|
+
const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
|
|
301
45
|
const totalBalance = clearedBalance + unclearedBalance;
|
|
302
|
-
const discrepancy = clearedBalance -
|
|
46
|
+
const discrepancy = clearedBalance - statementBalanceMilli;
|
|
303
47
|
return {
|
|
304
|
-
current_cleared:
|
|
305
|
-
current_uncleared:
|
|
306
|
-
current_total:
|
|
307
|
-
target_statement:
|
|
308
|
-
discrepancy:
|
|
309
|
-
on_track: Math.abs(discrepancy) <
|
|
48
|
+
current_cleared: toMoneyValue(clearedBalance, currency),
|
|
49
|
+
current_uncleared: toMoneyValue(unclearedBalance, currency),
|
|
50
|
+
current_total: toMoneyValue(totalBalance, currency),
|
|
51
|
+
target_statement: toMoneyValue(statementBalanceMilli, currency),
|
|
52
|
+
discrepancy: toMoneyValue(discrepancy, currency),
|
|
53
|
+
on_track: Math.abs(discrepancy) < 10,
|
|
310
54
|
};
|
|
311
55
|
}
|
|
312
56
|
function generateSummary(bankTransactions, ynabTransactions, autoMatches, suggestedMatches, unmatchedBank, unmatchedYNAB, balances) {
|
|
@@ -363,23 +107,23 @@ function generateNextSteps(summary) {
|
|
|
363
107
|
}
|
|
364
108
|
return steps;
|
|
365
109
|
}
|
|
366
|
-
function formatCurrency(
|
|
110
|
+
function formatCurrency(amountMilli, currency = 'USD') {
|
|
367
111
|
const formatter = new Intl.NumberFormat('en-US', {
|
|
368
112
|
style: 'currency',
|
|
369
|
-
currency:
|
|
113
|
+
currency: currency,
|
|
370
114
|
minimumFractionDigits: 2,
|
|
371
115
|
maximumFractionDigits: 2,
|
|
372
116
|
});
|
|
373
|
-
return formatter.format(
|
|
117
|
+
return formatter.format(amountMilli / 1000);
|
|
374
118
|
}
|
|
375
|
-
function repeatAmountInsights(unmatchedBank) {
|
|
119
|
+
function repeatAmountInsights(unmatchedBank, currency = 'USD') {
|
|
376
120
|
const insights = [];
|
|
377
121
|
if (unmatchedBank.length === 0) {
|
|
378
122
|
return insights;
|
|
379
123
|
}
|
|
380
124
|
const frequency = new Map();
|
|
381
125
|
for (const txn of unmatchedBank) {
|
|
382
|
-
const key = txn.amount
|
|
126
|
+
const key = txn.amount;
|
|
383
127
|
const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
|
|
384
128
|
entry.txns.push(txn);
|
|
385
129
|
frequency.set(key, entry);
|
|
@@ -392,72 +136,29 @@ function repeatAmountInsights(unmatchedBank) {
|
|
|
392
136
|
}
|
|
393
137
|
const top = repeated[0];
|
|
394
138
|
insights.push({
|
|
395
|
-
id: `repeat-${top.amount
|
|
139
|
+
id: `repeat-${top.amount}`,
|
|
396
140
|
type: 'repeat_amount',
|
|
397
141
|
severity: top.txns.length >= 4 ? 'critical' : 'warning',
|
|
398
|
-
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount)}`,
|
|
399
|
-
description: `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount)}. ` +
|
|
142
|
+
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
|
|
143
|
+
description: `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. ` +
|
|
400
144
|
'Repeated amounts are usually the quickest wins — reconcile these first.',
|
|
401
145
|
evidence: {
|
|
402
146
|
amount: top.amount,
|
|
403
147
|
occurrences: top.txns.length,
|
|
404
148
|
dates: top.txns.map((txn) => txn.date),
|
|
405
|
-
csv_rows: top.txns.map((txn) => txn.
|
|
149
|
+
csv_rows: top.txns.map((txn) => txn.sourceRow),
|
|
406
150
|
},
|
|
407
151
|
});
|
|
408
152
|
return insights;
|
|
409
153
|
}
|
|
410
|
-
function
|
|
154
|
+
function anomalyInsights(balances) {
|
|
411
155
|
const insights = [];
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
continue;
|
|
415
|
-
if (match.confidence === 'high')
|
|
416
|
-
continue;
|
|
417
|
-
const topCandidate = match.candidates[0];
|
|
418
|
-
const score = topCandidate.confidence;
|
|
419
|
-
const highSignal = (match.confidence === 'medium' && score >= config.autoMatchThreshold - 5) ||
|
|
420
|
-
(match.confidence === 'low' && score >= config.suggestionThreshold) ||
|
|
421
|
-
(match.confidence === 'none' && score >= config.suggestionThreshold);
|
|
422
|
-
if (!highSignal)
|
|
423
|
-
continue;
|
|
424
|
-
const bankTxn = match.bank_transaction;
|
|
425
|
-
const ynabTxn = topCandidate.ynab_transaction;
|
|
426
|
-
insights.push({
|
|
427
|
-
id: `near-${bankTxn.id}`,
|
|
428
|
-
type: 'near_match',
|
|
429
|
-
severity: score >= config.autoMatchThreshold ? 'warning' : 'info',
|
|
430
|
-
title: `${formatCurrency(bankTxn.amount)} nearly matches ${formatCurrency(ynabTxn.amount / 1000)}`,
|
|
431
|
-
description: `Bank transaction on ${bankTxn.date} (${formatCurrency(bankTxn.amount)}) nearly matches ` +
|
|
432
|
-
`${ynabTxn.payee_name ?? 'unknown payee'} on ${ynabTxn.date}. Confidence ${score}% — review and confirm.`,
|
|
433
|
-
evidence: {
|
|
434
|
-
bank_transaction: {
|
|
435
|
-
id: bankTxn.id,
|
|
436
|
-
date: bankTxn.date,
|
|
437
|
-
amount: bankTxn.amount,
|
|
438
|
-
payee: bankTxn.payee,
|
|
439
|
-
},
|
|
440
|
-
candidate: {
|
|
441
|
-
id: ynabTxn.id,
|
|
442
|
-
date: ynabTxn.date,
|
|
443
|
-
amount_milliunits: ynabTxn.amount,
|
|
444
|
-
payee_name: ynabTxn.payee_name,
|
|
445
|
-
confidence: score,
|
|
446
|
-
reasons: topCandidate.match_reason,
|
|
447
|
-
},
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
return insights.slice(0, 3);
|
|
452
|
-
}
|
|
453
|
-
function anomalyInsights(summary, balances) {
|
|
454
|
-
const insights = [];
|
|
455
|
-
const discrepancyAbs = Math.abs(balances.discrepancy.value);
|
|
456
|
-
if (discrepancyAbs >= 1) {
|
|
156
|
+
const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
|
|
157
|
+
if (discrepancyAbs >= 1000) {
|
|
457
158
|
insights.push({
|
|
458
159
|
id: 'balance-gap',
|
|
459
160
|
type: 'anomaly',
|
|
460
|
-
severity: discrepancyAbs >=
|
|
161
|
+
severity: discrepancyAbs >= 100000 ? 'critical' : 'warning',
|
|
461
162
|
title: `Cleared balance off by ${balances.discrepancy.value_display}`,
|
|
462
163
|
description: `YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
|
|
463
164
|
`${balances.target_statement.value_display}. Focus on closing this gap.`,
|
|
@@ -468,22 +169,9 @@ function anomalyInsights(summary, balances) {
|
|
|
468
169
|
},
|
|
469
170
|
});
|
|
470
171
|
}
|
|
471
|
-
if (summary.unmatched_bank >= 5) {
|
|
472
|
-
insights.push({
|
|
473
|
-
id: 'bulk-missing-bank',
|
|
474
|
-
type: 'anomaly',
|
|
475
|
-
severity: summary.unmatched_bank >= 10 ? 'critical' : 'warning',
|
|
476
|
-
title: `${summary.unmatched_bank} bank transactions still unmatched`,
|
|
477
|
-
description: `There are ${summary.unmatched_bank} bank transactions without a match. ` +
|
|
478
|
-
'Consider bulk importing or reviewing by date sequence.',
|
|
479
|
-
evidence: {
|
|
480
|
-
unmatched_bank: summary.unmatched_bank,
|
|
481
|
-
},
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
172
|
return insights;
|
|
485
173
|
}
|
|
486
|
-
function detectInsights(
|
|
174
|
+
function detectInsights(unmatchedBank, _summary, balances, currency, csvErrors = [], csvWarnings = []) {
|
|
487
175
|
const insights = [];
|
|
488
176
|
const seen = new Set();
|
|
489
177
|
const addUnique = (insight) => {
|
|
@@ -492,62 +180,90 @@ function detectInsights(matches, unmatchedBank, summary, balances, config) {
|
|
|
492
180
|
seen.add(insight.id);
|
|
493
181
|
insights.push(insight);
|
|
494
182
|
};
|
|
495
|
-
|
|
496
|
-
addUnique(
|
|
183
|
+
if (csvErrors.length > 0) {
|
|
184
|
+
addUnique({
|
|
185
|
+
id: 'csv-parse-errors',
|
|
186
|
+
type: 'anomaly',
|
|
187
|
+
severity: csvErrors.length >= 5 ? 'critical' : 'warning',
|
|
188
|
+
title: `${csvErrors.length} CSV parsing error(s)`,
|
|
189
|
+
description: csvErrors
|
|
190
|
+
.slice(0, 3)
|
|
191
|
+
.map((e) => `Row ${e.row}: ${e.message}`)
|
|
192
|
+
.join('; ') + (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ''),
|
|
193
|
+
evidence: {
|
|
194
|
+
error_count: csvErrors.length,
|
|
195
|
+
errors: csvErrors.slice(0, 5),
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (csvWarnings.length > 0) {
|
|
200
|
+
addUnique({
|
|
201
|
+
id: 'csv-parse-warnings',
|
|
202
|
+
type: 'anomaly',
|
|
203
|
+
severity: 'info',
|
|
204
|
+
title: `${csvWarnings.length} CSV parsing warning(s)`,
|
|
205
|
+
description: csvWarnings
|
|
206
|
+
.slice(0, 3)
|
|
207
|
+
.map((w) => `Row ${w.row}: ${w.message}`)
|
|
208
|
+
.join('; ') + (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ''),
|
|
209
|
+
evidence: {
|
|
210
|
+
warning_count: csvWarnings.length,
|
|
211
|
+
warnings: csvWarnings.slice(0, 5),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
497
214
|
}
|
|
498
|
-
for (const insight of
|
|
215
|
+
for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
|
|
499
216
|
addUnique(insight);
|
|
500
217
|
}
|
|
501
|
-
for (const insight of anomalyInsights(
|
|
218
|
+
for (const insight of anomalyInsights(balances)) {
|
|
502
219
|
addUnique(insight);
|
|
503
220
|
}
|
|
504
221
|
return insights.slice(0, 5);
|
|
505
222
|
}
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (seen.has(insight.id))
|
|
514
|
-
continue;
|
|
515
|
-
seen.add(insight.id);
|
|
516
|
-
merged.push(insight);
|
|
517
|
-
}
|
|
518
|
-
return merged.slice(0, 5);
|
|
519
|
-
}
|
|
520
|
-
export function analyzeReconciliation(csvContent, csvFilePath, ynabTransactions, statementBalance, config = DEFAULT_MATCHING_CONFIG, currency = 'USD', accountId, budgetId, invertBankAmounts = false) {
|
|
521
|
-
let bankTransactions = parseBankStatement(csvContent, csvFilePath);
|
|
522
|
-
if (invertBankAmounts) {
|
|
523
|
-
bankTransactions = bankTransactions.map((txn) => ({
|
|
524
|
-
...txn,
|
|
525
|
-
amount: -txn.amount,
|
|
526
|
-
}));
|
|
527
|
-
}
|
|
528
|
-
const convertedYNABTxns = ynabTransactions.map(convertYNABTransaction);
|
|
529
|
-
const matches = findMatches(bankTransactions, convertedYNABTxns, config);
|
|
530
|
-
const { autoMatches, suggestedMatches, unmatchedBank } = categorizeMatches(matches);
|
|
531
|
-
const unmatchedYNAB = findUnmatchedYNAB(convertedYNABTxns, matches);
|
|
532
|
-
let combinationMatches = [];
|
|
533
|
-
let combinationInsights = [];
|
|
534
|
-
if (ENABLE_COMBINATION_MATCHING) {
|
|
535
|
-
const combinationResult = findCombinationMatches(unmatchedBank, unmatchedYNAB, config);
|
|
536
|
-
combinationMatches = combinationResult.matches;
|
|
537
|
-
combinationInsights = combinationResult.insights;
|
|
223
|
+
export function analyzeReconciliation(csvContentOrParsed, _csvFilePath, ynabTransactions, statementBalance, config = DEFAULT_CONFIG, currency = 'USD', accountId, budgetId, invertBankAmounts = false, csvOptions) {
|
|
224
|
+
let parseResult;
|
|
225
|
+
if (typeof csvContentOrParsed === 'string') {
|
|
226
|
+
parseResult = parseCSV(csvContentOrParsed, {
|
|
227
|
+
...csvOptions,
|
|
228
|
+
invertAmounts: invertBankAmounts,
|
|
229
|
+
});
|
|
538
230
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
231
|
+
else {
|
|
232
|
+
parseResult = csvContentOrParsed;
|
|
233
|
+
}
|
|
234
|
+
const newBankTransactions = parseResult.transactions;
|
|
235
|
+
const csvParseErrors = parseResult.errors;
|
|
236
|
+
const csvParseWarnings = parseResult.warnings;
|
|
237
|
+
const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
238
|
+
const normalizedConfig = normalizeConfig(config);
|
|
239
|
+
const newMatches = findMatches(newBankTransactions, newYNABTransactions, normalizedConfig);
|
|
240
|
+
const matches = newMatches.map(mapToTransactionMatch);
|
|
241
|
+
const autoMatches = matches.filter((m) => m.confidence === 'high');
|
|
242
|
+
const autoMatchedYnabIds = new Set();
|
|
243
|
+
autoMatches.forEach((m) => {
|
|
244
|
+
if (m.ynabTransaction)
|
|
245
|
+
autoMatchedYnabIds.add(m.ynabTransaction.id);
|
|
246
|
+
});
|
|
247
|
+
const suggestedMatches = matches.filter((m) => m.confidence === 'medium' &&
|
|
248
|
+
(!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)));
|
|
249
|
+
const unmatchedBankMatches = matches.filter((m) => m.confidence === 'low' || m.confidence === 'none');
|
|
250
|
+
const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
|
|
251
|
+
const matchedYnabIds = new Set();
|
|
252
|
+
matches.forEach((m) => {
|
|
253
|
+
if (m.ynabTransaction)
|
|
254
|
+
matchedYnabIds.add(m.ynabTransaction.id);
|
|
255
|
+
});
|
|
256
|
+
const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
|
|
257
|
+
const balances = calculateBalances(newYNABTransactions, statementBalance, currency);
|
|
258
|
+
const summary = generateSummary(matches.map((m) => m.bankTransaction), newYNABTransactions, autoMatches, suggestedMatches, unmatchedBank, unmatchedYNAB, balances);
|
|
542
259
|
const nextSteps = generateNextSteps(summary);
|
|
543
|
-
const
|
|
544
|
-
const insights = mergeInsights(baseInsights, combinationInsights);
|
|
260
|
+
const insights = detectInsights(unmatchedBank, summary, balances, currency, csvParseErrors, csvParseWarnings);
|
|
545
261
|
const analysis = {
|
|
546
262
|
success: true,
|
|
547
263
|
phase: 'analysis',
|
|
548
264
|
summary,
|
|
549
265
|
auto_matches: autoMatches,
|
|
550
|
-
suggested_matches:
|
|
266
|
+
suggested_matches: suggestedMatches,
|
|
551
267
|
unmatched_bank: unmatchedBank,
|
|
552
268
|
unmatched_ynab: unmatchedYNAB,
|
|
553
269
|
balance_info: balances,
|
|
@@ -559,7 +275,7 @@ export function analyzeReconciliation(csvContent, csvFilePath, ynabTransactions,
|
|
|
559
275
|
account_id: accountId,
|
|
560
276
|
budget_id: budgetId,
|
|
561
277
|
analysis,
|
|
562
|
-
matching_config:
|
|
278
|
+
matching_config: normalizedConfig,
|
|
563
279
|
});
|
|
564
280
|
analysis.recommendations = recommendations;
|
|
565
281
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BankTransaction } from '../../types/reconciliation.js';
|
|
2
|
+
export interface CSVParseResult {
|
|
3
|
+
transactions: BankTransaction[];
|
|
4
|
+
errors: ParseError[];
|
|
5
|
+
warnings: ParseWarning[];
|
|
6
|
+
meta: {
|
|
7
|
+
detectedDelimiter: string;
|
|
8
|
+
detectedColumns: string[];
|
|
9
|
+
totalRows: number;
|
|
10
|
+
validRows: number;
|
|
11
|
+
skippedRows: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export interface ParseError {
|
|
15
|
+
row: number;
|
|
16
|
+
field: string;
|
|
17
|
+
message: string;
|
|
18
|
+
rawValue: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ParseWarning {
|
|
21
|
+
row: number;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
export interface BankPreset {
|
|
25
|
+
name: string;
|
|
26
|
+
dateColumn: string | string[];
|
|
27
|
+
amountColumn?: string | string[];
|
|
28
|
+
debitColumn?: string;
|
|
29
|
+
creditColumn?: string;
|
|
30
|
+
descriptionColumn: string | string[];
|
|
31
|
+
amountMultiplier?: number;
|
|
32
|
+
dateFormat?: 'YMD' | 'MDY' | 'DMY';
|
|
33
|
+
header?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare const BANK_PRESETS: Record<string, BankPreset>;
|
|
36
|
+
export interface ParseCSVOptions {
|
|
37
|
+
preset?: string;
|
|
38
|
+
invertAmounts?: boolean;
|
|
39
|
+
columns?: {
|
|
40
|
+
date?: string;
|
|
41
|
+
amount?: string;
|
|
42
|
+
debit?: string;
|
|
43
|
+
credit?: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
};
|
|
46
|
+
dateFormat?: 'YMD' | 'MDY' | 'DMY';
|
|
47
|
+
header?: boolean;
|
|
48
|
+
maxRows?: number;
|
|
49
|
+
maxBytes?: number;
|
|
50
|
+
}
|
|
51
|
+
export declare function parseCSV(content: string, options?: ParseCSVOptions): CSVParseResult;
|