@dizzlkheinz/ynab-mcpb 0.18.4 → 0.19.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/CLAUDE.md +87 -8
- package/bin/ynab-mcp-server.cjs +2 -2
- package/bin/ynab-mcp-server.js +3 -3
- package/biome.json +39 -0
- package/dist/bundle/index.cjs +67 -67
- package/dist/index.d.ts +1 -1
- package/dist/index.js +27 -27
- package/dist/server/YNABMCPServer.d.ts +3 -4
- package/dist/server/YNABMCPServer.js +111 -116
- package/dist/server/budgetResolver.d.ts +6 -5
- package/dist/server/budgetResolver.js +46 -36
- package/dist/server/cacheKeys.js +6 -6
- package/dist/server/cacheManager.js +14 -11
- package/dist/server/completions.d.ts +2 -2
- package/dist/server/completions.js +20 -15
- package/dist/server/config.d.ts +10 -5
- package/dist/server/config.js +24 -7
- package/dist/server/deltaCache.d.ts +2 -2
- package/dist/server/deltaCache.js +22 -16
- package/dist/server/deltaCache.merge.d.ts +2 -2
- package/dist/server/diagnostics.d.ts +4 -4
- package/dist/server/diagnostics.js +38 -32
- package/dist/server/errorHandler.d.ts +5 -12
- package/dist/server/errorHandler.js +219 -217
- package/dist/server/prompts.d.ts +2 -2
- package/dist/server/prompts.js +45 -45
- package/dist/server/rateLimiter.js +4 -4
- package/dist/server/requestLogger.d.ts +1 -1
- package/dist/server/requestLogger.js +40 -35
- package/dist/server/resources.d.ts +3 -3
- package/dist/server/resources.js +55 -52
- package/dist/server/responseFormatter.js +6 -6
- package/dist/server/securityMiddleware.d.ts +2 -2
- package/dist/server/securityMiddleware.js +22 -20
- package/dist/server/serverKnowledgeStore.js +1 -1
- package/dist/server/toolRegistry.d.ts +3 -3
- package/dist/server/toolRegistry.js +47 -40
- package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
- package/dist/tools/__tests__/deltaTestUtils.js +2 -2
- package/dist/tools/accountTools.d.ts +9 -8
- package/dist/tools/accountTools.js +47 -47
- package/dist/tools/adapters.d.ts +13 -8
- package/dist/tools/adapters.js +21 -11
- package/dist/tools/budgetTools.d.ts +8 -7
- package/dist/tools/budgetTools.js +22 -22
- package/dist/tools/categoryTools.d.ts +9 -8
- package/dist/tools/categoryTools.js +68 -59
- package/dist/tools/compareTransactions/formatter.d.ts +3 -3
- package/dist/tools/compareTransactions/formatter.js +9 -9
- package/dist/tools/compareTransactions/index.d.ts +6 -6
- package/dist/tools/compareTransactions/index.js +58 -43
- package/dist/tools/compareTransactions/matcher.d.ts +1 -1
- package/dist/tools/compareTransactions/matcher.js +28 -15
- package/dist/tools/compareTransactions/parser.d.ts +2 -2
- package/dist/tools/compareTransactions/parser.js +144 -138
- package/dist/tools/compareTransactions/types.d.ts +4 -4
- package/dist/tools/compareTransactions.d.ts +1 -1
- package/dist/tools/compareTransactions.js +1 -1
- package/dist/tools/deltaFetcher.d.ts +2 -2
- package/dist/tools/deltaFetcher.js +16 -15
- package/dist/tools/deltaSupport.d.ts +4 -4
- package/dist/tools/deltaSupport.js +35 -41
- package/dist/tools/exportTransactions.d.ts +5 -4
- package/dist/tools/exportTransactions.js +61 -59
- package/dist/tools/monthTools.d.ts +7 -6
- package/dist/tools/monthTools.js +31 -29
- package/dist/tools/payeeTools.d.ts +7 -6
- package/dist/tools/payeeTools.js +28 -28
- package/dist/tools/reconcileAdapter.d.ts +2 -2
- package/dist/tools/reconcileAdapter.js +19 -12
- package/dist/tools/reconciliation/analyzer.d.ts +4 -4
- package/dist/tools/reconciliation/analyzer.js +73 -59
- package/dist/tools/reconciliation/csvParser.d.ts +3 -3
- package/dist/tools/reconciliation/csvParser.js +128 -104
- package/dist/tools/reconciliation/executor.d.ts +4 -4
- package/dist/tools/reconciliation/executor.js +148 -109
- package/dist/tools/reconciliation/index.d.ts +10 -10
- package/dist/tools/reconciliation/index.js +96 -83
- package/dist/tools/reconciliation/matcher.d.ts +3 -3
- package/dist/tools/reconciliation/matcher.js +17 -16
- package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
- package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
- package/dist/tools/reconciliation/recommendationEngine.js +40 -40
- package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
- package/dist/tools/reconciliation/reportFormatter.js +59 -58
- package/dist/tools/reconciliation/signDetector.d.ts +1 -1
- package/dist/tools/reconciliation/types.d.ts +16 -16
- package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
- package/dist/tools/schemas/common.d.ts +1 -1
- package/dist/tools/schemas/common.js +1 -1
- package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
- package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
- package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
- package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
- package/dist/tools/schemas/outputs/index.d.ts +14 -14
- package/dist/tools/schemas/outputs/index.js +14 -14
- package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
- package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
- package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
- package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
- package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
- package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
- package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
- package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
- package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
- package/dist/tools/schemas/shared/commonOutputs.js +15 -9
- package/dist/tools/transactionReadTools.d.ts +11 -0
- package/dist/tools/transactionReadTools.js +202 -0
- package/dist/tools/transactionSchemas.d.ts +7 -7
- package/dist/tools/transactionSchemas.js +77 -57
- package/dist/tools/transactionTools.d.ts +6 -24
- package/dist/tools/transactionTools.js +7 -1499
- package/dist/tools/transactionUtils.d.ts +6 -6
- package/dist/tools/transactionUtils.js +78 -63
- package/dist/tools/transactionWriteTools.d.ts +20 -0
- package/dist/tools/transactionWriteTools.js +1342 -0
- package/dist/tools/utilityTools.d.ts +5 -4
- package/dist/tools/utilityTools.js +11 -11
- package/dist/types/index.d.ts +7 -7
- package/dist/types/index.js +6 -6
- package/dist/types/reconciliation.d.ts +1 -1
- package/dist/types/toolRegistration.d.ts +14 -12
- package/dist/utils/amountUtils.js +1 -1
- package/dist/utils/dateUtils.js +4 -4
- package/dist/utils/errors.d.ts +3 -3
- package/dist/utils/errors.js +4 -4
- package/dist/utils/money.d.ts +2 -2
- package/dist/utils/money.js +8 -8
- package/dist/utils/validationError.d.ts +1 -1
- package/dist/utils/validationError.js +1 -1
- package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
- package/docs/assets/schemas/reconciliation-v2.json +360 -336
- package/esbuild.config.mjs +53 -50
- package/meta.json +12548 -12548
- package/package.json +98 -111
- package/scripts/analyze-bundle.mjs +33 -30
- package/scripts/create-pr-description.js +169 -120
- package/scripts/run-all-tests.js +178 -169
- package/scripts/run-domain-integration-tests.js +28 -18
- package/scripts/run-generate-mcpb.js +19 -17
- package/scripts/run-throttled-integration-tests.js +92 -83
- package/scripts/test-delta-params.mjs +149 -120
- package/scripts/test-recommendations.ts +36 -32
- package/scripts/tmpTransaction.ts +80 -43
- package/scripts/validate-env.js +98 -91
- package/scripts/verify-build.js +78 -76
- package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
- package/src/__tests__/performance.test.ts +723 -671
- package/src/__tests__/setup.ts +442 -395
- package/src/__tests__/smoke.e2e.test.ts +41 -39
- package/src/__tests__/testRunner.ts +314 -295
- package/src/__tests__/testUtils.ts +456 -364
- package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
- package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
- package/src/index.ts +68 -59
- package/src/server/CLAUDE.md +480 -0
- package/src/server/YNABMCPServer.ts +821 -794
- package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
- package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
- package/src/server/__tests__/budgetResolver.test.ts +466 -423
- package/src/server/__tests__/cacheManager.test.ts +891 -874
- package/src/server/__tests__/completions.integration.test.ts +115 -106
- package/src/server/__tests__/completions.test.ts +334 -313
- package/src/server/__tests__/config.test.ts +98 -86
- package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
- package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
- package/src/server/__tests__/deltaCache.test.ts +946 -759
- package/src/server/__tests__/diagnostics.test.ts +825 -792
- package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
- package/src/server/__tests__/errorHandler.test.ts +402 -397
- package/src/server/__tests__/prompts.test.ts +424 -347
- package/src/server/__tests__/rateLimiter.test.ts +313 -309
- package/src/server/__tests__/requestLogger.test.ts +443 -403
- package/src/server/__tests__/resources.template.test.ts +196 -185
- package/src/server/__tests__/resources.test.ts +294 -288
- package/src/server/__tests__/security.integration.test.ts +487 -421
- package/src/server/__tests__/securityMiddleware.test.ts +519 -444
- package/src/server/__tests__/server-startup.integration.test.ts +509 -490
- package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
- package/src/server/__tests__/toolRegistration.test.ts +239 -210
- package/src/server/__tests__/toolRegistry.test.ts +907 -845
- package/src/server/budgetResolver.ts +221 -181
- package/src/server/cacheKeys.ts +6 -6
- package/src/server/cacheManager.ts +498 -484
- package/src/server/completions.ts +267 -243
- package/src/server/config.ts +35 -14
- package/src/server/deltaCache.merge.ts +146 -128
- package/src/server/deltaCache.ts +352 -309
- package/src/server/diagnostics.ts +257 -242
- package/src/server/errorHandler.ts +747 -744
- package/src/server/prompts.ts +181 -176
- package/src/server/rateLimiter.ts +131 -129
- package/src/server/requestLogger.ts +350 -322
- package/src/server/resources.ts +442 -374
- package/src/server/responseFormatter.ts +41 -37
- package/src/server/securityMiddleware.ts +223 -205
- package/src/server/serverKnowledgeStore.ts +67 -67
- package/src/server/toolRegistry.ts +508 -474
- package/src/tools/CLAUDE.md +604 -0
- package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
- package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
- package/src/tools/__tests__/accountTools.test.ts +685 -638
- package/src/tools/__tests__/adapters.test.ts +142 -108
- package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
- package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
- package/src/tools/__tests__/budgetTools.test.ts +442 -413
- package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
- package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
- package/src/tools/__tests__/categoryTools.test.ts +656 -625
- package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
- package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
- package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
- package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
- package/src/tools/__tests__/compareTransactions.test.ts +352 -332
- package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
- package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
- package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
- package/src/tools/__tests__/deltaSupport.test.ts +211 -184
- package/src/tools/__tests__/deltaTestUtils.ts +37 -33
- package/src/tools/__tests__/exportTransactions.test.ts +205 -200
- package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
- package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
- package/src/tools/__tests__/monthTools.test.ts +561 -512
- package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
- package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
- package/src/tools/__tests__/payeeTools.test.ts +486 -434
- package/src/tools/__tests__/transactionSchemas.test.ts +1202 -1186
- package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
- package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
- package/src/tools/__tests__/transactionUtils.test.ts +1004 -977
- package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
- package/src/tools/__tests__/utilityTools.test.ts +68 -58
- package/src/tools/accountTools.ts +293 -271
- package/src/tools/adapters.ts +120 -63
- package/src/tools/budgetTools.ts +121 -116
- package/src/tools/categoryTools.ts +379 -339
- package/src/tools/compareTransactions/formatter.ts +131 -119
- package/src/tools/compareTransactions/index.ts +249 -214
- package/src/tools/compareTransactions/matcher.ts +259 -209
- package/src/tools/compareTransactions/parser.ts +517 -487
- package/src/tools/compareTransactions/types.ts +38 -38
- package/src/tools/compareTransactions.ts +1 -1
- package/src/tools/deltaFetcher.ts +281 -260
- package/src/tools/deltaSupport.ts +264 -259
- package/src/tools/exportTransactions.ts +230 -218
- package/src/tools/monthTools.ts +180 -165
- package/src/tools/payeeTools.ts +152 -140
- package/src/tools/reconcileAdapter.ts +297 -252
- package/src/tools/reconciliation/CLAUDE.md +506 -0
- package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +133 -124
- package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -230
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -400
- package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
- package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
- package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
- package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
- package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
- package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -989
- package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -533
- package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -74
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -62
- package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
- package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +56 -55
- package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
- package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
- package/src/tools/reconciliation/analyzer.ts +564 -504
- package/src/tools/reconciliation/csvParser.ts +656 -609
- package/src/tools/reconciliation/executor.ts +1290 -1128
- package/src/tools/reconciliation/index.ts +580 -528
- package/src/tools/reconciliation/matcher.ts +256 -240
- package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
- package/src/tools/reconciliation/recommendationEngine.ts +357 -345
- package/src/tools/reconciliation/reportFormatter.ts +343 -307
- package/src/tools/reconciliation/signDetector.ts +89 -83
- package/src/tools/reconciliation/types.ts +164 -159
- package/src/tools/reconciliation/ynabAdapter.ts +17 -15
- package/src/tools/schemas/CLAUDE.md +546 -0
- package/src/tools/schemas/common.ts +1 -1
- package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
- package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
- package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
- package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
- package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
- package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
- package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
- package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
- package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
- package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
- package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
- package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
- package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
- package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
- package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
- package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
- package/src/tools/schemas/outputs/index.ts +163 -163
- package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
- package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
- package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
- package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
- package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
- package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
- package/src/tools/schemas/shared/commonOutputs.ts +27 -19
- package/src/tools/toolCategories.ts +114 -114
- package/src/tools/transactionReadTools.ts +327 -0
- package/src/tools/transactionSchemas.ts +322 -291
- package/src/tools/transactionTools.ts +84 -2246
- package/src/tools/transactionUtils.ts +507 -422
- package/src/tools/transactionWriteTools.ts +2110 -0
- package/src/tools/utilityTools.ts +46 -41
- package/src/types/CLAUDE.md +477 -0
- package/src/types/__tests__/index.test.ts +51 -51
- package/src/types/index.ts +43 -39
- package/src/types/integration-tests.d.ts +26 -26
- package/src/types/reconciliation.ts +29 -29
- package/src/types/toolAnnotations.ts +30 -30
- package/src/types/toolRegistration.ts +43 -32
- package/src/utils/CLAUDE.md +508 -0
- package/src/utils/__tests__/dateUtils.test.ts +174 -168
- package/src/utils/__tests__/money.test.ts +193 -187
- package/src/utils/amountUtils.ts +5 -5
- package/src/utils/baseError.ts +5 -5
- package/src/utils/dateUtils.ts +29 -26
- package/src/utils/errors.ts +14 -14
- package/src/utils/money.ts +66 -52
- package/src/utils/validationError.ts +1 -1
- package/tsconfig.json +29 -29
- package/tsconfig.prod.json +16 -16
- package/vitest-reporters/split-json-reporter.ts +247 -204
- package/vitest.config.ts +99 -95
- package/.prettierignore +0 -10
- package/.prettierrc.json +0 -10
- package/eslint.config.js +0 -49
|
@@ -1,56 +1,60 @@
|
|
|
1
|
-
import type * as ynab from
|
|
2
|
-
import type { SaveTransaction } from
|
|
3
|
-
import { YNABAPIError, YNABErrorCode } from
|
|
4
|
-
import type { ProgressCallback } from
|
|
5
|
-
import { toMilli, toMoneyValue
|
|
6
|
-
import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from './types.js';
|
|
7
|
-
import type { ReconcileAccountRequest } from './index.js';
|
|
1
|
+
import type * as ynab from "ynab";
|
|
2
|
+
import type { SaveTransaction } from "ynab/dist/models/SaveTransaction.js";
|
|
3
|
+
import { YNABAPIError, YNABErrorCode } from "../../server/errorHandler.js";
|
|
4
|
+
import type { ProgressCallback } from "../../server/toolRegistry.js";
|
|
5
|
+
import { addMilli, toMilli, toMoneyValue } from "../../utils/money.js";
|
|
8
6
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from
|
|
7
|
+
correlateResults,
|
|
8
|
+
generateCorrelationKey,
|
|
9
|
+
toCorrelationPayload,
|
|
10
|
+
} from "../transactionTools.js";
|
|
11
|
+
import type { ReconcileAccountRequest } from "./index.js";
|
|
12
|
+
import type {
|
|
13
|
+
BankTransaction,
|
|
14
|
+
ReconciliationAnalysis,
|
|
15
|
+
TransactionMatch,
|
|
16
|
+
} from "./types.js";
|
|
13
17
|
|
|
14
18
|
export interface AccountSnapshot {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
balance: number; // milliunits
|
|
20
|
+
cleared_balance: number; // milliunits
|
|
21
|
+
uncleared_balance: number; // milliunits
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export interface ExecutionOptions {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
ynabAPI: ynab.API;
|
|
26
|
+
analysis: ReconciliationAnalysis;
|
|
27
|
+
params: ReconcileAccountRequest;
|
|
28
|
+
budgetId: string;
|
|
29
|
+
accountId: string;
|
|
30
|
+
initialAccount: AccountSnapshot;
|
|
31
|
+
currencyCode: string;
|
|
32
|
+
/**
|
|
33
|
+
* Optional progress callback for emitting MCP progress notifications.
|
|
34
|
+
* When provided, progress updates are sent during bulk operations.
|
|
35
|
+
*/
|
|
36
|
+
sendProgress?: ProgressCallback;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface ExecutionActionRecord {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
type: string;
|
|
41
|
+
transaction: Record<string, unknown> | null;
|
|
42
|
+
reason: string;
|
|
43
|
+
bulk_chunk_index?: number;
|
|
44
|
+
correlation_key?: string;
|
|
45
|
+
duplicate?: boolean;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export interface ExecutionSummary {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
bank_transactions_count: number;
|
|
50
|
+
ynab_transactions_count: number;
|
|
51
|
+
matches_found: number;
|
|
52
|
+
missing_in_ynab: number;
|
|
53
|
+
missing_in_bank: number;
|
|
54
|
+
transactions_created: number;
|
|
55
|
+
transactions_updated: number;
|
|
56
|
+
dates_adjusted: number;
|
|
57
|
+
dry_run: boolean;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/**
|
|
@@ -62,31 +66,33 @@ export interface ExecutionSummary {
|
|
|
62
66
|
* mirror `transaction_failures` rather than represent an independent count
|
|
63
67
|
*/
|
|
64
68
|
export interface BulkOperationDetails {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
chunks_processed: number;
|
|
70
|
+
bulk_successes: number;
|
|
71
|
+
sequential_fallbacks: number;
|
|
72
|
+
duplicates_detected: number;
|
|
73
|
+
failed_transactions: number; // Backward-compatible alias for transaction_failures
|
|
74
|
+
bulk_chunk_failures: number; // API-level failures (entire chunk failed)
|
|
75
|
+
transaction_failures: number; // Per-transaction failures (from correlation or sequential)
|
|
76
|
+
sequential_attempts?: number; // Number of sequential creations attempted during fallback
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export interface ExecutionResult {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
summary: ExecutionSummary;
|
|
81
|
+
account_balance: {
|
|
82
|
+
before: AccountSnapshot;
|
|
83
|
+
after: AccountSnapshot;
|
|
84
|
+
};
|
|
85
|
+
actions_taken: ExecutionActionRecord[];
|
|
86
|
+
recommendations: string[];
|
|
87
|
+
balance_reconciliation?: Awaited<
|
|
88
|
+
ReturnType<typeof buildBalanceReconciliation>
|
|
89
|
+
>;
|
|
90
|
+
bulk_operation_details?: BulkOperationDetails;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
interface UpdateFlags {
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
needsClearedUpdate: boolean;
|
|
95
|
+
needsDateUpdate: boolean;
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
const MONEY_EPSILON_MILLI = 100; // $0.10
|
|
@@ -98,1155 +104,1311 @@ const BATCH_DELAY_MS = 200; // Delay between batch chunks to avoid rate limiting
|
|
|
98
104
|
const MAX_MEMO_LENGTH = 500; // YNAB's maximum memo length
|
|
99
105
|
|
|
100
106
|
function chunkArray<T>(array: T[], size: number): T[][] {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
if (size <= 0) {
|
|
108
|
+
throw new Error("chunk size must be positive");
|
|
109
|
+
}
|
|
110
|
+
const chunks: T[][] = [];
|
|
111
|
+
for (let i = 0; i < array.length; i += size) {
|
|
112
|
+
chunks.push(array.slice(i, i + size));
|
|
113
|
+
}
|
|
114
|
+
return chunks;
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
function sleep(ms: number): Promise<void> {
|
|
112
|
-
|
|
118
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
function truncateMemo(memo: string | null | undefined): string {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
if (!memo) return "Auto-reconciled from bank statement";
|
|
123
|
+
if (memo.length <= MAX_MEMO_LENGTH) return memo;
|
|
124
|
+
return `${memo.substring(0, MAX_MEMO_LENGTH - 3)}...`;
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
interface StatementWindow {
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
start?: Date;
|
|
129
|
+
end?: Date;
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
interface PreparedBulkCreateEntry {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
bankTransaction: BankTransaction;
|
|
134
|
+
saveTransaction: SaveTransaction;
|
|
135
|
+
amountMilli: number;
|
|
136
|
+
correlationKey: string;
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
function parseISODate(dateStr: string | undefined): Date | undefined {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
if (!dateStr) return undefined;
|
|
141
|
+
const d = new Date(dateStr);
|
|
142
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
function resolveStatementWindow(
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
params: ReconcileAccountRequest,
|
|
147
|
+
analysisDateRange?: string | undefined,
|
|
142
148
|
): StatementWindow | undefined {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
149
|
+
const start = parseISODate(params.statement_start_date);
|
|
150
|
+
const end =
|
|
151
|
+
parseISODate(params.statement_end_date ?? params.statement_date) ??
|
|
152
|
+
// If only start provided, end stays undefined
|
|
153
|
+
undefined;
|
|
154
|
+
|
|
155
|
+
if (start || end) {
|
|
156
|
+
const window: StatementWindow = {};
|
|
157
|
+
if (start) window.start = start;
|
|
158
|
+
if (end) window.end = end;
|
|
159
|
+
return window;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (analysisDateRange?.includes(" to ")) {
|
|
163
|
+
const [rawStart, rawEnd] = analysisDateRange
|
|
164
|
+
.split(" to ")
|
|
165
|
+
.map((part) => part.trim());
|
|
166
|
+
const parsedStart = parseISODate(rawStart);
|
|
167
|
+
const parsedEnd = parseISODate(rawEnd);
|
|
168
|
+
if (parsedStart || parsedEnd) {
|
|
169
|
+
const window: StatementWindow = {};
|
|
170
|
+
if (parsedStart) window.start = parsedStart;
|
|
171
|
+
if (parsedEnd) window.end = parsedEnd;
|
|
172
|
+
return window;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return undefined;
|
|
169
177
|
}
|
|
170
178
|
|
|
171
|
-
function isWithinStatementWindow(
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
function isWithinStatementWindow(
|
|
180
|
+
dateStr: string,
|
|
181
|
+
window: StatementWindow,
|
|
182
|
+
): boolean {
|
|
183
|
+
const date = parseISODate(dateStr);
|
|
184
|
+
if (!date) return false;
|
|
174
185
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
if (window.start && date < window.start) return false;
|
|
187
|
+
if (window.end && date > window.end) return false;
|
|
188
|
+
return true;
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
export async function executeReconciliation(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
191
|
+
export async function executeReconciliation(
|
|
192
|
+
options: ExecutionOptions,
|
|
193
|
+
): Promise<ExecutionResult> {
|
|
194
|
+
const {
|
|
195
|
+
analysis,
|
|
196
|
+
params,
|
|
197
|
+
ynabAPI,
|
|
198
|
+
budgetId,
|
|
199
|
+
accountId,
|
|
200
|
+
initialAccount,
|
|
201
|
+
currencyCode,
|
|
202
|
+
sendProgress,
|
|
203
|
+
} = options;
|
|
204
|
+
const actions_taken: ExecutionActionRecord[] = [];
|
|
205
|
+
|
|
206
|
+
const summary: ExecutionSummary = {
|
|
207
|
+
bank_transactions_count: analysis.summary.bank_transactions_count,
|
|
208
|
+
ynab_transactions_count: analysis.summary.ynab_transactions_count,
|
|
209
|
+
matches_found: analysis.auto_matches.length,
|
|
210
|
+
missing_in_ynab: analysis.summary.unmatched_bank,
|
|
211
|
+
missing_in_bank: analysis.summary.unmatched_ynab,
|
|
212
|
+
transactions_created: 0,
|
|
213
|
+
transactions_updated: 0,
|
|
214
|
+
dates_adjusted: 0,
|
|
215
|
+
dry_run: params.dry_run,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Progress tracking for MCP notifications
|
|
219
|
+
// Pre-filter matches to only count those that will actually be updated
|
|
220
|
+
// This ensures accurate progress percentages (skipped matches don't inflate total)
|
|
221
|
+
const matchesNeedingUpdate = analysis.auto_matches.filter((match) => {
|
|
222
|
+
const flags = computeUpdateFlags(match, params);
|
|
223
|
+
return flags.needsClearedUpdate || flags.needsDateUpdate;
|
|
224
|
+
});
|
|
225
|
+
const totalOperations =
|
|
226
|
+
(params.auto_create_transactions ? analysis.unmatched_bank.length : 0) +
|
|
227
|
+
matchesNeedingUpdate.length +
|
|
228
|
+
(params.auto_unclear_missing ? analysis.unmatched_ynab.length : 0);
|
|
229
|
+
let completedOperations = 0;
|
|
230
|
+
|
|
231
|
+
const reportProgress = async (message: string): Promise<void> => {
|
|
232
|
+
if (sendProgress && totalOperations > 0) {
|
|
233
|
+
await sendProgress({
|
|
234
|
+
progress: completedOperations,
|
|
235
|
+
total: totalOperations,
|
|
236
|
+
message,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
let afterAccount: AccountSnapshot = { ...initialAccount };
|
|
242
|
+
let accountSnapshotDirty = false;
|
|
243
|
+
const statementTargetMilli = resolveStatementBalanceMilli(
|
|
244
|
+
analysis.balance_info,
|
|
245
|
+
params.statement_balance,
|
|
246
|
+
);
|
|
247
|
+
let clearedDeltaMilli = addMilli(
|
|
248
|
+
initialAccount.cleared_balance ?? 0,
|
|
249
|
+
-statementTargetMilli,
|
|
250
|
+
);
|
|
251
|
+
const balanceToleranceMilli =
|
|
252
|
+
Math.max(0, params.amount_tolerance_cents ?? DEFAULT_TOLERANCE_CENTS) *
|
|
253
|
+
CENTS_TO_MILLI;
|
|
254
|
+
let balanceAligned = false;
|
|
255
|
+
|
|
256
|
+
const applyClearedDelta = (delta: number) => {
|
|
257
|
+
if (delta === 0) return;
|
|
258
|
+
clearedDeltaMilli = addMilli(clearedDeltaMilli, delta);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const recordAlignmentIfNeeded = (trigger: string, { log = true } = {}) => {
|
|
262
|
+
if (balanceAligned) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (Math.abs(clearedDeltaMilli) <= balanceToleranceMilli) {
|
|
266
|
+
balanceAligned = true;
|
|
267
|
+
if (log) {
|
|
268
|
+
const deltaDisplay = toMoneyValue(
|
|
269
|
+
clearedDeltaMilli,
|
|
270
|
+
currencyCode,
|
|
271
|
+
).value_display;
|
|
272
|
+
const toleranceDisplay = toMoneyValue(
|
|
273
|
+
balanceToleranceMilli,
|
|
274
|
+
currencyCode,
|
|
275
|
+
).value_display;
|
|
276
|
+
actions_taken.push({
|
|
277
|
+
type: "balance_checkpoint",
|
|
278
|
+
transaction: null,
|
|
279
|
+
reason: `Cleared delta ${deltaDisplay} within ±${toleranceDisplay} after ${trigger} - halting newest-to-oldest pass`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
recordAlignmentIfNeeded("initial balance check", { log: false });
|
|
288
|
+
|
|
289
|
+
const orderedUnmatchedBank = params.auto_create_transactions
|
|
290
|
+
? sortByDateDescending(analysis.unmatched_bank)
|
|
291
|
+
: [];
|
|
292
|
+
const orderedAutoMatches = sortMatchesByBankDateDescending(
|
|
293
|
+
analysis.auto_matches,
|
|
294
|
+
);
|
|
295
|
+
const statementWindow = resolveStatementWindow(
|
|
296
|
+
params,
|
|
297
|
+
analysis.summary.statement_date_range,
|
|
298
|
+
);
|
|
299
|
+
const orderedUnmatchedYNAB = sortByDateDescending(
|
|
300
|
+
statementWindow
|
|
301
|
+
? analysis.unmatched_ynab.filter((txn) =>
|
|
302
|
+
isWithinStatementWindow(txn.date, statementWindow),
|
|
303
|
+
)
|
|
304
|
+
: analysis.unmatched_ynab,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
let bulkOperationDetails: BulkOperationDetails | undefined;
|
|
308
|
+
|
|
309
|
+
// STEP 1: Auto-create missing transactions (bank -> YNAB)
|
|
310
|
+
if (params.auto_create_transactions && !balanceAligned) {
|
|
311
|
+
const buildPreparedEntry = (
|
|
312
|
+
bankTxn: BankTransaction,
|
|
313
|
+
): PreparedBulkCreateEntry => {
|
|
314
|
+
const amountMilli = bankTxn.amount;
|
|
315
|
+
const saveTransaction: SaveTransaction = {
|
|
316
|
+
account_id: accountId,
|
|
317
|
+
amount: amountMilli,
|
|
318
|
+
date: bankTxn.date,
|
|
319
|
+
payee_name: bankTxn.payee ?? undefined,
|
|
320
|
+
memo: truncateMemo(bankTxn.memo),
|
|
321
|
+
cleared: "cleared",
|
|
322
|
+
approved: true,
|
|
323
|
+
// Note: import_id intentionally omitted so transactions can match with bank imports
|
|
324
|
+
};
|
|
325
|
+
const correlationKey = generateCorrelationKey(
|
|
326
|
+
toCorrelationPayload(saveTransaction),
|
|
327
|
+
);
|
|
328
|
+
return {
|
|
329
|
+
bankTransaction: bankTxn,
|
|
330
|
+
saveTransaction,
|
|
331
|
+
amountMilli,
|
|
332
|
+
correlationKey,
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const recordCreateAction = (args: {
|
|
337
|
+
entry: PreparedBulkCreateEntry;
|
|
338
|
+
createdTxn: ynab.TransactionDetail | null;
|
|
339
|
+
chunkIndex?: number;
|
|
340
|
+
prefix?: string;
|
|
341
|
+
}) => {
|
|
342
|
+
const { entry, createdTxn, chunkIndex, prefix } = args;
|
|
343
|
+
summary.transactions_created += 1;
|
|
344
|
+
const action: ExecutionActionRecord = {
|
|
345
|
+
type: "create_transaction",
|
|
346
|
+
transaction: createdTxn as unknown as Record<string, unknown> | null,
|
|
347
|
+
reason: `${prefix ?? "Created missing transaction"}: ${
|
|
348
|
+
entry.bankTransaction.payee ?? "Unknown"
|
|
349
|
+
} (${formatDisplay(entry.bankTransaction.amount, currencyCode)})`,
|
|
350
|
+
correlation_key: entry.correlationKey,
|
|
351
|
+
};
|
|
352
|
+
if (chunkIndex !== undefined) {
|
|
353
|
+
action.bulk_chunk_index = chunkIndex;
|
|
354
|
+
}
|
|
355
|
+
actions_taken.push(action);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const processSequentialEntries = async (
|
|
359
|
+
entries: PreparedBulkCreateEntry[],
|
|
360
|
+
options: { chunkIndex?: number; fallbackError?: unknown } = {},
|
|
361
|
+
) => {
|
|
362
|
+
let sequentialAttempts = 0;
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (balanceAligned) break;
|
|
365
|
+
if (options.fallbackError) {
|
|
366
|
+
sequentialAttempts += 1;
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const response = await ynabAPI.transactions.createTransaction(
|
|
370
|
+
budgetId,
|
|
371
|
+
{
|
|
372
|
+
transaction: entry.saveTransaction,
|
|
373
|
+
},
|
|
374
|
+
);
|
|
375
|
+
const createdTransaction = response.data.transaction ?? null;
|
|
376
|
+
const recordArgs: Parameters<typeof recordCreateAction>[0] = {
|
|
377
|
+
entry,
|
|
378
|
+
createdTxn: createdTransaction,
|
|
379
|
+
prefix: options.fallbackError
|
|
380
|
+
? "Created missing transaction after bulk fallback"
|
|
381
|
+
: "Created missing transaction",
|
|
382
|
+
};
|
|
383
|
+
if (options.chunkIndex !== undefined) {
|
|
384
|
+
recordArgs.chunkIndex = options.chunkIndex;
|
|
385
|
+
}
|
|
386
|
+
recordCreateAction(recordArgs);
|
|
387
|
+
accountSnapshotDirty = true;
|
|
388
|
+
applyClearedDelta(entry.amountMilli);
|
|
389
|
+
// Report progress for sequential/fallback operations
|
|
390
|
+
completedOperations += 1;
|
|
391
|
+
await reportProgress(
|
|
392
|
+
`Created ${completedOperations} of ${totalOperations} transactions`,
|
|
393
|
+
);
|
|
394
|
+
const trigger = options.chunkIndex
|
|
395
|
+
? `creating ${entry.bankTransaction.payee ?? "missing transaction"} (chunk ${options.chunkIndex})`
|
|
396
|
+
: `creating ${entry.bankTransaction.payee ?? "missing transaction"}`;
|
|
397
|
+
recordAlignmentIfNeeded(trigger);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const ynabError = normalizeYnabError(error);
|
|
400
|
+
if (bulkOperationDetails) {
|
|
401
|
+
bulkOperationDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
|
|
402
|
+
}
|
|
403
|
+
const failureReason = ynabError.message || "Unknown error occurred";
|
|
404
|
+
const statusSuffix = ynabError.status
|
|
405
|
+
? ` (HTTP ${ynabError.status})`
|
|
406
|
+
: "";
|
|
407
|
+
const failureAction: ExecutionActionRecord = {
|
|
408
|
+
type: "create_transaction_failed",
|
|
409
|
+
transaction: entry.saveTransaction as unknown as Record<
|
|
410
|
+
string,
|
|
411
|
+
unknown
|
|
412
|
+
>,
|
|
413
|
+
reason: options.fallbackError
|
|
414
|
+
? `Bulk fallback failed for ${entry.bankTransaction.payee ?? "Unknown"} (${failureReason}${statusSuffix})`
|
|
415
|
+
: `Failed to create transaction ${entry.bankTransaction.payee ?? "Unknown"} (${failureReason}${statusSuffix})`,
|
|
416
|
+
correlation_key: entry.correlationKey,
|
|
417
|
+
};
|
|
418
|
+
if (options.chunkIndex !== undefined) {
|
|
419
|
+
failureAction.bulk_chunk_index = options.chunkIndex;
|
|
420
|
+
}
|
|
421
|
+
actions_taken.push(failureAction);
|
|
422
|
+
|
|
423
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
424
|
+
throw attachStatusToError(ynabError, error);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Update sequential_attempts metric if this was a fallback operation
|
|
429
|
+
if (
|
|
430
|
+
bulkOperationDetails &&
|
|
431
|
+
options.fallbackError &&
|
|
432
|
+
sequentialAttempts > 0
|
|
433
|
+
) {
|
|
434
|
+
bulkOperationDetails.sequential_attempts =
|
|
435
|
+
(bulkOperationDetails.sequential_attempts ?? 0) + sequentialAttempts;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const processBulkChunk = async (
|
|
440
|
+
chunk: PreparedBulkCreateEntry[],
|
|
441
|
+
chunkIndex: number,
|
|
442
|
+
) => {
|
|
443
|
+
// bulkOperationDetails is guaranteed to be defined when this function is called
|
|
444
|
+
// (it's only called from within the bulk operation block where it's initialized)
|
|
445
|
+
if (!bulkOperationDetails) {
|
|
446
|
+
throw new Error("Bulk operation details not initialized");
|
|
447
|
+
}
|
|
448
|
+
const bulkDetails = bulkOperationDetails;
|
|
449
|
+
|
|
450
|
+
const payload = chunk.map((entry) => entry.saveTransaction);
|
|
451
|
+
const response = await ynabAPI.transactions.createTransactions(budgetId, {
|
|
452
|
+
transactions: payload,
|
|
453
|
+
});
|
|
454
|
+
const responseData = response.data;
|
|
455
|
+
const duplicateImportIds = new Set(
|
|
456
|
+
responseData.duplicate_import_ids ?? [],
|
|
457
|
+
);
|
|
458
|
+
const correlationRequests = chunk.map((entry) =>
|
|
459
|
+
toCorrelationPayload(entry.saveTransaction),
|
|
460
|
+
) as Parameters<typeof correlateResults>[0];
|
|
461
|
+
const correlated = correlateResults(
|
|
462
|
+
correlationRequests,
|
|
463
|
+
responseData,
|
|
464
|
+
duplicateImportIds,
|
|
465
|
+
);
|
|
466
|
+
const transactionMap = new Map<string, ynab.TransactionDetail>();
|
|
467
|
+
for (const transaction of responseData.transactions ?? []) {
|
|
468
|
+
if (transaction.id) {
|
|
469
|
+
transactionMap.set(transaction.id, transaction);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
for (const result of correlated) {
|
|
473
|
+
const entry = chunk[result.request_index];
|
|
474
|
+
if (!entry) continue;
|
|
475
|
+
if (result.status === "created") {
|
|
476
|
+
const createdTransaction = result.transaction_id
|
|
477
|
+
? (transactionMap.get(result.transaction_id) ?? null)
|
|
478
|
+
: null;
|
|
479
|
+
recordCreateAction({
|
|
480
|
+
entry,
|
|
481
|
+
createdTxn: createdTransaction,
|
|
482
|
+
chunkIndex,
|
|
483
|
+
prefix: "Created missing transaction via bulk",
|
|
484
|
+
});
|
|
485
|
+
accountSnapshotDirty = true;
|
|
486
|
+
applyClearedDelta(entry.amountMilli);
|
|
487
|
+
recordAlignmentIfNeeded(
|
|
488
|
+
`creating ${entry.bankTransaction.payee ?? "missing transaction"} via bulk chunk ${chunkIndex}`,
|
|
489
|
+
);
|
|
490
|
+
} else if (result.status === "duplicate") {
|
|
491
|
+
bulkDetails.duplicates_detected += 1;
|
|
492
|
+
actions_taken.push({
|
|
493
|
+
type: "create_transaction_duplicate",
|
|
494
|
+
transaction: {
|
|
495
|
+
transaction_id: result.transaction_id ?? null,
|
|
496
|
+
import_id: entry.saveTransaction.import_id,
|
|
497
|
+
},
|
|
498
|
+
reason: `Duplicate import detected for ${
|
|
499
|
+
entry.bankTransaction.payee ?? "Unknown"
|
|
500
|
+
} (import_id ${entry.saveTransaction.import_id})`,
|
|
501
|
+
bulk_chunk_index: chunkIndex,
|
|
502
|
+
correlation_key: result.correlation_key,
|
|
503
|
+
duplicate: true,
|
|
504
|
+
});
|
|
505
|
+
} else {
|
|
506
|
+
bulkDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
|
|
507
|
+
actions_taken.push({
|
|
508
|
+
type: "create_transaction_failed",
|
|
509
|
+
transaction: entry.saveTransaction as unknown as Record<
|
|
510
|
+
string,
|
|
511
|
+
unknown
|
|
512
|
+
>,
|
|
513
|
+
reason:
|
|
514
|
+
result.error ??
|
|
515
|
+
`Bulk create failed for ${entry.bankTransaction.payee ?? "Unknown"}`,
|
|
516
|
+
bulk_chunk_index: chunkIndex,
|
|
517
|
+
correlation_key: result.correlation_key,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
if (params.dry_run) {
|
|
524
|
+
for (const bankTxn of orderedUnmatchedBank) {
|
|
525
|
+
if (balanceAligned) break;
|
|
526
|
+
const entry = buildPreparedEntry(bankTxn);
|
|
527
|
+
summary.transactions_created += 1;
|
|
528
|
+
actions_taken.push({
|
|
529
|
+
type: "create_transaction",
|
|
530
|
+
transaction: entry.saveTransaction as unknown as Record<
|
|
531
|
+
string,
|
|
532
|
+
unknown
|
|
533
|
+
>,
|
|
534
|
+
reason: `Would create missing transaction: ${bankTxn.payee ?? "Unknown"} (${formatDisplay(bankTxn.amount, currencyCode)})`,
|
|
535
|
+
correlation_key: entry.correlationKey,
|
|
536
|
+
});
|
|
537
|
+
applyClearedDelta(entry.amountMilli);
|
|
538
|
+
recordAlignmentIfNeeded(
|
|
539
|
+
`creating ${bankTxn.payee ?? "missing transaction"}`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
} else if (orderedUnmatchedBank.length >= 2) {
|
|
543
|
+
bulkOperationDetails = {
|
|
544
|
+
chunks_processed: 0,
|
|
545
|
+
bulk_successes: 0,
|
|
546
|
+
sequential_fallbacks: 0,
|
|
547
|
+
duplicates_detected: 0,
|
|
548
|
+
failed_transactions: 0,
|
|
549
|
+
bulk_chunk_failures: 0,
|
|
550
|
+
transaction_failures: 0,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
let nextBankIndex = 0;
|
|
554
|
+
while (nextBankIndex < orderedUnmatchedBank.length && !balanceAligned) {
|
|
555
|
+
const batch: PreparedBulkCreateEntry[] = [];
|
|
556
|
+
let projectedDelta = clearedDeltaMilli;
|
|
557
|
+
while (nextBankIndex < orderedUnmatchedBank.length) {
|
|
558
|
+
const bankTxn = orderedUnmatchedBank[nextBankIndex];
|
|
559
|
+
if (!bankTxn) {
|
|
560
|
+
nextBankIndex += 1;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const entry = buildPreparedEntry(bankTxn);
|
|
564
|
+
batch.push(entry);
|
|
565
|
+
nextBankIndex += 1;
|
|
566
|
+
projectedDelta = addMilli(projectedDelta, entry.amountMilli);
|
|
567
|
+
if (Math.abs(projectedDelta) <= balanceToleranceMilli) {
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (batch.length === 0) {
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const chunks = chunkArray(batch, MAX_BULK_CREATE_CHUNK);
|
|
577
|
+
for (const chunk of chunks) {
|
|
578
|
+
if (balanceAligned) break;
|
|
579
|
+
bulkOperationDetails.chunks_processed += 1;
|
|
580
|
+
const chunkIndex = bulkOperationDetails.chunks_processed;
|
|
581
|
+
try {
|
|
582
|
+
await processBulkChunk(chunk, chunkIndex);
|
|
583
|
+
bulkOperationDetails.bulk_successes += 1;
|
|
584
|
+
// Report progress after successful chunk processing
|
|
585
|
+
completedOperations += chunk.length;
|
|
586
|
+
await reportProgress(
|
|
587
|
+
`Created ${completedOperations} of ${totalOperations} transactions`,
|
|
588
|
+
);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const ynabError = normalizeYnabError(error);
|
|
591
|
+
const failureReason = ynabError.message || "unknown error";
|
|
592
|
+
bulkOperationDetails.bulk_chunk_failures += 1; // API-level failure (entire chunk failed)
|
|
593
|
+
|
|
594
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
595
|
+
bulkOperationDetails.transaction_failures += chunk.length;
|
|
596
|
+
throw attachStatusToError(ynabError, error);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
bulkOperationDetails.sequential_fallbacks += 1;
|
|
600
|
+
actions_taken.push({
|
|
601
|
+
type: "bulk_create_fallback",
|
|
602
|
+
transaction: null,
|
|
603
|
+
reason: `Bulk chunk #${chunkIndex} failed (${failureReason}${
|
|
604
|
+
ynabError.status ? ` (HTTP ${ynabError.status})` : ""
|
|
605
|
+
}) - falling back to sequential creation`,
|
|
606
|
+
bulk_chunk_index: chunkIndex,
|
|
607
|
+
});
|
|
608
|
+
await processSequentialEntries(chunk, {
|
|
609
|
+
chunkIndex,
|
|
610
|
+
fallbackError: ynabError,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
const entries = orderedUnmatchedBank.map((bankTxn) =>
|
|
617
|
+
buildPreparedEntry(bankTxn),
|
|
618
|
+
);
|
|
619
|
+
await processSequentialEntries(entries);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// STEP 2: Update matched YNAB transactions (cleared status / date)
|
|
624
|
+
// Collect all updates for batch processing
|
|
625
|
+
if (!balanceAligned) {
|
|
626
|
+
const transactionsToUpdate: ynab.SaveTransactionWithIdOrImportId[] = [];
|
|
627
|
+
|
|
628
|
+
for (const match of orderedAutoMatches) {
|
|
629
|
+
if (balanceAligned) break;
|
|
630
|
+
const flags = computeUpdateFlags(match, params);
|
|
631
|
+
if (!flags.needsClearedUpdate && !flags.needsDateUpdate) continue;
|
|
632
|
+
if (!match.ynabTransaction) continue;
|
|
633
|
+
|
|
634
|
+
// Build minimal update payload - only include ID and fields that are changing
|
|
635
|
+
// Including unnecessary fields (like amount, payee_name) can cause unexpected behavior
|
|
636
|
+
// BUT we must include memo to fix existing memos that exceed YNAB's 500 char limit
|
|
637
|
+
const updatePayload: ynab.SaveTransactionWithIdOrImportId = {
|
|
638
|
+
id: match.ynabTransaction.id,
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// Truncate memo if it exists and is too long (YNAB validates on update even if not changed)
|
|
642
|
+
if (match.ynabTransaction.memo) {
|
|
643
|
+
updatePayload.memo = truncateMemo(match.ynabTransaction.memo);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Only include fields that are actually changing
|
|
647
|
+
if (flags.needsDateUpdate) {
|
|
648
|
+
updatePayload.date = match.bankTransaction.date;
|
|
649
|
+
}
|
|
650
|
+
if (flags.needsClearedUpdate) {
|
|
651
|
+
updatePayload.cleared = "cleared" as ynab.TransactionClearedStatus;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (params.dry_run) {
|
|
655
|
+
summary.transactions_updated += 1;
|
|
656
|
+
if (flags.needsDateUpdate) summary.dates_adjusted += 1;
|
|
657
|
+
actions_taken.push({
|
|
658
|
+
type: "update_transaction",
|
|
659
|
+
transaction: {
|
|
660
|
+
transaction_id: match.ynabTransaction.id,
|
|
661
|
+
new_date: flags.needsDateUpdate
|
|
662
|
+
? match.bankTransaction.date
|
|
663
|
+
: undefined,
|
|
664
|
+
cleared: flags.needsClearedUpdate ? "cleared" : undefined,
|
|
665
|
+
},
|
|
666
|
+
reason: `Would update transaction: ${updateReason(match, flags, currencyCode)}`,
|
|
667
|
+
});
|
|
668
|
+
if (flags.needsClearedUpdate) {
|
|
669
|
+
applyClearedDelta(match.ynabTransaction.amount);
|
|
670
|
+
if (
|
|
671
|
+
recordAlignmentIfNeeded(
|
|
672
|
+
`clearing ${match.ynabTransaction.id ?? "transaction"} (dry run)`,
|
|
673
|
+
)
|
|
674
|
+
) {
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
transactionsToUpdate.push(updatePayload);
|
|
680
|
+
if (flags.needsDateUpdate) summary.dates_adjusted += 1;
|
|
681
|
+
if (flags.needsClearedUpdate) {
|
|
682
|
+
applyClearedDelta(match.ynabTransaction.amount);
|
|
683
|
+
if (recordAlignmentIfNeeded(`clearing ${match.ynabTransaction.id}`)) {
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Batch update all transactions in a single API call
|
|
691
|
+
// YNAB API has a limit of ~100 transactions per batch, so we chunk the updates
|
|
692
|
+
if (!params.dry_run && transactionsToUpdate.length > 0) {
|
|
693
|
+
const updateChunks = chunkArray(
|
|
694
|
+
transactionsToUpdate,
|
|
695
|
+
MAX_BULK_UPDATE_CHUNK,
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
for (let chunkIdx = 0; chunkIdx < updateChunks.length; chunkIdx++) {
|
|
699
|
+
const chunk = updateChunks[chunkIdx];
|
|
700
|
+
if (!chunk) continue;
|
|
701
|
+
try {
|
|
702
|
+
const response = await ynabAPI.transactions.updateTransactions(
|
|
703
|
+
budgetId,
|
|
704
|
+
{
|
|
705
|
+
transactions: chunk,
|
|
706
|
+
},
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const updatedTransactions = response.data.transactions ?? [];
|
|
710
|
+
summary.transactions_updated += updatedTransactions.length;
|
|
711
|
+
|
|
712
|
+
for (const updatedTransaction of updatedTransactions) {
|
|
713
|
+
const match = orderedAutoMatches.find(
|
|
714
|
+
(m) => m.ynabTransaction?.id === updatedTransaction.id,
|
|
715
|
+
);
|
|
716
|
+
const flags = match
|
|
717
|
+
? computeUpdateFlags(match, params)
|
|
718
|
+
: { needsClearedUpdate: false, needsDateUpdate: false };
|
|
719
|
+
actions_taken.push({
|
|
720
|
+
type: "update_transaction",
|
|
721
|
+
transaction: updatedTransaction as unknown as Record<
|
|
722
|
+
string,
|
|
723
|
+
unknown
|
|
724
|
+
> | null,
|
|
725
|
+
reason: `Updated transaction: ${match ? updateReason(match, flags, currencyCode) : "cleared"}`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
accountSnapshotDirty = true;
|
|
729
|
+
// Report progress after successful batch update
|
|
730
|
+
completedOperations += updatedTransactions.length;
|
|
731
|
+
await reportProgress(
|
|
732
|
+
`Updated ${completedOperations} of ${totalOperations} transactions`,
|
|
733
|
+
);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
const ynabError = normalizeYnabError(error);
|
|
736
|
+
const failureReason = ynabError.message || "Unknown error occurred";
|
|
737
|
+
const statusSuffix = ynabError.status
|
|
738
|
+
? ` (HTTP ${ynabError.status})`
|
|
739
|
+
: "";
|
|
740
|
+
actions_taken.push({
|
|
741
|
+
type: "batch_update_failed",
|
|
742
|
+
transaction: null,
|
|
743
|
+
reason: `Failed to update chunk ${chunkIdx + 1}/${updateChunks.length} (${chunk.length} transaction(s)): ${failureReason}${statusSuffix}`,
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
747
|
+
throw attachStatusToError(ynabError, error);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Add delay between chunks to avoid rate limiting (except after last chunk)
|
|
752
|
+
if (chunkIdx < updateChunks.length - 1) {
|
|
753
|
+
await sleep(BATCH_DELAY_MS);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// STEP 3: Auto-unclear YNAB transactions missing from bank
|
|
760
|
+
const shouldRunSanityPass = params.auto_unclear_missing && !balanceAligned;
|
|
761
|
+
|
|
762
|
+
// Diagnostic logging for auto_unclear_missing debugging
|
|
763
|
+
actions_taken.push({
|
|
764
|
+
type: "diagnostic_step3_entry",
|
|
765
|
+
transaction: null,
|
|
766
|
+
reason: `STEP 3 diagnostics: auto_unclear_missing=${params.auto_unclear_missing}, balanceAligned=${balanceAligned}, shouldRunSanityPass=${shouldRunSanityPass}, orderedUnmatchedYNAB.length=${orderedUnmatchedYNAB.length}`,
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
if (orderedUnmatchedYNAB.length > 0) {
|
|
770
|
+
const unmatchedDetails = orderedUnmatchedYNAB.slice(0, 10).map((t) => ({
|
|
771
|
+
id: t.id,
|
|
772
|
+
date: t.date,
|
|
773
|
+
cleared: t.cleared,
|
|
774
|
+
amount: formatDisplay(t.amount, currencyCode),
|
|
775
|
+
payee: t.payee ?? "Unknown",
|
|
776
|
+
}));
|
|
777
|
+
actions_taken.push({
|
|
778
|
+
type: "diagnostic_unmatched_ynab",
|
|
779
|
+
transaction: {
|
|
780
|
+
unmatched_transactions: unmatchedDetails,
|
|
781
|
+
} as unknown as Record<string, unknown>,
|
|
782
|
+
reason: `First ${Math.min(10, orderedUnmatchedYNAB.length)} unmatched YNAB transactions (cleared status and amounts)`,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (shouldRunSanityPass) {
|
|
787
|
+
const transactionsToUnclear: ynab.SaveTransactionWithIdOrImportId[] = [];
|
|
788
|
+
|
|
789
|
+
for (const ynabTxn of orderedUnmatchedYNAB) {
|
|
790
|
+
if (ynabTxn.cleared !== "cleared") continue;
|
|
791
|
+
if (balanceAligned) break;
|
|
792
|
+
|
|
793
|
+
if (params.dry_run) {
|
|
794
|
+
summary.transactions_updated += 1;
|
|
795
|
+
actions_taken.push({
|
|
796
|
+
type: "update_transaction",
|
|
797
|
+
transaction: { transaction_id: ynabTxn.id, cleared: "uncleared" },
|
|
798
|
+
reason: `Would mark transaction ${ynabTxn.id} as uncleared - not present on statement`,
|
|
799
|
+
});
|
|
800
|
+
applyClearedDelta(-ynabTxn.amount);
|
|
801
|
+
if (recordAlignmentIfNeeded(`unclearing ${ynabTxn.id} (dry run)`)) {
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
} else {
|
|
805
|
+
// Minimal update payload - only include ID and the field we're changing
|
|
806
|
+
transactionsToUnclear.push({
|
|
807
|
+
id: ynabTxn.id,
|
|
808
|
+
cleared: "uncleared" as ynab.TransactionClearedStatus,
|
|
809
|
+
});
|
|
810
|
+
applyClearedDelta(-ynabTxn.amount);
|
|
811
|
+
if (recordAlignmentIfNeeded(`unclearing ${ynabTxn.id}`)) {
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Batch update all unclear operations in a single API call
|
|
818
|
+
// YNAB API has a limit of ~100 transactions per batch, so we chunk the updates
|
|
819
|
+
if (!params.dry_run && transactionsToUnclear.length > 0) {
|
|
820
|
+
const unclearChunks = chunkArray(
|
|
821
|
+
transactionsToUnclear,
|
|
822
|
+
MAX_BULK_UPDATE_CHUNK,
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
for (let chunkIdx = 0; chunkIdx < unclearChunks.length; chunkIdx++) {
|
|
826
|
+
const chunk = unclearChunks[chunkIdx];
|
|
827
|
+
if (!chunk) continue;
|
|
828
|
+
try {
|
|
829
|
+
const response = await ynabAPI.transactions.updateTransactions(
|
|
830
|
+
budgetId,
|
|
831
|
+
{
|
|
832
|
+
transactions: chunk,
|
|
833
|
+
},
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
const updatedTransactions = response.data.transactions ?? [];
|
|
837
|
+
summary.transactions_updated += updatedTransactions.length;
|
|
838
|
+
|
|
839
|
+
for (const updatedTransaction of updatedTransactions) {
|
|
840
|
+
actions_taken.push({
|
|
841
|
+
type: "update_transaction",
|
|
842
|
+
transaction: updatedTransaction as unknown as Record<
|
|
843
|
+
string,
|
|
844
|
+
unknown
|
|
845
|
+
> | null,
|
|
846
|
+
reason: `Marked transaction ${updatedTransaction.id} as uncleared - not found on statement`,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
accountSnapshotDirty = true;
|
|
850
|
+
// Report progress after successful unclear batch
|
|
851
|
+
completedOperations += updatedTransactions.length;
|
|
852
|
+
await reportProgress(
|
|
853
|
+
`Marked ${completedOperations} of ${totalOperations} transactions uncleared`,
|
|
854
|
+
);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
const ynabError = normalizeYnabError(error);
|
|
857
|
+
const failureReason = ynabError.message || "Unknown error occurred";
|
|
858
|
+
const statusSuffix = ynabError.status
|
|
859
|
+
? ` (HTTP ${ynabError.status})`
|
|
860
|
+
: "";
|
|
861
|
+
actions_taken.push({
|
|
862
|
+
type: "batch_unclear_failed",
|
|
863
|
+
transaction: null,
|
|
864
|
+
reason: `Failed to unclear chunk ${chunkIdx + 1}/${unclearChunks.length} (${chunk.length} transaction(s)): ${failureReason}${statusSuffix}`,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
868
|
+
throw attachStatusToError(ynabError, error);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Add delay between chunks to avoid rate limiting (except after last chunk)
|
|
873
|
+
if (chunkIdx < unclearChunks.length - 1) {
|
|
874
|
+
await sleep(BATCH_DELAY_MS);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// STEP 4: Mark all matched transactions as reconciled when balance aligns
|
|
881
|
+
if (balanceAligned && !params.dry_run) {
|
|
882
|
+
const transactionsToReconcile: ynab.SaveTransactionWithIdOrImportId[] = [];
|
|
883
|
+
|
|
884
|
+
for (const match of orderedAutoMatches) {
|
|
885
|
+
if (!match.ynabTransaction) continue;
|
|
886
|
+
// Only reconcile transactions that are not already reconciled
|
|
887
|
+
if (match.ynabTransaction.cleared === "reconciled") continue;
|
|
888
|
+
|
|
889
|
+
transactionsToReconcile.push({
|
|
890
|
+
id: match.ynabTransaction.id,
|
|
891
|
+
cleared: "reconciled" as ynab.TransactionClearedStatus,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Batch update all reconciliations in chunks
|
|
896
|
+
if (transactionsToReconcile.length > 0) {
|
|
897
|
+
const reconcileChunks = chunkArray(
|
|
898
|
+
transactionsToReconcile,
|
|
899
|
+
MAX_BULK_UPDATE_CHUNK,
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
for (let chunkIdx = 0; chunkIdx < reconcileChunks.length; chunkIdx++) {
|
|
903
|
+
const chunk = reconcileChunks[chunkIdx];
|
|
904
|
+
if (!chunk) continue;
|
|
905
|
+
try {
|
|
906
|
+
const response = await ynabAPI.transactions.updateTransactions(
|
|
907
|
+
budgetId,
|
|
908
|
+
{
|
|
909
|
+
transactions: chunk,
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
const reconciledTransactions = response.data.transactions ?? [];
|
|
914
|
+
summary.transactions_updated += reconciledTransactions.length;
|
|
915
|
+
|
|
916
|
+
for (const reconciledTransaction of reconciledTransactions) {
|
|
917
|
+
const match = orderedAutoMatches.find(
|
|
918
|
+
(m) => m.ynabTransaction?.id === reconciledTransaction.id,
|
|
919
|
+
);
|
|
920
|
+
actions_taken.push({
|
|
921
|
+
type: "update_transaction",
|
|
922
|
+
transaction: reconciledTransaction as unknown as Record<
|
|
923
|
+
string,
|
|
924
|
+
unknown
|
|
925
|
+
> | null,
|
|
926
|
+
reason: `Marked as reconciled: ${match?.bankTransaction.payee ?? "transaction"} (${formatDisplay(reconciledTransaction.amount, currencyCode)})`,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
accountSnapshotDirty = true;
|
|
930
|
+
} catch (error) {
|
|
931
|
+
const ynabError = normalizeYnabError(error);
|
|
932
|
+
const failureReason = ynabError.message || "Unknown error occurred";
|
|
933
|
+
const statusSuffix = ynabError.status
|
|
934
|
+
? ` (HTTP ${ynabError.status})`
|
|
935
|
+
: "";
|
|
936
|
+
actions_taken.push({
|
|
937
|
+
type: "batch_reconcile_failed",
|
|
938
|
+
transaction: null,
|
|
939
|
+
reason: `Failed to reconcile chunk ${chunkIdx + 1}/${reconcileChunks.length} (${chunk.length} transaction(s)): ${failureReason}${statusSuffix}`,
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
if (shouldPropagateYnabError(ynabError)) {
|
|
943
|
+
throw attachStatusToError(ynabError, error);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Add delay between chunks to avoid rate limiting (except after last chunk)
|
|
948
|
+
if (chunkIdx < reconcileChunks.length - 1) {
|
|
949
|
+
await sleep(BATCH_DELAY_MS);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
actions_taken.push({
|
|
954
|
+
type: "reconciliation_complete",
|
|
955
|
+
transaction: null,
|
|
956
|
+
reason: `Marked ${transactionsToReconcile.length} matched transaction(s) as reconciled - balance aligned within tolerance`,
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// STEP 5: Balance reconciliation snapshot (only once per execution)
|
|
962
|
+
let balance_reconciliation: ExecutionResult["balance_reconciliation"];
|
|
963
|
+
if (params.statement_balance !== undefined && params.statement_date) {
|
|
964
|
+
balance_reconciliation = await buildBalanceReconciliation({
|
|
965
|
+
ynabAPI,
|
|
966
|
+
budgetId,
|
|
967
|
+
accountId,
|
|
968
|
+
statementDate: params.statement_date,
|
|
969
|
+
statementBalance: params.statement_balance,
|
|
970
|
+
analysis,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// STEP 6: Recommendations and balance changes
|
|
975
|
+
if (!params.dry_run && accountSnapshotDirty) {
|
|
976
|
+
afterAccount = await refreshAccountSnapshot(ynabAPI, budgetId, accountId);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const balanceChangeMilli =
|
|
980
|
+
params.dry_run || !accountSnapshotDirty
|
|
981
|
+
? 0
|
|
982
|
+
: afterAccount.balance - initialAccount.balance;
|
|
983
|
+
|
|
984
|
+
const recommendations = buildRecommendations({
|
|
985
|
+
summary,
|
|
986
|
+
params,
|
|
987
|
+
analysis,
|
|
988
|
+
balanceChangeMilli,
|
|
989
|
+
currencyCode,
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const result: ExecutionResult = {
|
|
993
|
+
summary,
|
|
994
|
+
account_balance: {
|
|
995
|
+
before: initialAccount,
|
|
996
|
+
after: afterAccount,
|
|
997
|
+
},
|
|
998
|
+
actions_taken,
|
|
999
|
+
recommendations,
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
if (balance_reconciliation !== undefined) {
|
|
1003
|
+
result.balance_reconciliation = balance_reconciliation;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (bulkOperationDetails) {
|
|
1007
|
+
// Ensure failed_transactions mirrors transaction_failures for backward compatibility
|
|
1008
|
+
bulkOperationDetails.failed_transactions =
|
|
1009
|
+
bulkOperationDetails.transaction_failures;
|
|
1010
|
+
result.bulk_operation_details = bulkOperationDetails;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return result;
|
|
898
1014
|
}
|
|
899
1015
|
|
|
900
1016
|
export interface NormalizedYnabError {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1017
|
+
status?: number;
|
|
1018
|
+
name?: string;
|
|
1019
|
+
message: string;
|
|
1020
|
+
detail?: string;
|
|
905
1021
|
}
|
|
906
1022
|
|
|
907
1023
|
const FATAL_YNAB_STATUS_CODES = new Set([400, 401, 403, 404, 429, 500, 503]);
|
|
908
1024
|
|
|
909
1025
|
export function normalizeYnabError(error: unknown): NormalizedYnabError {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1026
|
+
const parseStatus = (value: unknown): number | undefined => {
|
|
1027
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
1028
|
+
if (typeof value === "string") {
|
|
1029
|
+
const numeric = Number(value);
|
|
1030
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
1031
|
+
}
|
|
1032
|
+
return undefined;
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
if (error instanceof Error) {
|
|
1036
|
+
const status =
|
|
1037
|
+
parseStatus((error as { status?: unknown }).status) ??
|
|
1038
|
+
parseStatus(
|
|
1039
|
+
(error as { response?: { status?: unknown } }).response?.status,
|
|
1040
|
+
);
|
|
1041
|
+
const detailSource = (error as { detail?: unknown }).detail;
|
|
1042
|
+
const detail =
|
|
1043
|
+
typeof detailSource === "string" && detailSource.trim().length > 0
|
|
1044
|
+
? detailSource
|
|
1045
|
+
: undefined;
|
|
1046
|
+
|
|
1047
|
+
const result: NormalizedYnabError = {
|
|
1048
|
+
name: error.name,
|
|
1049
|
+
message: error.message || "Unknown error occurred",
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
if (status !== undefined) result.status = status;
|
|
1053
|
+
if (detail !== undefined) result.detail = detail;
|
|
1054
|
+
|
|
1055
|
+
return result;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (error && typeof error === "object") {
|
|
1059
|
+
const errObj = (error as { error?: unknown }).error ?? error;
|
|
1060
|
+
const status = parseStatus(
|
|
1061
|
+
(errObj as { id?: unknown }).id ??
|
|
1062
|
+
(errObj as { status?: unknown }).status,
|
|
1063
|
+
);
|
|
1064
|
+
const detailCandidate =
|
|
1065
|
+
(errObj as { detail?: unknown }).detail ??
|
|
1066
|
+
(errObj as { message?: unknown }).message ??
|
|
1067
|
+
(errObj as { name?: unknown }).name;
|
|
1068
|
+
const detail =
|
|
1069
|
+
typeof detailCandidate === "string" && detailCandidate.trim().length > 0
|
|
1070
|
+
? detailCandidate
|
|
1071
|
+
: undefined;
|
|
1072
|
+
const message =
|
|
1073
|
+
detail ??
|
|
1074
|
+
(typeof errObj === "string" && errObj.trim().length > 0
|
|
1075
|
+
? errObj
|
|
1076
|
+
: "Unknown error occurred");
|
|
1077
|
+
const name =
|
|
1078
|
+
typeof (errObj as { name?: unknown }).name === "string"
|
|
1079
|
+
? ((errObj as { name: string }).name as string)
|
|
1080
|
+
: undefined;
|
|
1081
|
+
|
|
1082
|
+
const result: NormalizedYnabError = { message };
|
|
1083
|
+
|
|
1084
|
+
if (status !== undefined) result.status = status;
|
|
1085
|
+
if (name !== undefined) result.name = name;
|
|
1086
|
+
if (detail !== undefined) result.detail = detail;
|
|
1087
|
+
|
|
1088
|
+
return result;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (typeof error === "string") {
|
|
1092
|
+
return { message: error };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return { message: "Unknown error occurred" };
|
|
973
1096
|
}
|
|
974
1097
|
|
|
975
1098
|
export function shouldPropagateYnabError(error: NormalizedYnabError): boolean {
|
|
976
|
-
|
|
1099
|
+
return (
|
|
1100
|
+
error.status !== undefined && FATAL_YNAB_STATUS_CODES.has(error.status)
|
|
1101
|
+
);
|
|
977
1102
|
}
|
|
978
1103
|
|
|
979
|
-
function attachStatusToError(
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1104
|
+
function attachStatusToError(
|
|
1105
|
+
error: NormalizedYnabError,
|
|
1106
|
+
originalError?: unknown,
|
|
1107
|
+
): Error {
|
|
1108
|
+
const message = error.message || "YNAB API error";
|
|
1109
|
+
|
|
1110
|
+
const isKnownCode =
|
|
1111
|
+
error.status === YNABErrorCode.BAD_REQUEST ||
|
|
1112
|
+
error.status === YNABErrorCode.UNAUTHORIZED ||
|
|
1113
|
+
error.status === YNABErrorCode.FORBIDDEN ||
|
|
1114
|
+
error.status === YNABErrorCode.NOT_FOUND ||
|
|
1115
|
+
error.status === YNABErrorCode.TOO_MANY_REQUESTS ||
|
|
1116
|
+
error.status === YNABErrorCode.INTERNAL_SERVER_ERROR;
|
|
1117
|
+
|
|
1118
|
+
if (isKnownCode) {
|
|
1119
|
+
return new YNABAPIError(
|
|
1120
|
+
error.status as YNABErrorCode,
|
|
1121
|
+
message,
|
|
1122
|
+
originalError,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const statusFragment = error.status ? ` (HTTP ${error.status})` : "";
|
|
1127
|
+
const detailFragment =
|
|
1128
|
+
error.detail && !message.includes(error.detail) ? ` (${error.detail})` : "";
|
|
1129
|
+
const err = new Error(`${message}${statusFragment}${detailFragment}`);
|
|
1130
|
+
if (error.status !== undefined) {
|
|
1131
|
+
(err as { status?: number }).status = error.status;
|
|
1132
|
+
}
|
|
1133
|
+
if (error.name) {
|
|
1134
|
+
err.name = error.name;
|
|
1135
|
+
}
|
|
1136
|
+
return err;
|
|
1005
1137
|
}
|
|
1006
1138
|
|
|
1007
1139
|
function formatDisplay(amount: number, currency: string): string {
|
|
1008
|
-
|
|
1140
|
+
return toMoneyValue(amount, currency).value_display;
|
|
1009
1141
|
}
|
|
1010
1142
|
|
|
1011
|
-
function computeUpdateFlags(
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1143
|
+
function computeUpdateFlags(
|
|
1144
|
+
match: TransactionMatch,
|
|
1145
|
+
params: ReconcileAccountRequest,
|
|
1146
|
+
): UpdateFlags {
|
|
1147
|
+
const ynabTxn = match.ynabTransaction;
|
|
1148
|
+
const bankTxn = match.bankTransaction;
|
|
1149
|
+
if (!ynabTxn) {
|
|
1150
|
+
return { needsClearedUpdate: false, needsDateUpdate: false };
|
|
1151
|
+
}
|
|
1152
|
+
const needsClearedUpdate = Boolean(
|
|
1153
|
+
params.auto_update_cleared_status && ynabTxn.cleared !== "cleared",
|
|
1154
|
+
);
|
|
1155
|
+
const needsDateUpdate = Boolean(
|
|
1156
|
+
params.auto_adjust_dates && ynabTxn.date !== bankTxn.date,
|
|
1157
|
+
);
|
|
1158
|
+
return { needsClearedUpdate, needsDateUpdate };
|
|
1022
1159
|
}
|
|
1023
1160
|
|
|
1024
|
-
function updateReason(
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1161
|
+
function updateReason(
|
|
1162
|
+
match: TransactionMatch,
|
|
1163
|
+
flags: UpdateFlags,
|
|
1164
|
+
_currency: string,
|
|
1165
|
+
): string {
|
|
1166
|
+
const parts: string[] = [];
|
|
1167
|
+
if (flags.needsClearedUpdate) {
|
|
1168
|
+
parts.push("marked as cleared");
|
|
1169
|
+
}
|
|
1170
|
+
if (flags.needsDateUpdate) {
|
|
1171
|
+
parts.push(`date adjusted to ${match.bankTransaction.date}`);
|
|
1172
|
+
}
|
|
1173
|
+
return parts.join(", ");
|
|
1033
1174
|
}
|
|
1034
1175
|
|
|
1035
1176
|
async function buildBalanceReconciliation(args: {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1177
|
+
ynabAPI: ynab.API;
|
|
1178
|
+
budgetId: string;
|
|
1179
|
+
accountId: string;
|
|
1180
|
+
statementDate: string;
|
|
1181
|
+
statementBalance: number;
|
|
1182
|
+
analysis: ReconciliationAnalysis;
|
|
1042
1183
|
}) {
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1184
|
+
const { ynabAPI, budgetId, accountId, statementDate, statementBalance } =
|
|
1185
|
+
args;
|
|
1186
|
+
const ynabMilli = await clearedBalanceAsOf(
|
|
1187
|
+
ynabAPI,
|
|
1188
|
+
budgetId,
|
|
1189
|
+
accountId,
|
|
1190
|
+
statementDate,
|
|
1191
|
+
);
|
|
1192
|
+
const bankMilli = toMilli(statementBalance);
|
|
1193
|
+
const discrepancy = bankMilli - ynabMilli;
|
|
1194
|
+
const status =
|
|
1195
|
+
discrepancy === 0 ? "PERFECTLY_RECONCILED" : "DISCREPANCY_FOUND";
|
|
1196
|
+
|
|
1197
|
+
const precision_calculations = {
|
|
1198
|
+
bank_statement_balance_milliunits: bankMilli,
|
|
1199
|
+
ynab_calculated_balance_milliunits: ynabMilli,
|
|
1200
|
+
discrepancy_milliunits: discrepancy,
|
|
1201
|
+
discrepancy_dollars: discrepancy / 1000,
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
const discrepancy_analysis =
|
|
1205
|
+
discrepancy === 0 ? undefined : buildLikelyCauses(discrepancy);
|
|
1206
|
+
|
|
1207
|
+
const result: {
|
|
1208
|
+
status: string;
|
|
1209
|
+
precision_calculations: typeof precision_calculations;
|
|
1210
|
+
discrepancy_analysis?: ReturnType<typeof buildLikelyCauses>;
|
|
1211
|
+
final_verification: {
|
|
1212
|
+
balance_matches_exactly: boolean;
|
|
1213
|
+
all_transactions_accounted: boolean;
|
|
1214
|
+
audit_trail_complete: boolean;
|
|
1215
|
+
reconciliation_complete: boolean;
|
|
1216
|
+
};
|
|
1217
|
+
} = {
|
|
1218
|
+
status,
|
|
1219
|
+
precision_calculations,
|
|
1220
|
+
final_verification: {
|
|
1221
|
+
balance_matches_exactly: discrepancy === 0,
|
|
1222
|
+
all_transactions_accounted: discrepancy === 0,
|
|
1223
|
+
audit_trail_complete: discrepancy === 0,
|
|
1224
|
+
reconciliation_complete: discrepancy === 0,
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
if (discrepancy_analysis !== undefined) {
|
|
1229
|
+
result.discrepancy_analysis = discrepancy_analysis;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return result;
|
|
1084
1233
|
}
|
|
1085
1234
|
|
|
1086
1235
|
async function clearedBalanceAsOf(
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1236
|
+
api: ynab.API,
|
|
1237
|
+
budgetId: string,
|
|
1238
|
+
accountId: string,
|
|
1239
|
+
dateISO: string,
|
|
1091
1240
|
): Promise<number> {
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1241
|
+
const response = await api.transactions.getTransactionsByAccount(
|
|
1242
|
+
budgetId,
|
|
1243
|
+
accountId,
|
|
1244
|
+
);
|
|
1245
|
+
const asOf = new Date(dateISO);
|
|
1246
|
+
const cleared = response.data.transactions.filter(
|
|
1247
|
+
(txn) => txn.cleared === "cleared" && new Date(txn.date) <= asOf,
|
|
1248
|
+
);
|
|
1249
|
+
const sum = cleared.reduce((acc, txn) => addMilli(acc, txn.amount ?? 0), 0);
|
|
1250
|
+
return sum;
|
|
1099
1251
|
}
|
|
1100
1252
|
|
|
1101
1253
|
async function refreshAccountSnapshot(
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1254
|
+
api: ynab.API,
|
|
1255
|
+
budgetId: string,
|
|
1256
|
+
accountId: string,
|
|
1105
1257
|
): Promise<AccountSnapshot> {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1258
|
+
const accountsApi = api.accounts as typeof api.accounts & {
|
|
1259
|
+
getAccount?: (
|
|
1260
|
+
budgetId: string,
|
|
1261
|
+
accountId: string,
|
|
1262
|
+
) => Promise<ynab.AccountResponse>;
|
|
1263
|
+
};
|
|
1264
|
+
const response = accountsApi.getAccount
|
|
1265
|
+
? await accountsApi.getAccount(budgetId, accountId)
|
|
1266
|
+
: await accountsApi.getAccountById(budgetId, accountId);
|
|
1267
|
+
const account = response.data.account;
|
|
1268
|
+
return {
|
|
1269
|
+
balance: account.balance,
|
|
1270
|
+
cleared_balance: account.cleared_balance,
|
|
1271
|
+
uncleared_balance: account.uncleared_balance,
|
|
1272
|
+
};
|
|
1118
1273
|
}
|
|
1119
1274
|
|
|
1120
1275
|
function buildLikelyCauses(discrepancyMilli: number) {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1276
|
+
const causes = [] as {
|
|
1277
|
+
cause_type: string;
|
|
1278
|
+
description: string;
|
|
1279
|
+
confidence: number;
|
|
1280
|
+
amount_milliunits: number;
|
|
1281
|
+
suggested_resolution: string;
|
|
1282
|
+
evidence: unknown[];
|
|
1283
|
+
}[];
|
|
1284
|
+
|
|
1285
|
+
const abs = Math.abs(discrepancyMilli);
|
|
1286
|
+
if (abs % 1000 === 0 || abs % 500 === 0) {
|
|
1287
|
+
causes.push({
|
|
1288
|
+
cause_type: "bank_fee",
|
|
1289
|
+
description: "Round amount suggests a bank fee or interest adjustment.",
|
|
1290
|
+
confidence: 0.8,
|
|
1291
|
+
amount_milliunits: discrepancyMilli,
|
|
1292
|
+
suggested_resolution:
|
|
1293
|
+
discrepancyMilli < 0
|
|
1294
|
+
? "Create bank fee transaction and mark cleared"
|
|
1295
|
+
: "Record interest income",
|
|
1296
|
+
evidence: [],
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
return causes.length > 0
|
|
1301
|
+
? {
|
|
1302
|
+
confidence_level: Math.max(...causes.map((cause) => cause.confidence)),
|
|
1303
|
+
likely_causes: causes,
|
|
1304
|
+
risk_assessment: "LOW",
|
|
1305
|
+
}
|
|
1306
|
+
: undefined;
|
|
1152
1307
|
}
|
|
1153
1308
|
|
|
1154
1309
|
function buildRecommendations(args: {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1310
|
+
summary: ExecutionSummary;
|
|
1311
|
+
params: ReconcileAccountRequest;
|
|
1312
|
+
analysis: ReconciliationAnalysis;
|
|
1313
|
+
balanceChangeMilli: number;
|
|
1314
|
+
currencyCode: string;
|
|
1160
1315
|
}): string[] {
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1316
|
+
const { summary, params, analysis, balanceChangeMilli, currencyCode } = args;
|
|
1317
|
+
const recommendations: string[] = [];
|
|
1318
|
+
|
|
1319
|
+
if (summary.dates_adjusted > 0) {
|
|
1320
|
+
recommendations.push(
|
|
1321
|
+
`✅ Adjusted ${summary.dates_adjusted} transaction date(s) to match bank statement dates`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (analysis.summary.unmatched_bank > 0 && !params.auto_create_transactions) {
|
|
1326
|
+
recommendations.push(
|
|
1327
|
+
`Consider enabling auto_create_transactions to automatically create ${analysis.summary.unmatched_bank} missing transaction(s)`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (!params.auto_adjust_dates && analysis.auto_matches.length > 0) {
|
|
1332
|
+
recommendations.push(
|
|
1333
|
+
"Consider enabling auto_adjust_dates to align YNAB dates with bank statement dates",
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (analysis.summary.unmatched_ynab > 0) {
|
|
1338
|
+
recommendations.push(
|
|
1339
|
+
`${analysis.summary.unmatched_ynab} transaction(s) exist in YNAB but not on the bank statement — review for duplicates or pending items`,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (params.dry_run) {
|
|
1344
|
+
recommendations.push(
|
|
1345
|
+
"Dry run only — re-run with dry_run=false to apply these changes",
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (Math.abs(balanceChangeMilli) > MONEY_EPSILON_MILLI) {
|
|
1350
|
+
recommendations.push(
|
|
1351
|
+
`Account balance changed by ${toMoneyValue(balanceChangeMilli, currencyCode).value_display} during reconciliation`,
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return recommendations;
|
|
1199
1356
|
}
|
|
1200
1357
|
|
|
1201
1358
|
export type { ExecutionResult as LegacyReconciliationResult };
|
|
1202
1359
|
|
|
1203
1360
|
function resolveStatementBalanceMilli(
|
|
1204
|
-
|
|
1205
|
-
|
|
1361
|
+
balanceInfo: ReconciliationAnalysis["balance_info"],
|
|
1362
|
+
provided?: number,
|
|
1206
1363
|
): number {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1364
|
+
if (typeof provided === "number" && Number.isFinite(provided)) {
|
|
1365
|
+
return toMilli(provided);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return (
|
|
1369
|
+
extractMoneyValue(balanceInfo?.target_statement) ??
|
|
1370
|
+
extractMoneyValue(balanceInfo?.current_cleared) ??
|
|
1371
|
+
0
|
|
1372
|
+
);
|
|
1216
1373
|
}
|
|
1217
1374
|
|
|
1218
1375
|
function extractMoneyValue(value: unknown): number | undefined {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1376
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1377
|
+
return toMilli(value);
|
|
1378
|
+
}
|
|
1379
|
+
if (
|
|
1380
|
+
value &&
|
|
1381
|
+
typeof value === "object" &&
|
|
1382
|
+
"value_milliunits" in value &&
|
|
1383
|
+
typeof (value as { value_milliunits: unknown }).value_milliunits ===
|
|
1384
|
+
"number"
|
|
1385
|
+
) {
|
|
1386
|
+
return (value as { value_milliunits: number }).value_milliunits;
|
|
1387
|
+
}
|
|
1388
|
+
return undefined;
|
|
1231
1389
|
}
|
|
1232
1390
|
|
|
1233
1391
|
function sortByDateDescending<T extends { date: string }>(items: T[]): T[] {
|
|
1234
|
-
|
|
1392
|
+
return [...items].sort((a, b) => compareDates(b.date, a.date));
|
|
1235
1393
|
}
|
|
1236
1394
|
|
|
1237
|
-
function sortMatchesByBankDateDescending(
|
|
1238
|
-
|
|
1395
|
+
function sortMatchesByBankDateDescending(
|
|
1396
|
+
matches: TransactionMatch[],
|
|
1397
|
+
): TransactionMatch[] {
|
|
1398
|
+
return [...matches].sort((a, b) =>
|
|
1399
|
+
compareDates(b.bankTransaction.date, a.bankTransaction.date),
|
|
1400
|
+
);
|
|
1239
1401
|
}
|
|
1240
1402
|
|
|
1241
1403
|
function compareDates(dateA: string, dateB: string): number {
|
|
1242
|
-
|
|
1404
|
+
return toChronoValue(dateA) - toChronoValue(dateB);
|
|
1243
1405
|
}
|
|
1244
1406
|
|
|
1245
1407
|
function toChronoValue(date: string): number {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1408
|
+
const parsed = Date.parse(date);
|
|
1409
|
+
if (!Number.isNaN(parsed)) {
|
|
1410
|
+
return parsed;
|
|
1411
|
+
}
|
|
1412
|
+
const fallback = Date.parse(`${date}T00:00:00Z`);
|
|
1413
|
+
return Number.isNaN(fallback) ? 0 : fallback;
|
|
1252
1414
|
}
|