@actual-app/core 26.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/.swcrc +11 -0
  2. package/bin/build-browser +40 -0
  3. package/bin/copy-migrations +9 -0
  4. package/db.sqlite +0 -0
  5. package/default-db.sqlite +0 -0
  6. package/migrations/.force-copy-windows +0 -0
  7. package/migrations/1548957970627_remove-db-version.sql +5 -0
  8. package/migrations/1550601598648_payees.sql +23 -0
  9. package/migrations/1555786194328_remove_category_group_unique.sql +25 -0
  10. package/migrations/1561751833510_indexes.sql +7 -0
  11. package/migrations/1567699552727_budget.sql +38 -0
  12. package/migrations/1582384163573_cleared.sql +6 -0
  13. package/migrations/1597756566448_rules.sql +10 -0
  14. package/migrations/1608652596043_parent_field.sql +13 -0
  15. package/migrations/1608652596044_trans_views.sql +56 -0
  16. package/migrations/1612625548236_optimize.sql +7 -0
  17. package/migrations/1614782639336_trans_views2.sql +33 -0
  18. package/migrations/1615745967948_meta.sql +10 -0
  19. package/migrations/1616167010796_accounts_order.sql +5 -0
  20. package/migrations/1618975177358_schedules.sql +28 -0
  21. package/migrations/1632571489012_remove_cache.js +136 -0
  22. package/migrations/1679728867040_rules_conditions.sql +5 -0
  23. package/migrations/1681115033845_add_schedule_name.sql +5 -0
  24. package/migrations/1682974838138_remove_payee_rules.sql +5 -0
  25. package/migrations/1685007876842_add_category_hidden.sql +6 -0
  26. package/migrations/1686139660866_remove_account_type.sql +5 -0
  27. package/migrations/1688749527273_transaction_filters.sql +10 -0
  28. package/migrations/1688841238000_add_account_type.sql +5 -0
  29. package/migrations/1691233396000_add_schedule_next_date_tombstone.sql +5 -0
  30. package/migrations/1694438752000_add_goal_targets.sql +7 -0
  31. package/migrations/1697046240000_add_reconciled.sql +5 -0
  32. package/migrations/1704572023730_add_account_sync_source.sql +5 -0
  33. package/migrations/1704572023731_add_missing_goCardless_sync_source.sql +9 -0
  34. package/migrations/1707267033000_reports.sql +28 -0
  35. package/migrations/1712784523000_unhide_input_group.sql +8 -0
  36. package/migrations/1716359441000_include_current.sql +5 -0
  37. package/migrations/1720310586000_link_transfer_schedules.sql +19 -0
  38. package/migrations/1720664867241_add_payee_favorite.sql +5 -0
  39. package/migrations/1720665000000_goal_context.sql +6 -0
  40. package/migrations/1722717601000_reports_move_selected_categories.js +55 -0
  41. package/migrations/1722804019000_create_dashboard_table.js +69 -0
  42. package/migrations/1723665565000_prefs.js +59 -0
  43. package/migrations/1730744182000_fix_dashboard_table.sql +7 -0
  44. package/migrations/1736640000000_custom_report_sorting.sql +7 -0
  45. package/migrations/1737158400000_add_learn_categories_to_payees.sql +5 -0
  46. package/migrations/1738491452000_sorting_rename.sql +13 -0
  47. package/migrations/1739139550000_bank_sync_page.sql +7 -0
  48. package/migrations/1740506588539_add_last_reconciled_at.sql +5 -0
  49. package/migrations/1745425408000_update_budgetType_pref.sql +7 -0
  50. package/migrations/1749799110000_add_tags.sql +10 -0
  51. package/migrations/1749799110001_tags_tombstone.sql +5 -0
  52. package/migrations/1754611200000_add_category_template_settings.sql +5 -0
  53. package/migrations/1759260219000_add_trim_interval_report_setting.sql +6 -0
  54. package/migrations/1759842823172_add_isGlobal_to_preferences.sql +1 -0
  55. package/migrations/1762178745667_rename_csv_skip_lines_pref.sql +8 -0
  56. package/migrations/1765518577215_multiple_dashboards.js +30 -0
  57. package/migrations/1768872504000_add_payee_locations.sql +21 -0
  58. package/package.json +128 -0
  59. package/src/mocks/arbitrary-schema.ts +162 -0
  60. package/src/mocks/budget.ts +901 -0
  61. package/src/mocks/files/8859-1.qfx +63 -0
  62. package/src/mocks/files/best.data-ever$.QFX +124 -0
  63. package/src/mocks/files/big.data.QiF +91 -0
  64. package/src/mocks/files/budgets/.commit-to-git +0 -0
  65. package/src/mocks/files/camt/camt.053.payee-memo.xml +127 -0
  66. package/src/mocks/files/camt/camt.053.xml +463 -0
  67. package/src/mocks/files/credit-card.ofx +11 -0
  68. package/src/mocks/files/data-multi-decimal.ofx +64 -0
  69. package/src/mocks/files/data-payee-memo.ofx +75 -0
  70. package/src/mocks/files/data-payee-memo.qif +17 -0
  71. package/src/mocks/files/data.ofx +124 -0
  72. package/src/mocks/files/data.qfx +124 -0
  73. package/src/mocks/files/data.qif +91 -0
  74. package/src/mocks/files/default-budget-template/db.sqlite +0 -0
  75. package/src/mocks/files/default-budget-template/metadata.json +6 -0
  76. package/src/mocks/files/html-vals.qfx +17 -0
  77. package/src/mocks/index.ts +221 -0
  78. package/src/mocks/migrations/1508717984291_up_add-poop.sql +13 -0
  79. package/src/mocks/migrations/1508718036311_up_modify-poop.sql +2 -0
  80. package/src/mocks/migrations/1508727787513_remove-is_income.sql +15 -0
  81. package/src/mocks/random.ts +16 -0
  82. package/src/mocks/setup.ts +180 -0
  83. package/src/mocks/spreadsheet.ts +101 -0
  84. package/src/mocks/util.ts +82 -0
  85. package/src/platform/client/connection/README.md +3 -0
  86. package/src/platform/client/connection/__mocks__/index.ts +67 -0
  87. package/src/platform/client/connection/index-types.ts +95 -0
  88. package/src/platform/client/connection/index.browser.ts +213 -0
  89. package/src/platform/client/connection/index.ts +155 -0
  90. package/src/platform/client/undo/index.ts +59 -0
  91. package/src/platform/exceptions/__mocks__/index.ts +7 -0
  92. package/src/platform/exceptions/index.ts +9 -0
  93. package/src/platform/server/asyncStorage/__mocks__/index.ts +50 -0
  94. package/src/platform/server/asyncStorage/index-types.ts +35 -0
  95. package/src/platform/server/asyncStorage/index.api.ts +2 -0
  96. package/src/platform/server/asyncStorage/index.electron.ts +88 -0
  97. package/src/platform/server/asyncStorage/index.ts +126 -0
  98. package/src/platform/server/connection/README.md +3 -0
  99. package/src/platform/server/connection/__mocks__/index.ts +15 -0
  100. package/src/platform/server/connection/index-types.ts +20 -0
  101. package/src/platform/server/connection/index.api.ts +13 -0
  102. package/src/platform/server/connection/index.electron.ts +102 -0
  103. package/src/platform/server/connection/index.ts +154 -0
  104. package/src/platform/server/fetch/__mocks__/index.ts +3 -0
  105. package/src/platform/server/fetch/index.api.ts +1 -0
  106. package/src/platform/server/fetch/index.electron.ts +18 -0
  107. package/src/platform/server/fetch/index.ts +20 -0
  108. package/src/platform/server/fs/index.api.ts +198 -0
  109. package/src/platform/server/fs/index.electron.ts +208 -0
  110. package/src/platform/server/fs/index.test.ts +117 -0
  111. package/src/platform/server/fs/index.ts +416 -0
  112. package/src/platform/server/fs/path-join.api.ts +1 -0
  113. package/src/platform/server/fs/path-join.electron.ts +1 -0
  114. package/src/platform/server/fs/path-join.ts +97 -0
  115. package/src/platform/server/fs/shared.ts +33 -0
  116. package/src/platform/server/indexeddb/index.ts +115 -0
  117. package/src/platform/server/log/index.ts +43 -0
  118. package/src/platform/server/sqlite/index.api.ts +2 -0
  119. package/src/platform/server/sqlite/index.electron.ts +134 -0
  120. package/src/platform/server/sqlite/index.test.ts +108 -0
  121. package/src/platform/server/sqlite/index.ts +241 -0
  122. package/src/platform/server/sqlite/normalise.ts +9 -0
  123. package/src/platform/server/sqlite/unicodeLike.test.ts +58 -0
  124. package/src/platform/server/sqlite/unicodeLike.ts +31 -0
  125. package/src/server/__mocks__/post.ts +9 -0
  126. package/src/server/__snapshots__/main.test.ts.snap +199 -0
  127. package/src/server/__snapshots__/sheet.test.ts.snap +9 -0
  128. package/src/server/accounts/__snapshots__/sync.test.ts.snap +136 -0
  129. package/src/server/accounts/app-bank-sync.test.ts +136 -0
  130. package/src/server/accounts/app.ts +1294 -0
  131. package/src/server/accounts/link.ts +25 -0
  132. package/src/server/accounts/payees.ts +36 -0
  133. package/src/server/accounts/sync.test.ts +679 -0
  134. package/src/server/accounts/sync.ts +1168 -0
  135. package/src/server/accounts/title/index.ts +60 -0
  136. package/src/server/accounts/title/lower-case.ts +93 -0
  137. package/src/server/accounts/title/specials.ts +21 -0
  138. package/src/server/admin/app.ts +241 -0
  139. package/src/server/api-models.ts +244 -0
  140. package/src/server/api.test.ts +36 -0
  141. package/src/server/api.ts +1030 -0
  142. package/src/server/app.ts +91 -0
  143. package/src/server/aql/compiler.test.ts +966 -0
  144. package/src/server/aql/compiler.ts +1222 -0
  145. package/src/server/aql/exec.test.ts +289 -0
  146. package/src/server/aql/exec.ts +128 -0
  147. package/src/server/aql/index.ts +41 -0
  148. package/src/server/aql/schema/executors.test.ts +420 -0
  149. package/src/server/aql/schema/executors.ts +345 -0
  150. package/src/server/aql/schema/index.test.ts +67 -0
  151. package/src/server/aql/schema/index.ts +409 -0
  152. package/src/server/aql/schema-helpers.test.ts +242 -0
  153. package/src/server/aql/schema-helpers.ts +208 -0
  154. package/src/server/aql/views.test.ts +62 -0
  155. package/src/server/aql/views.ts +57 -0
  156. package/src/server/auth/app.ts +387 -0
  157. package/src/server/bench.ts +29 -0
  158. package/src/server/budget/actions.ts +686 -0
  159. package/src/server/budget/app.ts +469 -0
  160. package/src/server/budget/base.test.ts +340 -0
  161. package/src/server/budget/base.ts +339 -0
  162. package/src/server/budget/category-template-context.test.ts +1658 -0
  163. package/src/server/budget/category-template-context.ts +862 -0
  164. package/src/server/budget/cleanup-template.pegjs +27 -0
  165. package/src/server/budget/cleanup-template.ts +408 -0
  166. package/src/server/budget/envelope.ts +403 -0
  167. package/src/server/budget/goal-template.pegjs +110 -0
  168. package/src/server/budget/goal-template.ts +309 -0
  169. package/src/server/budget/report.ts +308 -0
  170. package/src/server/budget/schedule-template.test.ts +184 -0
  171. package/src/server/budget/schedule-template.ts +351 -0
  172. package/src/server/budget/statements.ts +60 -0
  173. package/src/server/budget/template-notes.test.ts +393 -0
  174. package/src/server/budget/template-notes.ts +323 -0
  175. package/src/server/budget/util.ts +25 -0
  176. package/src/server/budgetfiles/__snapshots__/backups.test.ts.snap +101 -0
  177. package/src/server/budgetfiles/app.ts +672 -0
  178. package/src/server/budgetfiles/backups.test.ts +79 -0
  179. package/src/server/budgetfiles/backups.ts +251 -0
  180. package/src/server/cloud-storage.ts +467 -0
  181. package/src/server/dashboard/app.ts +373 -0
  182. package/src/server/db/__snapshots__/index.test.ts.snap +271 -0
  183. package/src/server/db/index.test.ts +300 -0
  184. package/src/server/db/index.ts +855 -0
  185. package/src/server/db/mappings.ts +59 -0
  186. package/src/server/db/sort.ts +58 -0
  187. package/src/server/db/types/index.ts +342 -0
  188. package/src/server/db/util.ts +36 -0
  189. package/src/server/encryption/app.ts +133 -0
  190. package/src/server/encryption/encryption-internals.api.ts +2 -0
  191. package/src/server/encryption/encryption-internals.electron.ts +89 -0
  192. package/src/server/encryption/encryption-internals.ts +109 -0
  193. package/src/server/encryption/encryption.test.ts +19 -0
  194. package/src/server/encryption/index.test.ts +19 -0
  195. package/src/server/encryption/index.ts +89 -0
  196. package/src/server/errors.ts +110 -0
  197. package/src/server/filters/app.ts +191 -0
  198. package/src/server/importers/actual.ts +49 -0
  199. package/src/server/importers/index.ts +58 -0
  200. package/src/server/importers/ynab4-types.ts +163 -0
  201. package/src/server/importers/ynab4.ts +470 -0
  202. package/src/server/importers/ynab5-types.ts +290 -0
  203. package/src/server/importers/ynab5.ts +1193 -0
  204. package/src/server/main-app.ts +25 -0
  205. package/src/server/main.test.ts +392 -0
  206. package/src/server/main.ts +336 -0
  207. package/src/server/migrate/__snapshots__/migrations.test.ts.snap +17 -0
  208. package/src/server/migrate/cli.ts +100 -0
  209. package/src/server/migrate/migrations.test.ts +81 -0
  210. package/src/server/migrate/migrations.ts +192 -0
  211. package/src/server/models.ts +184 -0
  212. package/src/server/mutators.ts +139 -0
  213. package/src/server/notes/app.ts +18 -0
  214. package/src/server/payees/app.ts +351 -0
  215. package/src/server/polyfills.ts +26 -0
  216. package/src/server/post.ts +219 -0
  217. package/src/server/preferences/app.ts +249 -0
  218. package/src/server/prefs.ts +91 -0
  219. package/src/server/reports/app.ts +187 -0
  220. package/src/server/rules/action.ts +344 -0
  221. package/src/server/rules/app.ts +193 -0
  222. package/src/server/rules/condition.ts +436 -0
  223. package/src/server/rules/customFunctions.ts +61 -0
  224. package/src/server/rules/formula-action.test.ts +175 -0
  225. package/src/server/rules/handlebars-helpers.ts +131 -0
  226. package/src/server/rules/index.test.ts +1095 -0
  227. package/src/server/rules/index.ts +22 -0
  228. package/src/server/rules/rule-indexer.ts +89 -0
  229. package/src/server/rules/rule-utils.ts +274 -0
  230. package/src/server/rules/rule.ts +193 -0
  231. package/src/server/schedules/app.test.ts +502 -0
  232. package/src/server/schedules/app.ts +644 -0
  233. package/src/server/schedules/find-schedules.ts +391 -0
  234. package/src/server/server-config.ts +59 -0
  235. package/src/server/sheet.test.ts +101 -0
  236. package/src/server/sheet.ts +280 -0
  237. package/src/server/spreadsheet/__snapshots__/spreadsheet.test.ts.snap +5 -0
  238. package/src/server/spreadsheet/app.ts +54 -0
  239. package/src/server/spreadsheet/globals.ts +13 -0
  240. package/src/server/spreadsheet/graph-data-structure.ts +165 -0
  241. package/src/server/spreadsheet/scratch +60 -0
  242. package/src/server/spreadsheet/spreadsheet.test.ts +191 -0
  243. package/src/server/spreadsheet/spreadsheet.ts +523 -0
  244. package/src/server/spreadsheet/util.ts +15 -0
  245. package/src/server/sql/init.sql +88 -0
  246. package/src/server/sync/__snapshots__/sync.test.ts.snap +31 -0
  247. package/src/server/sync/app.ts +29 -0
  248. package/src/server/sync/encoder.ts +129 -0
  249. package/src/server/sync/index.ts +820 -0
  250. package/src/server/sync/make-test-message.ts +19 -0
  251. package/src/server/sync/migrate.test.ts +169 -0
  252. package/src/server/sync/migrate.ts +48 -0
  253. package/src/server/sync/repair.ts +39 -0
  254. package/src/server/sync/reset.ts +91 -0
  255. package/src/server/sync/sync.property.test.ts +385 -0
  256. package/src/server/sync/sync.test.ts +349 -0
  257. package/src/server/sync/utils.ts +3 -0
  258. package/src/server/tags/app.ts +101 -0
  259. package/src/server/tests/mockData.json +9352 -0
  260. package/src/server/tests/mockSyncServer.ts +119 -0
  261. package/src/server/tools/app.ts +152 -0
  262. package/src/server/transactions/__snapshots__/transaction-rules.test.ts.snap +173 -0
  263. package/src/server/transactions/__snapshots__/transfer.test.ts.snap +655 -0
  264. package/src/server/transactions/app.ts +136 -0
  265. package/src/server/transactions/export/export-to-csv.ts +132 -0
  266. package/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap +1582 -0
  267. package/src/server/transactions/import/ofx2json.test.ts +33 -0
  268. package/src/server/transactions/import/ofx2json.ts +157 -0
  269. package/src/server/transactions/import/parse-file.test.ts +224 -0
  270. package/src/server/transactions/import/parse-file.ts +286 -0
  271. package/src/server/transactions/import/qif2json.ts +110 -0
  272. package/src/server/transactions/import/xmlcamt2json.ts +168 -0
  273. package/src/server/transactions/index.ts +196 -0
  274. package/src/server/transactions/merge.test.ts +370 -0
  275. package/src/server/transactions/merge.ts +139 -0
  276. package/src/server/transactions/transaction-rules.test.ts +994 -0
  277. package/src/server/transactions/transaction-rules.ts +1038 -0
  278. package/src/server/transactions/transfer.test.ts +221 -0
  279. package/src/server/transactions/transfer.ts +173 -0
  280. package/src/server/undo.ts +271 -0
  281. package/src/server/update.ts +37 -0
  282. package/src/server/util/budget-name.ts +61 -0
  283. package/src/server/util/custom-sync-mapping.ts +48 -0
  284. package/src/server/util/rschedule.ts +9 -0
  285. package/src/shared/__mocks__/platform.ts +7 -0
  286. package/src/shared/__snapshots__/months.test.ts.snap +21 -0
  287. package/src/shared/arithmetic.test.ts +112 -0
  288. package/src/shared/arithmetic.ts +170 -0
  289. package/src/shared/async.test.ts +135 -0
  290. package/src/shared/async.ts +76 -0
  291. package/src/shared/constants.ts +5 -0
  292. package/src/shared/currencies.ts +70 -0
  293. package/src/shared/dashboard.ts +260 -0
  294. package/src/shared/environment.ts +18 -0
  295. package/src/shared/errors.ts +195 -0
  296. package/src/shared/locale.ts +27 -0
  297. package/src/shared/location-utils.test.ts +69 -0
  298. package/src/shared/location-utils.ts +49 -0
  299. package/src/shared/months.test.ts +5 -0
  300. package/src/shared/months.ts +485 -0
  301. package/src/shared/normalisation.ts +6 -0
  302. package/src/shared/platform.electron.ts +21 -0
  303. package/src/shared/platform.ts +20 -0
  304. package/src/shared/query.ts +176 -0
  305. package/src/shared/rules.test.ts +56 -0
  306. package/src/shared/rules.ts +371 -0
  307. package/src/shared/schedules.test.ts +570 -0
  308. package/src/shared/schedules.ts +560 -0
  309. package/src/shared/test-helpers.ts +156 -0
  310. package/src/shared/transactions.test.ts +275 -0
  311. package/src/shared/transactions.ts +433 -0
  312. package/src/shared/transfer.test.ts +75 -0
  313. package/src/shared/transfer.ts +16 -0
  314. package/src/shared/user.ts +4 -0
  315. package/src/shared/util.test.ts +240 -0
  316. package/src/shared/util.ts +633 -0
  317. package/src/types/api-handlers.ts +287 -0
  318. package/src/types/budget.ts +8 -0
  319. package/src/types/file.ts +47 -0
  320. package/src/types/handlers.ts +46 -0
  321. package/src/types/models/account.ts +24 -0
  322. package/src/types/models/bank-sync.ts +23 -0
  323. package/src/types/models/bank.ts +6 -0
  324. package/src/types/models/category-group.ts +11 -0
  325. package/src/types/models/category.ts +13 -0
  326. package/src/types/models/dashboard.ts +199 -0
  327. package/src/types/models/gocardless.ts +84 -0
  328. package/src/types/models/import-transaction.ts +56 -0
  329. package/src/types/models/index.ts +23 -0
  330. package/src/types/models/nearby-payee.ts +7 -0
  331. package/src/types/models/note.ts +4 -0
  332. package/src/types/models/openid.ts +8 -0
  333. package/src/types/models/payee-location.ts +8 -0
  334. package/src/types/models/payee.ts +10 -0
  335. package/src/types/models/pluggyai.ts +19 -0
  336. package/src/types/models/reports.ts +144 -0
  337. package/src/types/models/rule.ts +174 -0
  338. package/src/types/models/schedule.ts +49 -0
  339. package/src/types/models/simplefin.ts +28 -0
  340. package/src/types/models/tags.ts +6 -0
  341. package/src/types/models/templates.ts +135 -0
  342. package/src/types/models/transaction-filter.ts +9 -0
  343. package/src/types/models/transaction.ts +39 -0
  344. package/src/types/models/user-access.ts +10 -0
  345. package/src/types/models/user.ts +25 -0
  346. package/src/types/prefs.ts +167 -0
  347. package/src/types/server-events.ts +86 -0
  348. package/src/types/server-handlers.ts +27 -0
  349. package/src/types/util.ts +26 -0
  350. package/tsconfig.json +34 -0
  351. package/typings/pegjs.ts +1 -0
  352. package/typings/process-worker.ts +12 -0
  353. package/typings/vite-plugin-peggy-loader.ts +1 -0
  354. package/typings/window.ts +62 -0
  355. package/vite.config.ts +109 -0
  356. package/vite.desktop.config.ts +59 -0
  357. package/vitest.config.ts +43 -0
  358. package/vitest.web.config.ts +38 -0
@@ -0,0 +1,1294 @@
1
+ import { t } from 'i18next';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ import { captureException } from '../../platform/exceptions';
5
+ import * as asyncStorage from '../../platform/server/asyncStorage';
6
+ import * as connection from '../../platform/server/connection';
7
+ import { logger } from '../../platform/server/log';
8
+ import { isNonProductionEnvironment } from '../../shared/environment';
9
+ import { dayFromDate } from '../../shared/months';
10
+ import * as monthUtils from '../../shared/months';
11
+ import { amountToInteger } from '../../shared/util';
12
+ import type {
13
+ AccountEntity,
14
+ CategoryEntity,
15
+ GoCardlessToken,
16
+ ImportTransactionEntity,
17
+ SyncServerGoCardlessAccount,
18
+ SyncServerPluggyAiAccount,
19
+ SyncServerSimpleFinAccount,
20
+ TransactionEntity,
21
+ } from '../../types/models';
22
+ import { createApp } from '../app';
23
+ import * as db from '../db';
24
+ import {
25
+ APIError,
26
+ BankSyncError,
27
+ PostError,
28
+ TransactionError,
29
+ } from '../errors';
30
+ import { app as mainApp } from '../main-app';
31
+ import { mutator } from '../mutators';
32
+ import { get, post } from '../post';
33
+ import { getServer } from '../server-config';
34
+ import { batchMessages } from '../sync';
35
+ import { undoable, withUndo } from '../undo';
36
+
37
+ import * as link from './link';
38
+ import { getStartingBalancePayee } from './payees';
39
+ import * as bankSync from './sync';
40
+
41
+ // Shared base type for link account parameters
42
+ type LinkAccountBaseParams = {
43
+ upgradingId?: AccountEntity['id'];
44
+ offBudget?: boolean;
45
+ startingDate?: string;
46
+ startingBalance?: number;
47
+ };
48
+
49
+ export type AccountHandlers = {
50
+ 'account-update': typeof updateAccount;
51
+ 'accounts-get': typeof getAccounts;
52
+ 'account-balance': typeof getAccountBalance;
53
+ 'account-properties': typeof getAccountProperties;
54
+ 'gocardless-accounts-link': typeof linkGoCardlessAccount;
55
+ 'simplefin-accounts-link': typeof linkSimpleFinAccount;
56
+ 'pluggyai-accounts-link': typeof linkPluggyAiAccount;
57
+ 'account-create': typeof createAccount;
58
+ 'account-close': typeof closeAccount;
59
+ 'account-reopen': typeof reopenAccount;
60
+ 'account-move': typeof moveAccount;
61
+ 'secret-set': typeof setSecret;
62
+ 'secret-check': typeof checkSecret;
63
+ 'gocardless-poll-web-token': typeof pollGoCardlessWebToken;
64
+ 'gocardless-poll-web-token-stop': typeof stopGoCardlessWebTokenPolling;
65
+ 'gocardless-status': typeof goCardlessStatus;
66
+ 'simplefin-status': typeof simpleFinStatus;
67
+ 'pluggyai-status': typeof pluggyAiStatus;
68
+ 'simplefin-accounts': typeof simpleFinAccounts;
69
+ 'pluggyai-accounts': typeof pluggyAiAccounts;
70
+ 'gocardless-get-banks': typeof getGoCardlessBanks;
71
+ 'gocardless-create-web-token': typeof createGoCardlessWebToken;
72
+ 'accounts-bank-sync': typeof accountsBankSync;
73
+ 'simplefin-batch-sync': typeof simpleFinBatchSync;
74
+ 'transactions-import': typeof importTransactions;
75
+ 'account-unlink': typeof unlinkAccount;
76
+ };
77
+
78
+ async function updateAccount({
79
+ id,
80
+ name,
81
+ last_reconciled,
82
+ }: Pick<AccountEntity, 'id' | 'name'> &
83
+ Partial<Pick<AccountEntity, 'last_reconciled'>>) {
84
+ await db.update('accounts', {
85
+ id,
86
+ name,
87
+ ...(last_reconciled && { last_reconciled }),
88
+ });
89
+ return {};
90
+ }
91
+
92
+ async function getAccounts(): Promise<AccountEntity[]> {
93
+ const dbAccounts = await db.getAccounts();
94
+ return dbAccounts.map(
95
+ dbAccount =>
96
+ ({
97
+ id: dbAccount.id,
98
+ name: dbAccount.name,
99
+ offbudget: dbAccount.offbudget,
100
+ closed: dbAccount.closed,
101
+ sort_order: dbAccount.sort_order,
102
+ last_reconciled: dbAccount.last_reconciled ?? null,
103
+ tombstone: dbAccount.tombstone,
104
+ account_id: dbAccount.account_id ?? null,
105
+ bank: dbAccount.bank ?? null,
106
+ bankName: dbAccount.bankName ?? null,
107
+ bankId: dbAccount.bankId ?? null,
108
+ mask: dbAccount.mask ?? null,
109
+ official_name: dbAccount.official_name ?? null,
110
+ balance_current: dbAccount.balance_current ?? null,
111
+ balance_available: dbAccount.balance_available ?? null,
112
+ balance_limit: dbAccount.balance_limit ?? null,
113
+ account_sync_source: dbAccount.account_sync_source ?? null,
114
+ last_sync: dbAccount.last_sync ?? null,
115
+ }) satisfies AccountEntity,
116
+ );
117
+ }
118
+
119
+ async function getAccountBalance({
120
+ id,
121
+ cutoff,
122
+ }: {
123
+ id: string;
124
+ cutoff: string | Date;
125
+ }) {
126
+ const result = await db.first<{ balance: number }>(
127
+ 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?',
128
+ [id, db.toDateRepr(dayFromDate(cutoff))],
129
+ );
130
+ return result?.balance ? result.balance : 0;
131
+ }
132
+
133
+ async function getAccountProperties({ id }: { id: AccountEntity['id'] }) {
134
+ const balanceResult = await db.first<{ balance: number }>(
135
+ 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
136
+ [id],
137
+ );
138
+ const countResult = await db.first<{ count: number }>(
139
+ 'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0',
140
+ [id],
141
+ );
142
+
143
+ return {
144
+ balance: balanceResult?.balance || 0,
145
+ numTransactions: countResult?.count || 0,
146
+ };
147
+ }
148
+
149
+ async function linkGoCardlessAccount({
150
+ requisitionId,
151
+ account,
152
+ upgradingId,
153
+ offBudget = false,
154
+ startingDate,
155
+ startingBalance,
156
+ }: LinkAccountBaseParams & {
157
+ requisitionId: string;
158
+ account: SyncServerGoCardlessAccount;
159
+ }) {
160
+ let id;
161
+ const bank = await link.findOrCreateBank(account.institution, requisitionId);
162
+
163
+ if (upgradingId) {
164
+ const accRow = await db.first<db.DbAccount>(
165
+ 'SELECT * FROM accounts WHERE id = ?',
166
+ [upgradingId],
167
+ );
168
+
169
+ if (!accRow) {
170
+ throw new Error(`Account with ID ${upgradingId} not found.`);
171
+ }
172
+
173
+ id = accRow.id;
174
+ await db.update('accounts', {
175
+ id,
176
+ account_id: account.account_id,
177
+ bank: bank.id,
178
+ account_sync_source: 'goCardless',
179
+ });
180
+ } else {
181
+ id = uuidv4();
182
+ await db.insertWithUUID('accounts', {
183
+ id,
184
+ account_id: account.account_id,
185
+ mask: account.mask,
186
+ name: account.name,
187
+ official_name: account.official_name,
188
+ bank: bank.id,
189
+ offbudget: offBudget ? 1 : 0,
190
+ account_sync_source: 'goCardless',
191
+ });
192
+ await db.insertPayee({
193
+ name: '',
194
+ transfer_acct: id,
195
+ });
196
+ }
197
+
198
+ await bankSync.syncAccount(
199
+ undefined,
200
+ undefined,
201
+ id,
202
+ account.account_id,
203
+ bank.bank_id,
204
+ startingDate,
205
+ startingBalance,
206
+ );
207
+
208
+ connection.send('sync-event', {
209
+ type: 'success',
210
+ tables: ['transactions'],
211
+ });
212
+
213
+ return 'ok';
214
+ }
215
+
216
+ async function linkSimpleFinAccount({
217
+ externalAccount,
218
+ upgradingId,
219
+ offBudget = false,
220
+ startingDate,
221
+ startingBalance,
222
+ }: LinkAccountBaseParams & {
223
+ externalAccount: SyncServerSimpleFinAccount;
224
+ }) {
225
+ let id;
226
+
227
+ const institution = {
228
+ name: externalAccount.institution ?? t('Unknown'),
229
+ };
230
+
231
+ const bank = await link.findOrCreateBank(
232
+ institution,
233
+ externalAccount.orgDomain ?? externalAccount.orgId,
234
+ );
235
+
236
+ if (upgradingId) {
237
+ const accRow = await db.first<db.DbAccount>(
238
+ 'SELECT * FROM accounts WHERE id = ?',
239
+ [upgradingId],
240
+ );
241
+
242
+ if (!accRow) {
243
+ throw new Error(`Account with ID ${upgradingId} not found.`);
244
+ }
245
+
246
+ id = accRow.id;
247
+ await db.update('accounts', {
248
+ id,
249
+ account_id: externalAccount.account_id,
250
+ bank: bank.id,
251
+ account_sync_source: 'simpleFin',
252
+ });
253
+ } else {
254
+ id = uuidv4();
255
+ await db.insertWithUUID('accounts', {
256
+ id,
257
+ account_id: externalAccount.account_id,
258
+ name: externalAccount.name,
259
+ official_name: externalAccount.name,
260
+ bank: bank.id,
261
+ offbudget: offBudget ? 1 : 0,
262
+ account_sync_source: 'simpleFin',
263
+ });
264
+ await db.insertPayee({
265
+ name: '',
266
+ transfer_acct: id,
267
+ });
268
+ }
269
+
270
+ await bankSync.syncAccount(
271
+ undefined,
272
+ undefined,
273
+ id,
274
+ externalAccount.account_id,
275
+ bank.bank_id,
276
+ startingDate,
277
+ startingBalance,
278
+ );
279
+
280
+ connection.send('sync-event', {
281
+ type: 'success',
282
+ tables: ['transactions'],
283
+ });
284
+
285
+ return 'ok';
286
+ }
287
+
288
+ async function linkPluggyAiAccount({
289
+ externalAccount,
290
+ upgradingId,
291
+ offBudget = false,
292
+ startingDate,
293
+ startingBalance,
294
+ }: LinkAccountBaseParams & {
295
+ externalAccount: SyncServerPluggyAiAccount;
296
+ }) {
297
+ let id;
298
+
299
+ const institution = {
300
+ name: externalAccount.institution ?? t('Unknown'),
301
+ };
302
+
303
+ const bank = await link.findOrCreateBank(
304
+ institution,
305
+ externalAccount.orgDomain ?? externalAccount.orgId,
306
+ );
307
+
308
+ if (upgradingId) {
309
+ const accRow = await db.first<db.DbAccount>(
310
+ 'SELECT * FROM accounts WHERE id = ?',
311
+ [upgradingId],
312
+ );
313
+
314
+ if (!accRow) {
315
+ throw new Error(`Account with ID ${upgradingId} not found.`);
316
+ }
317
+
318
+ id = accRow.id;
319
+ await db.update('accounts', {
320
+ id,
321
+ account_id: externalAccount.account_id,
322
+ bank: bank.id,
323
+ account_sync_source: 'pluggyai',
324
+ });
325
+ } else {
326
+ id = uuidv4();
327
+ await db.insertWithUUID('accounts', {
328
+ id,
329
+ account_id: externalAccount.account_id,
330
+ name: externalAccount.name,
331
+ official_name: externalAccount.name,
332
+ bank: bank.id,
333
+ offbudget: offBudget ? 1 : 0,
334
+ account_sync_source: 'pluggyai',
335
+ });
336
+ await db.insertPayee({
337
+ name: '',
338
+ transfer_acct: id,
339
+ });
340
+ }
341
+
342
+ await bankSync.syncAccount(
343
+ undefined,
344
+ undefined,
345
+ id,
346
+ externalAccount.account_id,
347
+ bank.bank_id,
348
+ startingDate,
349
+ startingBalance,
350
+ );
351
+
352
+ connection.send('sync-event', {
353
+ type: 'success',
354
+ tables: ['transactions'],
355
+ });
356
+
357
+ return 'ok';
358
+ }
359
+
360
+ async function createAccount({
361
+ name,
362
+ balance = 0,
363
+ offBudget = false,
364
+ closed = false,
365
+ }: {
366
+ name: string;
367
+ balance?: number | undefined;
368
+ offBudget?: boolean | undefined;
369
+ closed?: boolean | undefined;
370
+ }) {
371
+ const id: AccountEntity['id'] = await db.insertAccount({
372
+ name,
373
+ offbudget: offBudget ? 1 : 0,
374
+ closed: closed ? 1 : 0,
375
+ });
376
+
377
+ await db.insertPayee({
378
+ name: '',
379
+ transfer_acct: id,
380
+ });
381
+
382
+ if (balance != null && balance !== 0) {
383
+ const payee = await getStartingBalancePayee();
384
+
385
+ await db.insertTransaction({
386
+ account: id,
387
+ amount: amountToInteger(balance),
388
+ category: offBudget ? null : payee.category,
389
+ payee: payee.id,
390
+ date: monthUtils.currentDay(),
391
+ cleared: true,
392
+ starting_balance_flag: true,
393
+ });
394
+ }
395
+
396
+ return id;
397
+ }
398
+
399
+ async function closeAccount({
400
+ id,
401
+ transferAccountId,
402
+ categoryId,
403
+ forced = false,
404
+ }: {
405
+ id: AccountEntity['id'];
406
+ transferAccountId?: AccountEntity['id'] | undefined;
407
+ categoryId?: CategoryEntity['id'] | undefined;
408
+ forced?: boolean | undefined;
409
+ }) {
410
+ // Unlink the account if it's linked. This makes sure to remove it from
411
+ // bank-sync providers. (This should not be undo-able, as it mutates the
412
+ // remote server and the user will have to link the account again)
413
+ await unlinkAccount({ id });
414
+
415
+ return withUndo(async () => {
416
+ const account = await db.first<db.DbAccount>(
417
+ 'SELECT * FROM accounts WHERE id = ? AND tombstone = 0',
418
+ [id],
419
+ );
420
+
421
+ // Do nothing if the account doesn't exist or it's already been
422
+ // closed
423
+ if (!account || account.closed === 1) {
424
+ return;
425
+ }
426
+
427
+ const { balance, numTransactions } = await getAccountProperties({ id });
428
+
429
+ // If there are no transactions, we can simply delete the account
430
+ if (numTransactions === 0) {
431
+ await db.deleteAccount({ id });
432
+ } else if (forced) {
433
+ const rows = db.runQuery<
434
+ Pick<db.DbViewTransaction, 'id' | 'transfer_id'>
435
+ >(
436
+ 'SELECT id, transfer_id FROM v_transactions WHERE account = ?',
437
+ [id],
438
+ true,
439
+ );
440
+
441
+ const transferPayee = await db.first<Pick<db.DbPayee, 'id'>>(
442
+ 'SELECT id FROM payees WHERE transfer_acct = ?',
443
+ [id],
444
+ );
445
+
446
+ if (!transferPayee) {
447
+ throw new Error(`Transfer payee with account ID ${id} not found.`);
448
+ }
449
+
450
+ await batchMessages(async () => {
451
+ // TODO: what this should really do is send a special message that
452
+ // automatically marks the tombstone value for all transactions
453
+ // within an account... or something? This is problematic
454
+ // because another client could easily add new data that
455
+ // should be marked as deleted.
456
+
457
+ rows.forEach(row => {
458
+ if (row.transfer_id) {
459
+ void db.updateTransaction({
460
+ id: row.transfer_id,
461
+ payee: null,
462
+ transfer_id: null,
463
+ });
464
+ }
465
+
466
+ void db.deleteTransaction({ id: row.id });
467
+ });
468
+
469
+ void db.deleteAccount({ id });
470
+ void db.deleteTransferPayee({ id: transferPayee.id });
471
+ });
472
+ } else {
473
+ if (balance !== 0 && transferAccountId == null) {
474
+ throw APIError('balance is non-zero: transferAccountId is required');
475
+ }
476
+
477
+ if (id === transferAccountId) {
478
+ throw APIError('transfer account can not be the account being closed');
479
+ }
480
+
481
+ await db.update('accounts', { id, closed: 1 });
482
+
483
+ // If there is a balance we need to transfer it to the specified
484
+ // account (and possibly categorize it)
485
+ if (balance !== 0 && transferAccountId) {
486
+ const transferPayee = await db.first<Pick<db.DbPayee, 'id'>>(
487
+ 'SELECT id FROM payees WHERE transfer_acct = ?',
488
+ [transferAccountId],
489
+ );
490
+
491
+ if (!transferPayee) {
492
+ throw new Error(
493
+ `Transfer payee with account ID ${transferAccountId} not found.`,
494
+ );
495
+ }
496
+
497
+ await mainApp.handlers['transaction-add']({
498
+ id: uuidv4(),
499
+ payee: transferPayee.id,
500
+ amount: -balance,
501
+ account: id,
502
+ date: monthUtils.currentDay(),
503
+ notes: 'Closing account',
504
+ category: categoryId,
505
+ });
506
+ }
507
+ }
508
+ });
509
+ }
510
+
511
+ async function reopenAccount({ id }: { id: AccountEntity['id'] }) {
512
+ await db.update('accounts', { id, closed: 0 });
513
+ }
514
+
515
+ async function moveAccount({
516
+ id,
517
+ targetId,
518
+ }: {
519
+ id: AccountEntity['id'];
520
+ targetId: AccountEntity['id'] | null;
521
+ }) {
522
+ await db.moveAccount(id, targetId);
523
+ }
524
+
525
+ async function setSecret({
526
+ name,
527
+ value,
528
+ }: {
529
+ name: string;
530
+ value: string | null;
531
+ }) {
532
+ const userToken = await asyncStorage.getItem('user-token');
533
+
534
+ if (!userToken) {
535
+ return { error: 'unauthorized' };
536
+ }
537
+
538
+ const serverConfig = getServer();
539
+ if (!serverConfig) {
540
+ throw new Error('Failed to get server config.');
541
+ }
542
+
543
+ try {
544
+ return await post(
545
+ serverConfig.BASE_SERVER + '/secret',
546
+ {
547
+ name,
548
+ value,
549
+ },
550
+ {
551
+ 'X-ACTUAL-TOKEN': userToken,
552
+ },
553
+ );
554
+ } catch (error) {
555
+ return {
556
+ error: 'failed',
557
+ reason: error instanceof PostError ? error.reason : undefined,
558
+ };
559
+ }
560
+ }
561
+ async function checkSecret(name: string) {
562
+ const userToken = await asyncStorage.getItem('user-token');
563
+
564
+ if (!userToken) {
565
+ return { error: 'unauthorized' };
566
+ }
567
+
568
+ const serverConfig = getServer();
569
+ if (!serverConfig) {
570
+ throw new Error('Failed to get server config.');
571
+ }
572
+
573
+ try {
574
+ return await get(serverConfig.BASE_SERVER + '/secret/' + name, {
575
+ 'X-ACTUAL-TOKEN': userToken,
576
+ });
577
+ } catch (error) {
578
+ logger.error(error);
579
+ return { error: 'failed' };
580
+ }
581
+ }
582
+
583
+ let stopPolling = false;
584
+
585
+ async function pollGoCardlessWebToken({
586
+ requisitionId,
587
+ }: {
588
+ requisitionId: string;
589
+ }) {
590
+ const userToken = await asyncStorage.getItem('user-token');
591
+ if (!userToken) return { error: 'unknown' };
592
+
593
+ const startTime = Date.now();
594
+ stopPolling = false;
595
+
596
+ async function getData(
597
+ cb: (
598
+ data:
599
+ | { status: 'timeout' }
600
+ | { status: 'unknown'; message?: string }
601
+ | { status: 'success'; data: GoCardlessToken },
602
+ ) => void,
603
+ ) {
604
+ if (stopPolling) {
605
+ return;
606
+ }
607
+
608
+ if (Date.now() - startTime >= 1000 * 60 * 10) {
609
+ cb({ status: 'timeout' });
610
+ return;
611
+ }
612
+
613
+ const serverConfig = getServer();
614
+ if (!serverConfig) {
615
+ throw new Error('Failed to get server config.');
616
+ }
617
+
618
+ const data = await post(
619
+ serverConfig.GOCARDLESS_SERVER + '/get-accounts',
620
+ {
621
+ requisitionId,
622
+ },
623
+ {
624
+ 'X-ACTUAL-TOKEN': userToken,
625
+ },
626
+ );
627
+
628
+ if (data) {
629
+ if (data.error_code) {
630
+ logger.error('Failed linking gocardless account:', data);
631
+ cb({ status: 'unknown', message: data.error_type });
632
+ } else {
633
+ cb({ status: 'success', data });
634
+ }
635
+ } else {
636
+ setTimeout(() => getData(cb), 3000);
637
+ }
638
+ }
639
+
640
+ return new Promise(resolve => {
641
+ void getData(data => {
642
+ if (data.status === 'success') {
643
+ resolve({ data: data.data });
644
+ return;
645
+ }
646
+
647
+ if (data.status === 'timeout') {
648
+ resolve({ error: data.status });
649
+ return;
650
+ }
651
+
652
+ resolve({
653
+ error: data.status,
654
+ message: data.message,
655
+ });
656
+ });
657
+ });
658
+ }
659
+
660
+ async function stopGoCardlessWebTokenPolling() {
661
+ stopPolling = true;
662
+ return 'ok';
663
+ }
664
+
665
+ async function goCardlessStatus() {
666
+ const userToken = await asyncStorage.getItem('user-token');
667
+
668
+ if (!userToken) {
669
+ return { error: 'unauthorized' };
670
+ }
671
+
672
+ const serverConfig = getServer();
673
+ if (!serverConfig) {
674
+ throw new Error('Failed to get server config.');
675
+ }
676
+
677
+ return post(
678
+ serverConfig.GOCARDLESS_SERVER + '/status',
679
+ {},
680
+ {
681
+ 'X-ACTUAL-TOKEN': userToken,
682
+ },
683
+ );
684
+ }
685
+
686
+ async function simpleFinStatus() {
687
+ const userToken = await asyncStorage.getItem('user-token');
688
+
689
+ if (!userToken) {
690
+ return { error: 'unauthorized' };
691
+ }
692
+
693
+ const serverConfig = getServer();
694
+ if (!serverConfig) {
695
+ throw new Error('Failed to get server config.');
696
+ }
697
+
698
+ return post(
699
+ serverConfig.SIMPLEFIN_SERVER + '/status',
700
+ {},
701
+ {
702
+ 'X-ACTUAL-TOKEN': userToken,
703
+ },
704
+ );
705
+ }
706
+
707
+ async function pluggyAiStatus() {
708
+ const userToken = await asyncStorage.getItem('user-token');
709
+
710
+ if (!userToken) {
711
+ return { error: 'unauthorized' };
712
+ }
713
+
714
+ const serverConfig = getServer();
715
+ if (!serverConfig) {
716
+ throw new Error('Failed to get server config.');
717
+ }
718
+
719
+ return post(
720
+ serverConfig.PLUGGYAI_SERVER + '/status',
721
+ {},
722
+ {
723
+ 'X-ACTUAL-TOKEN': userToken,
724
+ },
725
+ );
726
+ }
727
+
728
+ async function simpleFinAccounts() {
729
+ const userToken = await asyncStorage.getItem('user-token');
730
+
731
+ if (!userToken) {
732
+ return { error: 'unauthorized' };
733
+ }
734
+
735
+ const serverConfig = getServer();
736
+ if (!serverConfig) {
737
+ throw new Error('Failed to get server config.');
738
+ }
739
+
740
+ try {
741
+ return await post(
742
+ serverConfig.SIMPLEFIN_SERVER + '/accounts',
743
+ {},
744
+ {
745
+ 'X-ACTUAL-TOKEN': userToken,
746
+ },
747
+ 60000,
748
+ );
749
+ } catch {
750
+ return { error_code: 'TIMED_OUT' };
751
+ }
752
+ }
753
+
754
+ async function pluggyAiAccounts() {
755
+ const userToken = await asyncStorage.getItem('user-token');
756
+
757
+ if (!userToken) {
758
+ return { error: 'unauthorized' };
759
+ }
760
+
761
+ const serverConfig = getServer();
762
+ if (!serverConfig) {
763
+ throw new Error('Failed to get server config.');
764
+ }
765
+
766
+ try {
767
+ return await post(
768
+ serverConfig.PLUGGYAI_SERVER + '/accounts',
769
+ {},
770
+ {
771
+ 'X-ACTUAL-TOKEN': userToken,
772
+ },
773
+ 60000,
774
+ );
775
+ } catch {
776
+ return { error_code: 'TIMED_OUT' };
777
+ }
778
+ }
779
+
780
+ async function getGoCardlessBanks(country: string) {
781
+ const userToken = await asyncStorage.getItem('user-token');
782
+
783
+ if (!userToken) {
784
+ return { error: 'unauthorized' };
785
+ }
786
+
787
+ const serverConfig = getServer();
788
+ if (!serverConfig) {
789
+ throw new Error('Failed to get server config.');
790
+ }
791
+
792
+ return post(
793
+ serverConfig.GOCARDLESS_SERVER + '/get-banks',
794
+ { country, showDemo: isNonProductionEnvironment() },
795
+ {
796
+ 'X-ACTUAL-TOKEN': userToken,
797
+ },
798
+ );
799
+ }
800
+
801
+ async function createGoCardlessWebToken({
802
+ institutionId,
803
+ accessValidForDays,
804
+ }: {
805
+ institutionId: string;
806
+ accessValidForDays: number;
807
+ }) {
808
+ const userToken = await asyncStorage.getItem('user-token');
809
+
810
+ if (!userToken) {
811
+ return { error: 'unauthorized' };
812
+ }
813
+
814
+ const serverConfig = getServer();
815
+ if (!serverConfig) {
816
+ throw new Error('Failed to get server config.');
817
+ }
818
+
819
+ try {
820
+ return await post(
821
+ serverConfig.GOCARDLESS_SERVER + '/create-web-token',
822
+ {
823
+ institutionId,
824
+ accessValidForDays,
825
+ },
826
+ {
827
+ 'X-ACTUAL-TOKEN': userToken,
828
+ },
829
+ );
830
+ } catch (error) {
831
+ logger.error(error);
832
+ return { error: 'failed' };
833
+ }
834
+ }
835
+
836
+ type SyncResponse = {
837
+ newTransactions: Array<TransactionEntity['id']>;
838
+ matchedTransactions: Array<TransactionEntity['id']>;
839
+ updatedAccounts: Array<AccountEntity['id']>;
840
+ };
841
+
842
+ async function handleSyncResponse(
843
+ res: {
844
+ added: Array<TransactionEntity['id']>;
845
+ updated: Array<TransactionEntity['id']>;
846
+ },
847
+ acct: db.DbAccount,
848
+ ): Promise<SyncResponse> {
849
+ const { added, updated } = res;
850
+ const newTransactions: Array<TransactionEntity['id']> = [];
851
+ const matchedTransactions: Array<TransactionEntity['id']> = [];
852
+ const updatedAccounts: Array<AccountEntity['id']> = [];
853
+
854
+ newTransactions.push(...added);
855
+ matchedTransactions.push(...updated);
856
+
857
+ if (added.length > 0) {
858
+ updatedAccounts.push(acct.id);
859
+ }
860
+
861
+ const ts = new Date().getTime().toString();
862
+ await db.update('accounts', { id: acct.id, last_sync: ts });
863
+
864
+ return {
865
+ newTransactions,
866
+ matchedTransactions,
867
+ updatedAccounts,
868
+ };
869
+ }
870
+
871
+ type SyncError =
872
+ | {
873
+ type: 'SyncError';
874
+ accountId: AccountEntity['id'];
875
+ message: string;
876
+ category: string;
877
+ code: string;
878
+ }
879
+ | {
880
+ accountId: AccountEntity['id'];
881
+ message: string;
882
+ internal?: string;
883
+ };
884
+
885
+ /**
886
+ * Type guard to check if an error is a BankSyncError.
887
+ * Handles both class instances and plain objects with the BankSyncError shape.
888
+ */
889
+ function isBankSyncError(err: unknown): err is BankSyncError {
890
+ return (
891
+ err instanceof BankSyncError ||
892
+ (typeof err === 'object' &&
893
+ err !== null &&
894
+ 'type' in err &&
895
+ err.type === 'BankSyncError')
896
+ );
897
+ }
898
+
899
+ /**
900
+ * Converts a sync error into a standardized SyncError response object.
901
+ */
902
+ function handleSyncError(
903
+ err: Error | PostError | BankSyncError,
904
+ acct: db.DbAccount,
905
+ ): SyncError {
906
+ if (isBankSyncError(err)) {
907
+ const syncError = {
908
+ type: 'SyncError',
909
+ accountId: acct.id,
910
+ message: 'Failed syncing account "' + acct.name + '."',
911
+ category: err.category,
912
+ code: err.code,
913
+ };
914
+
915
+ if (err.category === 'RATE_LIMIT_EXCEEDED') {
916
+ return {
917
+ ...syncError,
918
+ message: `Failed syncing account ${acct.name}. Rate limit exceeded. Please try again later.`,
919
+ };
920
+ }
921
+
922
+ return syncError;
923
+ }
924
+
925
+ if (err instanceof PostError && err.reason !== 'internal') {
926
+ return {
927
+ accountId: acct.id,
928
+ message: err.reason
929
+ ? err.reason
930
+ : `Account "${acct.name}" is not linked properly. Please link it again.`,
931
+ };
932
+ }
933
+
934
+ return {
935
+ accountId: acct.id,
936
+ message:
937
+ 'There was an internal error. Please get in touch https://actualbudget.org/contact for support.',
938
+ internal: err.stack,
939
+ };
940
+ }
941
+
942
+ export type SyncResponseWithErrors = SyncResponse & {
943
+ errors: SyncError[];
944
+ };
945
+
946
+ async function accountsBankSync({
947
+ ids = [],
948
+ }: {
949
+ ids: Array<AccountEntity['id']>;
950
+ }): Promise<SyncResponseWithErrors> {
951
+ const { 'user-id': userId, 'user-key': userKey } =
952
+ await asyncStorage.multiGet(['user-id', 'user-key']);
953
+
954
+ const accounts = db.runQuery<db.DbAccount & { bankId: db.DbBank['bank_id'] }>(
955
+ `
956
+ SELECT a.*, b.bank_id as bankId
957
+ FROM accounts a
958
+ LEFT JOIN banks b ON a.bank = b.id
959
+ WHERE a.tombstone = 0 AND a.closed = 0
960
+ ${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''}
961
+ ORDER BY a.offbudget, a.sort_order
962
+ `,
963
+ ids,
964
+ true,
965
+ );
966
+
967
+ const errors: ReturnType<typeof handleSyncError>[] = [];
968
+ const newTransactions: Array<TransactionEntity['id']> = [];
969
+ const matchedTransactions: Array<TransactionEntity['id']> = [];
970
+ const updatedAccounts: Array<AccountEntity['id']> = [];
971
+
972
+ for (const acct of accounts) {
973
+ if (acct.bankId && acct.account_id) {
974
+ try {
975
+ logger.group('Bank Sync operation for account:', acct.name);
976
+ const syncResponse = await bankSync.syncAccount(
977
+ userId as string,
978
+ userKey as string,
979
+ acct.id,
980
+ acct.account_id,
981
+ acct.bankId,
982
+ );
983
+
984
+ const syncResponseData = await handleSyncResponse(syncResponse, acct);
985
+
986
+ newTransactions.push(...syncResponseData.newTransactions);
987
+ matchedTransactions.push(...syncResponseData.matchedTransactions);
988
+ updatedAccounts.push(...syncResponseData.updatedAccounts);
989
+ } catch (err) {
990
+ const error = err as Error;
991
+ errors.push(handleSyncError(error, acct));
992
+ captureException({
993
+ ...error,
994
+ message: 'Failed syncing account "' + acct.name + '."',
995
+ } as Error);
996
+ } finally {
997
+ logger.groupEnd();
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ if (updatedAccounts.length > 0) {
1003
+ connection.send('sync-event', {
1004
+ type: 'success',
1005
+ tables: ['transactions'],
1006
+ });
1007
+ }
1008
+
1009
+ return { errors, newTransactions, matchedTransactions, updatedAccounts };
1010
+ }
1011
+
1012
+ async function simpleFinBatchSync({
1013
+ ids = [],
1014
+ }: {
1015
+ ids: Array<AccountEntity['id']>;
1016
+ }): Promise<
1017
+ Array<{ accountId: AccountEntity['id']; res: SyncResponseWithErrors }>
1018
+ > {
1019
+ const accounts = db.runQuery<db.DbAccount & { bankId: db.DbBank['bank_id'] }>(
1020
+ `SELECT a.*, b.bank_id as bankId FROM accounts a
1021
+ LEFT JOIN banks b ON a.bank = b.id
1022
+ WHERE
1023
+ a.tombstone = 0
1024
+ AND a.closed = 0
1025
+ AND a.account_sync_source = 'simpleFin'
1026
+ ${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''}
1027
+ ORDER BY a.offbudget, a.sort_order`,
1028
+ ids.length ? ids : [],
1029
+ true,
1030
+ );
1031
+
1032
+ const retVal: Array<{
1033
+ accountId: AccountEntity['id'];
1034
+ res: {
1035
+ errors: ReturnType<typeof handleSyncError>[];
1036
+ newTransactions: Array<TransactionEntity['id']>;
1037
+ matchedTransactions: Array<TransactionEntity['id']>;
1038
+ updatedAccounts: Array<AccountEntity['id']>;
1039
+ };
1040
+ }> = [];
1041
+
1042
+ logger.group('Bank Sync operation for all SimpleFin accounts');
1043
+ try {
1044
+ const syncResponses: Array<{
1045
+ accountId: AccountEntity['id'];
1046
+ res: {
1047
+ error_code: string;
1048
+ error_type: string;
1049
+ added: Array<TransactionEntity['id']>;
1050
+ updated: Array<TransactionEntity['id']>;
1051
+ };
1052
+ }> = await bankSync.simpleFinBatchSync(
1053
+ accounts.map(a => ({
1054
+ id: a.id,
1055
+ account_id: a.account_id || null,
1056
+ })),
1057
+ );
1058
+ for (const syncResponse of syncResponses) {
1059
+ const account = accounts.find(a => a.id === syncResponse.accountId);
1060
+ if (!account) {
1061
+ logger.error(
1062
+ `Invalid account ID found in response: ${syncResponse.accountId}. Proceeding to the next account...`,
1063
+ );
1064
+ continue;
1065
+ }
1066
+
1067
+ const errors: ReturnType<typeof handleSyncError>[] = [];
1068
+ const newTransactions: Array<TransactionEntity['id']> = [];
1069
+ const matchedTransactions: Array<TransactionEntity['id']> = [];
1070
+ const updatedAccounts: Array<AccountEntity['id']> = [];
1071
+
1072
+ if (syncResponse.res?.error_code) {
1073
+ errors.push(
1074
+ handleSyncError(
1075
+ {
1076
+ type: 'BankSyncError',
1077
+ reason: 'Failed syncing account "' + account.name + '."',
1078
+ category: syncResponse.res.error_type,
1079
+ code: syncResponse.res.error_code,
1080
+ } as BankSyncError,
1081
+ account,
1082
+ ),
1083
+ );
1084
+ } else if (syncResponse.res) {
1085
+ const syncResponseData = await handleSyncResponse(
1086
+ syncResponse.res,
1087
+ account,
1088
+ );
1089
+
1090
+ newTransactions.push(...syncResponseData.newTransactions);
1091
+ matchedTransactions.push(...syncResponseData.matchedTransactions);
1092
+ updatedAccounts.push(...syncResponseData.updatedAccounts);
1093
+ } else {
1094
+ errors.push(
1095
+ handleSyncError(
1096
+ new Error(
1097
+ 'Failed syncing account "' + account.name + '": empty response',
1098
+ ),
1099
+ account,
1100
+ ),
1101
+ );
1102
+ }
1103
+
1104
+ retVal.push({
1105
+ accountId: syncResponse.accountId,
1106
+ res: { errors, newTransactions, matchedTransactions, updatedAccounts },
1107
+ });
1108
+ }
1109
+ } catch (err) {
1110
+ for (const account of accounts) {
1111
+ const error = err as Error;
1112
+ retVal.push({
1113
+ accountId: account.id,
1114
+ res: {
1115
+ errors: [handleSyncError(error, account)],
1116
+ newTransactions: [],
1117
+ matchedTransactions: [],
1118
+ updatedAccounts: [],
1119
+ },
1120
+ });
1121
+ }
1122
+ }
1123
+
1124
+ if (retVal.some(a => a.res.updatedAccounts.length > 0)) {
1125
+ connection.send('sync-event', {
1126
+ type: 'success',
1127
+ tables: ['transactions'],
1128
+ });
1129
+ }
1130
+
1131
+ logger.groupEnd();
1132
+
1133
+ return retVal;
1134
+ }
1135
+
1136
+ export type ImportTransactionsResult = bankSync.ReconcileTransactionsResult & {
1137
+ errors: Array<{
1138
+ message: string;
1139
+ }>;
1140
+ };
1141
+
1142
+ async function importTransactions({
1143
+ accountId,
1144
+ transactions,
1145
+ isPreview,
1146
+ opts,
1147
+ }: {
1148
+ accountId: AccountEntity['id'];
1149
+ transactions: ImportTransactionEntity[];
1150
+ isPreview: boolean;
1151
+ opts?: {
1152
+ defaultCleared?: boolean;
1153
+ };
1154
+ }): Promise<ImportTransactionsResult> {
1155
+ if (typeof accountId !== 'string') {
1156
+ throw APIError('transactions-import: accountId must be an id');
1157
+ }
1158
+
1159
+ try {
1160
+ const reconciled = await bankSync.reconcileTransactions(
1161
+ accountId,
1162
+ transactions,
1163
+ false,
1164
+ true,
1165
+ isPreview,
1166
+ opts?.defaultCleared,
1167
+ );
1168
+ return {
1169
+ errors: [],
1170
+ added: reconciled.added,
1171
+ updated: reconciled.updated,
1172
+ updatedPreview: reconciled.updatedPreview,
1173
+ };
1174
+ } catch (err) {
1175
+ if (err instanceof TransactionError) {
1176
+ return {
1177
+ errors: [{ message: err.message }],
1178
+ added: [],
1179
+ updated: [],
1180
+ updatedPreview: [],
1181
+ };
1182
+ }
1183
+
1184
+ throw err;
1185
+ }
1186
+ }
1187
+
1188
+ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) {
1189
+ const accRow = await db.first<db.DbAccount>(
1190
+ 'SELECT * FROM accounts WHERE id = ?',
1191
+ [id],
1192
+ );
1193
+
1194
+ if (!accRow) {
1195
+ throw new Error(`Account with ID ${id} not found.`);
1196
+ }
1197
+
1198
+ const bankId = accRow.bank;
1199
+
1200
+ if (!bankId) {
1201
+ return 'ok';
1202
+ }
1203
+
1204
+ const isGoCardless = accRow.account_sync_source === 'goCardless';
1205
+
1206
+ await db.updateAccount({
1207
+ id,
1208
+ account_id: null,
1209
+ bank: null,
1210
+ balance_current: null,
1211
+ balance_available: null,
1212
+ balance_limit: null,
1213
+ account_sync_source: null,
1214
+ });
1215
+
1216
+ if (isGoCardless === false) {
1217
+ return;
1218
+ }
1219
+
1220
+ const accountWithBankResult = await db.first<{ count: number }>(
1221
+ 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?',
1222
+ [bankId],
1223
+ );
1224
+
1225
+ // No more accounts are associated with this bank. We can remove
1226
+ // it from GoCardless.
1227
+ const userToken = await asyncStorage.getItem('user-token');
1228
+ if (!userToken) {
1229
+ return 'ok';
1230
+ }
1231
+
1232
+ if (!accountWithBankResult || accountWithBankResult.count === 0) {
1233
+ const bank = await db.first<Pick<db.DbBank, 'bank_id'>>(
1234
+ 'SELECT bank_id FROM banks WHERE id = ?',
1235
+ [bankId],
1236
+ );
1237
+
1238
+ if (!bank) {
1239
+ throw new Error(`Bank with ID ${bankId} not found.`);
1240
+ }
1241
+
1242
+ const serverConfig = getServer();
1243
+ if (!serverConfig) {
1244
+ throw new Error('Failed to get server config.');
1245
+ }
1246
+
1247
+ const requisitionId = bank.bank_id;
1248
+
1249
+ try {
1250
+ await post(
1251
+ serverConfig.GOCARDLESS_SERVER + '/remove-account',
1252
+ {
1253
+ requisitionId,
1254
+ },
1255
+ {
1256
+ 'X-ACTUAL-TOKEN': userToken,
1257
+ },
1258
+ );
1259
+ } catch (error) {
1260
+ logger.log({ error });
1261
+ }
1262
+ }
1263
+
1264
+ return 'ok';
1265
+ }
1266
+
1267
+ export const app = createApp<AccountHandlers>();
1268
+
1269
+ app.method('account-update', mutator(undoable(updateAccount)));
1270
+ app.method('accounts-get', getAccounts);
1271
+ app.method('account-balance', getAccountBalance);
1272
+ app.method('account-properties', getAccountProperties);
1273
+ app.method('gocardless-accounts-link', linkGoCardlessAccount);
1274
+ app.method('simplefin-accounts-link', linkSimpleFinAccount);
1275
+ app.method('pluggyai-accounts-link', linkPluggyAiAccount);
1276
+ app.method('account-create', mutator(undoable(createAccount)));
1277
+ app.method('account-close', mutator(closeAccount));
1278
+ app.method('account-reopen', mutator(undoable(reopenAccount)));
1279
+ app.method('account-move', mutator(undoable(moveAccount)));
1280
+ app.method('secret-set', setSecret);
1281
+ app.method('secret-check', checkSecret);
1282
+ app.method('gocardless-poll-web-token', pollGoCardlessWebToken);
1283
+ app.method('gocardless-poll-web-token-stop', stopGoCardlessWebTokenPolling);
1284
+ app.method('gocardless-status', goCardlessStatus);
1285
+ app.method('simplefin-status', simpleFinStatus);
1286
+ app.method('pluggyai-status', pluggyAiStatus);
1287
+ app.method('simplefin-accounts', simpleFinAccounts);
1288
+ app.method('pluggyai-accounts', pluggyAiAccounts);
1289
+ app.method('gocardless-get-banks', getGoCardlessBanks);
1290
+ app.method('gocardless-create-web-token', createGoCardlessWebToken);
1291
+ app.method('accounts-bank-sync', accountsBankSync);
1292
+ app.method('simplefin-batch-sync', simpleFinBatchSync);
1293
+ app.method('transactions-import', mutator(undoable(importTransactions)));
1294
+ app.method('account-unlink', mutator(unlinkAccount));