@dizzlkheinz/ynab-mcpb 0.13.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.code/agents/01a13ef4-3f23-4f52-b33b-3585b73cfa60/error.txt +3 -0
- package/.code/agents/084fd32f-e298-4728-9103-a78d7dc39613/error.txt +3 -0
- package/.code/agents/0fed51e1-a943-4b97-a2a8-a6f0f27c844d/status.txt +1 -0
- package/.code/agents/1059b6bd-5ccd-4d83-a12c-7c9d89137399/error.txt +5 -0
- package/.code/agents/110/exec-call_F9BDNG7JfxKkq7Vc8ESAvdft.txt +1569 -0
- package/.code/agents/11ebcef3-b13f-4e44-ad80-d94a866804b7/error.txt +3 -0
- package/.code/agents/1398/exec-call_CjItcWMU1G6JoPshX62QvpaR.txt +2832 -0
- package/.code/agents/1398/exec-call_SUVq2ivmONQ5LMCmd7ngmOqr.txt +2709 -0
- package/.code/agents/1398/exec-call_SdNY4NOffdcC5pRYjVXHjPCK.txt +2832 -0
- package/.code/agents/1398/exec-call_qblJo9et1gsFFB63TtLOiji2.txt +2832 -0
- package/.code/agents/1398/exec-call_zaRrzlGz7GJcNzVfkAmML7Zg.txt +2709 -0
- package/.code/agents/171834fd-5905-42fc-bbcc-2c755145b0fc/status.txt +1 -0
- package/.code/agents/1724/exec-call_HvHQe0w5CCG3T7Q3ULT6MO3g.txt +5217 -0
- package/.code/agents/1724/exec-call_QwUNESVzfxxk78K1frh1Vahb.txt +2594 -0
- package/.code/agents/1724/exec-call_aJ1Xwz71XmIpD4SBxSHERzLe.txt +2594 -0
- package/.code/agents/1d7d7ab7-7473-4b69-8b97-6e914f56056a/result.txt +231 -0
- package/.code/agents/210/exec-call_0tQCsKNJ1WTuIchb8wlcFJpW.txt +2590 -0
- package/.code/agents/210/exec-call_8ZlY9cUc8Ft1twi4ch8UJ6IN.txt +5195 -0
- package/.code/agents/2188/exec-call_5HqayBxIteJtoI8oPTiLWgvJ.txt +286 -0
- package/.code/agents/2188/exec-call_XRbBKBq3adZe6dcppAvQtM7G.txt +218 -0
- package/.code/agents/2188/exec-call_ehA0SjpYtrUi6GJXmibLjp4i.txt +180 -0
- package/.code/agents/21902821-ecaf-4759-bb9d-222b90921af5/error.txt +3 -0
- package/.code/agents/232073be-aa0e-46da-b478-5b64dbf03cf5/status.txt +1 -0
- package/.code/agents/234ff534-2336-4771-a8d9-aa04421a63be/result.txt +747 -0
- package/.code/agents/253e2695-dc36-4022-b436-27655e0fc6c7/status.txt +1 -0
- package/.code/agents/2583/exec-call_M59I4eDjpjlBIWBiSxyS0YlJ.txt +2594 -0
- package/.code/agents/2583/exec-call_usLRGh7OhVHtsRBL4iUwRhjq.txt +2594 -0
- package/.code/agents/292aa3ff-dbab-470f-97c9-e7e8fd65e0db/result.txt +144 -0
- package/.code/agents/3134/exec-call_IgCAMGx19lWfuo8zfYIt5FFC.txt +416 -0
- package/.code/agents/3134/exec-call_IxvLR2Oo7kba2QTsI1gHVko8.txt +2590 -0
- package/.code/agents/3134/exec-call_jYvc8hksZChSiysbzKjl2ZbB.txt +2590 -0
- package/.code/agents/329/exec-call_4QdP3SfSO7HGPCwVcqZIth6s.txt +2590 -0
- package/.code/agents/472/exec-call_4AxzEEcWwkKhpqRB3bE8Ha4L.txt +790 -0
- package/.code/agents/472/exec-call_CB3LPYQA8QIZRi8I6kj4J17A.txt +766 -0
- package/.code/agents/472/exec-call_YeoUWvaFoktay2nqVUsa9KKX.txt +790 -0
- package/.code/agents/472/exec-call_jPWgKVquBBXTg0T3Lks5ZfkK.txt +2594 -0
- package/.code/agents/472/exec-call_qBkvunpGBDEHph2jPmTwtcsb.txt +1000 -0
- package/.code/agents/472/exec-call_v0ffRV1p0kTckBmJPzzHAEy0.txt +3489 -0
- package/.code/agents/472/exec-call_xAX5FXqWIlk02d9WubHbHWh8.txt +766 -0
- package/.code/agents/5346/exec-call_9q0muXUuLaucwEqI51Pt7idT.txt +2594 -0
- package/.code/agents/5346/exec-call_B2el3B79rVkq9LhWTI2VYlz7.txt +2456 -0
- package/.code/agents/5346/exec-call_BfX08f02qkZI9uJD5dvCvuoj.txt +2594 -0
- package/.code/agents/543328d0-61d6-4fd1-a723-bb168656e2e2/error.txt +18 -0
- package/.code/agents/5580c02c-1383-4d18-9cbd-cc8a06e3408d/result.txt +48 -0
- package/.code/agents/60ce1a22-5126-44b2-b977-1d5b56142a7b/status.txt +1 -0
- package/.code/agents/6215d9db-7fa9-4429-aeec-3835c3212291/error.txt +1 -0
- package/.code/agents/6743db55-30e5-4b4e-9366-a8214fc7f714/error.txt +1 -0
- package/.code/agents/6bf9591b-b9c9-422c-b0a5-e968c7d8422a/status.txt +1 -0
- package/.code/agents/7/exec-call_eww3GfdEiJZx61sJEQ9wNmt3.txt +1271 -0
- package/.code/agents/70/exec-call_owUtDMYiVgqDf8vsz1i32PFf.txt +1570 -0
- package/.code/agents/8/exec-call_UtrjAcLbhYLatxR4O97fZgnm.txt +2590 -0
- package/.code/agents/82490bc9-f34e-4b1b-8a8e-bccc2e6254f5/error.txt +3 -0
- package/.code/agents/841/exec-call_7nTNhSBCNjTDUIJv7py6CepO.txt +3299 -0
- package/.code/agents/841/exec-call_TLI0yUdUijuUAvI4o3DXEvHO.txt +3299 -0
- package/.code/agents/9/exec-call_XaABQT1hIlRpnKZ2uyBMWsTC.txt +1882 -0
- package/.code/agents/941/exec-call_GuGHRx7NNXWIDAnxUG2NEWPa.txt +2594 -0
- package/.code/agents/95d9fbab-19a2-48af-83f9-c792566a347f/error.txt +1 -0
- package/.code/agents/b0098cb8-cb32-4ada-9bc4-37c587518896/result.txt +170 -0
- package/.code/agents/b4fe59a4-81df-42e2-a112-0153e504faca/error.txt +1 -0
- package/.code/agents/bf4ce152-f623-49d7-aa52-c18631625c3c/error.txt +3 -0
- package/.code/agents/d7d1db75-d7eb-468e-adea-4ef4d916d187/status.txt +1 -0
- package/.code/agents/e2baa9c8-bac3-49e3-a39d-024333e6a990/status.txt +1 -0
- package/.code/agents/e350b8c3-8483-408c-b2bb-94515f492a11/error.txt +3 -0
- package/.code/agents/e63f9919-719f-4ad0-bccf-01b1a596e1e9/status.txt +1 -0
- package/.code/agents/e71695a8-3044-478d-8f12-ed13d02884c7/status.txt +1 -0
- package/.code/agents/f95b7464-3e25-4897-b153-c8dfd63fd605/error.txt +5 -0
- package/.code/agents/fa3c5ddf-cdf7-47a2-930a-b806c6363689/status.txt +1 -0
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/release.yml +4 -0
- package/CHANGELOG.md +75 -0
- package/NUL +1 -0
- package/dist/bundle/index.cjs +65 -42
- package/dist/server/errorHandler.d.ts +2 -0
- package/dist/server/errorHandler.js +49 -5
- package/dist/tools/reconcileAdapter.js +10 -5
- package/dist/tools/reconciliation/analyzer.d.ts +8 -2
- package/dist/tools/reconciliation/analyzer.js +127 -409
- package/dist/tools/reconciliation/csvParser.d.ts +51 -0
- package/dist/tools/reconciliation/csvParser.js +413 -0
- package/dist/tools/reconciliation/executor.d.ts +8 -0
- package/dist/tools/reconciliation/executor.js +204 -58
- package/dist/tools/reconciliation/index.d.ts +7 -7
- package/dist/tools/reconciliation/index.js +115 -39
- package/dist/tools/reconciliation/matcher.d.ts +24 -3
- package/dist/tools/reconciliation/matcher.js +175 -133
- package/dist/tools/reconciliation/recommendationEngine.js +22 -18
- package/dist/tools/reconciliation/reportFormatter.js +9 -8
- package/dist/tools/reconciliation/signDetector.d.ts +2 -0
- package/dist/tools/reconciliation/signDetector.js +54 -0
- package/dist/tools/reconciliation/types.d.ts +20 -34
- package/dist/tools/reconciliation/types.js +1 -7
- package/dist/tools/reconciliation/ynabAdapter.d.ts +4 -0
- package/dist/tools/reconciliation/ynabAdapter.js +15 -0
- package/dist/types/reconciliation.d.ts +24 -0
- package/dist/types/reconciliation.js +1 -0
- package/docs/guides/ARCHITECTURE.md +12 -129
- package/docs/plans/2025-11-21-v014-hardening.md +153 -0
- package/docs/plans/reconciliation-v2-redesign.md +1571 -0
- package/package.json +6 -1
- package/scripts/test-recommendations.ts +1 -1
- package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +129 -0
- package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +53 -0
- package/src/server/errorHandler.ts +52 -5
- package/src/tools/reconcileAdapter.ts +10 -5
- package/src/tools/reconciliation/__tests__/adapter.test.ts +28 -22
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +114 -180
- package/src/tools/reconciliation/__tests__/csvParser.test.ts +87 -0
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +1 -1
- package/src/tools/reconciliation/__tests__/executor.test.ts +88 -61
- package/src/tools/reconciliation/__tests__/matcher.test.ts +68 -54
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +37 -30
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +6 -5
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +30 -11
- package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +50 -15
- package/src/tools/reconciliation/__tests__/signDetector.test.ts +211 -0
- package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +61 -0
- package/src/tools/reconciliation/analyzer.ts +191 -550
- package/src/tools/reconciliation/csvParser.ts +617 -0
- package/src/tools/reconciliation/executor.ts +249 -66
- package/src/tools/reconciliation/index.ts +148 -54
- package/src/tools/reconciliation/matcher.ts +234 -214
- package/src/tools/reconciliation/recommendationEngine.ts +23 -19
- package/src/tools/reconciliation/reportFormatter.ts +16 -11
- package/src/tools/reconciliation/signDetector.ts +117 -0
- package/src/tools/reconciliation/types.ts +39 -61
- package/src/tools/reconciliation/ynabAdapter.ts +33 -0
- package/src/types/reconciliation.ts +49 -0
- package/test-exports/ynab_since_2025-10-16_account_53298e13_238items_2025-11-28_13-46-20.json +3662 -0
- package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +0 -8
- package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +0 -3
- package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +0 -3
- package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +0 -1
- package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +0 -12
- package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +0 -1
- package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +0 -1
- package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +0 -5
- package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +0 -1453
- package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +0 -8
- package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +0 -9
- package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +0 -1
- package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +0 -5
- package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +0 -81
- package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +0 -610
- package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +0 -1
- package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +0 -1
- package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +0 -1
- package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +0 -3
- package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +0 -8
- package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +0 -5
- package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +0 -1
- package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +0 -9
- package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +0 -1
- package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +0 -13
- package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +0 -14
- package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +0 -5
- package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +0 -5
- package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +0 -5
- package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +0 -3
- package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +0 -1
- package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +0 -1
- package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +0 -5
- package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +0 -5
- package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +0 -8
- package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +0 -8
- package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +0 -5
- package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +0 -1
- package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +0 -1
- package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +0 -1
- package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +0 -1
- package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +0 -1
- package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +0 -5
- package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +0 -1
- package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +0 -1
- package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +0 -1
- package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +0 -3
- package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +0 -8
- package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +0 -1
- package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +0 -1
- package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +0 -8
- package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +0 -3
- package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +0 -5
- package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +0 -1
- package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +0 -1
- package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +0 -5
- package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +0 -1
- package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +0 -14
- package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +0 -1
- package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +0 -5
- package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +0 -5
- package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +0 -11
- package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +0 -1
- package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +0 -8
- package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +0 -1
- package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +0 -3
- package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +0 -3
- package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +0 -8
- package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +0 -1
- package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +0 -5
- package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +0 -1
- package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +0 -1
- package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +0 -1
- package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +0 -1
- package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +0 -3
- package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +0 -8
- package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +0 -1
- package/.gemini/settings.json +0 -8
- package/WARP.md +0 -245
|
@@ -1,312 +1,58 @@
|
|
|
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,
|
|
290
|
-
let
|
|
291
|
-
let
|
|
32
|
+
function calculateBalances(ynabTransactions, statementBalanceDecimal, currency, accountSnapshot) {
|
|
33
|
+
let computedCleared = 0;
|
|
34
|
+
let computedUncleared = 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
|
+
computedCleared += amount;
|
|
296
39
|
}
|
|
297
40
|
else {
|
|
298
|
-
|
|
41
|
+
computedUncleared += amount;
|
|
299
42
|
}
|
|
300
43
|
}
|
|
301
|
-
const
|
|
302
|
-
const
|
|
44
|
+
const clearedBalance = accountSnapshot?.cleared_balance ?? computedCleared;
|
|
45
|
+
const unclearedBalance = accountSnapshot?.uncleared_balance ?? computedUncleared;
|
|
46
|
+
const totalBalance = accountSnapshot?.balance ?? clearedBalance + unclearedBalance;
|
|
47
|
+
const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
|
|
48
|
+
const discrepancy = clearedBalance - statementBalanceMilli;
|
|
303
49
|
return {
|
|
304
|
-
current_cleared:
|
|
305
|
-
current_uncleared:
|
|
306
|
-
current_total:
|
|
307
|
-
target_statement:
|
|
308
|
-
discrepancy:
|
|
309
|
-
on_track: Math.abs(discrepancy) <
|
|
50
|
+
current_cleared: toMoneyValue(clearedBalance, currency),
|
|
51
|
+
current_uncleared: toMoneyValue(unclearedBalance, currency),
|
|
52
|
+
current_total: toMoneyValue(totalBalance, currency),
|
|
53
|
+
target_statement: toMoneyValue(statementBalanceMilli, currency),
|
|
54
|
+
discrepancy: toMoneyValue(discrepancy, currency),
|
|
55
|
+
on_track: Math.abs(discrepancy) < 10,
|
|
310
56
|
};
|
|
311
57
|
}
|
|
312
58
|
function generateSummary(bankTransactions, ynabTransactions, autoMatches, suggestedMatches, unmatchedBank, unmatchedYNAB, balances) {
|
|
@@ -363,23 +109,23 @@ function generateNextSteps(summary) {
|
|
|
363
109
|
}
|
|
364
110
|
return steps;
|
|
365
111
|
}
|
|
366
|
-
function formatCurrency(
|
|
112
|
+
function formatCurrency(amountMilli, currency = 'USD') {
|
|
367
113
|
const formatter = new Intl.NumberFormat('en-US', {
|
|
368
114
|
style: 'currency',
|
|
369
|
-
currency:
|
|
115
|
+
currency: currency,
|
|
370
116
|
minimumFractionDigits: 2,
|
|
371
117
|
maximumFractionDigits: 2,
|
|
372
118
|
});
|
|
373
|
-
return formatter.format(
|
|
119
|
+
return formatter.format(amountMilli / 1000);
|
|
374
120
|
}
|
|
375
|
-
function repeatAmountInsights(unmatchedBank) {
|
|
121
|
+
function repeatAmountInsights(unmatchedBank, currency = 'USD') {
|
|
376
122
|
const insights = [];
|
|
377
123
|
if (unmatchedBank.length === 0) {
|
|
378
124
|
return insights;
|
|
379
125
|
}
|
|
380
126
|
const frequency = new Map();
|
|
381
127
|
for (const txn of unmatchedBank) {
|
|
382
|
-
const key = txn.amount
|
|
128
|
+
const key = txn.amount;
|
|
383
129
|
const entry = frequency.get(key) ?? { amount: txn.amount, txns: [] };
|
|
384
130
|
entry.txns.push(txn);
|
|
385
131
|
frequency.set(key, entry);
|
|
@@ -392,72 +138,29 @@ function repeatAmountInsights(unmatchedBank) {
|
|
|
392
138
|
}
|
|
393
139
|
const top = repeated[0];
|
|
394
140
|
insights.push({
|
|
395
|
-
id: `repeat-${top.amount
|
|
141
|
+
id: `repeat-${top.amount}`,
|
|
396
142
|
type: 'repeat_amount',
|
|
397
143
|
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)}. ` +
|
|
144
|
+
title: `${top.txns.length} unmatched transactions at ${formatCurrency(top.amount, currency)}`,
|
|
145
|
+
description: `The bank statement shows ${top.txns.length} unmatched transaction(s) at ${formatCurrency(top.amount, currency)}. ` +
|
|
400
146
|
'Repeated amounts are usually the quickest wins — reconcile these first.',
|
|
401
147
|
evidence: {
|
|
402
148
|
amount: top.amount,
|
|
403
149
|
occurrences: top.txns.length,
|
|
404
150
|
dates: top.txns.map((txn) => txn.date),
|
|
405
|
-
csv_rows: top.txns.map((txn) => txn.
|
|
151
|
+
csv_rows: top.txns.map((txn) => txn.sourceRow),
|
|
406
152
|
},
|
|
407
153
|
});
|
|
408
154
|
return insights;
|
|
409
155
|
}
|
|
410
|
-
function
|
|
156
|
+
function anomalyInsights(balances) {
|
|
411
157
|
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) {
|
|
158
|
+
const discrepancyAbs = Math.abs(balances.discrepancy.value_milliunits);
|
|
159
|
+
if (discrepancyAbs >= 1000) {
|
|
457
160
|
insights.push({
|
|
458
161
|
id: 'balance-gap',
|
|
459
162
|
type: 'anomaly',
|
|
460
|
-
severity: discrepancyAbs >=
|
|
163
|
+
severity: discrepancyAbs >= 100000 ? 'critical' : 'warning',
|
|
461
164
|
title: `Cleared balance off by ${balances.discrepancy.value_display}`,
|
|
462
165
|
description: `YNAB cleared balance is ${balances.current_cleared.value_display} but the statement expects ` +
|
|
463
166
|
`${balances.target_statement.value_display}. Focus on closing this gap.`,
|
|
@@ -468,22 +171,9 @@ function anomalyInsights(summary, balances) {
|
|
|
468
171
|
},
|
|
469
172
|
});
|
|
470
173
|
}
|
|
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
174
|
return insights;
|
|
485
175
|
}
|
|
486
|
-
function detectInsights(
|
|
176
|
+
function detectInsights(unmatchedBank, _summary, balances, currency, csvErrors = [], csvWarnings = []) {
|
|
487
177
|
const insights = [];
|
|
488
178
|
const seen = new Set();
|
|
489
179
|
const addUnique = (insight) => {
|
|
@@ -492,62 +182,90 @@ function detectInsights(matches, unmatchedBank, summary, balances, config) {
|
|
|
492
182
|
seen.add(insight.id);
|
|
493
183
|
insights.push(insight);
|
|
494
184
|
};
|
|
495
|
-
|
|
496
|
-
addUnique(
|
|
185
|
+
if (csvErrors.length > 0) {
|
|
186
|
+
addUnique({
|
|
187
|
+
id: 'csv-parse-errors',
|
|
188
|
+
type: 'anomaly',
|
|
189
|
+
severity: csvErrors.length >= 5 ? 'critical' : 'warning',
|
|
190
|
+
title: `${csvErrors.length} CSV parsing error(s)`,
|
|
191
|
+
description: csvErrors
|
|
192
|
+
.slice(0, 3)
|
|
193
|
+
.map((e) => `Row ${e.row}: ${e.message}`)
|
|
194
|
+
.join('; ') + (csvErrors.length > 3 ? ` (+${csvErrors.length - 3} more)` : ''),
|
|
195
|
+
evidence: {
|
|
196
|
+
error_count: csvErrors.length,
|
|
197
|
+
errors: csvErrors.slice(0, 5),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (csvWarnings.length > 0) {
|
|
202
|
+
addUnique({
|
|
203
|
+
id: 'csv-parse-warnings',
|
|
204
|
+
type: 'anomaly',
|
|
205
|
+
severity: 'info',
|
|
206
|
+
title: `${csvWarnings.length} CSV parsing warning(s)`,
|
|
207
|
+
description: csvWarnings
|
|
208
|
+
.slice(0, 3)
|
|
209
|
+
.map((w) => `Row ${w.row}: ${w.message}`)
|
|
210
|
+
.join('; ') + (csvWarnings.length > 3 ? ` (+${csvWarnings.length - 3} more)` : ''),
|
|
211
|
+
evidence: {
|
|
212
|
+
warning_count: csvWarnings.length,
|
|
213
|
+
warnings: csvWarnings.slice(0, 5),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
497
216
|
}
|
|
498
|
-
for (const insight of
|
|
217
|
+
for (const insight of repeatAmountInsights(unmatchedBank, currency)) {
|
|
499
218
|
addUnique(insight);
|
|
500
219
|
}
|
|
501
|
-
for (const insight of anomalyInsights(
|
|
220
|
+
for (const insight of anomalyInsights(balances)) {
|
|
502
221
|
addUnique(insight);
|
|
503
222
|
}
|
|
504
223
|
return insights.slice(0, 5);
|
|
505
224
|
}
|
|
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;
|
|
225
|
+
export function analyzeReconciliation(csvContentOrParsed, _csvFilePath, ynabTransactions, statementBalance, config = DEFAULT_CONFIG, currency = 'USD', accountId, budgetId, invertBankAmounts = false, csvOptions, accountSnapshot) {
|
|
226
|
+
let parseResult;
|
|
227
|
+
if (typeof csvContentOrParsed === 'string') {
|
|
228
|
+
parseResult = parseCSV(csvContentOrParsed, {
|
|
229
|
+
...csvOptions,
|
|
230
|
+
invertAmounts: invertBankAmounts,
|
|
231
|
+
});
|
|
538
232
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
233
|
+
else {
|
|
234
|
+
parseResult = csvContentOrParsed;
|
|
235
|
+
}
|
|
236
|
+
const newBankTransactions = parseResult.transactions;
|
|
237
|
+
const csvParseErrors = parseResult.errors;
|
|
238
|
+
const csvParseWarnings = parseResult.warnings;
|
|
239
|
+
const newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
240
|
+
const normalizedConfig = normalizeConfig(config);
|
|
241
|
+
const newMatches = findMatches(newBankTransactions, newYNABTransactions, normalizedConfig);
|
|
242
|
+
const matches = newMatches.map(mapToTransactionMatch);
|
|
243
|
+
const autoMatches = matches.filter((m) => m.confidence === 'high');
|
|
244
|
+
const autoMatchedYnabIds = new Set();
|
|
245
|
+
autoMatches.forEach((m) => {
|
|
246
|
+
if (m.ynabTransaction)
|
|
247
|
+
autoMatchedYnabIds.add(m.ynabTransaction.id);
|
|
248
|
+
});
|
|
249
|
+
const suggestedMatches = matches.filter((m) => m.confidence === 'medium' &&
|
|
250
|
+
(!m.ynabTransaction || !autoMatchedYnabIds.has(m.ynabTransaction.id)));
|
|
251
|
+
const unmatchedBankMatches = matches.filter((m) => m.confidence === 'low' || m.confidence === 'none');
|
|
252
|
+
const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
|
|
253
|
+
const matchedYnabIds = new Set();
|
|
254
|
+
matches.forEach((m) => {
|
|
255
|
+
if (m.ynabTransaction)
|
|
256
|
+
matchedYnabIds.add(m.ynabTransaction.id);
|
|
257
|
+
});
|
|
258
|
+
const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
|
|
259
|
+
const balances = calculateBalances(newYNABTransactions, statementBalance, currency, accountSnapshot);
|
|
260
|
+
const summary = generateSummary(matches.map((m) => m.bankTransaction), newYNABTransactions, autoMatches, suggestedMatches, unmatchedBank, unmatchedYNAB, balances);
|
|
542
261
|
const nextSteps = generateNextSteps(summary);
|
|
543
|
-
const
|
|
544
|
-
const insights = mergeInsights(baseInsights, combinationInsights);
|
|
262
|
+
const insights = detectInsights(unmatchedBank, summary, balances, currency, csvParseErrors, csvParseWarnings);
|
|
545
263
|
const analysis = {
|
|
546
264
|
success: true,
|
|
547
265
|
phase: 'analysis',
|
|
548
266
|
summary,
|
|
549
267
|
auto_matches: autoMatches,
|
|
550
|
-
suggested_matches:
|
|
268
|
+
suggested_matches: suggestedMatches,
|
|
551
269
|
unmatched_bank: unmatchedBank,
|
|
552
270
|
unmatched_ynab: unmatchedYNAB,
|
|
553
271
|
balance_info: balances,
|
|
@@ -559,7 +277,7 @@ export function analyzeReconciliation(csvContent, csvFilePath, ynabTransactions,
|
|
|
559
277
|
account_id: accountId,
|
|
560
278
|
budget_id: budgetId,
|
|
561
279
|
analysis,
|
|
562
|
-
matching_config:
|
|
280
|
+
matching_config: normalizedConfig,
|
|
563
281
|
});
|
|
564
282
|
analysis.recommendations = recommendations;
|
|
565
283
|
}
|
|
@@ -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;
|