From dd4b891fd90f6454833fa07efa70b3c91ab97fcb Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Thu, 20 Feb 2025 19:20:39 +0000 Subject: [PATCH 1/5] bump express dependencies (#4411) --- packages/sync-server/package.json | 8 +- upcoming-release-notes/4411.md | 6 ++ yarn.lock | 132 +++++++++--------------------- 3 files changed, 49 insertions(+), 97 deletions(-) create mode 100644 upcoming-release-notes/4411.md diff --git a/packages/sync-server/package.json b/packages/sync-server/package.json index 9b8f9b63e1e..13ed1a6511e 100644 --- a/packages/sync-server/package.json +++ b/packages/sync-server/package.json @@ -31,9 +31,9 @@ "cors": "^2.8.5", "date-fns": "^2.30.0", "debug": "^4.3.4", - "express": "4.20.0", + "express": "4.21.2", "express-actuator": "1.8.4", - "express-rate-limit": "^6.7.0", + "express-rate-limit": "^7.5.0", "express-response-size": "^0.0.3", "express-winston": "^4.2.0", "jws": "^4.0.0", @@ -48,8 +48,8 @@ "@types/bcrypt": "^5.0.2", "@types/better-sqlite3": "^7.6.12", "@types/cors": "^2.8.13", - "@types/express": "^4.17.17", - "@types/express-actuator": "^1.8.0", + "@types/express": "^5.0.0", + "@types/express-actuator": "^1.8.3", "@types/jest": "^29.2.3", "@types/node": "^17.0.45", "@types/supertest": "^2.0.12", diff --git a/upcoming-release-notes/4411.md b/upcoming-release-notes/4411.md new file mode 100644 index 00000000000..53458d6e487 --- /dev/null +++ b/upcoming-release-notes/4411.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [matt-fidd] +--- + +Bump express version diff --git a/yarn.lock b/yarn.lock index f0e30144b31..bd221ab1dde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -89,8 +89,8 @@ __metadata: "@types/bcrypt": "npm:^5.0.2" "@types/better-sqlite3": "npm:^7.6.12" "@types/cors": "npm:^2.8.13" - "@types/express": "npm:^4.17.17" - "@types/express-actuator": "npm:^1.8.0" + "@types/express": "npm:^5.0.0" + "@types/express-actuator": "npm:^1.8.3" "@types/jest": "npm:^29.2.3" "@types/node": "npm:^17.0.45" "@types/supertest": "npm:^2.0.12" @@ -105,9 +105,9 @@ __metadata: debug: "npm:^4.3.4" eslint: "npm:^8.33.0" eslint-plugin-prettier: "npm:^4.2.1" - express: "npm:4.20.0" + express: "npm:4.21.2" express-actuator: "npm:1.8.4" - express-rate-limit: "npm:^6.7.0" + express-rate-limit: "npm:^7.5.0" express-response-size: "npm:^0.0.3" express-winston: "npm:^4.2.0" http-proxy-middleware: "npm:^3.0.3" @@ -6257,7 +6257,7 @@ __metadata: languageName: node linkType: hard -"@types/express-actuator@npm:^1.8.0": +"@types/express-actuator@npm:^1.8.3": version: 1.8.3 resolution: "@types/express-actuator@npm:1.8.3" dependencies: @@ -6266,18 +6266,6 @@ __metadata: languageName: node linkType: hard -"@types/express-serve-static-core@npm:^4.17.33": - version: 4.19.6 - resolution: "@types/express-serve-static-core@npm:4.19.6" - dependencies: - "@types/node": "npm:*" - "@types/qs": "npm:*" - "@types/range-parser": "npm:*" - "@types/send": "npm:*" - checksum: 10/a2e00b6c5993f0dd63ada2239be81076fe0220314b9e9fde586e8946c9c09ce60f9a2dd0d74410ee2b5fd10af8c3e755a32bb3abf134533e2158142488995455 - languageName: node - linkType: hard - "@types/express-serve-static-core@npm:^5.0.0": version: 5.0.6 resolution: "@types/express-serve-static-core@npm:5.0.6" @@ -6290,7 +6278,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*": +"@types/express@npm:*, @types/express@npm:^5.0.0": version: 5.0.0 resolution: "@types/express@npm:5.0.0" dependencies: @@ -6302,18 +6290,6 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:^4.17.17": - version: 4.17.21 - resolution: "@types/express@npm:4.17.21" - dependencies: - "@types/body-parser": "npm:*" - "@types/express-serve-static-core": "npm:^4.17.33" - "@types/qs": "npm:*" - "@types/serve-static": "npm:*" - checksum: 10/7a6d26cf6f43d3151caf4fec66ea11c9d23166e4f3102edfe45a94170654a54ea08cf3103d26b3928d7ebcc24162c90488e33986b7e3a5f8941225edd5eb18c7 - languageName: node - linkType: hard - "@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11": version: 9.0.13 resolution: "@types/fs-extra@npm:9.0.13" @@ -9406,10 +9382,10 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.6.0": - version: 0.6.0 - resolution: "cookie@npm:0.6.0" - checksum: 10/c1f8f2ea7d443b9331680598b0ae4e6af18a618c37606d1bbdc75bec8361cce09fe93e727059a673f2ba24467131a9fb5a4eec76bb1b149c1b3e1ccb268dc583 +"cookie@npm:0.7.1": + version: 0.7.1 + resolution: "cookie@npm:0.7.1" + checksum: 10/aec6a6aa0781761bf55d60447d6be08861d381136a0fe94aa084fddd4f0300faa2b064df490c6798adfa1ebaef9e0af9b08a189c823e0811b8b313b3d9a03380 languageName: node linkType: hard @@ -11922,12 +11898,12 @@ __metadata: languageName: node linkType: hard -"express-rate-limit@npm:^6.7.0": - version: 6.11.2 - resolution: "express-rate-limit@npm:6.11.2" +"express-rate-limit@npm:^7.5.0": + version: 7.5.0 + resolution: "express-rate-limit@npm:7.5.0" peerDependencies: - express: ^4 || ^5 - checksum: 10/9b482cf91e030edcb88292831b2515208ddb9ec92330c54fb487c700fe8ac5000c7f5d2623ae4913b5a7fcce8e9ef65eb017e28edc96ace0ed111c16b996ccfc + express: ^4.11 || 5 || ^5.0.0-beta.1 + checksum: 10/eff34c83bf586789933a332a339b66649e2cca95c8e977d193aa8bead577d3182ac9f0e9c26f39389287539b8038890ff023f910b54ebb506a26a2ce135b92ca languageName: node linkType: hard @@ -11952,42 +11928,42 @@ __metadata: languageName: node linkType: hard -"express@npm:4.20.0": - version: 4.20.0 - resolution: "express@npm:4.20.0" +"express@npm:4.21.2": + version: 4.21.2 + resolution: "express@npm:4.21.2" dependencies: accepts: "npm:~1.3.8" array-flatten: "npm:1.1.1" body-parser: "npm:1.20.3" content-disposition: "npm:0.5.4" content-type: "npm:~1.0.4" - cookie: "npm:0.6.0" + cookie: "npm:0.7.1" cookie-signature: "npm:1.0.6" debug: "npm:2.6.9" depd: "npm:2.0.0" encodeurl: "npm:~2.0.0" escape-html: "npm:~1.0.3" etag: "npm:~1.8.1" - finalhandler: "npm:1.2.0" + finalhandler: "npm:1.3.1" fresh: "npm:0.5.2" http-errors: "npm:2.0.0" merge-descriptors: "npm:1.0.3" methods: "npm:~1.1.2" on-finished: "npm:2.4.1" parseurl: "npm:~1.3.3" - path-to-regexp: "npm:0.1.10" + path-to-regexp: "npm:0.1.12" proxy-addr: "npm:~2.0.7" - qs: "npm:6.11.0" + qs: "npm:6.13.0" range-parser: "npm:~1.2.1" safe-buffer: "npm:5.2.1" send: "npm:0.19.0" - serve-static: "npm:1.16.0" + serve-static: "npm:1.16.2" setprototypeof: "npm:1.2.0" statuses: "npm:2.0.1" type-is: "npm:~1.6.18" utils-merge: "npm:1.0.1" vary: "npm:~1.1.2" - checksum: 10/4131f566cf8f6d1611475d5ff5d0dbc5c628ad8b525aa2aa2b3da9a23a041efcce09ede10b8a31315b0258ac4e53208a009fd7669ee1eb385936a0d54adb3cde + checksum: 10/34571c442fc8c9f2c4b442d2faa10ea1175cf8559237fc6a278f5ce6254a8ffdbeb9a15d99f77c1a9f2926ab183e3b7ba560e3261f1ad4149799e3412ab66bd1 languageName: node linkType: hard @@ -12261,18 +12237,18 @@ __metadata: languageName: node linkType: hard -"finalhandler@npm:1.2.0": - version: 1.2.0 - resolution: "finalhandler@npm:1.2.0" +"finalhandler@npm:1.3.1": + version: 1.3.1 + resolution: "finalhandler@npm:1.3.1" dependencies: debug: "npm:2.6.9" - encodeurl: "npm:~1.0.2" + encodeurl: "npm:~2.0.0" escape-html: "npm:~1.0.3" on-finished: "npm:2.4.1" parseurl: "npm:~1.3.3" statuses: "npm:2.0.1" unpipe: "npm:~1.0.0" - checksum: 10/635718cb203c6d18e6b48dfbb6c54ccb08ea470e4f474ddcef38c47edcf3227feec316f886dd701235997d8af35240cae49856721ce18f539ad038665ebbf163 + checksum: 10/4babe72969b7373b5842bc9f75c3a641a4d0f8eb53af6b89fa714d4460ce03fb92b28de751d12ba415e96e7e02870c436d67412120555e2b382640535697305b languageName: node linkType: hard @@ -18184,10 +18160,10 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:0.1.10": - version: 0.1.10 - resolution: "path-to-regexp@npm:0.1.10" - checksum: 10/894e31f1b20e592732a87db61fff5b95c892a3fe430f9ab18455ebe69ee88ef86f8eb49912e261f9926fc53da9f93b46521523e33aefd9cb0a7b0d85d7096006 +"path-to-regexp@npm:0.1.12": + version: 0.1.12 + resolution: "path-to-regexp@npm:0.1.12" + checksum: 10/2e30f6a0144679c1f95c98e166b96e6acd1e72be9417830fefc8de7ac1992147eb9a4c7acaa59119fb1b3c34eec393b2129ef27e24b2054a3906fc4fb0d1398e languageName: node linkType: hard @@ -18662,15 +18638,6 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.11.0": - version: 6.11.0 - resolution: "qs@npm:6.11.0" - dependencies: - side-channel: "npm:^1.0.4" - checksum: 10/5a3bfea3e2f359ede1bfa5d2f0dbe54001aa55e40e27dc3e60fab814362d83a9b30758db057c2011b6f53a2d4e4e5150194b5bac45372652aecb3e3c0d4b256e - languageName: node - linkType: hard - "qs@npm:6.13.0": version: 6.13.0 resolution: "qs@npm:6.13.0" @@ -20079,27 +20046,6 @@ __metadata: languageName: node linkType: hard -"send@npm:0.18.0": - version: 0.18.0 - resolution: "send@npm:0.18.0" - dependencies: - debug: "npm:2.6.9" - depd: "npm:2.0.0" - destroy: "npm:1.2.0" - encodeurl: "npm:~1.0.2" - escape-html: "npm:~1.0.3" - etag: "npm:~1.8.1" - fresh: "npm:0.5.2" - http-errors: "npm:2.0.0" - mime: "npm:1.6.0" - ms: "npm:2.1.3" - on-finished: "npm:2.4.1" - range-parser: "npm:~1.2.1" - statuses: "npm:2.0.1" - checksum: 10/ec66c0ad109680ad8141d507677cfd8b4e40b9559de23191871803ed241718e99026faa46c398dcfb9250676076573bd6bfe5d0ec347f88f4b7b8533d1d391cb - languageName: node - linkType: hard - "send@npm:0.19.0": version: 0.19.0 resolution: "send@npm:0.19.0" @@ -20139,15 +20085,15 @@ __metadata: languageName: node linkType: hard -"serve-static@npm:1.16.0": - version: 1.16.0 - resolution: "serve-static@npm:1.16.0" +"serve-static@npm:1.16.2": + version: 1.16.2 + resolution: "serve-static@npm:1.16.2" dependencies: - encodeurl: "npm:~1.0.2" + encodeurl: "npm:~2.0.0" escape-html: "npm:~1.0.3" parseurl: "npm:~1.3.3" - send: "npm:0.18.0" - checksum: 10/29a01f67e8c64a359d49dd0c46bc95bb4aa99781f97845dccbf0c8cd0284c5fd79ad7fb9433a36fac4b6c58b577d3eab314a379142412413b8b5cd73be3cd551 + send: "npm:0.19.0" + checksum: 10/7fa9d9c68090f6289976b34fc13c50ac8cd7f16ae6bce08d16459300f7fc61fbc2d7ebfa02884c073ec9d6ab9e7e704c89561882bbe338e99fcacb2912fde737 languageName: node linkType: hard From c1a70377b9d16e3a49100e72c74df0256c4f160b Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Thu, 20 Feb 2025 20:33:20 +0000 Subject: [PATCH 2/5] prevent bank sync from crashing when payee not found (#4413) --- packages/loot-core/src/server/transactions/transfer.ts | 7 ++++--- upcoming-release-notes/4413.md | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 upcoming-release-notes/4413.md diff --git a/packages/loot-core/src/server/transactions/transfer.ts b/packages/loot-core/src/server/transactions/transfer.ts index d180244dd9c..4538e4c26c8 100644 --- a/packages/loot-core/src/server/transactions/transfer.ts +++ b/packages/loot-core/src/server/transactions/transfer.ts @@ -7,11 +7,12 @@ async function getPayee(acct) { async function getTransferredAccount(transaction) { if (transaction.payee) { - const { transfer_acct } = await db.first( - 'SELECT id, transfer_acct FROM v_payees WHERE id = ?', + const result = await db.first( + 'SELECT transfer_acct FROM v_payees WHERE id = ?', [transaction.payee], ); - return transfer_acct; + + return result?.transfer_acct || null; } return null; } diff --git a/upcoming-release-notes/4413.md b/upcoming-release-notes/4413.md new file mode 100644 index 00000000000..a0ab7fd769c --- /dev/null +++ b/upcoming-release-notes/4413.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [matt-fidd] +--- + +Fix crash during bank sync when the payee is not found From 18423a3167d9f1678115d0ac6e5bd62ac80fe3ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:56:37 +0000 Subject: [PATCH 3/5] Bump vitest from 1.6.0 to 1.6.1 (#4410) * Bump vitest from 1.6.0 to 1.6.1 Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.6.0 to 1.6.1. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v1.6.1/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-type: direct:development ... Signed-off-by: dependabot[bot] * note --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Matt Fiddaman --- packages/desktop-client/package.json | 2 +- packages/eslint-plugin-actual/package.json | 2 +- upcoming-release-notes/4410.md | 6 ++ yarn.lock | 82 +++++++++++----------- 4 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 upcoming-release-notes/4410.md diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 43336d5de03..5b537a79358 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -77,7 +77,7 @@ "vite": "^5.2.14", "vite-plugin-pwa": "^0.20.0", "vite-tsconfig-paths": "^4.3.2", - "vitest": "^1.6.0", + "vitest": "^1.6.1", "webpack-bundle-analyzer": "^4.10.1", "xml2js": "^0.6.2" }, diff --git a/packages/eslint-plugin-actual/package.json b/packages/eslint-plugin-actual/package.json index 44f374609e8..922dd892ce3 100644 --- a/packages/eslint-plugin-actual/package.json +++ b/packages/eslint-plugin-actual/package.json @@ -14,7 +14,7 @@ "eslint-plugin-eslint-plugin": "^5.5.1", "eslint-plugin-node": "^11.1.0", "eslint-vitest-rule-tester": "^0.7.1", - "vitest": "^1.6.0" + "vitest": "^1.6.1" }, "peerDependencies": { "eslint": ">=7" diff --git a/upcoming-release-notes/4410.md b/upcoming-release-notes/4410.md new file mode 100644 index 00000000000..b0d8594ebfa --- /dev/null +++ b/upcoming-release-notes/4410.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [matt-fidd] +--- + +Bump vitest to 1.6.1 diff --git a/yarn.lock b/yarn.lock index bd221ab1dde..928e1e1471a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -207,7 +207,7 @@ __metadata: vite: "npm:^5.2.14" vite-plugin-pwa: "npm:^0.20.0" vite-tsconfig-paths: "npm:^4.3.2" - vitest: "npm:^1.6.0" + vitest: "npm:^1.6.1" webpack-bundle-analyzer: "npm:^4.10.1" xml2js: "npm:^0.6.2" languageName: unknown @@ -7155,57 +7155,57 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/expect@npm:1.6.0" +"@vitest/expect@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/expect@npm:1.6.1" dependencies: - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" + "@vitest/spy": "npm:1.6.1" + "@vitest/utils": "npm:1.6.1" chai: "npm:^4.3.10" - checksum: 10/e82304a12e22b98c1ccea81e8f33c838561deb878588eac463164cc4f8fc0c401ace3a9e6758d9e3a6bcc01313e845e8478aaefb7548eaded04b8de12c1928f6 + checksum: 10/8aa366cc629bba4170eadebf092de9f64b46592fde9455b070cb7616dcba54f03d479e5844da0ddadecbc19a4f781a0b0d72ab2275cfccca54fd51398ac1b5d5 languageName: node linkType: hard -"@vitest/runner@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/runner@npm:1.6.0" +"@vitest/runner@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/runner@npm:1.6.1" dependencies: - "@vitest/utils": "npm:1.6.0" + "@vitest/utils": "npm:1.6.1" p-limit: "npm:^5.0.0" pathe: "npm:^1.1.1" - checksum: 10/d83a608be36dace77f91a9d15ab7753f9c5923281188a8d9cb5ccec770df9cc9ba80e5e1e3465328c7605977be0f0708610855abf5f4af037a4ede5f51a83e47 + checksum: 10/b3ee2cb7b80108c48505f71e291b7a70c819dc4c704c77d44beb722d641c5ef8e6f623e95a0259a3d0e8178d1b3559f426d03f13a3500420d1c2b8802e0128c4 languageName: node linkType: hard -"@vitest/snapshot@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/snapshot@npm:1.6.0" +"@vitest/snapshot@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/snapshot@npm:1.6.1" dependencies: magic-string: "npm:^0.30.5" pathe: "npm:^1.1.1" pretty-format: "npm:^29.7.0" - checksum: 10/0bfc26a48b45814604ff0f7276d73a047b79f3618e0b620ff54ea2de548e9603a9770963ba6ebb19f7ea1ed51001cbca58d74aa0271651d4f8e88c6233885eba + checksum: 10/f78876503ac850ac3f0a0766133cd020d83c1e665711d4e4370f5f408051b8da7a6294882c549b00a90f03c4ca25b7c41893514a7d5f9f336e6a47ad533b4cb1 languageName: node linkType: hard -"@vitest/spy@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/spy@npm:1.6.0" +"@vitest/spy@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/spy@npm:1.6.1" dependencies: tinyspy: "npm:^2.2.0" - checksum: 10/1c9698272a58aa47708bb8a1672d655fcec3285b02067cc3f70bfe76f4eda7a756eb379f8c945ccbe61677f5189aeb5ba93c2737a9d7db2de8c4e7bbdffcd372 + checksum: 10/55076c8dad8585c4d3923ec1e948e97746150d9d259a7b6045d8dd0e22babc631b22c31882c976c25b68cfbaf11d9d47fe0a77e68c3f1b8973b90c6b835becdb languageName: node linkType: hard -"@vitest/utils@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/utils@npm:1.6.0" +"@vitest/utils@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/utils@npm:1.6.1" dependencies: diff-sequences: "npm:^29.6.3" estree-walker: "npm:^3.0.3" loupe: "npm:^2.3.7" pretty-format: "npm:^29.7.0" - checksum: 10/5c5d7295ac13fcea1da039232bcc7c3fc6f070070fe12ba2ad152456af6e216e48a3ae169016cfcd5055706a00dc567b8f62e4a9b1914f069f52b8f0a3c25e60 + checksum: 10/2aa8718c5e0705f28a8e94ac00055a48789b1badda79d3578d21241557195816508677ecd5f41fe355edb204e6f817124f059c4806102e040cc8890d8691ae9a languageName: node linkType: hard @@ -11266,7 +11266,7 @@ __metadata: eslint-plugin-node: "npm:^11.1.0" eslint-vitest-rule-tester: "npm:^0.7.1" requireindex: "npm:^1.2.0" - vitest: "npm:^1.6.0" + vitest: "npm:^1.6.1" peerDependencies: eslint: ">=7" languageName: unknown @@ -22514,9 +22514,9 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:1.6.0": - version: 1.6.0 - resolution: "vite-node@npm:1.6.0" +"vite-node@npm:1.6.1": + version: 1.6.1 + resolution: "vite-node@npm:1.6.1" dependencies: cac: "npm:^6.7.14" debug: "npm:^4.3.4" @@ -22525,7 +22525,7 @@ __metadata: vite: "npm:^5.0.0" bin: vite-node: vite-node.mjs - checksum: 10/40230598c3c285cf65f407ac50b1c7753ab2dfa960de76ec1a95a0ce0ff963919d065c29ba538d9fb2fba3e0703a051d49d1ad6486001ba2f90616cc706ddc3d + checksum: 10/35f77a9efa38fae349e9c383780984deee185e0fdd107394ffe320586c9a896c59e9b098a9a9f96412adb293abf1a27671ca592b39013edadb9e0614aa817419 languageName: node linkType: hard @@ -22609,15 +22609,15 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^1.6.0": - version: 1.6.0 - resolution: "vitest@npm:1.6.0" +"vitest@npm:^1.6.1": + version: 1.6.1 + resolution: "vitest@npm:1.6.1" dependencies: - "@vitest/expect": "npm:1.6.0" - "@vitest/runner": "npm:1.6.0" - "@vitest/snapshot": "npm:1.6.0" - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" + "@vitest/expect": "npm:1.6.1" + "@vitest/runner": "npm:1.6.1" + "@vitest/snapshot": "npm:1.6.1" + "@vitest/spy": "npm:1.6.1" + "@vitest/utils": "npm:1.6.1" acorn-walk: "npm:^8.3.2" chai: "npm:^4.3.10" debug: "npm:^4.3.4" @@ -22631,13 +22631,13 @@ __metadata: tinybench: "npm:^2.5.1" tinypool: "npm:^0.8.3" vite: "npm:^5.0.0" - vite-node: "npm:1.6.0" + vite-node: "npm:1.6.1" why-is-node-running: "npm:^2.2.2" peerDependencies: "@edge-runtime/vm": "*" "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 1.6.0 - "@vitest/ui": 1.6.0 + "@vitest/browser": 1.6.1 + "@vitest/ui": 1.6.1 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -22655,7 +22655,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10/ad921a723ac9438636d37111f0b2ea5afd0ba4a7813fb75382b9f75574e10d533cf950573ebb9332a595ce197cb83593737a6b55a3b6e6eb00bddbcd0920a03e + checksum: 10/50d551be2cf6621d3844c42924595007befd73e10e9406e0fa08f1239e2c012d08f85b0a70d8656a11364a6a58930600c35a5ee00d8445071f0ab0afcacd085a languageName: node linkType: hard From 6e934e46a86258f77914be99a8dc78c59645e614 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 20 Feb 2025 16:36:27 -0800 Subject: [PATCH 4/5] Extract accounts related server handlers from `main.ts` to `server/accounts/app.ts` (#4227) * Move transaction related handlers to server/transactions folder and use the new convention * Fix lint and typecheck * Move server account handlers from main.ts to accounts/app.ts * Add accounts app to main.ts * Release notes * Fix types * Fix typecheck error * Fix types * Fix build error * Use main app --- .../components/accounts/AccountSyncCheck.tsx | 2 +- .../components/modals/CreateAccountModal.tsx | 4 +- packages/desktop-client/src/gocardless.ts | 11 +- .../src/client/accounts/accountsSlice.ts | 41 +- packages/loot-core/src/server/accounts/app.ts | 1038 +++++++++++++++++ .../loot-core/src/server/accounts/payees.ts | 7 +- .../loot-core/src/server/accounts/sync.ts | 27 +- packages/loot-core/src/server/errors.ts | 15 + packages/loot-core/src/server/main.ts | 797 +------------ packages/loot-core/src/server/post.ts | 25 +- packages/loot-core/src/types/handlers.d.ts | 4 +- packages/loot-core/src/types/models/bank.d.ts | 6 + .../src/types/models/gocardless.d.ts | 8 + .../loot-core/src/types/models/simplefin.d.ts | 8 + .../loot-core/src/types/server-handlers.d.ts | 121 -- upcoming-release-notes/4227.md | 6 + 16 files changed, 1147 insertions(+), 973 deletions(-) create mode 100644 packages/loot-core/src/server/accounts/app.ts create mode 100644 packages/loot-core/src/types/models/bank.d.ts create mode 100644 upcoming-release-notes/4227.md diff --git a/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx b/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx index cf1143ccd2b..4d3b07b3fc3 100644 --- a/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx +++ b/packages/desktop-client/src/components/accounts/AccountSyncCheck.tsx @@ -96,7 +96,7 @@ export function AccountSyncCheck() { setOpen(false); if (acc.account_id) { - authorizeBank(dispatch, { upgradingAccountId: acc.account_id }); + authorizeBank(dispatch); } }, [dispatch], diff --git a/packages/desktop-client/src/components/modals/CreateAccountModal.tsx b/packages/desktop-client/src/components/modals/CreateAccountModal.tsx index 5b554f45bf8..a753458409c 100644 --- a/packages/desktop-client/src/components/modals/CreateAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CreateAccountModal.tsx @@ -54,9 +54,7 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { if (upgradingAccountId == null) { authorizeBank(dispatch); } else { - authorizeBank(dispatch, { - upgradingAccountId, - }); + authorizeBank(dispatch); } }; diff --git a/packages/desktop-client/src/gocardless.ts b/packages/desktop-client/src/gocardless.ts index 8d868ebb920..6cbb6b905dc 100644 --- a/packages/desktop-client/src/gocardless.ts +++ b/packages/desktop-client/src/gocardless.ts @@ -5,7 +5,6 @@ import { type GoCardlessToken } from 'loot-core/types/models'; function _authorize( dispatch: AppDispatch, - upgradingAccountId: string | undefined, { onSuccess, onClose, @@ -18,7 +17,6 @@ function _authorize( pushModal('gocardless-external-msg', { onMoveExternal: async ({ institutionId }) => { const resp = await send('gocardless-create-web-token', { - upgradingAccountId, institutionId, accessValidForDays: 90, }); @@ -28,7 +26,6 @@ function _authorize( window.Actual.openURLInBrowser(link); return send('gocardless-poll-web-token', { - upgradingAccountId, requisitionId, }); }, @@ -39,17 +36,13 @@ function _authorize( ); } -export async function authorizeBank( - dispatch: AppDispatch, - { upgradingAccountId }: { upgradingAccountId?: string } = {}, -) { - _authorize(dispatch, upgradingAccountId, { +export async function authorizeBank(dispatch: AppDispatch) { + _authorize(dispatch, { onSuccess: async data => { dispatch( pushModal('select-linked-accounts', { accounts: data.accounts, requisitionId: data.id, - upgradingAccountId, syncSource: 'goCardless', }), ); diff --git a/packages/loot-core/src/client/accounts/accountsSlice.ts b/packages/loot-core/src/client/accounts/accountsSlice.ts index f1ff845f4fd..0f032115b14 100644 --- a/packages/loot-core/src/client/accounts/accountsSlice.ts +++ b/packages/loot-core/src/client/accounts/accountsSlice.ts @@ -1,7 +1,13 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { send } from '../../platform/client/fetch'; -import { type AccountEntity, type TransactionEntity } from '../../types/models'; +import { type SyncResponseWithErrors } from '../../server/accounts/app'; +import { + type SyncServerGoCardlessAccount, + type AccountEntity, + type TransactionEntity, + type SyncServerSimpleFinAccount, +} from '../../types/models'; import { addNotification } from '../actions'; import { getAccounts, @@ -80,9 +86,9 @@ export const unlinkAccount = createAppAsyncThunk( type LinkAccountPayload = { requisitionId: string; - account: unknown; - upgradingId?: AccountEntity['id']; - offBudget?: boolean; + account: SyncServerGoCardlessAccount; + upgradingId?: AccountEntity['id'] | undefined; + offBudget?: boolean | undefined; }; export const linkAccount = createAppAsyncThunk( @@ -103,9 +109,9 @@ export const linkAccount = createAppAsyncThunk( ); type LinkAccountSimpleFinPayload = { - externalAccount: unknown; - upgradingId?: AccountEntity['id']; - offBudget?: boolean; + externalAccount: SyncServerSimpleFinAccount; + upgradingId?: AccountEntity['id'] | undefined; + offBudget?: boolean | undefined; }; export const linkAccountSimpleFin = createAppAsyncThunk( @@ -124,22 +130,9 @@ export const linkAccountSimpleFin = createAppAsyncThunk( }, ); -type SyncResponse = { - errors: Array<{ - type: string; - category: string; - code: string; - message: string; - internal?: string; - }>; - newTransactions: Array; - matchedTransactions: Array; - updatedAccounts: Array; -}; - function handleSyncResponse( accountId: AccountEntity['id'], - res: SyncResponse, + res: SyncResponseWithErrors, dispatch: AppDispatch, resNewTransactions: Array, resMatchedTransactions: Array, @@ -153,7 +146,7 @@ function handleSyncResponse( if (error) { // We only want to mark the account as having problem if it // was a real syncing error. - if (error.type === 'SyncError') { + if ('type' in error && error.type === 'SyncError') { dispatch( markAccountFailed({ id: accountId, @@ -168,7 +161,7 @@ function handleSyncResponse( // Dispatch errors (if any) errors.forEach(error => { - if (error.type === 'SyncError') { + if ('type' in error && error.type === 'SyncError') { dispatch( addNotification({ type: 'error', @@ -180,7 +173,7 @@ function handleSyncResponse( addNotification({ type: 'error', message: error.message, - internal: error.internal, + internal: 'internal' in error ? error.internal : undefined, }), ); } diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts new file mode 100644 index 00000000000..c9a8ca3c377 --- /dev/null +++ b/packages/loot-core/src/server/accounts/app.ts @@ -0,0 +1,1038 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { captureException } from '../../platform/exceptions'; +import * as asyncStorage from '../../platform/server/asyncStorage'; +import * as connection from '../../platform/server/connection'; +import { isNonProductionEnvironment } from '../../shared/environment'; +import { dayFromDate } from '../../shared/months'; +import * as monthUtils from '../../shared/months'; +import { amountToInteger } from '../../shared/util'; +import { + AccountEntity, + CategoryEntity, + SyncServerGoCardlessAccount, + PayeeEntity, + TransactionEntity, + SyncServerSimpleFinAccount, +} from '../../types/models'; +import { BankEntity } from '../../types/models/bank'; +import { createApp } from '../app'; +import * as db from '../db'; +import { + APIError, + BankSyncError, + PostError, + TransactionError, +} from '../errors'; +import { app as mainApp } from '../main-app'; +import { mutator } from '../mutators'; +import { get, post } from '../post'; +import { getServer } from '../server-config'; +import { batchMessages } from '../sync'; +import { undoable, withUndo } from '../undo'; + +import * as link from './link'; +import { getStartingBalancePayee } from './payees'; +import * as bankSync from './sync'; + +export type AccountHandlers = { + 'account-update': typeof updateAccount; + 'accounts-get': typeof getAccounts; + 'account-balance': typeof getAccountBalance; + 'account-properties': typeof getAccountProperties; + 'gocardless-accounts-link': typeof linkGoCardlessAccount; + 'simplefin-accounts-link': typeof linkSimpleFinAccount; + 'account-create': typeof createAccount; + 'account-close': typeof closeAccount; + 'account-reopen': typeof reopenAccount; + 'account-move': typeof moveAccount; + 'secret-set': typeof setSecret; + 'secret-check': typeof checkSecret; + 'gocardless-poll-web-token': typeof pollGoCardlessWebToken; + 'gocardless-poll-web-token-stop': typeof stopGoCardlessWebTokenPolling; + 'gocardless-status': typeof goCardlessStatus; + 'simplefin-status': typeof simpleFinStatus; + 'simplefin-accounts': typeof simpleFinAccounts; + 'gocardless-get-banks': typeof getGoCardlessBanks; + 'gocardless-create-web-token': typeof createGoCardlessWebToken; + 'accounts-bank-sync': typeof accountsBankSync; + 'simplefin-batch-sync': typeof simpleFinBatchSync; + 'transactions-import': typeof importTransactions; + 'account-unlink': typeof unlinkAccount; +}; + +async function updateAccount({ id, name }: Pick) { + await db.update('accounts', { id, name }); + return {}; +} + +async function getAccounts() { + return db.getAccounts(); +} + +async function getAccountBalance({ + id, + cutoff, +}: { + id: string; + cutoff: string | Date; +}) { + const { balance }: { balance: number } = await db.first( + 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?', + [id, db.toDateRepr(dayFromDate(cutoff))], + ); + return balance ? balance : 0; +} + +async function getAccountProperties({ id }: { id: AccountEntity['id'] }) { + const { balance }: { balance: number } = await db.first( + 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0', + [id], + ); + const { count }: { count: number } = await db.first( + 'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0', + [id], + ); + + return { balance: balance || 0, numTransactions: count }; +} + +async function linkGoCardlessAccount({ + requisitionId, + account, + upgradingId, + offBudget = false, +}: { + requisitionId: string; + account: SyncServerGoCardlessAccount; + upgradingId?: AccountEntity['id'] | undefined; + offBudget?: boolean | undefined; +}) { + let id; + const bank = await link.findOrCreateBank(account.institution, requisitionId); + + if (upgradingId) { + const accRow: AccountEntity = await db.first( + 'SELECT * FROM accounts WHERE id = ?', + [upgradingId], + ); + id = accRow.id; + await db.update('accounts', { + id, + account_id: account.account_id, + bank: bank.id, + account_sync_source: 'goCardless', + }); + } else { + id = uuidv4(); + await db.insertWithUUID('accounts', { + id, + account_id: account.account_id, + mask: account.mask, + name: account.name, + official_name: account.official_name, + bank: bank.id, + offbudget: offBudget ? 1 : 0, + account_sync_source: 'goCardless', + }); + await db.insertPayee({ + name: '', + transfer_acct: id, + }); + } + + await bankSync.syncAccount( + undefined, + undefined, + id, + account.account_id, + bank.bank_id, + ); + + connection.send('sync-event', { + type: 'success', + tables: ['transactions'], + }); + + return 'ok'; +} + +async function linkSimpleFinAccount({ + externalAccount, + upgradingId, + offBudget = false, +}: { + externalAccount: SyncServerSimpleFinAccount; + upgradingId?: AccountEntity['id'] | undefined; + offBudget?: boolean | undefined; +}) { + let id; + + const institution = { + name: externalAccount.institution ?? 'Unknown', + }; + + const bank = await link.findOrCreateBank( + institution, + externalAccount.orgDomain ?? externalAccount.orgId, + ); + + if (upgradingId) { + const accRow: AccountEntity = await db.first( + 'SELECT * FROM accounts WHERE id = ?', + [upgradingId], + ); + id = accRow.id; + await db.update('accounts', { + id, + account_id: externalAccount.account_id, + bank: bank.id, + account_sync_source: 'simpleFin', + }); + } else { + id = uuidv4(); + await db.insertWithUUID('accounts', { + id, + account_id: externalAccount.account_id, + name: externalAccount.name, + official_name: externalAccount.name, + bank: bank.id, + offbudget: offBudget ? 1 : 0, + account_sync_source: 'simpleFin', + }); + await db.insertPayee({ + name: '', + transfer_acct: id, + }); + } + + await bankSync.syncAccount( + undefined, + undefined, + id, + externalAccount.account_id, + bank.bank_id, + ); + + await connection.send('sync-event', { + type: 'success', + tables: ['transactions'], + }); + + return 'ok'; +} + +async function createAccount({ + name, + balance = 0, + offBudget = false, + closed = false, +}: { + name: string; + balance?: number | undefined; + offBudget?: boolean | undefined; + closed?: boolean | undefined; +}) { + const id: AccountEntity['id'] = await db.insertAccount({ + name, + offbudget: offBudget ? 1 : 0, + closed: closed ? 1 : 0, + }); + + await db.insertPayee({ + name: '', + transfer_acct: id, + }); + + if (balance != null && balance !== 0) { + const payee = await getStartingBalancePayee(); + + await db.insertTransaction({ + account: id, + amount: amountToInteger(balance), + category: offBudget ? null : payee.category, + payee: payee.id, + date: monthUtils.currentDay(), + cleared: true, + starting_balance_flag: true, + }); + } + + return id; +} + +async function closeAccount({ + id, + transferAccountId, + categoryId, + forced = false, +}: { + id: AccountEntity['id']; + transferAccountId?: AccountEntity['id'] | undefined; + categoryId?: CategoryEntity['id'] | undefined; + forced?: boolean | undefined; +}) { + // Unlink the account if it's linked. This makes sure to remove it from + // bank-sync providers. (This should not be undo-able, as it mutates the + // remote server and the user will have to link the account again) + await unlinkAccount({ id }); + + return withUndo(async () => { + const account: AccountEntity = await db.first( + 'SELECT * FROM accounts WHERE id = ? AND tombstone = 0', + [id], + ); + + // Do nothing if the account doesn't exist or it's already been + // closed + if (!account || account.closed === 1) { + return; + } + + const { balance, numTransactions } = await getAccountProperties({ id }); + + // If there are no transactions, we can simply delete the account + if (numTransactions === 0) { + await db.deleteAccount({ id }); + } else if (forced) { + const rows = await db.runQuery< + Pick + >( + 'SELECT id, transfer_id FROM v_transactions WHERE account = ?', + [id], + true, + ); + + const { id: payeeId }: Pick = await db.first( + 'SELECT id FROM payees WHERE transfer_acct = ?', + [id], + ); + + await batchMessages(async () => { + // TODO: what this should really do is send a special message that + // automatically marks the tombstone value for all transactions + // within an account... or something? This is problematic + // because another client could easily add new data that + // should be marked as deleted. + + rows.forEach(row => { + if (row.transfer_id) { + db.updateTransaction({ + id: row.transfer_id, + payee: null, + transfer_id: null, + }); + } + + db.deleteTransaction({ id: row.id }); + }); + + db.deleteAccount({ id }); + db.deleteTransferPayee({ id: payeeId }); + }); + } else { + if (balance !== 0 && transferAccountId == null) { + throw APIError('balance is non-zero: transferAccountId is required'); + } + + await db.update('accounts', { id, closed: 1 }); + + // If there is a balance we need to transfer it to the specified + // account (and possibly categorize it) + if (balance !== 0 && transferAccountId) { + const { id: payeeId }: Pick = await db.first( + 'SELECT id FROM payees WHERE transfer_acct = ?', + [transferAccountId], + ); + + await mainApp.handlers['transaction-add']({ + id: uuidv4(), + payee: payeeId, + amount: -balance, + account: id, + date: monthUtils.currentDay(), + notes: 'Closing account', + category: categoryId, + }); + } + } + }); +} + +async function reopenAccount({ id }: { id: AccountEntity['id'] }) { + await db.update('accounts', { id, closed: 0 }); +} + +async function moveAccount({ + id, + targetId, +}: { + id: AccountEntity['id']; + targetId: AccountEntity['id']; +}) { + await db.moveAccount(id, targetId); +} + +async function setSecret({ + name, + value, +}: { + name: string; + value: string | null; +}) { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + try { + return await post( + serverConfig.BASE_SERVER + '/secret', + { + name, + value, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + } catch (error) { + return { + error: 'failed', + reason: error instanceof PostError ? error.reason : undefined, + }; + } +} +async function checkSecret(name: string) { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + try { + return await get(serverConfig.BASE_SERVER + '/secret/' + name, { + 'X-ACTUAL-TOKEN': userToken, + }); + } catch (error) { + console.error(error); + return { error: 'failed' }; + } +} + +let stopPolling = false; + +async function pollGoCardlessWebToken({ + requisitionId, +}: { + requisitionId: string; +}) { + const userToken = await asyncStorage.getItem('user-token'); + if (!userToken) return { error: 'unknown' }; + + const startTime = Date.now(); + stopPolling = false; + + async function getData( + cb: (error: string | null, data?: unknown | undefined) => void, + ) { + if (stopPolling) { + return; + } + + if (Date.now() - startTime >= 1000 * 60 * 10) { + cb('timeout'); + return; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + const data = await post( + serverConfig.GOCARDLESS_SERVER + '/get-accounts', + { + requisitionId, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + + if (data) { + if (data.error) { + cb('unknown'); + } else { + cb(null, data); + } + } else { + setTimeout(() => getData(cb), 3000); + } + } + + return new Promise(resolve => { + getData((error, data) => { + if (error) { + resolve({ error }); + } else { + resolve({ data }); + } + }); + }); +} + +async function stopGoCardlessWebTokenPolling() { + stopPolling = true; + return 'ok'; +} + +async function goCardlessStatus() { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + return post( + serverConfig.GOCARDLESS_SERVER + '/status', + {}, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); +} + +async function simpleFinStatus() { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + return post( + serverConfig.SIMPLEFIN_SERVER + '/status', + {}, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); +} + +async function simpleFinAccounts() { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + try { + return await post( + serverConfig.SIMPLEFIN_SERVER + '/accounts', + {}, + { + 'X-ACTUAL-TOKEN': userToken, + }, + 60000, + ); + } catch (error) { + return { error_code: 'TIMED_OUT' }; + } +} + +async function getGoCardlessBanks(country: string) { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + return post( + serverConfig.GOCARDLESS_SERVER + '/get-banks', + { country, showDemo: isNonProductionEnvironment() }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); +} + +async function createGoCardlessWebToken({ + institutionId, + accessValidForDays, +}: { + institutionId: string; + accessValidForDays: number; +}) { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + try { + return await post( + serverConfig.GOCARDLESS_SERVER + '/create-web-token', + { + institutionId, + accessValidForDays, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + } catch (error) { + console.error(error); + return { error: 'failed' }; + } +} + +type SyncResponse = { + newTransactions: Array; + matchedTransactions: Array; + updatedAccounts: Array; +}; + +function handleSyncResponse( + res: { + added: Array; + updated: Array; + }, + acct: db.DbAccount, +): SyncResponse { + const { added, updated } = res; + const newTransactions: Array = []; + const matchedTransactions: Array = []; + const updatedAccounts: Array = []; + + newTransactions.push(...added); + matchedTransactions.push(...updated); + + if (added.length > 0) { + updatedAccounts.push(acct.id); + } + + return { + newTransactions, + matchedTransactions, + updatedAccounts, + }; +} + +type SyncError = + | { + type: 'SyncError'; + accountId: AccountEntity['id']; + message: string; + category: string; + code: string; + } + | { + accountId: AccountEntity['id']; + message: string; + internal?: string; + }; + +function handleSyncError( + err: Error | PostError | BankSyncError, + acct: db.DbAccount, +): SyncError { + if (err instanceof BankSyncError) { + return { + type: 'SyncError', + accountId: acct.id, + message: 'Failed syncing account “' + acct.name + '.”', + category: err.category, + code: err.code, + }; + } + + if (err instanceof PostError && err.reason !== 'internal') { + return { + accountId: acct.id, + message: err.reason + ? err.reason + : `Account “${acct.name}” is not linked properly. Please link it again.`, + }; + } + + return { + accountId: acct.id, + message: + 'There was an internal error. Please get in touch https://actualbudget.org/contact for support.', + internal: err.stack, + }; +} + +export type SyncResponseWithErrors = SyncResponse & { + errors: SyncError[]; +}; + +async function accountsBankSync({ + ids = [], +}: { + ids: Array; +}): Promise { + const [[, userId], [, userKey]] = await asyncStorage.multiGet([ + 'user-id', + 'user-key', + ]); + + const accounts = await db.runQuery< + db.DbAccount & { bankId: db.DbBank['bank_id'] } + >( + ` + SELECT a.*, b.bank_id as bankId + FROM accounts a + LEFT JOIN banks b ON a.bank = b.id + WHERE a.tombstone = 0 AND a.closed = 0 + ${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''} + ORDER BY a.offbudget, a.sort_order + `, + ids, + true, + ); + + const errors: ReturnType[] = []; + const newTransactions: Array = []; + const matchedTransactions: Array = []; + const updatedAccounts: Array = []; + + for (const acct of accounts) { + if (acct.bankId && acct.account_id) { + try { + console.group('Bank Sync operation for account:', acct.name); + const syncResponse = await bankSync.syncAccount( + userId as string, + userKey as string, + acct.id, + acct.account_id, + acct.bankId, + ); + + const syncResponseData = handleSyncResponse(syncResponse, acct); + + newTransactions.push(...syncResponseData.newTransactions); + matchedTransactions.push(...syncResponseData.matchedTransactions); + updatedAccounts.push(...syncResponseData.updatedAccounts); + } catch (err) { + const error = err as Error; + errors.push(handleSyncError(error, acct)); + captureException({ + ...error, + message: 'Failed syncing account “' + acct.name + '.”', + } as Error); + } finally { + console.groupEnd(); + } + } + } + + if (updatedAccounts.length > 0) { + connection.send('sync-event', { + type: 'success', + tables: ['transactions'], + }); + } + + return { errors, newTransactions, matchedTransactions, updatedAccounts }; +} + +async function simpleFinBatchSync({ + ids = [], +}: { + ids: Array; +}): Promise< + Array<{ accountId: AccountEntity['id']; res: SyncResponseWithErrors }> +> { + const accounts = await db.runQuery< + db.DbAccount & { bankId: db.DbBank['bank_id'] } + >( + `SELECT a.*, b.bank_id as bankId FROM accounts a + LEFT JOIN banks b ON a.bank = b.id + WHERE + a.tombstone = 0 + AND a.closed = 0 + AND a.account_sync_source = 'simpleFin' + ${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''} + ORDER BY a.offbudget, a.sort_order`, + ids.length ? ids : [], + true, + ); + + const retVal: Array<{ + accountId: AccountEntity['id']; + res: { + errors: ReturnType[]; + newTransactions: Array; + matchedTransactions: Array; + updatedAccounts: Array; + }; + }> = []; + + console.group('Bank Sync operation for all SimpleFin accounts'); + try { + const syncResponses: Array<{ + accountId: AccountEntity['id']; + res: { + error_code: string; + error_type: string; + added: Array; + updated: Array; + }; + }> = await bankSync.simpleFinBatchSync( + accounts.map(a => ({ + id: a.id, + account_id: a.account_id || null, + })), + ); + for (const syncResponse of syncResponses) { + const account = accounts.find(a => a.id === syncResponse.accountId); + if (!account) { + console.error( + `Invalid account ID found in response: ${syncResponse.accountId}. Proceeding to the next account...`, + ); + continue; + } + + const errors: ReturnType[] = []; + const newTransactions: Array = []; + const matchedTransactions: Array = []; + const updatedAccounts: Array = []; + + if (syncResponse.res.error_code) { + errors.push( + handleSyncError( + { + type: 'BankSyncError', + reason: 'Failed syncing account “' + account.name + '.”', + category: syncResponse.res.error_type, + code: syncResponse.res.error_code, + } as BankSyncError, + account, + ), + ); + } else { + const syncResponseData = handleSyncResponse(syncResponse.res, account); + + newTransactions.push(...syncResponseData.newTransactions); + matchedTransactions.push(...syncResponseData.matchedTransactions); + updatedAccounts.push(...syncResponseData.updatedAccounts); + } + + retVal.push({ + accountId: syncResponse.accountId, + res: { errors, newTransactions, matchedTransactions, updatedAccounts }, + }); + } + } catch (err) { + const errors = []; + for (const account of accounts) { + retVal.push({ + accountId: account.id, + res: { + errors, + newTransactions: [], + matchedTransactions: [], + updatedAccounts: [], + }, + }); + const error = err as Error; + errors.push(handleSyncError(error, account)); + } + } + + if (retVal.some(a => a.res.updatedAccounts.length > 0)) { + connection.send('sync-event', { + type: 'success', + tables: ['transactions'], + }); + } + + console.groupEnd(); + + return retVal; +} + +type ImportTransactionsResult = bankSync.ReconcileTransactionsResult & { + errors: Array<{ + message: string; + }>; +}; + +async function importTransactions({ + accountId, + transactions, + isPreview, + opts, +}: { + accountId: AccountEntity['id']; + transactions: TransactionEntity[]; + isPreview: boolean; + opts?: { + defaultCleared: boolean; + }; +}): Promise { + if (typeof accountId !== 'string') { + throw APIError('transactions-import: accountId must be an id'); + } + + try { + const reconciled = await bankSync.reconcileTransactions( + accountId, + transactions, + false, + true, + isPreview, + opts?.defaultCleared, + ); + return { + errors: [], + added: reconciled.added, + updated: reconciled.updated, + updatedPreview: reconciled.updatedPreview, + }; + } catch (err) { + if (err instanceof TransactionError) { + return { + errors: [{ message: err.message }], + added: [], + updated: [], + updatedPreview: [], + }; + } + + throw err; + } +} + +async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { + const { bank: bankId }: Pick = await db.first( + 'SELECT bank FROM accounts WHERE id = ?', + [id], + ); + + if (!bankId) { + return 'ok'; + } + + const accRow: AccountEntity = await db.first( + 'SELECT * FROM accounts WHERE id = ?', + [id], + ); + + const isGoCardless = accRow.account_sync_source === 'goCardless'; + + await db.updateAccount({ + id, + account_id: null, + bank: null, + balance_current: null, + balance_available: null, + balance_limit: null, + account_sync_source: null, + }); + + if (isGoCardless === false) { + return; + } + + const { count }: { count: number } = await db.first( + 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?', + [bankId], + ); + + // No more accounts are associated with this bank. We can remove + // it from GoCardless. + const userToken = await asyncStorage.getItem('user-token'); + if (!userToken) { + return 'ok'; + } + + if (count === 0) { + const { bank_id: requisitionId }: Pick = + await db.first('SELECT bank_id FROM banks WHERE id = ?', [bankId]); + + const serverConfig = getServer(); + if (!serverConfig) { + throw new Error('Failed to get server config.'); + } + + try { + await post( + serverConfig.GOCARDLESS_SERVER + '/remove-account', + { + requisitionId, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + } catch (error) { + console.log({ error }); + } + } + + return 'ok'; +} + +export const app = createApp(); + +app.method('account-update', mutator(undoable(updateAccount))); +app.method('accounts-get', getAccounts); +app.method('account-balance', getAccountBalance); +app.method('account-properties', getAccountProperties); +app.method('gocardless-accounts-link', linkGoCardlessAccount); +app.method('simplefin-accounts-link', linkSimpleFinAccount); +app.method('account-create', mutator(undoable(createAccount))); +app.method('account-close', mutator(closeAccount)); +app.method('account-reopen', mutator(undoable(reopenAccount))); +app.method('account-move', mutator(undoable(moveAccount))); +app.method('secret-set', setSecret); +app.method('secret-check', checkSecret); +app.method('gocardless-poll-web-token', pollGoCardlessWebToken); +app.method('gocardless-poll-web-token-stop', stopGoCardlessWebTokenPolling); +app.method('gocardless-status', goCardlessStatus); +app.method('simplefin-status', simpleFinStatus); +app.method('simplefin-accounts', simpleFinAccounts); +app.method('gocardless-get-banks', getGoCardlessBanks); +app.method('gocardless-create-web-token', createGoCardlessWebToken); +app.method('accounts-bank-sync', accountsBankSync); +app.method('simplefin-batch-sync', simpleFinBatchSync); +app.method('transactions-import', mutator(undoable(importTransactions))); +app.method('account-unlink', mutator(unlinkAccount)); diff --git a/packages/loot-core/src/server/accounts/payees.ts b/packages/loot-core/src/server/accounts/payees.ts index 30eba38b5e2..bb8183f4e96 100644 --- a/packages/loot-core/src/server/accounts/payees.ts +++ b/packages/loot-core/src/server/accounts/payees.ts @@ -1,10 +1,11 @@ // @ts-strict-ignore +import { CategoryEntity, PayeeEntity } from '../../types/models'; import * as db from '../db'; export async function createPayee(description) { // Check to make sure no payee already exists with exactly the same // name - const row = await db.first( + const row: Pick = await db.first( `SELECT id FROM payees WHERE UNICODE_LOWER(name) = ? AND tombstone = 0`, [description.toLowerCase()], ); @@ -12,12 +13,12 @@ export async function createPayee(description) { if (row) { return row.id; } else { - return db.insertPayee({ name: description }); + return (await db.insertPayee({ name: description })) as PayeeEntity['id']; } } export async function getStartingBalancePayee() { - let category = await db.first(` + let category: CategoryEntity = await db.first(` SELECT * FROM categories WHERE is_income = 1 AND LOWER(name) = 'starting balances' AND diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index 168883ebb32..dfe4ab4f879 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -423,6 +423,16 @@ async function createNewPayees(payeesToCreate, addsAndUpdates) { }); } +export type ReconcileTransactionsResult = { + added: string[]; + updated: string[]; + updatedPreview: Array<{ + transaction: TransactionEntity; + existing?: TransactionEntity; + ignored?: boolean; + }>; +}; + export async function reconcileTransactions( acctId, transactions, @@ -430,7 +440,7 @@ export async function reconcileTransactions( strictIdChecking = true, isPreview = false, defaultCleared = true, -) { +): Promise { console.log('Performing transaction reconciliation'); const updated = []; @@ -857,8 +867,8 @@ async function processBankSyncDownload( } export async function syncAccount( - userId: string, - userKey: string, + userId: string | undefined, + userKey: string | undefined, id: string, acctId: string, bankId: string, @@ -890,25 +900,22 @@ export async function syncAccount( return processBankSyncDownload(download, id, acctRow, newAccount); } -export async function SimpleFinBatchSync( - accounts: { - id: AccountEntity['id']; - accountId: AccountEntity['account_id']; - }[], +export async function simpleFinBatchSync( + accounts: Array>, ) { const startDates = await Promise.all( accounts.map(async a => getAccountSyncStartDate(a.id)), ); const res = await downloadSimpleFinTransactions( - accounts.map(a => a.accountId), + accounts.map(a => a.account_id), startDates, ); const promises = []; for (let i = 0; i < accounts.length; i++) { const account = accounts[i]; - const download = res[account.accountId]; + const download = res[account.account_id]; const acctRow = await db.select('accounts', account.id); const oldestTransaction = await getAccountOldestTransaction(account.id); diff --git a/packages/loot-core/src/server/errors.ts b/packages/loot-core/src/server/errors.ts index e218c13c15f..a8da01d7452 100644 --- a/packages/loot-core/src/server/errors.ts +++ b/packages/loot-core/src/server/errors.ts @@ -12,6 +12,21 @@ export class PostError extends Error { } } +export class BankSyncError extends Error { + reason: string; + category: string; + code: string; + type: 'BankSyncError'; + + constructor(reason: string, category: string, code: string) { + super('BankSyncError: ' + reason); + this.type = 'BankSyncError'; + this.reason = reason; + this.category = category; + this.code = code; + } +} + export class HTTPError extends Error { statusCode: number; responseBody: string; diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 328e83f3730..3e25b3723c0 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -12,18 +12,14 @@ import * as connection from '../platform/server/connection'; import * as fs from '../platform/server/fs'; import { logger } from '../platform/server/log'; import * as sqlite from '../platform/server/sqlite'; -import { isNonProductionEnvironment } from '../shared/environment'; import * as monthUtils from '../shared/months'; -import { dayFromDate } from '../shared/months'; import { q } from '../shared/query'; -import { amountToInteger, stringToInteger } from '../shared/util'; +import { stringToInteger } from '../shared/util'; import { type Budget } from '../types/budget'; import { Handlers } from '../types/handlers'; import { OpenIdConfig } from '../types/models/openid'; -import * as link from './accounts/link'; -import { getStartingBalancePayee } from './accounts/payees'; -import * as bankSync from './accounts/sync'; +import { app as accountsApp } from './accounts/app'; import { app as adminApp } from './admin/app'; import { installAPI } from './api'; import { runQuery as aqlQuery } from './aql'; @@ -41,7 +37,7 @@ import { app as dashboardApp } from './dashboard/app'; import * as db from './db'; import * as mappings from './db/mappings'; import * as encryption from './encryption'; -import { APIError, TransactionError, PostError } from './errors'; +import { APIError } from './errors'; import { app as filtersApp } from './filters/app'; import { handleBudgetImport } from './importers'; import { app } from './main-app'; @@ -505,792 +501,6 @@ handlers['query'] = async function (query) { return aqlQuery(query); }; -handlers['account-update'] = mutator(async function ({ id, name }) { - return withUndo(async () => { - await db.update('accounts', { id, name }); - return {}; - }); -}); - -handlers['accounts-get'] = async function () { - return db.getAccounts(); -}; - -handlers['account-balance'] = async function ({ id, cutoff }) { - const { balance } = await db.first( - 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?', - [id, db.toDateRepr(dayFromDate(cutoff))], - ); - return balance ? balance : 0; -}; - -handlers['account-properties'] = async function ({ id }) { - const { balance } = await db.first( - 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0', - [id], - ); - const { count } = await db.first( - 'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0', - [id], - ); - - return { balance: balance || 0, numTransactions: count }; -}; - -handlers['gocardless-accounts-link'] = async function ({ - requisitionId, - account, - upgradingId, - offBudget, -}) { - let id; - const bank = await link.findOrCreateBank(account.institution, requisitionId); - - if (upgradingId) { - const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [ - upgradingId, - ]); - id = accRow.id; - await db.update('accounts', { - id, - account_id: account.account_id, - bank: bank.id, - account_sync_source: 'goCardless', - }); - } else { - id = uuidv4(); - await db.insertWithUUID('accounts', { - id, - account_id: account.account_id, - mask: account.mask, - name: account.name, - official_name: account.official_name, - bank: bank.id, - offbudget: offBudget ? 1 : 0, - account_sync_source: 'goCardless', - }); - await db.insertPayee({ - name: '', - transfer_acct: id, - }); - } - - await bankSync.syncAccount( - undefined, - undefined, - id, - account.account_id, - bank.bank_id, - ); - - connection.send('sync-event', { - type: 'success', - tables: ['transactions'], - }); - - return 'ok'; -}; - -handlers['simplefin-accounts-link'] = async function ({ - externalAccount, - upgradingId, - offBudget, -}) { - let id; - - const institution = { - name: externalAccount.institution ?? 'Unknown', - }; - - const bank = await link.findOrCreateBank( - institution, - externalAccount.orgDomain ?? externalAccount.orgId, - ); - - if (upgradingId) { - const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [ - upgradingId, - ]); - id = accRow.id; - await db.update('accounts', { - id, - account_id: externalAccount.account_id, - bank: bank.id, - account_sync_source: 'simpleFin', - }); - } else { - id = uuidv4(); - await db.insertWithUUID('accounts', { - id, - account_id: externalAccount.account_id, - name: externalAccount.name, - official_name: externalAccount.name, - bank: bank.id, - offbudget: offBudget ? 1 : 0, - account_sync_source: 'simpleFin', - }); - await db.insertPayee({ - name: '', - transfer_acct: id, - }); - } - - await bankSync.syncAccount( - undefined, - undefined, - id, - externalAccount.account_id, - bank.bank_id, - ); - - await connection.send('sync-event', { - type: 'success', - tables: ['transactions'], - }); - - return 'ok'; -}; - -handlers['account-create'] = mutator(async function ({ - name, - balance, - offBudget, - closed, -}) { - return withUndo(async () => { - const id = await db.insertAccount({ - name, - offbudget: offBudget ? 1 : 0, - closed: closed ? 1 : 0, - }); - - await db.insertPayee({ - name: '', - transfer_acct: id, - }); - - if (balance != null && balance !== 0) { - const payee = await getStartingBalancePayee(); - - await db.insertTransaction({ - account: id, - amount: amountToInteger(balance), - category: offBudget ? null : payee.category, - payee: payee.id, - date: monthUtils.currentDay(), - cleared: true, - starting_balance_flag: true, - }); - } - - return id; - }); -}); - -handlers['account-close'] = mutator(async function ({ - id, - transferAccountId, - categoryId, - forced, -}) { - // Unlink the account if it's linked. This makes sure to remove it from - // bank-sync providers. (This should not be undo-able, as it mutates the - // remote server and the user will have to link the account again) - await handlers['account-unlink']({ id }); - - return withUndo(async () => { - const account = await db.first( - 'SELECT * FROM accounts WHERE id = ? AND tombstone = 0', - [id], - ); - - // Do nothing if the account doesn't exist or it's already been - // closed - if (!account || account.closed === 1) { - return; - } - - const { balance, numTransactions } = await handlers['account-properties']({ - id, - }); - - // If there are no transactions, we can simply delete the account - if (numTransactions === 0) { - await db.deleteAccount({ id }); - } else if (forced) { - const rows = await db.runQuery< - Pick - >( - 'SELECT id, transfer_id FROM v_transactions WHERE account = ?', - [id], - true, - ); - - const { id: payeeId } = await db.first( - 'SELECT id FROM payees WHERE transfer_acct = ?', - [id], - ); - - await batchMessages(async () => { - // TODO: what this should really do is send a special message that - // automatically marks the tombstone value for all transactions - // within an account... or something? This is problematic - // because another client could easily add new data that - // should be marked as deleted. - - rows.forEach(row => { - if (row.transfer_id) { - db.updateTransaction({ - id: row.transfer_id, - payee: null, - transfer_id: null, - }); - } - - db.deleteTransaction({ id: row.id }); - }); - - db.deleteAccount({ id }); - db.deleteTransferPayee({ id: payeeId }); - }); - } else { - if (balance !== 0 && transferAccountId == null) { - throw APIError('balance is non-zero: transferAccountId is required'); - } - - await db.update('accounts', { id, closed: 1 }); - - // If there is a balance we need to transfer it to the specified - // account (and possibly categorize it) - if (balance !== 0) { - const { id: payeeId } = await db.first( - 'SELECT id FROM payees WHERE transfer_acct = ?', - [transferAccountId], - ); - - await handlers['transaction-add']({ - id: uuidv4(), - payee: payeeId, - amount: -balance, - account: id, - date: monthUtils.currentDay(), - notes: 'Closing account', - category: categoryId || null, - }); - } - } - }); -}); - -handlers['account-reopen'] = mutator(async function ({ id }) { - return withUndo(async () => { - await db.update('accounts', { id, closed: 0 }); - }); -}); - -handlers['account-move'] = mutator(async function ({ id, targetId }) { - return withUndo(async () => { - await db.moveAccount(id, targetId); - }); -}); - -let stopPolling = false; - -handlers['secret-set'] = async function ({ name, value }) { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - try { - return await post( - getServer().BASE_SERVER + '/secret', - { - name, - value, - }, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); - } catch (error) { - return { error: 'failed', reason: error.reason }; - } -}; - -handlers['secret-check'] = async function (name) { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - try { - return await get(getServer().BASE_SERVER + '/secret/' + name, { - 'X-ACTUAL-TOKEN': userToken, - }); - } catch (error) { - console.error(error); - return { error: 'failed' }; - } -}; - -handlers['gocardless-poll-web-token'] = async function ({ - upgradingAccountId, - requisitionId, -}) { - const userToken = await asyncStorage.getItem('user-token'); - if (!userToken) return { error: 'unknown' }; - - const startTime = Date.now(); - stopPolling = false; - - async function getData(cb) { - if (stopPolling) { - return; - } - - if (Date.now() - startTime >= 1000 * 60 * 10) { - cb('timeout'); - return; - } - - const data = await post( - getServer().GOCARDLESS_SERVER + '/get-accounts', - { - upgradingAccountId, - requisitionId, - }, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); - - if (data) { - if (data.error) { - cb('unknown'); - } else { - cb(null, data); - } - } else { - setTimeout(() => getData(cb), 3000); - } - } - - return new Promise(resolve => { - getData((error, data) => { - if (error) { - resolve({ error }); - } else { - resolve({ data }); - } - }); - }); -}; - -handlers['gocardless-status'] = async function () { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - return post( - getServer().GOCARDLESS_SERVER + '/status', - {}, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); -}; - -handlers['simplefin-status'] = async function () { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - return post( - getServer().SIMPLEFIN_SERVER + '/status', - {}, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); -}; - -handlers['simplefin-accounts'] = async function () { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - try { - return await post( - getServer().SIMPLEFIN_SERVER + '/accounts', - {}, - { - 'X-ACTUAL-TOKEN': userToken, - }, - 60000, - ); - } catch (error) { - return { error_code: 'TIMED_OUT' }; - } -}; - -handlers['gocardless-get-banks'] = async function (country) { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - return post( - getServer().GOCARDLESS_SERVER + '/get-banks', - { country, showDemo: isNonProductionEnvironment() }, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); -}; - -handlers['gocardless-poll-web-token-stop'] = async function () { - stopPolling = true; - return 'ok'; -}; - -handlers['gocardless-create-web-token'] = async function ({ - upgradingAccountId, - institutionId, - accessValidForDays, -}) { - const userToken = await asyncStorage.getItem('user-token'); - - if (!userToken) { - return { error: 'unauthorized' }; - } - - try { - return await post( - getServer().GOCARDLESS_SERVER + '/create-web-token', - { - upgradingAccountId, - institutionId, - accessValidForDays, - }, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); - } catch (error) { - console.error(error); - return { error: 'failed' }; - } -}; - -async function handleSyncResponse( - res, - acct, - newTransactions, - matchedTransactions, - updatedAccounts, -) { - const { added, updated } = res; - - newTransactions.push(...added); - matchedTransactions.push(...updated); - - if (added.length > 0) { - updatedAccounts.push(acct.id); - } - - const ts = new Date().getTime().toString(); - const id = acct.id; - await db.runQuery(`UPDATE accounts SET last_sync = ? WHERE id = ?`, [ts, id]); -} - -function handleSyncError(err, acct) { - if (err.type === 'BankSyncError') { - return { - type: 'SyncError', - accountId: acct.id, - message: 'Failed syncing account “' + acct.name + '.”', - category: err.category, - code: err.code, - }; - } - - if (err instanceof PostError && err.reason !== 'internal') { - return { - accountId: acct.id, - message: err.reason - ? err.reason - : `Account “${acct.name}” is not linked properly. Please link it again.`, - }; - } - - return { - accountId: acct.id, - message: - 'There was an internal error. Please get in touch https://actualbudget.org/contact for support.', - internal: err.stack, - }; -} - -handlers['accounts-bank-sync'] = async function ({ ids = [] }) { - const [[, userId], [, userKey]] = await asyncStorage.multiGet([ - 'user-id', - 'user-key', - ] as const); - - const accounts = await db.runQuery< - db.DbAccount & { bankId: db.DbBank['bank_id'] } - >( - ` - SELECT a.*, b.bank_id as bankId - FROM accounts a - LEFT JOIN banks b ON a.bank = b.id - WHERE a.tombstone = 0 AND a.closed = 0 - ${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''} - ORDER BY a.offbudget, a.sort_order - `, - ids, - true, - ); - - const errors = []; - const newTransactions = []; - const matchedTransactions = []; - const updatedAccounts = []; - - for (let i = 0; i < accounts.length; i++) { - const acct = accounts[i]; - if (acct.bankId) { - try { - console.group('Bank Sync operation for account:', acct.name); - const res = await bankSync.syncAccount( - userId, - userKey, - acct.id, - acct.account_id, - acct.bankId, - ); - - await handleSyncResponse( - res, - acct, - newTransactions, - matchedTransactions, - updatedAccounts, - ); - } catch (err) { - errors.push(handleSyncError(err, acct)); - err.message = 'Failed syncing account “' + acct.name + '.”'; - captureException(err); - } finally { - console.groupEnd(); - } - } - } - - if (updatedAccounts.length > 0) { - connection.send('sync-event', { - type: 'success', - tables: ['transactions'], - }); - } - - return { errors, newTransactions, matchedTransactions, updatedAccounts }; -}; - -handlers['simplefin-batch-sync'] = async function ({ ids = [] }) { - const accounts = await db.runQuery< - db.DbAccount & { bankId: db.DbBank['bank_id'] } - >( - `SELECT a.*, b.bank_id as bankId FROM accounts a - LEFT JOIN banks b ON a.bank = b.id - WHERE - a.tombstone = 0 - AND a.closed = 0 - AND a.account_sync_source = 'simpleFin' - ${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''} - ORDER BY a.offbudget, a.sort_order`, - ids.length ? ids : [], - true, - ); - - const retVal = []; - - console.group('Bank Sync operation for all SimpleFin accounts'); - try { - const res = await bankSync.SimpleFinBatchSync( - accounts.map(a => ({ - id: a.id, - accountId: a.account_id, - })), - ); - for (const account of res) { - const errors = []; - const newTransactions = []; - const matchedTransactions = []; - const updatedAccounts = []; - - if (account.res.error_code) { - errors.push( - handleSyncError( - { - type: 'BankSyncError', - category: account.res.error_type, - code: account.res.error_code, - }, - accounts.find(a => a.id === account.accountId), - ), - ); - } else { - await handleSyncResponse( - account.res, - accounts.find(a => a.id === account.accountId), - newTransactions, - matchedTransactions, - updatedAccounts, - ); - } - - retVal.push({ - accountId: account.accountId, - res: { errors, newTransactions, matchedTransactions, updatedAccounts }, - }); - } - } catch (err) { - const errors = []; - for (const account of accounts) { - retVal.push({ - accountId: account.id, - res: { - errors, - newTransactions: [], - matchedTransactions: [], - updatedAccounts: [], - }, - }); - errors.push(handleSyncError(err, account)); - } - } - - if (retVal.some(a => a.res.updatedAccounts.length > 0)) { - connection.send('sync-event', { - type: 'success', - tables: ['transactions'], - }); - } - - console.groupEnd(); - - return retVal; -}; - -handlers['transactions-import'] = mutator(function ({ - accountId, - transactions, - isPreview, - opts, -}) { - return withUndo(async () => { - if (typeof accountId !== 'string') { - throw APIError('transactions-import: accountId must be an id'); - } - - try { - return await bankSync.reconcileTransactions( - accountId, - transactions, - false, - true, - isPreview, - opts?.defaultCleared, - ); - } catch (err) { - if (err instanceof TransactionError) { - return { - errors: [{ message: err.message }], - added: [], - updated: [], - updatedPreview: [], - }; - } - - throw err; - } - }); -}); - -handlers['account-unlink'] = mutator(async function ({ id }) { - const { bank: bankId } = await db.first( - 'SELECT bank FROM accounts WHERE id = ?', - [id], - ); - - if (!bankId) { - return 'ok'; - } - - const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [id]); - - const isGoCardless = accRow.account_sync_source === 'goCardless'; - - await db.updateAccount({ - id, - account_id: null, - bank: null, - balance_current: null, - balance_available: null, - balance_limit: null, - account_sync_source: null, - }); - - if (isGoCardless === false) { - return; - } - - const { count } = await db.first( - 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?', - [bankId], - ); - - // No more accounts are associated with this bank. We can remove - // it from GoCardless. - const userToken = await asyncStorage.getItem('user-token'); - if (!userToken) { - return 'ok'; - } - - if (count === 0) { - const { bank_id: requisitionId } = await db.first( - 'SELECT bank_id FROM banks WHERE id = ?', - [bankId], - ); - try { - await post( - getServer().GOCARDLESS_SERVER + '/remove-account', - { - requisitionId, - }, - { - 'X-ACTUAL-TOKEN': userToken, - }, - ); - } catch (error) { - console.log({ error }); - } - } - - return 'ok'; -}); - handlers['save-global-prefs'] = async function (prefs) { if ('maxMonths' in prefs) { await asyncStorage.setItem('max-months', '' + prefs.maxMonths); @@ -2393,6 +1603,7 @@ app.combine( rulesApp, adminApp, transactionsApp, + accountsApp, ); function getDefaultDocumentDir() { diff --git a/packages/loot-core/src/server/post.ts b/packages/loot-core/src/server/post.ts index b521d87a147..90533596be7 100644 --- a/packages/loot-core/src/server/post.ts +++ b/packages/loot-core/src/server/post.ts @@ -4,7 +4,7 @@ import { fetch } from '../platform/server/fetch'; import { PostError } from './errors'; import * as Platform from './platform'; -function throwIfNot200(res, text) { +function throwIfNot200(res: Response, text: string) { if (res.status !== 200) { if (res.status === 500) { throw new PostError(res.status === 500 ? 'internal' : text); @@ -32,9 +32,14 @@ function throwIfNot200(res, text) { } } -export async function post(url, data, headers = {}, timeout = null) { - let text; - let res; +export async function post( + url: RequestInfo, + data: unknown, + headers = {}, + timeout: number | null = null, +) { + let text: string; + let res: Response; try { const controller = new AbortController(); @@ -57,14 +62,16 @@ export async function post(url, data, headers = {}, timeout = null) { throwIfNot200(res, text); + let responseData; + try { - res = JSON.parse(text); + responseData = JSON.parse(text); } catch (err) { // Something seriously went wrong. TODO handle errors throw new PostError('parse-json', { meta: text }); } - if (res.status !== 'ok') { + if (responseData.status !== 'ok') { console.log( 'API call failed: ' + url + @@ -74,10 +81,12 @@ export async function post(url, data, headers = {}, timeout = null) { JSON.stringify(res, null, 2), ); - throw new PostError(res.description || res.reason || 'unknown'); + throw new PostError( + responseData.description || responseData.reason || 'unknown', + ); } - return res.data; + return responseData.data; } export async function del(url, data, headers = {}, timeout = null) { diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index 5fc1d912a75..1b5057bbcf2 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -1,3 +1,4 @@ +import type { AccountHandlers } from '../server/accounts/app'; import type { AdminHandlers } from '../server/admin/types/handlers'; import type { BudgetHandlers } from '../server/budget/types/handlers'; import type { DashboardHandlers } from '../server/dashboard/types/handlers'; @@ -26,6 +27,7 @@ export interface Handlers SchedulesHandlers, TransactionHandlers, AdminHandlers, - ToolsHandlers {} + ToolsHandlers, + AccountHandlers {} export type HandlerFunctions = Handlers[keyof Handlers]; diff --git a/packages/loot-core/src/types/models/bank.d.ts b/packages/loot-core/src/types/models/bank.d.ts new file mode 100644 index 00000000000..6630e5670f3 --- /dev/null +++ b/packages/loot-core/src/types/models/bank.d.ts @@ -0,0 +1,6 @@ +export type BankEntity = { + id: string; + name: string; + bank_id: string; + tombstone: 0 | 1; +}; diff --git a/packages/loot-core/src/types/models/gocardless.d.ts b/packages/loot-core/src/types/models/gocardless.d.ts index b1003e88369..365c3314c97 100644 --- a/packages/loot-core/src/types/models/gocardless.d.ts +++ b/packages/loot-core/src/types/models/gocardless.d.ts @@ -73,3 +73,11 @@ export type GoCardlessTransaction = { valueDate?: string; valueDateTime?: string; }; + +export type SyncServerGoCardlessAccount = { + institution: string; + account_id: string; + mask: string; + name: string; + official_name: string; +}; diff --git a/packages/loot-core/src/types/models/simplefin.d.ts b/packages/loot-core/src/types/models/simplefin.d.ts index cd76cb7ba14..31ff188fb98 100644 --- a/packages/loot-core/src/types/models/simplefin.d.ts +++ b/packages/loot-core/src/types/models/simplefin.d.ts @@ -17,3 +17,11 @@ export type SimpleFinAccount = { export interface SimpleFinBatchSyncResponse { [accountId: AccountEntity['account_id']]: BankSyncResponse; } + +export type SyncServerSimpleFinAccount = { + account_id: string; + institution?: string; + orgDomain?: string; + orgId?: string; + name: string; +}; diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 039ec849e00..80f1001502e 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -1,5 +1,3 @@ -import { ImportTransactionsOpts } from '@actual-app/api'; - import { Backup } from '../server/backups'; import { RemoteFile } from '../server/cloud-storage'; import { Node as SpreadsheetNode } from '../server/spreadsheet/spreadsheet'; @@ -7,12 +5,8 @@ import { Message } from '../server/sync'; import { Budget } from './budget'; import { - AccountEntity, CategoryEntity, CategoryGroupEntity, - GoCardlessToken, - GoCardlessInstitution, - SimpleFinAccount, RuleEntity, PayeeEntity, } from './models'; @@ -124,121 +118,6 @@ export interface ServerHandlers { // eslint-disable-next-line @typescript-eslint/no-explicit-any query: (query: Query) => Promise<{ data: any; dependencies: string[] }>; - 'account-update': (arg: { id; name }) => Promise; - - 'accounts-get': () => Promise; - - 'account-properties': (arg: { - id; - }) => Promise<{ balance: number; numTransactions: number }>; - - 'gocardless-accounts-link': (arg: { - requisitionId; - account; - upgradingId; - offBudget; - }) => Promise<'ok'>; - - 'simplefin-accounts-link': (arg: { - externalAccount; - upgradingId; - offBudget; - }) => Promise<'ok'>; - - 'account-create': (arg: { - name: string; - balance?: number; - offBudget?: boolean; - closed?: 0 | 1; - }) => Promise; - - 'account-close': (arg: { - id; - transferAccountId?; - categoryId?; - forced?; - }) => Promise; - - 'account-reopen': (arg: { id }) => Promise; - - 'account-move': (arg: { id; targetId }) => Promise; - - 'secret-set': (arg: { - name: string; - value: string | null; - }) => Promise<{ error?: string; reason?: string }>; - 'secret-check': (arg: string) => Promise; - - 'gocardless-poll-web-token': (arg: { - upgradingAccountId?: string | undefined; - requisitionId: string; - }) => Promise< - { error: 'unknown' } | { error: 'timeout' } | { data: GoCardlessToken } - >; - - 'gocardless-status': () => Promise<{ configured: boolean }>; - - 'simplefin-status': () => Promise<{ configured: boolean }>; - - 'simplefin-accounts': () => Promise<{ - accounts?: SimpleFinAccount[]; - error_code?: string; - reason?: string; - }>; - - 'simplefin-batch-sync': ({ ids }: { ids: string[] }) => Promise< - { - accountId: string; - res: { - errors; - newTransactions; - matchedTransactions; - updatedAccounts; - }; - }[] - >; - - 'gocardless-get-banks': (country: string) => Promise<{ - data: GoCardlessInstitution[]; - error?: { reason: string }; - }>; - - 'gocardless-poll-web-token-stop': () => Promise<'ok'>; - - 'gocardless-create-web-token': (arg: { - upgradingAccountId?: string | undefined; - institutionId: string; - accessValidForDays: number; - }) => Promise< - | { - requisitionId: string; - link: string; - } - | { error: 'unauthorized' } - | { error: 'failed' } - >; - - 'accounts-bank-sync': (arg: { ids?: AccountEntity['id'][] }) => Promise<{ - errors; - newTransactions; - matchedTransactions; - updatedAccounts; - }>; - - 'transactions-import': (arg: { - accountId; - transactions; - isPreview; - opts?: ImportTransactionsOpts; - }) => Promise<{ - errors?: { message: string }[]; - added; - updated; - updatedPreview; - }>; - - 'account-unlink': (arg: { id }) => Promise<'ok'>; - 'save-global-prefs': (prefs) => Promise<'ok'>; 'load-global-prefs': () => Promise; diff --git a/upcoming-release-notes/4227.md b/upcoming-release-notes/4227.md new file mode 100644 index 00000000000..3ce168ffa10 --- /dev/null +++ b/upcoming-release-notes/4227.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Move accounts related server handlers from main.ts to server/accounts/app.ts From 5a79beadfa10b85faa5d2fc163bf261fbe764297 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 20 Feb 2025 16:38:03 -0800 Subject: [PATCH 5/5] Fix react-hooks/exhaustive-deps error on TransactionList.jsx (#4272) * Fix react-hooks/exhaustive-deps error on TransactionList.jsx * Release notes * Remove useLayoutEffect * Add back the useLayoutEffect --- eslint.config.mjs | 1 - .../transactions/TransactionList.jsx | 149 +++++++++++------- upcoming-release-notes/4272.md | 6 + 3 files changed, 99 insertions(+), 57 deletions(-) create mode 100644 upcoming-release-notes/4272.md diff --git a/eslint.config.mjs b/eslint.config.mjs index 96509d58c46..1396a4fce40 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -790,7 +790,6 @@ export default [ 'packages/desktop-client/src/components/sidebar/Tools.tsx', 'packages/desktop-client/src/components/sort.tsx', 'packages/desktop-client/src/components/spreadsheet/useSheetValue.ts', - 'packages/desktop-client/src/components/transactions/TransactionList.jsx', ], rules: { diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index 81bafe51cd8..3bbf25cc328 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -100,59 +100,84 @@ export function TransactionList({ onMakeAsNonSplitTransactions, }) { const dispatch = useDispatch(); - const transactionsLatest = useRef(); const navigate = useNavigate(); const [learnCategories = 'true'] = useSyncedPref('learn-categories'); const isLearnCategoriesEnabled = String(learnCategories) === 'true'; + const transactionsLatest = useRef(); useLayoutEffect(() => { transactionsLatest.current = transactions; }, [transactions]); - const onAdd = useCallback(async newTransactions => { - newTransactions = realizeTempTransactions(newTransactions); + const onAdd = useCallback( + async newTransactions => { + newTransactions = realizeTempTransactions(newTransactions); - await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled); - onRefetch(); - }, []); + await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled); + onRefetch(); + }, + [isLearnCategoriesEnabled, onRefetch], + ); - const onSave = useCallback(async transaction => { - const changes = updateTransaction(transactionsLatest.current, transaction); - transactionsLatest.current = changes.data; + const onSave = useCallback( + async transaction => { + const changes = updateTransaction( + transactionsLatest.current, + transaction, + ); + transactionsLatest.current = changes.data; - if (changes.diff.updated.length > 0) { - const dateChanged = !!changes.diff.updated[0].date; - if (dateChanged) { - // Make sure it stays at the top of the list of transactions - // for that date - changes.diff.updated[0].sort_order = Date.now(); - await saveDiff(changes.diff, isLearnCategoriesEnabled); - onRefetch(); - } else { - onChange(changes.newTransaction, changes.data); - saveDiffAndApply( - changes.diff, - changes, - onChange, - isLearnCategoriesEnabled, - ); + if (changes.diff.updated.length > 0) { + const dateChanged = !!changes.diff.updated[0].date; + if (dateChanged) { + // Make sure it stays at the top of the list of transactions + // for that date + changes.diff.updated[0].sort_order = Date.now(); + await saveDiff(changes.diff, isLearnCategoriesEnabled); + onRefetch(); + } else { + onChange(changes.newTransaction, changes.data); + saveDiffAndApply( + changes.diff, + changes, + onChange, + isLearnCategoriesEnabled, + ); + } } - } - }, []); + }, + [isLearnCategoriesEnabled, onChange, onRefetch], + ); - const onAddSplit = useCallback(id => { - const changes = addSplitTransaction(transactionsLatest.current, id); - onChange(changes.newTransaction, changes.data); - saveDiffAndApply(changes.diff, changes, onChange, isLearnCategoriesEnabled); - return changes.diff.added[0].id; - }, []); + const onAddSplit = useCallback( + id => { + const changes = addSplitTransaction(transactionsLatest.current, id); + onChange(changes.newTransaction, changes.data); + saveDiffAndApply( + changes.diff, + changes, + onChange, + isLearnCategoriesEnabled, + ); + return changes.diff.added[0].id; + }, + [isLearnCategoriesEnabled, onChange], + ); - const onSplit = useCallback(id => { - const changes = splitTransaction(transactionsLatest.current, id); - onChange(changes.newTransaction, changes.data); - saveDiffAndApply(changes.diff, changes, onChange, isLearnCategoriesEnabled); - return changes.diff.added[0].id; - }, []); + const onSplit = useCallback( + id => { + const changes = splitTransaction(transactionsLatest.current, id); + onChange(changes.newTransaction, changes.data); + saveDiffAndApply( + changes.diff, + changes, + onChange, + isLearnCategoriesEnabled, + ); + return changes.diff.added[0].id; + }, + [isLearnCategoriesEnabled, onChange], + ); const onApplyRules = useCallback( async (transaction, updatedFieldName = null) => { @@ -193,26 +218,38 @@ export function TransactionList({ [], ); - const onManagePayees = useCallback(id => { - navigate('/payees', { state: { selectedPayee: id } }); - }); + const onManagePayees = useCallback( + id => { + navigate('/payees', { state: { selectedPayee: id } }); + }, + [navigate], + ); - const onNavigateToTransferAccount = useCallback(accountId => { - navigate(`/accounts/${accountId}`); - }); + const onNavigateToTransferAccount = useCallback( + accountId => { + navigate(`/accounts/${accountId}`); + }, + [navigate], + ); - const onNavigateToSchedule = useCallback(scheduleId => { - dispatch(pushModal('schedule-edit', { id: scheduleId })); - }); + const onNavigateToSchedule = useCallback( + scheduleId => { + dispatch(pushModal('schedule-edit', { id: scheduleId })); + }, + [dispatch], + ); - const onNotesTagClick = useCallback(tag => { - onApplyFilter({ - field: 'notes', - op: 'hasTags', - value: tag, - type: 'string', - }); - }); + const onNotesTagClick = useCallback( + tag => { + onApplyFilter({ + field: 'notes', + op: 'hasTags', + value: tag, + type: 'string', + }); + }, + [onApplyFilter], + ); return (