@dizzlkheinz/ynab-mcpb 0.12.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/.chunkhound.json +11 -0
- package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +8 -0
- package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +3 -0
- package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +3 -0
- package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +1 -0
- package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +12 -0
- package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +1 -0
- package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +1 -0
- package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +5 -0
- package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +1453 -0
- package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +8 -0
- package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +9 -0
- package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +1 -0
- package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +5 -0
- package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +81 -0
- package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +610 -0
- package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +1 -0
- package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +1 -0
- package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +1 -0
- package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +3 -0
- package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +8 -0
- package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +5 -0
- package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +1 -0
- package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +9 -0
- package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +1 -0
- package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +13 -0
- package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +14 -0
- package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +5 -0
- package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +5 -0
- package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +5 -0
- package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +3 -0
- package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +1 -0
- package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +1 -0
- package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +5 -0
- package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +5 -0
- package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +8 -0
- package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +8 -0
- package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +5 -0
- package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +1 -0
- package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +1 -0
- package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +1 -0
- package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +1 -0
- package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +1 -0
- package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +5 -0
- package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +1 -0
- package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +1 -0
- package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +1 -0
- package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +3 -0
- package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +8 -0
- package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +1 -0
- package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +1 -0
- package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +8 -0
- package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +3 -0
- package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +5 -0
- package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +1 -0
- package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +1 -0
- package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +5 -0
- package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +1 -0
- package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +14 -0
- package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +1 -0
- package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +5 -0
- package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +5 -0
- package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +11 -0
- package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +1 -0
- package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +8 -0
- package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +1 -0
- package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +3 -0
- package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +3 -0
- package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +8 -0
- package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +1 -0
- package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +5 -0
- package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +1 -0
- package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +1 -0
- package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +1 -0
- package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +1 -0
- package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +3 -0
- package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +8 -0
- package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +1 -0
- package/.dxtignore +57 -0
- package/.env.example +44 -0
- package/.gemini/settings.json +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
- package/.github/ISSUE_TEMPLATE/release_checklist.md +31 -0
- package/.github/pull_request_template.md +41 -0
- package/.github/workflows/ci-tests.yml +41 -0
- package/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/full-integration.yml +22 -0
- package/.github/workflows/pr-description-check.yml +88 -0
- package/.github/workflows/publish.yml +33 -0
- package/.github/workflows/release.yml +89 -0
- package/.mcpbignore +58 -0
- package/.prettierignore +10 -0
- package/.prettierrc.json +10 -0
- package/ADOS-2-Module-1-Complete-Manual.md +757 -0
- package/AGENTS.md +36 -0
- package/CHANGELOG.md +187 -0
- package/CLAUDE.md +414 -0
- package/CODEREVIEW_RESPONSE.md +128 -0
- package/LICENSE +17 -0
- package/NUL +1 -0
- package/README.md +222 -0
- package/SCHEMA_IMPROVEMENT_SUMMARY.md +120 -0
- package/TESTING_NOTES.md +217 -0
- package/WARP.md +245 -0
- package/accountactivity-merged.csv +149 -0
- package/bin/ynab-mcp-server.cjs +4 -0
- package/bin/ynab-mcp-server.js +8 -0
- package/bundle-analysis.html +13110 -0
- package/dist/bundle/index.cjs +124 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +85 -0
- package/dist/server/YNABMCPServer.d.ts +264 -0
- package/dist/server/YNABMCPServer.js +845 -0
- package/dist/server/budgetResolver.d.ts +15 -0
- package/dist/server/budgetResolver.js +99 -0
- package/dist/server/cacheManager.d.ts +74 -0
- package/dist/server/cacheManager.js +306 -0
- package/dist/server/config.d.ts +3 -0
- package/dist/server/config.js +19 -0
- package/dist/server/deltaCache.d.ts +61 -0
- package/dist/server/deltaCache.js +206 -0
- package/dist/server/deltaCache.merge.d.ts +9 -0
- package/dist/server/deltaCache.merge.js +111 -0
- package/dist/server/diagnostics.d.ts +90 -0
- package/dist/server/diagnostics.js +163 -0
- package/dist/server/errorHandler.d.ts +69 -0
- package/dist/server/errorHandler.js +524 -0
- package/dist/server/prompts.d.ts +31 -0
- package/dist/server/prompts.js +205 -0
- package/dist/server/rateLimiter.d.ts +27 -0
- package/dist/server/rateLimiter.js +82 -0
- package/dist/server/requestLogger.d.ts +62 -0
- package/dist/server/requestLogger.js +190 -0
- package/dist/server/resources.d.ts +39 -0
- package/dist/server/resources.js +85 -0
- package/dist/server/responseFormatter.d.ts +14 -0
- package/dist/server/responseFormatter.js +42 -0
- package/dist/server/securityMiddleware.d.ts +87 -0
- package/dist/server/securityMiddleware.js +117 -0
- package/dist/server/serverKnowledgeStore.d.ts +11 -0
- package/dist/server/serverKnowledgeStore.js +42 -0
- package/dist/server/toolRegistry.d.ts +85 -0
- package/dist/server/toolRegistry.js +272 -0
- package/dist/tools/__tests__/deltaTestUtils.d.ts +18 -0
- package/dist/tools/__tests__/deltaTestUtils.js +26 -0
- package/dist/tools/accountTools.d.ts +37 -0
- package/dist/tools/accountTools.js +175 -0
- package/dist/tools/budgetTools.d.ts +10 -0
- package/dist/tools/budgetTools.js +68 -0
- package/dist/tools/categoryTools.d.ts +27 -0
- package/dist/tools/categoryTools.js +232 -0
- package/dist/tools/compareTransactions/formatter.d.ts +71 -0
- package/dist/tools/compareTransactions/formatter.js +97 -0
- package/dist/tools/compareTransactions/index.d.ts +30 -0
- package/dist/tools/compareTransactions/index.js +160 -0
- package/dist/tools/compareTransactions/matcher.d.ts +12 -0
- package/dist/tools/compareTransactions/matcher.js +140 -0
- package/dist/tools/compareTransactions/parser.d.ts +14 -0
- package/dist/tools/compareTransactions/parser.js +430 -0
- package/dist/tools/compareTransactions/types.d.ts +27 -0
- package/dist/tools/compareTransactions/types.js +1 -0
- package/dist/tools/compareTransactions.d.ts +1 -0
- package/dist/tools/compareTransactions.js +1 -0
- package/dist/tools/deltaFetcher.d.ts +22 -0
- package/dist/tools/deltaFetcher.js +137 -0
- package/dist/tools/deltaSupport.d.ts +20 -0
- package/dist/tools/deltaSupport.js +176 -0
- package/dist/tools/exportTransactions.d.ts +17 -0
- package/dist/tools/exportTransactions.js +191 -0
- package/dist/tools/monthTools.d.ts +16 -0
- package/dist/tools/monthTools.js +107 -0
- package/dist/tools/payeeTools.d.ts +17 -0
- package/dist/tools/payeeTools.js +82 -0
- package/dist/tools/reconcileAdapter.d.ts +25 -0
- package/dist/tools/reconcileAdapter.js +167 -0
- package/dist/tools/reconciliation/analyzer.d.ts +3 -0
- package/dist/tools/reconciliation/analyzer.js +567 -0
- package/dist/tools/reconciliation/executor.d.ts +94 -0
- package/dist/tools/reconciliation/executor.js +611 -0
- package/dist/tools/reconciliation/index.d.ts +54 -0
- package/dist/tools/reconciliation/index.js +249 -0
- package/dist/tools/reconciliation/matcher.d.ts +3 -0
- package/dist/tools/reconciliation/matcher.js +160 -0
- package/dist/tools/reconciliation/payeeNormalizer.d.ts +6 -0
- package/dist/tools/reconciliation/payeeNormalizer.js +77 -0
- package/dist/tools/reconciliation/recommendationEngine.d.ts +2 -0
- package/dist/tools/reconciliation/recommendationEngine.js +273 -0
- package/dist/tools/reconciliation/reportFormatter.d.ts +13 -0
- package/dist/tools/reconciliation/reportFormatter.js +214 -0
- package/dist/tools/reconciliation/types.d.ts +172 -0
- package/dist/tools/reconciliation/types.js +7 -0
- package/dist/tools/schemas/outputs/accountOutputs.d.ts +58 -0
- package/dist/tools/schemas/outputs/accountOutputs.js +24 -0
- package/dist/tools/schemas/outputs/budgetOutputs.d.ts +48 -0
- package/dist/tools/schemas/outputs/budgetOutputs.js +15 -0
- package/dist/tools/schemas/outputs/categoryOutputs.d.ts +93 -0
- package/dist/tools/schemas/outputs/categoryOutputs.js +37 -0
- package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +269 -0
- package/dist/tools/schemas/outputs/comparisonOutputs.js +181 -0
- package/dist/tools/schemas/outputs/index.d.ts +14 -0
- package/dist/tools/schemas/outputs/index.js +14 -0
- package/dist/tools/schemas/outputs/monthOutputs.d.ts +122 -0
- package/dist/tools/schemas/outputs/monthOutputs.js +51 -0
- package/dist/tools/schemas/outputs/payeeOutputs.d.ts +34 -0
- package/dist/tools/schemas/outputs/payeeOutputs.js +16 -0
- package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +1275 -0
- package/dist/tools/schemas/outputs/reconciliationOutputs.js +377 -0
- package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +717 -0
- package/dist/tools/schemas/outputs/transactionMutationOutputs.js +260 -0
- package/dist/tools/schemas/outputs/transactionOutputs.d.ts +98 -0
- package/dist/tools/schemas/outputs/transactionOutputs.js +49 -0
- package/dist/tools/schemas/outputs/utilityOutputs.d.ts +219 -0
- package/dist/tools/schemas/outputs/utilityOutputs.js +120 -0
- package/dist/tools/schemas/shared/commonOutputs.d.ts +24 -0
- package/dist/tools/schemas/shared/commonOutputs.js +27 -0
- package/dist/tools/toolCategories.d.ts +32 -0
- package/dist/tools/toolCategories.js +32 -0
- package/dist/tools/transactionTools.d.ts +315 -0
- package/dist/tools/transactionTools.js +1722 -0
- package/dist/tools/utilityTools.d.ts +10 -0
- package/dist/tools/utilityTools.js +56 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/index.js +16 -0
- package/dist/types/toolAnnotations.d.ts +7 -0
- package/dist/types/toolAnnotations.js +1 -0
- package/dist/utils/amountUtils.d.ts +3 -0
- package/dist/utils/amountUtils.js +10 -0
- package/dist/utils/dateUtils.d.ts +9 -0
- package/dist/utils/dateUtils.js +43 -0
- package/dist/utils/money.d.ts +21 -0
- package/dist/utils/money.js +51 -0
- package/docs/README.md +72 -0
- package/docs/assets/examples/reconciliation-with-recommendations.json +68 -0
- package/docs/assets/schemas/reconciliation-v2.json +338 -0
- package/docs/getting-started/CONFIGURATION.md +175 -0
- package/docs/getting-started/INSTALLATION.md +333 -0
- package/docs/getting-started/QUICKSTART.md +282 -0
- package/docs/guides/ARCHITECTURE.md +650 -0
- package/docs/guides/DEPLOYMENT.md +189 -0
- package/docs/guides/INTEGRATION_TESTING.md +730 -0
- package/docs/guides/TESTING.md +591 -0
- package/docs/reconciliation-flow.md +83 -0
- package/docs/reference/API.md +1450 -0
- package/docs/reference/EXAMPLES.md +946 -0
- package/docs/reference/TOOLS.md +348 -0
- package/docs/reference/TROUBLESHOOTING.md +481 -0
- package/esbuild.config.mjs +68 -0
- package/eslint.config.js +49 -0
- package/fix-types.sh +17 -0
- package/meta.json +12550 -0
- package/package.json +105 -0
- package/package.json.tmp +105 -0
- package/scripts/analyze-bundle.mjs +41 -0
- package/scripts/create-pr-description.js +203 -0
- package/scripts/generate-mcpb.ps1 +96 -0
- package/scripts/run-domain-integration-tests.js +33 -0
- package/scripts/run-generate-mcpb.js +29 -0
- package/scripts/run-throttled-integration-tests.js +116 -0
- package/scripts/test-delta-params.mjs +140 -0
- package/scripts/test-recommendations.ts +53 -0
- package/scripts/tmpTransaction.ts +48 -0
- package/scripts/validate-env.js +122 -0
- package/scripts/verify-build.js +105 -0
- package/scripts/watch-and-restart.ps1 +50 -0
- package/src/__tests__/comprehensive.integration.test.ts +1196 -0
- package/src/__tests__/delta.performance.test.ts +80 -0
- package/src/__tests__/performance.test.ts +725 -0
- package/src/__tests__/setup.ts +449 -0
- package/src/__tests__/testRunner.ts +444 -0
- package/src/__tests__/testUtils.ts +563 -0
- package/src/__tests__/workflows.e2e.test.ts +1675 -0
- package/src/index.ts +124 -0
- package/src/server/.gitkeep +1 -0
- package/src/server/YNABMCPServer.ts +1188 -0
- package/src/server/__tests__/YNABMCPServer.integration.test.ts +903 -0
- package/src/server/__tests__/YNABMCPServer.test.ts +894 -0
- package/src/server/__tests__/budgetResolver.test.ts +425 -0
- package/src/server/__tests__/cacheManager.test.ts +880 -0
- package/src/server/__tests__/config.test.ts +166 -0
- package/src/server/__tests__/deltaCache.merge.test.ts +724 -0
- package/src/server/__tests__/deltaCache.swr.test.ts +168 -0
- package/src/server/__tests__/deltaCache.test.ts +774 -0
- package/src/server/__tests__/diagnostics.test.ts +823 -0
- package/src/server/__tests__/errorHandler.integration.test.ts +466 -0
- package/src/server/__tests__/errorHandler.test.ts +416 -0
- package/src/server/__tests__/prompts.test.ts +354 -0
- package/src/server/__tests__/rateLimiter.test.ts +314 -0
- package/src/server/__tests__/requestLogger.test.ts +408 -0
- package/src/server/__tests__/resources.test.ts +299 -0
- package/src/server/__tests__/security.integration.test.ts +426 -0
- package/src/server/__tests__/securityMiddleware.test.ts +449 -0
- package/src/server/__tests__/server-startup.integration.test.ts +477 -0
- package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -0
- package/src/server/__tests__/toolRegistry.test.ts +855 -0
- package/src/server/budgetResolver.ts +235 -0
- package/src/server/cacheManager.ts +503 -0
- package/src/server/config.ts +41 -0
- package/src/server/deltaCache.merge.ts +149 -0
- package/src/server/deltaCache.ts +341 -0
- package/src/server/diagnostics.ts +338 -0
- package/src/server/errorHandler.ts +756 -0
- package/src/server/prompts.ts +291 -0
- package/src/server/rateLimiter.ts +156 -0
- package/src/server/requestLogger.ts +344 -0
- package/src/server/resources.ts +168 -0
- package/src/server/responseFormatter.ts +51 -0
- package/src/server/securityMiddleware.ts +236 -0
- package/src/server/serverKnowledgeStore.ts +91 -0
- package/src/server/toolRegistry.ts +489 -0
- package/src/tools/.gitkeep +1 -0
- package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -0
- package/src/tools/__tests__/accountTools.integration.test.ts +117 -0
- package/src/tools/__tests__/accountTools.test.ts +653 -0
- package/src/tools/__tests__/budgetTools.delta.integration.test.ts +90 -0
- package/src/tools/__tests__/budgetTools.integration.test.ts +134 -0
- package/src/tools/__tests__/budgetTools.test.ts +423 -0
- package/src/tools/__tests__/categoryTools.delta.integration.test.ts +80 -0
- package/src/tools/__tests__/categoryTools.integration.test.ts +295 -0
- package/src/tools/__tests__/categoryTools.test.ts +622 -0
- package/src/tools/__tests__/compareTransactions/formatter.test.ts +486 -0
- package/src/tools/__tests__/compareTransactions/index.test.ts +383 -0
- package/src/tools/__tests__/compareTransactions/matcher.test.ts +410 -0
- package/src/tools/__tests__/compareTransactions/parser.test.ts +764 -0
- package/src/tools/__tests__/compareTransactions.test.ts +342 -0
- package/src/tools/__tests__/compareTransactions.window.test.ts +147 -0
- package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +76 -0
- package/src/tools/__tests__/deltaFetcher.test.ts +270 -0
- package/src/tools/__tests__/deltaSupport.test.ts +188 -0
- package/src/tools/__tests__/deltaTestUtils.ts +46 -0
- package/src/tools/__tests__/exportTransactions.test.ts +213 -0
- package/src/tools/__tests__/monthTools.delta.integration.test.ts +80 -0
- package/src/tools/__tests__/monthTools.integration.test.ts +174 -0
- package/src/tools/__tests__/monthTools.test.ts +523 -0
- package/src/tools/__tests__/payeeTools.delta.integration.test.ts +80 -0
- package/src/tools/__tests__/payeeTools.integration.test.ts +150 -0
- package/src/tools/__tests__/payeeTools.test.ts +445 -0
- package/src/tools/__tests__/transactionTools.integration.test.ts +762 -0
- package/src/tools/__tests__/transactionTools.test.ts +3521 -0
- package/src/tools/__tests__/utilityTools.integration.test.ts +128 -0
- package/src/tools/__tests__/utilityTools.test.ts +205 -0
- package/src/tools/accountTools.ts +283 -0
- package/src/tools/budgetTools.ts +112 -0
- package/src/tools/categoryTools.ts +366 -0
- package/src/tools/compareTransactions/formatter.ts +163 -0
- package/src/tools/compareTransactions/index.ts +228 -0
- package/src/tools/compareTransactions/matcher.ts +240 -0
- package/src/tools/compareTransactions/parser.ts +557 -0
- package/src/tools/compareTransactions/types.ts +60 -0
- package/src/tools/compareTransactions.ts +3 -0
- package/src/tools/deltaFetcher.ts +278 -0
- package/src/tools/deltaSupport.ts +293 -0
- package/src/tools/exportTransactions.ts +273 -0
- package/src/tools/monthTools.ts +164 -0
- package/src/tools/payeeTools.ts +140 -0
- package/src/tools/reconcileAdapter.ts +312 -0
- package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +122 -0
- package/src/tools/reconciliation/__tests__/adapter.test.ts +234 -0
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +406 -0
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +366 -0
- package/src/tools/reconciliation/__tests__/executor.test.ts +779 -0
- package/src/tools/reconciliation/__tests__/matcher.test.ts +650 -0
- package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +278 -0
- package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +658 -0
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1000 -0
- package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +151 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +573 -0
- package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +78 -0
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +47 -0
- package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +61 -0
- package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +49 -0
- package/src/tools/reconciliation/analyzer.ts +824 -0
- package/src/tools/reconciliation/executor.ts +880 -0
- package/src/tools/reconciliation/index.ts +400 -0
- package/src/tools/reconciliation/matcher.ts +269 -0
- package/src/tools/reconciliation/payeeNormalizer.ts +167 -0
- package/src/tools/reconciliation/recommendationEngine.ts +506 -0
- package/src/tools/reconciliation/reportFormatter.ts +363 -0
- package/src/tools/reconciliation/types.ts +314 -0
- package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +424 -0
- package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +310 -0
- package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +448 -0
- package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +519 -0
- package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +155 -0
- package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +288 -0
- package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +478 -0
- package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +370 -0
- package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +401 -0
- package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +213 -0
- package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +474 -0
- package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +333 -0
- package/src/tools/schemas/outputs/accountOutputs.ts +137 -0
- package/src/tools/schemas/outputs/budgetOutputs.ts +86 -0
- package/src/tools/schemas/outputs/categoryOutputs.ts +194 -0
- package/src/tools/schemas/outputs/comparisonOutputs.ts +600 -0
- package/src/tools/schemas/outputs/index.ts +270 -0
- package/src/tools/schemas/outputs/monthOutputs.ts +243 -0
- package/src/tools/schemas/outputs/payeeOutputs.ts +105 -0
- package/src/tools/schemas/outputs/reconciliationOutputs.ts +796 -0
- package/src/tools/schemas/outputs/transactionMutationOutputs.ts +758 -0
- package/src/tools/schemas/outputs/transactionOutputs.ts +243 -0
- package/src/tools/schemas/outputs/utilityOutputs.ts +411 -0
- package/src/tools/schemas/shared/commonOutputs.ts +140 -0
- package/src/tools/toolCategories.ts +140 -0
- package/src/tools/transactionTools.ts +2509 -0
- package/src/tools/utilityTools.ts +90 -0
- package/src/types/.gitkeep +1 -0
- package/src/types/__tests__/index.test.ts +52 -0
- package/src/types/index.ts +67 -0
- package/src/types/integration-tests.d.ts +35 -0
- package/src/types/toolAnnotations.ts +44 -0
- package/src/utils/__tests__/dateUtils.test.ts +170 -0
- package/src/utils/__tests__/money.test.ts +189 -0
- package/src/utils/amountUtils.ts +32 -0
- package/src/utils/dateUtils.ts +108 -0
- package/src/utils/money.ts +123 -0
- package/test-csv-sample.csv +28 -0
- package/test-exports/sample_bank_statement.csv +7 -0
- package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +23 -0
- package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +23 -0
- package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +44 -0
- package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +58 -0
- package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +115 -0
- package/test-reconcile-autodetect.js +40 -0
- package/test-reconcile-tool.js +152 -0
- package/test-reconcile-with-csv.cjs +89 -0
- package/test-statement.csv +8 -0
- package/test_debug.js +47 -0
- package/test_simple.mjs +16 -0
- package/tsconfig.json +31 -0
- package/tsconfig.prod.json +18 -0
- package/vitest-reporters/split-json-reporter.ts +211 -0
- package/vitest.config.ts +96 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import type * as ynab from 'ynab';
|
|
3
|
+
import type { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
|
|
4
|
+
import { toMilli, toMoneyValue, toMoneyValueFromDecimal, addMilli } from '../../utils/money.js';
|
|
5
|
+
import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from './types.js';
|
|
6
|
+
import type { ReconcileAccountRequest } from './index.js';
|
|
7
|
+
import {
|
|
8
|
+
generateCorrelationKey,
|
|
9
|
+
correlateResults,
|
|
10
|
+
toCorrelationPayload,
|
|
11
|
+
} from '../transactionTools.js';
|
|
12
|
+
|
|
13
|
+
export interface AccountSnapshot {
|
|
14
|
+
balance: number; // milliunits
|
|
15
|
+
cleared_balance: number; // milliunits
|
|
16
|
+
uncleared_balance: number; // milliunits
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExecutionOptions {
|
|
20
|
+
ynabAPI: ynab.API;
|
|
21
|
+
analysis: ReconciliationAnalysis;
|
|
22
|
+
params: ReconcileAccountRequest;
|
|
23
|
+
budgetId: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
initialAccount: AccountSnapshot;
|
|
26
|
+
currencyCode: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExecutionActionRecord {
|
|
30
|
+
type: string;
|
|
31
|
+
transaction: Record<string, unknown> | null;
|
|
32
|
+
reason: string;
|
|
33
|
+
bulk_chunk_index?: number;
|
|
34
|
+
correlation_key?: string;
|
|
35
|
+
duplicate?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ExecutionSummary {
|
|
39
|
+
bank_transactions_count: number;
|
|
40
|
+
ynab_transactions_count: number;
|
|
41
|
+
matches_found: number;
|
|
42
|
+
missing_in_ynab: number;
|
|
43
|
+
missing_in_bank: number;
|
|
44
|
+
transactions_created: number;
|
|
45
|
+
transactions_updated: number;
|
|
46
|
+
dates_adjusted: number;
|
|
47
|
+
dry_run: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bulk operation metrics for reconciliation transaction creation.
|
|
52
|
+
*
|
|
53
|
+
* Note on failure counters:
|
|
54
|
+
* - `transaction_failures` is the canonical counter for per-transaction failures
|
|
55
|
+
* - `failed_transactions` is maintained for backward compatibility and should always
|
|
56
|
+
* mirror `transaction_failures` rather than represent an independent count
|
|
57
|
+
*/
|
|
58
|
+
export interface BulkOperationDetails {
|
|
59
|
+
chunks_processed: number;
|
|
60
|
+
bulk_successes: number;
|
|
61
|
+
sequential_fallbacks: number;
|
|
62
|
+
duplicates_detected: number;
|
|
63
|
+
failed_transactions: number; // Backward-compatible alias for transaction_failures
|
|
64
|
+
bulk_chunk_failures: number; // API-level failures (entire chunk failed)
|
|
65
|
+
transaction_failures: number; // Per-transaction failures (from correlation or sequential)
|
|
66
|
+
sequential_attempts?: number; // Number of sequential creations attempted during fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ExecutionResult {
|
|
70
|
+
summary: ExecutionSummary;
|
|
71
|
+
account_balance: {
|
|
72
|
+
before: AccountSnapshot;
|
|
73
|
+
after: AccountSnapshot;
|
|
74
|
+
};
|
|
75
|
+
actions_taken: ExecutionActionRecord[];
|
|
76
|
+
recommendations: string[];
|
|
77
|
+
balance_reconciliation?: Awaited<ReturnType<typeof buildBalanceReconciliation>>;
|
|
78
|
+
bulk_operation_details?: BulkOperationDetails;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface UpdateFlags {
|
|
82
|
+
needsClearedUpdate: boolean;
|
|
83
|
+
needsDateUpdate: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const MONEY_EPSILON_MILLI = 100; // $0.10
|
|
87
|
+
const DEFAULT_TOLERANCE_CENTS = 1;
|
|
88
|
+
const CENTS_TO_MILLI = 10;
|
|
89
|
+
const MAX_BULK_CREATE_CHUNK = 100;
|
|
90
|
+
|
|
91
|
+
function chunkArray<T>(array: T[], size: number): T[][] {
|
|
92
|
+
if (size <= 0) {
|
|
93
|
+
throw new Error('chunk size must be positive');
|
|
94
|
+
}
|
|
95
|
+
const chunks: T[][] = [];
|
|
96
|
+
for (let i = 0; i < array.length; i += size) {
|
|
97
|
+
chunks.push(array.slice(i, i + size));
|
|
98
|
+
}
|
|
99
|
+
return chunks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface PreparedBulkCreateEntry {
|
|
103
|
+
bankTransaction: BankTransaction;
|
|
104
|
+
saveTransaction: SaveTransaction;
|
|
105
|
+
amountMilli: number;
|
|
106
|
+
correlationKey: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Generates a deterministic import_id for reconciliation-created transactions.
|
|
111
|
+
*
|
|
112
|
+
* Uses a dedicated `YNAB:bulk:` prefix to distinguish reconciliation-created transactions
|
|
113
|
+
* from manual bulk creates. This namespace separation is intentional:
|
|
114
|
+
* - Reconciliation operations are automated and system-generated
|
|
115
|
+
* - Manual bulk creates via create_transactions tool can use custom import_id formats
|
|
116
|
+
* - Both interact with YNAB's global duplicate detection via the same import_id mechanism
|
|
117
|
+
*
|
|
118
|
+
* The hash-based correlation in transactionTools.ts uses `hash:` prefix for correlation
|
|
119
|
+
* (when no import_id provided), which is separate from this import_id generation.
|
|
120
|
+
*/
|
|
121
|
+
function generateBulkImportId(
|
|
122
|
+
accountId: string,
|
|
123
|
+
date: string,
|
|
124
|
+
amountMilli: number,
|
|
125
|
+
payee?: string | null,
|
|
126
|
+
): string {
|
|
127
|
+
const normalizedPayee = (payee ?? '').trim().toLowerCase();
|
|
128
|
+
const raw = `${accountId}|${date}|${amountMilli}|${normalizedPayee}`;
|
|
129
|
+
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
130
|
+
return `YNAB:bulk:${digest}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function executeReconciliation(options: ExecutionOptions): Promise<ExecutionResult> {
|
|
134
|
+
const { analysis, params, ynabAPI, budgetId, accountId, initialAccount, currencyCode } = options;
|
|
135
|
+
const actions_taken: ExecutionActionRecord[] = [];
|
|
136
|
+
|
|
137
|
+
const summary: ExecutionSummary = {
|
|
138
|
+
bank_transactions_count: analysis.summary.bank_transactions_count,
|
|
139
|
+
ynab_transactions_count: analysis.summary.ynab_transactions_count,
|
|
140
|
+
matches_found: analysis.auto_matches.length,
|
|
141
|
+
missing_in_ynab: analysis.summary.unmatched_bank,
|
|
142
|
+
missing_in_bank: analysis.summary.unmatched_ynab,
|
|
143
|
+
transactions_created: 0,
|
|
144
|
+
transactions_updated: 0,
|
|
145
|
+
dates_adjusted: 0,
|
|
146
|
+
dry_run: params.dry_run,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let afterAccount: AccountSnapshot = { ...initialAccount };
|
|
150
|
+
let accountSnapshotDirty = false;
|
|
151
|
+
const statementTargetMilli = resolveStatementBalanceMilli(
|
|
152
|
+
analysis.balance_info,
|
|
153
|
+
params.statement_balance,
|
|
154
|
+
);
|
|
155
|
+
let clearedDeltaMilli = addMilli(initialAccount.cleared_balance ?? 0, -statementTargetMilli);
|
|
156
|
+
const balanceToleranceMilli =
|
|
157
|
+
Math.max(0, params.amount_tolerance_cents ?? DEFAULT_TOLERANCE_CENTS) * CENTS_TO_MILLI;
|
|
158
|
+
let balanceAligned = false;
|
|
159
|
+
|
|
160
|
+
const applyClearedDelta = (delta: number) => {
|
|
161
|
+
if (delta === 0) return;
|
|
162
|
+
clearedDeltaMilli = addMilli(clearedDeltaMilli, delta);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const recordAlignmentIfNeeded = (trigger: string, { log = true } = {}) => {
|
|
166
|
+
if (balanceAligned) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (Math.abs(clearedDeltaMilli) <= balanceToleranceMilli) {
|
|
170
|
+
balanceAligned = true;
|
|
171
|
+
if (log) {
|
|
172
|
+
const deltaDisplay = toMoneyValue(clearedDeltaMilli, currencyCode).value_display;
|
|
173
|
+
const toleranceDisplay = toMoneyValue(balanceToleranceMilli, currencyCode).value_display;
|
|
174
|
+
actions_taken.push({
|
|
175
|
+
type: 'balance_checkpoint',
|
|
176
|
+
transaction: null,
|
|
177
|
+
reason: `Cleared delta ${deltaDisplay} within ±${toleranceDisplay} after ${trigger} - halting newest-to-oldest pass`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
recordAlignmentIfNeeded('initial balance check', { log: false });
|
|
186
|
+
|
|
187
|
+
const orderedUnmatchedBank = params.auto_create_transactions
|
|
188
|
+
? sortByDateDescending(analysis.unmatched_bank)
|
|
189
|
+
: [];
|
|
190
|
+
const orderedAutoMatches = sortMatchesByBankDateDescending(analysis.auto_matches);
|
|
191
|
+
const orderedUnmatchedYNAB = sortByDateDescending(analysis.unmatched_ynab);
|
|
192
|
+
|
|
193
|
+
let bulkOperationDetails: BulkOperationDetails | undefined;
|
|
194
|
+
|
|
195
|
+
// STEP 1: Auto-create missing transactions (bank -> YNAB)
|
|
196
|
+
if (params.auto_create_transactions && !balanceAligned) {
|
|
197
|
+
const buildPreparedEntry = (bankTxn: BankTransaction): PreparedBulkCreateEntry => {
|
|
198
|
+
const amountMilli = toMilli(bankTxn.amount);
|
|
199
|
+
const saveTransaction: SaveTransaction = {
|
|
200
|
+
account_id: accountId,
|
|
201
|
+
amount: amountMilli,
|
|
202
|
+
date: bankTxn.date,
|
|
203
|
+
payee_name: bankTxn.payee ?? undefined,
|
|
204
|
+
memo: bankTxn.memo ?? 'Auto-reconciled from bank statement',
|
|
205
|
+
cleared: 'cleared',
|
|
206
|
+
approved: true,
|
|
207
|
+
import_id: generateBulkImportId(accountId, bankTxn.date, amountMilli, bankTxn.payee),
|
|
208
|
+
};
|
|
209
|
+
const correlationKey = generateCorrelationKey(toCorrelationPayload(saveTransaction));
|
|
210
|
+
return {
|
|
211
|
+
bankTransaction: bankTxn,
|
|
212
|
+
saveTransaction,
|
|
213
|
+
amountMilli,
|
|
214
|
+
correlationKey,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const recordCreateAction = (args: {
|
|
219
|
+
entry: PreparedBulkCreateEntry;
|
|
220
|
+
createdTxn: ynab.TransactionDetail | null;
|
|
221
|
+
chunkIndex?: number;
|
|
222
|
+
prefix?: string;
|
|
223
|
+
}) => {
|
|
224
|
+
const { entry, createdTxn, chunkIndex, prefix } = args;
|
|
225
|
+
summary.transactions_created += 1;
|
|
226
|
+
const action: ExecutionActionRecord = {
|
|
227
|
+
type: 'create_transaction',
|
|
228
|
+
transaction: createdTxn as unknown as Record<string, unknown> | null,
|
|
229
|
+
reason: `${prefix ?? 'Created missing transaction'}: ${
|
|
230
|
+
entry.bankTransaction.payee ?? 'Unknown'
|
|
231
|
+
} (${formatDisplay(entry.bankTransaction.amount, currencyCode)})`,
|
|
232
|
+
correlation_key: entry.correlationKey,
|
|
233
|
+
};
|
|
234
|
+
if (chunkIndex !== undefined) {
|
|
235
|
+
action.bulk_chunk_index = chunkIndex;
|
|
236
|
+
}
|
|
237
|
+
actions_taken.push(action);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const processSequentialEntries = async (
|
|
241
|
+
entries: PreparedBulkCreateEntry[],
|
|
242
|
+
options: { chunkIndex?: number; fallbackError?: unknown } = {},
|
|
243
|
+
) => {
|
|
244
|
+
let sequentialAttempts = 0;
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (balanceAligned) break;
|
|
247
|
+
if (options.fallbackError) {
|
|
248
|
+
sequentialAttempts += 1;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const response = await ynabAPI.transactions.createTransaction(budgetId, {
|
|
252
|
+
transaction: entry.saveTransaction,
|
|
253
|
+
});
|
|
254
|
+
const createdTransaction = response.data.transaction ?? null;
|
|
255
|
+
const recordArgs: Parameters<typeof recordCreateAction>[0] = {
|
|
256
|
+
entry,
|
|
257
|
+
createdTxn: createdTransaction,
|
|
258
|
+
prefix: options.fallbackError
|
|
259
|
+
? 'Created missing transaction after bulk fallback'
|
|
260
|
+
: 'Created missing transaction',
|
|
261
|
+
};
|
|
262
|
+
if (options.chunkIndex !== undefined) {
|
|
263
|
+
recordArgs.chunkIndex = options.chunkIndex;
|
|
264
|
+
}
|
|
265
|
+
recordCreateAction(recordArgs);
|
|
266
|
+
accountSnapshotDirty = true;
|
|
267
|
+
applyClearedDelta(entry.amountMilli);
|
|
268
|
+
const trigger = options.chunkIndex
|
|
269
|
+
? `creating ${entry.bankTransaction.payee ?? 'missing transaction'} (chunk ${options.chunkIndex})`
|
|
270
|
+
: `creating ${entry.bankTransaction.payee ?? 'missing transaction'}`;
|
|
271
|
+
recordAlignmentIfNeeded(trigger);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (bulkOperationDetails) {
|
|
274
|
+
bulkOperationDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
|
|
275
|
+
}
|
|
276
|
+
const failureReason = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
277
|
+
const failureAction: ExecutionActionRecord = {
|
|
278
|
+
type: 'create_transaction_failed',
|
|
279
|
+
transaction: entry.saveTransaction as unknown as Record<string, unknown>,
|
|
280
|
+
reason: options.fallbackError
|
|
281
|
+
? `Bulk fallback failed for ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`
|
|
282
|
+
: `Failed to create transaction ${entry.bankTransaction.payee ?? 'Unknown'} (${failureReason})`,
|
|
283
|
+
correlation_key: entry.correlationKey,
|
|
284
|
+
};
|
|
285
|
+
if (options.chunkIndex !== undefined) {
|
|
286
|
+
failureAction.bulk_chunk_index = options.chunkIndex;
|
|
287
|
+
}
|
|
288
|
+
actions_taken.push(failureAction);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Update sequential_attempts metric if this was a fallback operation
|
|
292
|
+
if (bulkOperationDetails && options.fallbackError && sequentialAttempts > 0) {
|
|
293
|
+
bulkOperationDetails.sequential_attempts =
|
|
294
|
+
(bulkOperationDetails.sequential_attempts ?? 0) + sequentialAttempts;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const processBulkChunk = async (chunk: PreparedBulkCreateEntry[], chunkIndex: number) => {
|
|
299
|
+
// bulkOperationDetails is guaranteed to be defined when this function is called
|
|
300
|
+
// (it's only called from within the bulk operation block where it's initialized)
|
|
301
|
+
const bulkDetails = bulkOperationDetails!;
|
|
302
|
+
|
|
303
|
+
const payload = chunk.map((entry) => entry.saveTransaction);
|
|
304
|
+
const response = await ynabAPI.transactions.createTransactions(budgetId, {
|
|
305
|
+
transactions: payload,
|
|
306
|
+
});
|
|
307
|
+
const responseData = response.data;
|
|
308
|
+
const duplicateImportIds = new Set(responseData.duplicate_import_ids ?? []);
|
|
309
|
+
const correlationRequests = chunk.map((entry) =>
|
|
310
|
+
toCorrelationPayload(entry.saveTransaction),
|
|
311
|
+
) as Parameters<typeof correlateResults>[0];
|
|
312
|
+
const correlated = correlateResults(correlationRequests, responseData, duplicateImportIds);
|
|
313
|
+
const transactionMap = new Map<string, ynab.TransactionDetail>();
|
|
314
|
+
for (const transaction of responseData.transactions ?? []) {
|
|
315
|
+
if (transaction.id) {
|
|
316
|
+
transactionMap.set(transaction.id, transaction);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
for (const result of correlated) {
|
|
320
|
+
const entry = chunk[result.request_index];
|
|
321
|
+
if (!entry) continue;
|
|
322
|
+
if (result.status === 'created') {
|
|
323
|
+
const createdTransaction = result.transaction_id
|
|
324
|
+
? (transactionMap.get(result.transaction_id) ?? null)
|
|
325
|
+
: null;
|
|
326
|
+
recordCreateAction({
|
|
327
|
+
entry,
|
|
328
|
+
createdTxn: createdTransaction,
|
|
329
|
+
chunkIndex,
|
|
330
|
+
prefix: 'Created missing transaction via bulk',
|
|
331
|
+
});
|
|
332
|
+
accountSnapshotDirty = true;
|
|
333
|
+
applyClearedDelta(entry.amountMilli);
|
|
334
|
+
recordAlignmentIfNeeded(
|
|
335
|
+
`creating ${entry.bankTransaction.payee ?? 'missing transaction'} via bulk chunk ${chunkIndex}`,
|
|
336
|
+
);
|
|
337
|
+
} else if (result.status === 'duplicate') {
|
|
338
|
+
bulkDetails.duplicates_detected += 1;
|
|
339
|
+
actions_taken.push({
|
|
340
|
+
type: 'create_transaction_duplicate',
|
|
341
|
+
transaction: {
|
|
342
|
+
transaction_id: result.transaction_id ?? null,
|
|
343
|
+
import_id: entry.saveTransaction.import_id,
|
|
344
|
+
},
|
|
345
|
+
reason: `Duplicate import detected for ${
|
|
346
|
+
entry.bankTransaction.payee ?? 'Unknown'
|
|
347
|
+
} (import_id ${entry.saveTransaction.import_id})`,
|
|
348
|
+
bulk_chunk_index: chunkIndex,
|
|
349
|
+
correlation_key: result.correlation_key,
|
|
350
|
+
duplicate: true,
|
|
351
|
+
});
|
|
352
|
+
} else {
|
|
353
|
+
bulkDetails.transaction_failures += 1; // Canonical counter for per-transaction failures
|
|
354
|
+
actions_taken.push({
|
|
355
|
+
type: 'create_transaction_failed',
|
|
356
|
+
transaction: entry.saveTransaction as unknown as Record<string, unknown>,
|
|
357
|
+
reason:
|
|
358
|
+
result.error ?? `Bulk create failed for ${entry.bankTransaction.payee ?? 'Unknown'}`,
|
|
359
|
+
bulk_chunk_index: chunkIndex,
|
|
360
|
+
correlation_key: result.correlation_key,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
if (params.dry_run) {
|
|
367
|
+
for (const bankTxn of orderedUnmatchedBank) {
|
|
368
|
+
if (balanceAligned) break;
|
|
369
|
+
const entry = buildPreparedEntry(bankTxn);
|
|
370
|
+
summary.transactions_created += 1;
|
|
371
|
+
actions_taken.push({
|
|
372
|
+
type: 'create_transaction',
|
|
373
|
+
transaction: entry.saveTransaction as unknown as Record<string, unknown>,
|
|
374
|
+
reason: `Would create missing transaction: ${bankTxn.payee ?? 'Unknown'} (${formatDisplay(bankTxn.amount, currencyCode)})`,
|
|
375
|
+
correlation_key: entry.correlationKey,
|
|
376
|
+
});
|
|
377
|
+
applyClearedDelta(entry.amountMilli);
|
|
378
|
+
recordAlignmentIfNeeded(`creating ${bankTxn.payee ?? 'missing transaction'}`);
|
|
379
|
+
}
|
|
380
|
+
} else if (orderedUnmatchedBank.length >= 2) {
|
|
381
|
+
bulkOperationDetails = {
|
|
382
|
+
chunks_processed: 0,
|
|
383
|
+
bulk_successes: 0,
|
|
384
|
+
sequential_fallbacks: 0,
|
|
385
|
+
duplicates_detected: 0,
|
|
386
|
+
failed_transactions: 0,
|
|
387
|
+
bulk_chunk_failures: 0,
|
|
388
|
+
transaction_failures: 0,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
let nextBankIndex = 0;
|
|
392
|
+
while (nextBankIndex < orderedUnmatchedBank.length && !balanceAligned) {
|
|
393
|
+
const batch: PreparedBulkCreateEntry[] = [];
|
|
394
|
+
let projectedDelta = clearedDeltaMilli;
|
|
395
|
+
while (nextBankIndex < orderedUnmatchedBank.length) {
|
|
396
|
+
const bankTxn = orderedUnmatchedBank[nextBankIndex];
|
|
397
|
+
if (!bankTxn) {
|
|
398
|
+
nextBankIndex += 1;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const entry = buildPreparedEntry(bankTxn);
|
|
402
|
+
batch.push(entry);
|
|
403
|
+
nextBankIndex += 1;
|
|
404
|
+
projectedDelta = addMilli(projectedDelta, entry.amountMilli);
|
|
405
|
+
if (Math.abs(projectedDelta) <= balanceToleranceMilli) {
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (batch.length === 0) {
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const chunks = chunkArray(batch, MAX_BULK_CREATE_CHUNK);
|
|
415
|
+
for (const chunk of chunks) {
|
|
416
|
+
if (balanceAligned) break;
|
|
417
|
+
bulkOperationDetails.chunks_processed += 1;
|
|
418
|
+
const chunkIndex = bulkOperationDetails.chunks_processed;
|
|
419
|
+
try {
|
|
420
|
+
await processBulkChunk(chunk, chunkIndex);
|
|
421
|
+
bulkOperationDetails.bulk_successes += 1;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
bulkOperationDetails.sequential_fallbacks += 1;
|
|
424
|
+
bulkOperationDetails.bulk_chunk_failures += 1; // API-level failure (entire chunk failed)
|
|
425
|
+
actions_taken.push({
|
|
426
|
+
type: 'bulk_create_fallback',
|
|
427
|
+
transaction: null,
|
|
428
|
+
reason: `Bulk chunk #${chunkIndex} failed (${
|
|
429
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
430
|
+
}) - falling back to sequential creation`,
|
|
431
|
+
bulk_chunk_index: chunkIndex,
|
|
432
|
+
});
|
|
433
|
+
await processSequentialEntries(chunk, { chunkIndex, fallbackError: error });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
const entries = orderedUnmatchedBank.map((bankTxn) => buildPreparedEntry(bankTxn));
|
|
439
|
+
await processSequentialEntries(entries);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// STEP 2: Update matched YNAB transactions (cleared status / date)
|
|
444
|
+
// Collect all updates for batch processing
|
|
445
|
+
if (!balanceAligned) {
|
|
446
|
+
const transactionsToUpdate: ynab.SaveTransactionWithIdOrImportId[] = [];
|
|
447
|
+
|
|
448
|
+
for (const match of orderedAutoMatches) {
|
|
449
|
+
if (balanceAligned) break;
|
|
450
|
+
const flags = computeUpdateFlags(match, params);
|
|
451
|
+
if (!flags.needsClearedUpdate && !flags.needsDateUpdate) continue;
|
|
452
|
+
if (!match.ynab_transaction) continue;
|
|
453
|
+
|
|
454
|
+
// Build minimal update payload - only include ID and fields that are changing
|
|
455
|
+
// Including unnecessary fields (like amount, payee_name, memo) can cause unexpected behavior
|
|
456
|
+
const updatePayload: ynab.SaveTransactionWithIdOrImportId = {
|
|
457
|
+
id: match.ynab_transaction.id,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Only include fields that are actually changing
|
|
461
|
+
if (flags.needsDateUpdate) {
|
|
462
|
+
updatePayload.date = match.bank_transaction.date;
|
|
463
|
+
}
|
|
464
|
+
if (flags.needsClearedUpdate) {
|
|
465
|
+
updatePayload.cleared = 'cleared' as ynab.TransactionClearedStatus;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (params.dry_run) {
|
|
469
|
+
summary.transactions_updated += 1;
|
|
470
|
+
if (flags.needsDateUpdate) summary.dates_adjusted += 1;
|
|
471
|
+
actions_taken.push({
|
|
472
|
+
type: 'update_transaction',
|
|
473
|
+
transaction: {
|
|
474
|
+
transaction_id: match.ynab_transaction.id,
|
|
475
|
+
new_date: flags.needsDateUpdate ? match.bank_transaction.date : undefined,
|
|
476
|
+
cleared: flags.needsClearedUpdate ? 'cleared' : undefined,
|
|
477
|
+
},
|
|
478
|
+
reason: `Would update transaction: ${updateReason(match, flags, currencyCode)}`,
|
|
479
|
+
});
|
|
480
|
+
if (flags.needsClearedUpdate) {
|
|
481
|
+
applyClearedDelta(match.ynab_transaction.amount);
|
|
482
|
+
if (
|
|
483
|
+
recordAlignmentIfNeeded(
|
|
484
|
+
`clearing ${match.ynab_transaction.id ?? 'transaction'} (dry run)`,
|
|
485
|
+
)
|
|
486
|
+
) {
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
transactionsToUpdate.push(updatePayload);
|
|
492
|
+
if (flags.needsDateUpdate) summary.dates_adjusted += 1;
|
|
493
|
+
if (flags.needsClearedUpdate) {
|
|
494
|
+
applyClearedDelta(match.ynab_transaction.amount);
|
|
495
|
+
if (recordAlignmentIfNeeded(`clearing ${match.ynab_transaction.id}`)) {
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Batch update all transactions in a single API call
|
|
503
|
+
if (!params.dry_run && transactionsToUpdate.length > 0) {
|
|
504
|
+
const response = await ynabAPI.transactions.updateTransactions(budgetId, {
|
|
505
|
+
transactions: transactionsToUpdate,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const updatedTransactions = response.data.transactions ?? [];
|
|
509
|
+
summary.transactions_updated += updatedTransactions.length;
|
|
510
|
+
|
|
511
|
+
for (const updatedTransaction of updatedTransactions) {
|
|
512
|
+
const match = orderedAutoMatches.find(
|
|
513
|
+
(m) => m.ynab_transaction?.id === updatedTransaction.id,
|
|
514
|
+
);
|
|
515
|
+
const flags = match
|
|
516
|
+
? computeUpdateFlags(match, params)
|
|
517
|
+
: { needsClearedUpdate: false, needsDateUpdate: false };
|
|
518
|
+
actions_taken.push({
|
|
519
|
+
type: 'update_transaction',
|
|
520
|
+
transaction: updatedTransaction as unknown as Record<string, unknown> | null,
|
|
521
|
+
reason: `Updated transaction: ${match ? updateReason(match, flags, currencyCode) : 'cleared'}`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
accountSnapshotDirty = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// STEP 3: Auto-unclear YNAB transactions missing from bank
|
|
529
|
+
const shouldRunSanityPass = params.auto_unclear_missing && !balanceAligned;
|
|
530
|
+
if (shouldRunSanityPass) {
|
|
531
|
+
const transactionsToUnclear: ynab.SaveTransactionWithIdOrImportId[] = [];
|
|
532
|
+
|
|
533
|
+
for (const ynabTxn of orderedUnmatchedYNAB) {
|
|
534
|
+
if (ynabTxn.cleared !== 'cleared') continue;
|
|
535
|
+
if (balanceAligned) break;
|
|
536
|
+
|
|
537
|
+
if (params.dry_run) {
|
|
538
|
+
summary.transactions_updated += 1;
|
|
539
|
+
actions_taken.push({
|
|
540
|
+
type: 'update_transaction',
|
|
541
|
+
transaction: { transaction_id: ynabTxn.id, cleared: 'uncleared' },
|
|
542
|
+
reason: `Would mark transaction ${ynabTxn.id} as uncleared - not present on statement`,
|
|
543
|
+
});
|
|
544
|
+
applyClearedDelta(-ynabTxn.amount);
|
|
545
|
+
if (recordAlignmentIfNeeded(`unclearing ${ynabTxn.id} (dry run)`)) {
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
// Minimal update payload - only include ID and the field we're changing
|
|
550
|
+
transactionsToUnclear.push({
|
|
551
|
+
id: ynabTxn.id,
|
|
552
|
+
cleared: 'uncleared' as ynab.TransactionClearedStatus,
|
|
553
|
+
});
|
|
554
|
+
applyClearedDelta(-ynabTxn.amount);
|
|
555
|
+
if (recordAlignmentIfNeeded(`unclearing ${ynabTxn.id}`)) {
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Batch update all unclear operations in a single API call
|
|
562
|
+
if (!params.dry_run && transactionsToUnclear.length > 0) {
|
|
563
|
+
const response = await ynabAPI.transactions.updateTransactions(budgetId, {
|
|
564
|
+
transactions: transactionsToUnclear,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const updatedTransactions = response.data.transactions ?? [];
|
|
568
|
+
summary.transactions_updated += updatedTransactions.length;
|
|
569
|
+
|
|
570
|
+
for (const updatedTransaction of updatedTransactions) {
|
|
571
|
+
actions_taken.push({
|
|
572
|
+
type: 'update_transaction',
|
|
573
|
+
transaction: updatedTransaction as unknown as Record<string, unknown> | null,
|
|
574
|
+
reason: `Marked transaction ${updatedTransaction.id} as uncleared - not found on statement`,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
accountSnapshotDirty = true;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// STEP 4: Balance reconciliation snapshot (only once per execution)
|
|
582
|
+
let balance_reconciliation: ExecutionResult['balance_reconciliation'];
|
|
583
|
+
if (params.statement_balance !== undefined && params.statement_date) {
|
|
584
|
+
balance_reconciliation = await buildBalanceReconciliation({
|
|
585
|
+
ynabAPI,
|
|
586
|
+
budgetId,
|
|
587
|
+
accountId,
|
|
588
|
+
statementDate: params.statement_date,
|
|
589
|
+
statementBalance: params.statement_balance,
|
|
590
|
+
analysis,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// STEP 5: Recommendations and balance changes
|
|
595
|
+
if (!params.dry_run && accountSnapshotDirty) {
|
|
596
|
+
afterAccount = await refreshAccountSnapshot(ynabAPI, budgetId, accountId);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const balanceChangeMilli =
|
|
600
|
+
params.dry_run || !accountSnapshotDirty ? 0 : afterAccount.balance - initialAccount.balance;
|
|
601
|
+
|
|
602
|
+
const recommendations = buildRecommendations({
|
|
603
|
+
summary,
|
|
604
|
+
params,
|
|
605
|
+
analysis,
|
|
606
|
+
balanceChangeMilli,
|
|
607
|
+
currencyCode,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const result: ExecutionResult = {
|
|
611
|
+
summary,
|
|
612
|
+
account_balance: {
|
|
613
|
+
before: initialAccount,
|
|
614
|
+
after: afterAccount,
|
|
615
|
+
},
|
|
616
|
+
actions_taken,
|
|
617
|
+
recommendations,
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
if (balance_reconciliation !== undefined) {
|
|
621
|
+
result.balance_reconciliation = balance_reconciliation;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (bulkOperationDetails) {
|
|
625
|
+
// Ensure failed_transactions mirrors transaction_failures for backward compatibility
|
|
626
|
+
bulkOperationDetails.failed_transactions = bulkOperationDetails.transaction_failures;
|
|
627
|
+
result.bulk_operation_details = bulkOperationDetails;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function formatDisplay(amount: number, currency: string): string {
|
|
634
|
+
return toMoneyValueFromDecimal(amount, currency).value_display;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function computeUpdateFlags(match: TransactionMatch, params: ReconcileAccountRequest): UpdateFlags {
|
|
638
|
+
const ynabTxn = match.ynab_transaction;
|
|
639
|
+
const bankTxn = match.bank_transaction;
|
|
640
|
+
if (!ynabTxn) {
|
|
641
|
+
return { needsClearedUpdate: false, needsDateUpdate: false };
|
|
642
|
+
}
|
|
643
|
+
const needsClearedUpdate = Boolean(
|
|
644
|
+
params.auto_update_cleared_status && ynabTxn.cleared !== 'cleared',
|
|
645
|
+
);
|
|
646
|
+
const needsDateUpdate = Boolean(params.auto_adjust_dates && ynabTxn.date !== bankTxn.date);
|
|
647
|
+
return { needsClearedUpdate, needsDateUpdate };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function updateReason(match: TransactionMatch, flags: UpdateFlags, _currency: string): string {
|
|
651
|
+
const parts: string[] = [];
|
|
652
|
+
if (flags.needsClearedUpdate) {
|
|
653
|
+
parts.push('marked as cleared');
|
|
654
|
+
}
|
|
655
|
+
if (flags.needsDateUpdate) {
|
|
656
|
+
parts.push(`date adjusted to ${match.bank_transaction.date}`);
|
|
657
|
+
}
|
|
658
|
+
return parts.join(', ');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function buildBalanceReconciliation(args: {
|
|
662
|
+
ynabAPI: ynab.API;
|
|
663
|
+
budgetId: string;
|
|
664
|
+
accountId: string;
|
|
665
|
+
statementDate: string;
|
|
666
|
+
statementBalance: number;
|
|
667
|
+
analysis: ReconciliationAnalysis;
|
|
668
|
+
}) {
|
|
669
|
+
const { ynabAPI, budgetId, accountId, statementDate, statementBalance } = args;
|
|
670
|
+
const ynabMilli = await clearedBalanceAsOf(ynabAPI, budgetId, accountId, statementDate);
|
|
671
|
+
const bankMilli = toMilli(statementBalance);
|
|
672
|
+
const discrepancy = bankMilli - ynabMilli;
|
|
673
|
+
const status = discrepancy === 0 ? 'PERFECTLY_RECONCILED' : 'DISCREPANCY_FOUND';
|
|
674
|
+
|
|
675
|
+
const precision_calculations = {
|
|
676
|
+
bank_statement_balance_milliunits: bankMilli,
|
|
677
|
+
ynab_calculated_balance_milliunits: ynabMilli,
|
|
678
|
+
discrepancy_milliunits: discrepancy,
|
|
679
|
+
discrepancy_dollars: discrepancy / 1000,
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const discrepancy_analysis = discrepancy === 0 ? undefined : buildLikelyCauses(discrepancy);
|
|
683
|
+
|
|
684
|
+
const result: {
|
|
685
|
+
status: string;
|
|
686
|
+
precision_calculations: typeof precision_calculations;
|
|
687
|
+
discrepancy_analysis?: ReturnType<typeof buildLikelyCauses>;
|
|
688
|
+
final_verification: {
|
|
689
|
+
balance_matches_exactly: boolean;
|
|
690
|
+
all_transactions_accounted: boolean;
|
|
691
|
+
audit_trail_complete: boolean;
|
|
692
|
+
reconciliation_complete: boolean;
|
|
693
|
+
};
|
|
694
|
+
} = {
|
|
695
|
+
status,
|
|
696
|
+
precision_calculations,
|
|
697
|
+
final_verification: {
|
|
698
|
+
balance_matches_exactly: discrepancy === 0,
|
|
699
|
+
all_transactions_accounted: discrepancy === 0,
|
|
700
|
+
audit_trail_complete: discrepancy === 0,
|
|
701
|
+
reconciliation_complete: discrepancy === 0,
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
if (discrepancy_analysis !== undefined) {
|
|
706
|
+
result.discrepancy_analysis = discrepancy_analysis;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function clearedBalanceAsOf(
|
|
713
|
+
api: ynab.API,
|
|
714
|
+
budgetId: string,
|
|
715
|
+
accountId: string,
|
|
716
|
+
dateISO: string,
|
|
717
|
+
): Promise<number> {
|
|
718
|
+
const response = await api.transactions.getTransactionsByAccount(budgetId, accountId);
|
|
719
|
+
const asOf = new Date(dateISO);
|
|
720
|
+
const cleared = response.data.transactions.filter(
|
|
721
|
+
(txn) => txn.cleared === 'cleared' && new Date(txn.date) <= asOf,
|
|
722
|
+
);
|
|
723
|
+
const sum = cleared.reduce((acc, txn) => addMilli(acc, txn.amount ?? 0), 0);
|
|
724
|
+
return sum;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function refreshAccountSnapshot(
|
|
728
|
+
api: ynab.API,
|
|
729
|
+
budgetId: string,
|
|
730
|
+
accountId: string,
|
|
731
|
+
): Promise<AccountSnapshot> {
|
|
732
|
+
const accountsApi = api.accounts as typeof api.accounts & {
|
|
733
|
+
getAccount?: (budgetId: string, accountId: string) => Promise<ynab.AccountResponse>;
|
|
734
|
+
};
|
|
735
|
+
const response = accountsApi.getAccount
|
|
736
|
+
? await accountsApi.getAccount(budgetId, accountId)
|
|
737
|
+
: await accountsApi.getAccountById(budgetId, accountId);
|
|
738
|
+
const account = response.data.account;
|
|
739
|
+
return {
|
|
740
|
+
balance: account.balance,
|
|
741
|
+
cleared_balance: account.cleared_balance,
|
|
742
|
+
uncleared_balance: account.uncleared_balance,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function buildLikelyCauses(discrepancyMilli: number) {
|
|
747
|
+
const causes = [] as {
|
|
748
|
+
cause_type: string;
|
|
749
|
+
description: string;
|
|
750
|
+
confidence: number;
|
|
751
|
+
amount_milliunits: number;
|
|
752
|
+
suggested_resolution: string;
|
|
753
|
+
evidence: unknown[];
|
|
754
|
+
}[];
|
|
755
|
+
|
|
756
|
+
const abs = Math.abs(discrepancyMilli);
|
|
757
|
+
if (abs % 1000 === 0 || abs % 500 === 0) {
|
|
758
|
+
causes.push({
|
|
759
|
+
cause_type: 'bank_fee',
|
|
760
|
+
description: 'Round amount suggests a bank fee or interest adjustment.',
|
|
761
|
+
confidence: 0.8,
|
|
762
|
+
amount_milliunits: discrepancyMilli,
|
|
763
|
+
suggested_resolution:
|
|
764
|
+
discrepancyMilli < 0
|
|
765
|
+
? 'Create bank fee transaction and mark cleared'
|
|
766
|
+
: 'Record interest income',
|
|
767
|
+
evidence: [],
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return causes.length > 0
|
|
772
|
+
? {
|
|
773
|
+
confidence_level: Math.max(...causes.map((cause) => cause.confidence)),
|
|
774
|
+
likely_causes: causes,
|
|
775
|
+
risk_assessment: 'LOW',
|
|
776
|
+
}
|
|
777
|
+
: undefined;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function buildRecommendations(args: {
|
|
781
|
+
summary: ExecutionSummary;
|
|
782
|
+
params: ReconcileAccountRequest;
|
|
783
|
+
analysis: ReconciliationAnalysis;
|
|
784
|
+
balanceChangeMilli: number;
|
|
785
|
+
currencyCode: string;
|
|
786
|
+
}): string[] {
|
|
787
|
+
const { summary, params, analysis, balanceChangeMilli, currencyCode } = args;
|
|
788
|
+
const recommendations: string[] = [];
|
|
789
|
+
|
|
790
|
+
if (summary.dates_adjusted > 0) {
|
|
791
|
+
recommendations.push(
|
|
792
|
+
`✅ Adjusted ${summary.dates_adjusted} transaction date(s) to match bank statement dates`,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (analysis.summary.unmatched_bank > 0 && !params.auto_create_transactions) {
|
|
797
|
+
recommendations.push(
|
|
798
|
+
`Consider enabling auto_create_transactions to automatically create ${analysis.summary.unmatched_bank} missing transaction(s)`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (!params.auto_adjust_dates && analysis.auto_matches.length > 0) {
|
|
803
|
+
recommendations.push(
|
|
804
|
+
'Consider enabling auto_adjust_dates to align YNAB dates with bank statement dates',
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (analysis.summary.unmatched_ynab > 0) {
|
|
809
|
+
recommendations.push(
|
|
810
|
+
`${analysis.summary.unmatched_ynab} transaction(s) exist in YNAB but not on the bank statement — review for duplicates or pending items`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (params.dry_run) {
|
|
815
|
+
recommendations.push('Dry run only — re-run with dry_run=false to apply these changes');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (Math.abs(balanceChangeMilli) > MONEY_EPSILON_MILLI) {
|
|
819
|
+
recommendations.push(
|
|
820
|
+
`Account balance changed by ${toMoneyValue(balanceChangeMilli, currencyCode).value_display} during reconciliation`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return recommendations;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export type { ExecutionResult as LegacyReconciliationResult };
|
|
828
|
+
|
|
829
|
+
function resolveStatementBalanceMilli(
|
|
830
|
+
balanceInfo: ReconciliationAnalysis['balance_info'],
|
|
831
|
+
provided?: number,
|
|
832
|
+
): number {
|
|
833
|
+
if (typeof provided === 'number' && Number.isFinite(provided)) {
|
|
834
|
+
return toMilli(provided);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return (
|
|
838
|
+
extractMoneyValue(balanceInfo?.target_statement) ??
|
|
839
|
+
extractMoneyValue(balanceInfo?.current_cleared) ??
|
|
840
|
+
0
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function extractMoneyValue(value: unknown): number | undefined {
|
|
845
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
846
|
+
return toMilli(value);
|
|
847
|
+
}
|
|
848
|
+
if (
|
|
849
|
+
value &&
|
|
850
|
+
typeof value === 'object' &&
|
|
851
|
+
'value_milliunits' in value &&
|
|
852
|
+
typeof (value as { value_milliunits: unknown }).value_milliunits === 'number'
|
|
853
|
+
) {
|
|
854
|
+
return (value as { value_milliunits: number }).value_milliunits;
|
|
855
|
+
}
|
|
856
|
+
return undefined;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function sortByDateDescending<T extends { date: string }>(items: T[]): T[] {
|
|
860
|
+
return [...items].sort((a, b) => compareDates(b.date, a.date));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function sortMatchesByBankDateDescending(matches: TransactionMatch[]): TransactionMatch[] {
|
|
864
|
+
return [...matches].sort((a, b) =>
|
|
865
|
+
compareDates(b.bank_transaction.date, a.bank_transaction.date),
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function compareDates(dateA: string, dateB: string): number {
|
|
870
|
+
return toChronoValue(dateA) - toChronoValue(dateB);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function toChronoValue(date: string): number {
|
|
874
|
+
const parsed = Date.parse(date);
|
|
875
|
+
if (!Number.isNaN(parsed)) {
|
|
876
|
+
return parsed;
|
|
877
|
+
}
|
|
878
|
+
const fallback = Date.parse(`${date}T00:00:00Z`);
|
|
879
|
+
return Number.isNaN(fallback) ? 0 : fallback;
|
|
880
|
+
}
|