@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,1168 @@
1
+ // @ts-strict-ignore
2
+ import * as dateFns from 'date-fns';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+
5
+ import * as asyncStorage from '../../platform/server/asyncStorage';
6
+ import { logger } from '../../platform/server/log';
7
+ import * as monthUtils from '../../shared/months';
8
+ import { q } from '../../shared/query';
9
+ import {
10
+ makeChild as makeChildTransaction,
11
+ recalculateSplit,
12
+ } from '../../shared/transactions';
13
+ import {
14
+ amountToInteger,
15
+ hasFieldsChanged,
16
+ integerToAmount,
17
+ } from '../../shared/util';
18
+ import type {
19
+ AccountEntity,
20
+ BankSyncResponse,
21
+ TransactionEntity,
22
+ } from '../../types/models';
23
+ import { aqlQuery } from '../aql';
24
+ import * as db from '../db';
25
+ import { runMutator } from '../mutators';
26
+ import { post } from '../post';
27
+ import { getServer } from '../server-config';
28
+ import { batchMessages } from '../sync';
29
+ import { batchUpdateTransactions } from '../transactions';
30
+ import { runRules } from '../transactions/transaction-rules';
31
+ import {
32
+ defaultMappings,
33
+ mappingsFromString,
34
+ } from '../util/custom-sync-mapping';
35
+
36
+ import { getStartingBalancePayee } from './payees';
37
+ import { title } from './title';
38
+
39
+ function BankSyncError(type: string, code: string, details?: object) {
40
+ return { type: 'BankSyncError', category: type, code, details };
41
+ }
42
+
43
+ function makeSplitTransaction(trans, subtransactions) {
44
+ // We need to calculate the final state of split transactions
45
+ const { subtransactions: sub, ...parent } = recalculateSplit({
46
+ ...trans,
47
+ is_parent: true,
48
+ subtransactions: subtransactions.map((transaction, idx) =>
49
+ makeChildTransaction(trans, {
50
+ ...transaction,
51
+ sort_order: 0 - idx,
52
+ }),
53
+ ),
54
+ });
55
+ return [parent, ...sub];
56
+ }
57
+
58
+ function getAccountBalance(account) {
59
+ // Debt account types need their balance reversed
60
+ switch (account.type) {
61
+ case 'credit':
62
+ case 'loan':
63
+ return -account.balances.current;
64
+ default:
65
+ return account.balances.current;
66
+ }
67
+ }
68
+
69
+ async function updateAccountBalance(id: AccountEntity['id'], balance: number) {
70
+ db.runQuery('UPDATE accounts SET balance_current = ? WHERE id = ?', [
71
+ balance,
72
+ id,
73
+ ]);
74
+ }
75
+
76
+ async function getAccountOldestTransaction(id): Promise<TransactionEntity> {
77
+ return (
78
+ await aqlQuery(
79
+ q('transactions')
80
+ .filter({
81
+ account: id,
82
+ date: { $lte: monthUtils.currentDay() },
83
+ })
84
+ .select('date')
85
+ .orderBy('date')
86
+ .limit(1),
87
+ )
88
+ ).data?.[0];
89
+ }
90
+
91
+ async function getAccountSyncStartDate(id) {
92
+ // Many GoCardless integrations do not support getting more than 90 days
93
+ // worth of data, so make that the earliest possible limit.
94
+ const dates = [monthUtils.subDays(monthUtils.currentDay(), 90)];
95
+
96
+ const oldestTransaction = await getAccountOldestTransaction(id);
97
+
98
+ if (oldestTransaction) dates.push(oldestTransaction.date);
99
+
100
+ return monthUtils.dayFromDate(
101
+ dateFns.max(dates.map(d => monthUtils.parseDate(d))),
102
+ );
103
+ }
104
+
105
+ export async function getGoCardlessAccounts(userId, userKey, id) {
106
+ const userToken = await asyncStorage.getItem('user-token');
107
+ if (!userToken) return;
108
+
109
+ const res = await post(
110
+ getServer().GOCARDLESS_SERVER + '/accounts',
111
+ {
112
+ userId,
113
+ key: userKey,
114
+ item_id: id,
115
+ },
116
+ {
117
+ 'X-ACTUAL-TOKEN': userToken,
118
+ },
119
+ );
120
+
121
+ const { accounts } = res;
122
+
123
+ accounts.forEach(acct => {
124
+ acct.balances.current = getAccountBalance(acct);
125
+ });
126
+
127
+ return accounts;
128
+ }
129
+
130
+ async function downloadGoCardlessTransactions(
131
+ userId,
132
+ userKey,
133
+ acctId,
134
+ bankId,
135
+ since,
136
+ includeBalance = true,
137
+ ) {
138
+ const userToken = await asyncStorage.getItem('user-token');
139
+ if (!userToken) return;
140
+
141
+ logger.log('Pulling transactions from GoCardless');
142
+
143
+ const res = await post(
144
+ getServer().GOCARDLESS_SERVER + '/transactions',
145
+ {
146
+ userId,
147
+ key: userKey,
148
+ requisitionId: bankId,
149
+ accountId: acctId,
150
+ startDate: since,
151
+ includeBalance,
152
+ },
153
+ {
154
+ 'X-ACTUAL-TOKEN': userToken,
155
+ },
156
+ );
157
+
158
+ if (res.error_code) {
159
+ const errorDetails = {
160
+ rateLimitHeaders: res.rateLimitHeaders,
161
+ };
162
+
163
+ throw BankSyncError(res.error_type, res.error_code, errorDetails);
164
+ }
165
+
166
+ if (includeBalance) {
167
+ const {
168
+ transactions: { all },
169
+ balances,
170
+ startingBalance,
171
+ } = res;
172
+
173
+ logger.log('Response:', res);
174
+
175
+ return {
176
+ transactions: all,
177
+ accountBalance: balances,
178
+ startingBalance,
179
+ };
180
+ } else {
181
+ logger.log('Response:', res);
182
+
183
+ return {
184
+ transactions: res.transactions.all,
185
+ };
186
+ }
187
+ }
188
+
189
+ async function downloadSimpleFinTransactions(
190
+ acctId: AccountEntity['id'] | AccountEntity['id'][],
191
+ since: string | string[],
192
+ ) {
193
+ const userToken = await asyncStorage.getItem('user-token');
194
+ if (!userToken) return;
195
+
196
+ const batchSync = Array.isArray(acctId);
197
+
198
+ logger.log('Pulling transactions from SimpleFin');
199
+
200
+ let res;
201
+ try {
202
+ res = await post(
203
+ getServer().SIMPLEFIN_SERVER + '/transactions',
204
+ {
205
+ accountId: acctId,
206
+ startDate: since,
207
+ },
208
+ {
209
+ 'X-ACTUAL-TOKEN': userToken,
210
+ },
211
+ // 5 minute timeout for batch sync, one minute for individual accounts
212
+ Array.isArray(acctId) ? 300000 : 60000,
213
+ );
214
+ } catch (error) {
215
+ logger.error('Suspected timeout during bank sync:', error);
216
+ throw BankSyncError('TIMED_OUT', 'TIMED_OUT');
217
+ }
218
+
219
+ if (Object.keys(res).length === 0) {
220
+ throw BankSyncError('NO_DATA', 'NO_DATA');
221
+ }
222
+ if (res.error_code) {
223
+ throw BankSyncError(res.error_type, res.error_code);
224
+ }
225
+
226
+ let retVal = {};
227
+ if (batchSync) {
228
+ const batchErrors = res.errors;
229
+ for (const accountId of Object.keys(res)) {
230
+ if (accountId === 'errors') continue;
231
+
232
+ const data = res[accountId];
233
+ const error = batchErrors?.[accountId]?.[0];
234
+
235
+ retVal[accountId] = {
236
+ transactions: data?.transactions?.all,
237
+ accountBalance: data?.balances,
238
+ startingBalance: data?.startingBalance,
239
+ };
240
+
241
+ if (error) {
242
+ retVal[accountId].error_type = error.error_type;
243
+ retVal[accountId].error_code = error.error_code;
244
+ }
245
+ }
246
+
247
+ // Add entries for accounts that only have errors (no data in the response)
248
+ if (batchErrors) {
249
+ for (const [accountId, errorList] of Object.entries(batchErrors)) {
250
+ if (
251
+ !retVal[accountId] &&
252
+ Array.isArray(errorList) &&
253
+ errorList.length > 0
254
+ ) {
255
+ const error = errorList[0];
256
+ retVal[accountId] = {
257
+ transactions: [],
258
+ accountBalance: [],
259
+ startingBalance: 0,
260
+ error_type: error.error_type,
261
+ error_code: error.error_code,
262
+ };
263
+ }
264
+ }
265
+ }
266
+ } else {
267
+ retVal = {
268
+ transactions: res.transactions.all,
269
+ accountBalance: res.balances,
270
+ startingBalance: res.startingBalance,
271
+ };
272
+ }
273
+
274
+ logger.log('Response:', retVal);
275
+ return retVal;
276
+ }
277
+
278
+ async function downloadPluggyAiTransactions(
279
+ acctId: AccountEntity['id'],
280
+ since: string,
281
+ ) {
282
+ const userToken = await asyncStorage.getItem('user-token');
283
+ if (!userToken) return;
284
+
285
+ logger.log('Pulling transactions from Pluggy.ai');
286
+
287
+ const res = await post(
288
+ getServer().PLUGGYAI_SERVER + '/transactions',
289
+ {
290
+ accountId: acctId,
291
+ startDate: since,
292
+ },
293
+ {
294
+ 'X-ACTUAL-TOKEN': userToken,
295
+ },
296
+ 60000,
297
+ );
298
+
299
+ if (res.error_code) {
300
+ throw BankSyncError(res.error_type, res.error_code);
301
+ } else if ('error' in res) {
302
+ throw BankSyncError('Connection', res.error);
303
+ }
304
+
305
+ let retVal = {};
306
+ const singleRes = res as BankSyncResponse;
307
+ retVal = {
308
+ transactions: singleRes.transactions.all,
309
+ accountBalance: singleRes.balances,
310
+ startingBalance: singleRes.startingBalance,
311
+ };
312
+
313
+ logger.log('Response:', retVal);
314
+ return retVal;
315
+ }
316
+
317
+ async function resolvePayee(trans, payeeName, payeesToCreate) {
318
+ if (trans.payee == null && payeeName) {
319
+ // First check our registry of new payees (to avoid a db access)
320
+ // then check the db for existing payees
321
+ let payee = payeesToCreate.get(payeeName.toLowerCase());
322
+ payee = payee || (await db.getPayeeByName(payeeName));
323
+
324
+ if (payee != null) {
325
+ return payee.id;
326
+ } else {
327
+ // Otherwise we're going to create a new one
328
+ const newPayee = { id: uuidv4(), name: payeeName };
329
+ payeesToCreate.set(payeeName.toLowerCase(), newPayee);
330
+ return newPayee.id;
331
+ }
332
+ }
333
+
334
+ return trans.payee;
335
+ }
336
+
337
+ async function normalizeTransactions(
338
+ transactions,
339
+ acctId,
340
+ { rawPayeeName = false } = {},
341
+ ) {
342
+ const payeesToCreate = new Map();
343
+
344
+ const normalized = [];
345
+ for (let trans of transactions) {
346
+ // Validate the date because we do some stuff with it. The db
347
+ // layer does better validation, but this will give nicer errors
348
+ if (trans.date == null) {
349
+ throw new Error('`date` is required when adding a transaction');
350
+ }
351
+
352
+ // Strip off the irregular properties
353
+ const { payee_name: originalPayeeName, subtransactions, ...rest } = trans;
354
+ trans = rest;
355
+
356
+ let payee_name = originalPayeeName;
357
+ if (payee_name) {
358
+ const trimmed = payee_name.trim();
359
+ if (trimmed === '') {
360
+ payee_name = null;
361
+ } else {
362
+ payee_name = rawPayeeName ? trimmed : title(trimmed);
363
+ }
364
+ }
365
+
366
+ trans.imported_payee = trans.imported_payee || payee_name;
367
+ if (trans.imported_payee) {
368
+ trans.imported_payee = trans.imported_payee.trim();
369
+ }
370
+
371
+ // It's important to resolve both the account and payee early so
372
+ // when rules are run, they have the right data. Resolving payees
373
+ // also simplifies the payee creation process
374
+ trans.account = acctId;
375
+ trans.payee = await resolvePayee(trans, payee_name, payeesToCreate);
376
+
377
+ trans.category = trans.category ?? null;
378
+
379
+ normalized.push({
380
+ payee_name,
381
+ subtransactions: subtransactions
382
+ ? subtransactions.map(t => ({ ...t, account: acctId }))
383
+ : null,
384
+ trans,
385
+ });
386
+ }
387
+
388
+ return { normalized, payeesToCreate };
389
+ }
390
+
391
+ async function normalizeBankSyncTransactions(transactions, acctId) {
392
+ const payeesToCreate = new Map();
393
+
394
+ const [customMappingsRaw, importPending, importNotes] = await Promise.all([
395
+ aqlQuery(
396
+ q('preferences')
397
+ .filter({ id: `custom-sync-mappings-${acctId}` })
398
+ .select('value'),
399
+ ).then(data => data?.data?.[0]?.value),
400
+ aqlQuery(
401
+ q('preferences')
402
+ .filter({ id: `sync-import-pending-${acctId}` })
403
+ .select('value'),
404
+ ).then(data => String(data?.data?.[0]?.value ?? 'true') === 'true'),
405
+ aqlQuery(
406
+ q('preferences')
407
+ .filter({ id: `sync-import-notes-${acctId}` })
408
+ .select('value'),
409
+ ).then(data => String(data?.data?.[0]?.value ?? 'true') === 'true'),
410
+ ]);
411
+
412
+ const mappings = customMappingsRaw
413
+ ? mappingsFromString(customMappingsRaw)
414
+ : defaultMappings;
415
+
416
+ const normalized = [];
417
+ for (const trans of transactions) {
418
+ trans.cleared = Boolean(trans.booked);
419
+
420
+ if (!importPending && !trans.cleared) continue;
421
+
422
+ if (!trans.amount) {
423
+ trans.amount = trans.transactionAmount.amount;
424
+ }
425
+
426
+ const mapping = mappings.get(trans.amount <= 0 ? 'payment' : 'deposit');
427
+
428
+ const date = trans[mapping.get('date')] ?? trans.date;
429
+ const payeeName = trans[mapping.get('payee')] ?? trans.payeeName;
430
+ const notes = trans[mapping.get('notes')];
431
+
432
+ // Validate the date because we do some stuff with it. The db
433
+ // layer does better validation, but this will give nicer errors
434
+ if (date == null) {
435
+ throw new Error('`date` is required when adding a transaction');
436
+ }
437
+
438
+ if (payeeName == null) {
439
+ throw new Error('`payeeName` is required when adding a transaction');
440
+ }
441
+
442
+ trans.imported_payee = trans.imported_payee || payeeName;
443
+ if (trans.imported_payee) {
444
+ trans.imported_payee = trans.imported_payee.trim();
445
+ }
446
+
447
+ let imported_id = trans.transactionId;
448
+ if (trans.cleared && !trans.transactionId && trans.internalTransactionId) {
449
+ imported_id = `${trans.account}-${trans.internalTransactionId}`;
450
+ }
451
+
452
+ // It's important to resolve both the account and payee early so
453
+ // when rules are run, they have the right data. Resolving payees
454
+ // also simplifies the payee creation process
455
+ trans.account = acctId;
456
+ trans.payee = await resolvePayee(trans, payeeName, payeesToCreate);
457
+
458
+ normalized.push({
459
+ payee_name: payeeName,
460
+ trans: {
461
+ amount: amountToInteger(trans.amount),
462
+ payee: trans.payee,
463
+ account: trans.account,
464
+ date,
465
+ notes: importNotes && notes ? notes.trim().replace(/#/g, '##') : null,
466
+ category: trans.category ?? null,
467
+ imported_id,
468
+ imported_payee: trans.imported_payee,
469
+ cleared: trans.cleared,
470
+ raw_synced_data: JSON.stringify(trans),
471
+ },
472
+ });
473
+ }
474
+
475
+ return { normalized, payeesToCreate };
476
+ }
477
+
478
+ async function createNewPayees(payeesToCreate, addsAndUpdates) {
479
+ const usedPayeeIds = new Set(addsAndUpdates.map(t => t.payee));
480
+
481
+ await batchMessages(async () => {
482
+ for (const payee of payeesToCreate.values()) {
483
+ // Only create the payee if it ended up being used
484
+ if (usedPayeeIds.has(payee.id)) {
485
+ await db.insertPayee(payee);
486
+ }
487
+ }
488
+ });
489
+ }
490
+
491
+ export type ReconcileTransactionsResult = {
492
+ added: string[];
493
+ updated: string[];
494
+ updatedPreview: Array<{
495
+ transaction: TransactionEntity;
496
+ existing?: TransactionEntity;
497
+ ignored?: boolean;
498
+ tombstone?: boolean;
499
+ }>;
500
+ };
501
+
502
+ export async function reconcileTransactions(
503
+ acctId,
504
+ transactions,
505
+ isBankSyncAccount = false,
506
+ strictIdChecking = true,
507
+ isPreview = false,
508
+ defaultCleared = true,
509
+ updateDates = false,
510
+ ): Promise<ReconcileTransactionsResult> {
511
+ logger.log('Performing transaction reconciliation');
512
+
513
+ const updated = [];
514
+ const added = [];
515
+ const updatedPreview = [];
516
+ const existingPayeeMap = new Map<string, string>();
517
+
518
+ const {
519
+ payeesToCreate,
520
+ transactionsStep1,
521
+ transactionsStep2,
522
+ transactionsStep3,
523
+ } = await matchTransactions(
524
+ acctId,
525
+ transactions,
526
+ isBankSyncAccount,
527
+ strictIdChecking,
528
+ );
529
+
530
+ // Finally, generate & commit the changes
531
+ for (const { trans, subtransactions, match } of transactionsStep3) {
532
+ if (match && !trans.forceAddTransaction) {
533
+ // Skip updating already reconciled (locked) transactions
534
+ if (match.reconciled) {
535
+ updatedPreview.push({ transaction: trans, ignored: true });
536
+ continue;
537
+ }
538
+
539
+ // TODO: change the above sql query to use aql
540
+ const existing = {
541
+ ...match,
542
+ cleared: match.cleared === 1,
543
+ date: db.fromDateRepr(match.date),
544
+ };
545
+
546
+ // Update the transaction
547
+ const updates = {
548
+ imported_id: trans.imported_id || null,
549
+ payee: existing.payee || trans.payee || null,
550
+ category: existing.category || trans.category || null,
551
+ imported_payee: trans.imported_payee || null,
552
+ notes: existing.notes || trans.notes || null,
553
+ cleared: existing.cleared || trans.cleared || false,
554
+ raw_synced_data:
555
+ existing.raw_synced_data ?? trans.raw_synced_data ?? null,
556
+ };
557
+
558
+ if (updateDates && trans.date) {
559
+ updates['date'] = trans.date;
560
+ }
561
+
562
+ const fieldsToMarkUpdated = Object.keys(updates).filter(k => {
563
+ // do not mark raw_synced_data if it's gone from falsy to falsy
564
+ if (!existing.raw_synced_data && !trans.raw_synced_data) {
565
+ return k !== 'raw_synced_data';
566
+ }
567
+
568
+ return true;
569
+ });
570
+
571
+ if (hasFieldsChanged(existing, updates, fieldsToMarkUpdated)) {
572
+ updated.push({ id: existing.id, ...updates });
573
+ if (!existingPayeeMap.has(existing.payee)) {
574
+ const payee = await db.getPayee(existing.payee);
575
+ existingPayeeMap.set(existing.payee, payee?.name);
576
+ }
577
+ existing.payee_name = existingPayeeMap.get(existing.payee);
578
+ existing.amount = integerToAmount(existing.amount);
579
+ updatedPreview.push({ transaction: trans, existing });
580
+ } else {
581
+ updatedPreview.push({ transaction: trans, ignored: true });
582
+ }
583
+
584
+ const clearedUpdated = existing.cleared !== updates.cleared;
585
+ const dateUpdated =
586
+ updateDates && trans.date && existing.date !== trans.date;
587
+
588
+ if (existing.is_parent && (clearedUpdated || dateUpdated)) {
589
+ const children = await db.all<Pick<db.DbViewTransaction, 'id'>>(
590
+ 'SELECT id FROM v_transactions WHERE parent_id = ?',
591
+ [existing.id],
592
+ );
593
+ const childUpdates = {};
594
+
595
+ if (clearedUpdated) {
596
+ childUpdates['cleared'] = updates.cleared;
597
+ }
598
+
599
+ if (dateUpdated) {
600
+ childUpdates['date'] = trans.date;
601
+ }
602
+
603
+ for (const child of children) {
604
+ updated.push({ id: child.id, ...childUpdates });
605
+ }
606
+ }
607
+ } else if (trans.tombstone) {
608
+ if (isPreview) {
609
+ updatedPreview.push({
610
+ transaction: trans,
611
+ existing: false,
612
+ tombstone: true,
613
+ });
614
+ }
615
+ } else {
616
+ // Insert a new transaction
617
+ const { forceAddTransaction: _forceAddTransaction, ...newTrans } = trans;
618
+ const finalTransaction = {
619
+ ...newTrans,
620
+ id: uuidv4(),
621
+ category: trans.category || null,
622
+ cleared: trans.cleared ?? defaultCleared,
623
+ };
624
+
625
+ if (subtransactions && subtransactions.length > 0) {
626
+ added.push(...makeSplitTransaction(finalTransaction, subtransactions));
627
+ } else {
628
+ added.push(finalTransaction);
629
+ }
630
+ }
631
+ }
632
+
633
+ // Maintain the sort order of the server
634
+ const now = Date.now();
635
+ added.forEach((t, index) => {
636
+ t.sort_order ??= now - index;
637
+ });
638
+
639
+ if (!isPreview) {
640
+ await createNewPayees(payeesToCreate, [...added, ...updated]);
641
+ await batchUpdateTransactions({ added, updated });
642
+ }
643
+
644
+ logger.log('Debug data for the operations:', {
645
+ transactionsStep1,
646
+ transactionsStep2,
647
+ transactionsStep3,
648
+ added,
649
+ updated,
650
+ updatedPreview,
651
+ });
652
+
653
+ return {
654
+ added: added.map(trans => trans.id),
655
+ updated: updated.map(trans => trans.id),
656
+ updatedPreview,
657
+ };
658
+ }
659
+
660
+ export async function matchTransactions(
661
+ acctId,
662
+ transactions,
663
+ isBankSyncAccount = false,
664
+ strictIdChecking = true,
665
+ ) {
666
+ logger.log('Performing transaction reconciliation matching');
667
+
668
+ const reimportDeleted = await aqlQuery(
669
+ q('preferences')
670
+ .filter({ id: `sync-reimport-deleted-${acctId}` })
671
+ .select('value'),
672
+ ).then(data => String(data?.data?.[0]?.value ?? 'true') === 'true');
673
+
674
+ const hasMatched = new Set();
675
+
676
+ const transactionNormalization = isBankSyncAccount
677
+ ? normalizeBankSyncTransactions
678
+ : normalizeTransactions;
679
+
680
+ const { normalized, payeesToCreate } = await transactionNormalization(
681
+ transactions,
682
+ acctId,
683
+ );
684
+
685
+ // The first pass runs the rules, and preps data for fuzzy matching
686
+ const accounts: db.DbAccount[] = await db.getAccounts();
687
+ const accountsMap = new Map(accounts.map(account => [account.id, account]));
688
+
689
+ const transactionsStep1 = [];
690
+ for (const {
691
+ payee_name,
692
+ trans: originalTrans,
693
+ subtransactions,
694
+ } of normalized) {
695
+ // Run the rules
696
+ const trans = await runRules(originalTrans, accountsMap);
697
+
698
+ let match = null;
699
+ let fuzzyDataset = null;
700
+
701
+ // First, match with an existing transaction's imported_id. This
702
+ // is the highest fidelity match and should always be attempted
703
+ // first.
704
+ if (trans.imported_id) {
705
+ const table = reimportDeleted
706
+ ? 'v_transactions'
707
+ : 'v_transactions_internal';
708
+ match = await db.first<db.DbTransaction>(
709
+ `SELECT * FROM ${table} WHERE imported_id = ? AND account = ?`,
710
+ [trans.imported_id, acctId],
711
+ );
712
+
713
+ if (match) {
714
+ hasMatched.add(match.id);
715
+ }
716
+ }
717
+
718
+ // If it didn't match, query data needed for fuzzy matching
719
+ if (!match) {
720
+ // Fuzzy matching looks 7 days ahead and 7 days back. This
721
+ // needs to select all fields that need to be read from the
722
+ // matched transaction. See the final pass below for the needed
723
+ // fields.
724
+ const sevenDaysBefore = db.toDateRepr(monthUtils.subDays(trans.date, 7));
725
+ const sevenDaysAfter = db.toDateRepr(monthUtils.addDays(trans.date, 7));
726
+ // strictIdChecking has the added behaviour of only matching on transactions with no import ID
727
+ // if the transaction being imported has an import ID.
728
+ if (strictIdChecking) {
729
+ fuzzyDataset = await db.all<
730
+ Pick<
731
+ db.DbViewTransaction,
732
+ | 'id'
733
+ | 'is_parent'
734
+ | 'date'
735
+ | 'imported_id'
736
+ | 'payee'
737
+ | 'imported_payee'
738
+ | 'category'
739
+ | 'notes'
740
+ | 'reconciled'
741
+ | 'cleared'
742
+ | 'amount'
743
+ >
744
+ >(
745
+ `SELECT id, is_parent, date, imported_id, payee, imported_payee, category, notes, reconciled, cleared, amount
746
+ FROM v_transactions
747
+ WHERE
748
+ -- If both ids are set, and we didn't match earlier then skip dedup
749
+ (imported_id IS NULL OR ? IS NULL)
750
+ AND date >= ? AND date <= ? AND amount = ?
751
+ AND account = ?`,
752
+ [
753
+ trans.imported_id || null,
754
+ sevenDaysBefore,
755
+ sevenDaysAfter,
756
+ trans.amount || 0,
757
+ acctId,
758
+ ],
759
+ );
760
+ } else {
761
+ fuzzyDataset = await db.all<
762
+ Pick<
763
+ db.DbViewTransaction,
764
+ | 'id'
765
+ | 'is_parent'
766
+ | 'date'
767
+ | 'imported_id'
768
+ | 'payee'
769
+ | 'imported_payee'
770
+ | 'category'
771
+ | 'notes'
772
+ | 'reconciled'
773
+ | 'cleared'
774
+ | 'amount'
775
+ >
776
+ >(
777
+ `SELECT id, is_parent, date, imported_id, payee, imported_payee, category, notes, reconciled, cleared, amount
778
+ FROM v_transactions
779
+ WHERE date >= ? AND date <= ? AND amount = ? AND account = ?`,
780
+ [sevenDaysBefore, sevenDaysAfter, trans.amount || 0, acctId],
781
+ );
782
+ }
783
+
784
+ // Sort the matched transactions according to the distance from the original
785
+ // transactions date. i.e. if the original transaction is in 21-02-2024 and
786
+ // the matched transactions are: 20-02-2024, 21-02-2024, 29-02-2024 then
787
+ // the resulting data-set should be: 21-02-2024, 20-02-2024, 29-02-2024.
788
+ fuzzyDataset = fuzzyDataset.sort((a, b) => {
789
+ const aDistance = Math.abs(
790
+ dateFns.differenceInMilliseconds(
791
+ dateFns.parseISO(trans.date),
792
+ dateFns.parseISO(db.fromDateRepr(a.date)),
793
+ ),
794
+ );
795
+ const bDistance = Math.abs(
796
+ dateFns.differenceInMilliseconds(
797
+ dateFns.parseISO(trans.date),
798
+ dateFns.parseISO(db.fromDateRepr(b.date)),
799
+ ),
800
+ );
801
+ return aDistance > bDistance ? 1 : -1;
802
+ });
803
+ }
804
+
805
+ transactionsStep1.push({
806
+ payee_name,
807
+ trans,
808
+ subtransactions: trans.subtransactions || subtransactions,
809
+ match,
810
+ fuzzyDataset,
811
+ });
812
+ }
813
+
814
+ // Next, do the fuzzy matching. This first pass matches based on the
815
+ // payee id. We do this in multiple passes so that higher fidelity
816
+ // matching always happens first, i.e. a transaction should match
817
+ // match with low fidelity if a later transaction is going to match
818
+ // the same one with high fidelity.
819
+ const transactionsStep2 = transactionsStep1.map(data => {
820
+ if (!data.match && data.fuzzyDataset) {
821
+ // Try to find one where the payees match.
822
+ const match = data.fuzzyDataset.find(
823
+ row => !hasMatched.has(row.id) && data.trans.payee === row.payee,
824
+ );
825
+
826
+ if (match) {
827
+ hasMatched.add(match.id);
828
+ return { ...data, match };
829
+ }
830
+ }
831
+ return data;
832
+ });
833
+
834
+ // The final fuzzy matching pass. This is the lowest fidelity
835
+ // matching: it just find the first transaction that hasn't been
836
+ // matched yet. Remember the dataset only contains transactions
837
+ // around the same date with the same amount.
838
+ const transactionsStep3 = transactionsStep2.map(data => {
839
+ if (!data.match && data.fuzzyDataset) {
840
+ const match = data.fuzzyDataset.find(row => !hasMatched.has(row.id));
841
+ if (match) {
842
+ hasMatched.add(match.id);
843
+ return { ...data, match };
844
+ }
845
+ }
846
+ return data;
847
+ });
848
+
849
+ return {
850
+ payeesToCreate,
851
+ transactionsStep1,
852
+ transactionsStep2,
853
+ transactionsStep3,
854
+ };
855
+ }
856
+
857
+ // This is similar to `reconcileTransactions` except much simpler: it
858
+ // does not try to match any transactions. It just adds them
859
+ export async function addTransactions(
860
+ acctId,
861
+ transactions,
862
+ { runTransfers = true, learnCategories = false } = {},
863
+ ) {
864
+ const added = [];
865
+
866
+ const { normalized, payeesToCreate } = await normalizeTransactions(
867
+ transactions,
868
+ acctId,
869
+ { rawPayeeName: true },
870
+ );
871
+
872
+ const accounts: db.DbAccount[] = await db.getAccounts();
873
+ const accountsMap = new Map(accounts.map(account => [account.id, account]));
874
+
875
+ for (const { trans: originalTrans, subtransactions } of normalized) {
876
+ // Run the rules
877
+ const trans = await runRules(originalTrans, accountsMap);
878
+
879
+ const finalTransaction = {
880
+ id: uuidv4(),
881
+ ...trans,
882
+ account: acctId,
883
+ cleared: trans.cleared != null ? trans.cleared : true,
884
+ };
885
+
886
+ // Add split transactions if they are given
887
+ const updatedSubtransactions =
888
+ finalTransaction.subtransactions || subtransactions;
889
+ if (updatedSubtransactions && updatedSubtransactions.length > 0) {
890
+ added.push(
891
+ ...makeSplitTransaction(finalTransaction, updatedSubtransactions),
892
+ );
893
+ } else {
894
+ added.push(finalTransaction);
895
+ }
896
+ }
897
+
898
+ await createNewPayees(payeesToCreate, added);
899
+
900
+ let newTransactions;
901
+ if (runTransfers || learnCategories) {
902
+ const res = await batchUpdateTransactions({
903
+ added,
904
+ learnCategories,
905
+ runTransfers,
906
+ });
907
+ newTransactions = res.added.map(t => t.id);
908
+ } else {
909
+ await batchMessages(async () => {
910
+ newTransactions = await Promise.all(
911
+ added.map(async trans => db.insertTransaction(trans)),
912
+ );
913
+ });
914
+ }
915
+ return newTransactions;
916
+ }
917
+
918
+ async function processBankSyncDownload(
919
+ download,
920
+ id,
921
+ acctRow,
922
+ initialSync = false,
923
+ customStartingBalance?: number,
924
+ customStartingDate?: string,
925
+ ) {
926
+ // If syncing an account from sync source it must not use strictIdChecking. This allows
927
+ // the fuzzy search to match transactions where the import IDs are different. It is a known quirk
928
+ // that account sync sources can give two different transaction IDs even though it's the same transaction.
929
+ const useStrictIdChecking = !acctRow.account_sync_source;
930
+
931
+ const importTransactions = await aqlQuery(
932
+ q('preferences')
933
+ .filter({ id: `sync-import-transactions-${id}` })
934
+ .select('value'),
935
+ ).then(data => String(data?.data?.[0]?.value ?? 'true') === 'true');
936
+
937
+ const updateDates = await aqlQuery(
938
+ q('preferences')
939
+ .filter({ id: `sync-update-dates-${id}` })
940
+ .select('value'),
941
+ ).then(data => String(data?.data?.[0]?.value ?? 'false') === 'true');
942
+
943
+ /** Starting balance is actually the current balance of the account. */
944
+ const {
945
+ transactions: originalTransactions,
946
+ startingBalance: currentBalance,
947
+ } = download;
948
+
949
+ if (initialSync) {
950
+ const { transactions } = download;
951
+ let balanceToUse = currentBalance;
952
+
953
+ // Use custom starting balance if provided, otherwise calculate it
954
+ if (customStartingBalance !== undefined) {
955
+ balanceToUse = customStartingBalance;
956
+ } else if (acctRow.account_sync_source === 'simpleFin') {
957
+ const previousBalance = transactions.reduce((total, trans) => {
958
+ return (
959
+ total - parseInt(trans.transactionAmount.amount.replace('.', ''))
960
+ );
961
+ }, currentBalance);
962
+ balanceToUse = previousBalance;
963
+ } else if (acctRow.account_sync_source === 'pluggyai') {
964
+ const currentBalance = download.startingBalance;
965
+ const previousBalance = transactions.reduce(
966
+ (total, trans) => total - trans.transactionAmount.amount * 100,
967
+ currentBalance,
968
+ );
969
+ balanceToUse = Math.round(previousBalance);
970
+ }
971
+
972
+ const oldestTransaction = transactions[transactions.length - 1];
973
+
974
+ // Use custom starting date if provided, otherwise use oldest transaction date or current day
975
+ let startingBalanceDate: string;
976
+ if (customStartingDate) {
977
+ startingBalanceDate = customStartingDate;
978
+ } else if (transactions.length > 0) {
979
+ startingBalanceDate = oldestTransaction.date;
980
+ } else {
981
+ startingBalanceDate = monthUtils.currentDay();
982
+ }
983
+
984
+ const payee = await getStartingBalancePayee();
985
+
986
+ return runMutator(async () => {
987
+ const initialId = await db.insertTransaction({
988
+ account: id,
989
+ amount: balanceToUse,
990
+ category: acctRow.offbudget === 0 ? payee.category : null,
991
+ payee: payee.id,
992
+ date: startingBalanceDate,
993
+ cleared: true,
994
+ starting_balance_flag: true,
995
+ });
996
+
997
+ const result = await reconcileTransactions(
998
+ id,
999
+ transactions,
1000
+ true,
1001
+ useStrictIdChecking,
1002
+ false,
1003
+ true,
1004
+ updateDates,
1005
+ );
1006
+ return {
1007
+ ...result,
1008
+ added: [initialId, ...result.added],
1009
+ };
1010
+ });
1011
+ }
1012
+
1013
+ const transactions = originalTransactions.map(trans => ({
1014
+ ...trans,
1015
+ account: id,
1016
+ }));
1017
+
1018
+ return runMutator(async () => {
1019
+ const result = await reconcileTransactions(
1020
+ id,
1021
+ importTransactions ? transactions : [],
1022
+ true,
1023
+ useStrictIdChecking,
1024
+ false,
1025
+ true,
1026
+ updateDates,
1027
+ );
1028
+
1029
+ if (currentBalance != null) {
1030
+ await updateAccountBalance(id, currentBalance);
1031
+ }
1032
+
1033
+ return result;
1034
+ });
1035
+ }
1036
+
1037
+ export async function syncAccount(
1038
+ userId: string | undefined,
1039
+ userKey: string | undefined,
1040
+ id: string,
1041
+ acctId: string,
1042
+ bankId: string,
1043
+ customStartingDate?: string,
1044
+ customStartingBalance?: number,
1045
+ ) {
1046
+ const acctRow = await db.select('accounts', id);
1047
+
1048
+ const syncStartDate =
1049
+ customStartingDate ?? (await getAccountSyncStartDate(id));
1050
+ const oldestTransaction = await getAccountOldestTransaction(id);
1051
+ const newAccount = oldestTransaction == null;
1052
+
1053
+ let download;
1054
+ if (acctRow.account_sync_source === 'simpleFin') {
1055
+ download = await downloadSimpleFinTransactions(acctId, syncStartDate);
1056
+ } else if (acctRow.account_sync_source === 'pluggyai') {
1057
+ download = await downloadPluggyAiTransactions(acctId, syncStartDate);
1058
+ } else if (acctRow.account_sync_source === 'goCardless') {
1059
+ download = await downloadGoCardlessTransactions(
1060
+ userId,
1061
+ userKey,
1062
+ acctId,
1063
+ bankId,
1064
+ syncStartDate,
1065
+ newAccount,
1066
+ );
1067
+ } else {
1068
+ throw new Error(
1069
+ `Unrecognized bank-sync provider: ${acctRow.account_sync_source}`,
1070
+ );
1071
+ }
1072
+
1073
+ return processBankSyncDownload(
1074
+ download,
1075
+ id,
1076
+ acctRow,
1077
+ newAccount,
1078
+ customStartingBalance,
1079
+ customStartingDate,
1080
+ );
1081
+ }
1082
+
1083
+ export async function simpleFinBatchSync(
1084
+ accounts: Array<Pick<AccountEntity, 'id' | 'account_id'>>,
1085
+ ) {
1086
+ const startDates = await Promise.all(
1087
+ accounts.map(async a => getAccountSyncStartDate(a.id)),
1088
+ );
1089
+
1090
+ const res = await downloadSimpleFinTransactions(
1091
+ accounts.map(a => a.account_id),
1092
+ startDates,
1093
+ );
1094
+
1095
+ if (!res) {
1096
+ return accounts.map(account => ({
1097
+ accountId: account.id,
1098
+ res: {
1099
+ error_type: 'NO_DATA',
1100
+ error_code: 'NO_DATA',
1101
+ },
1102
+ }));
1103
+ }
1104
+
1105
+ const promises = [];
1106
+ for (let i = 0; i < accounts.length; i++) {
1107
+ const account = accounts[i];
1108
+ const download = res[account.account_id];
1109
+
1110
+ if (!download || Object.keys(download).length === 0) {
1111
+ promises.push(
1112
+ Promise.resolve({
1113
+ accountId: account.id,
1114
+ res: {
1115
+ error_type: 'ACCOUNT_MISSING',
1116
+ error_code: 'ACCOUNT_MISSING',
1117
+ },
1118
+ }),
1119
+ );
1120
+ continue;
1121
+ }
1122
+
1123
+ const acctRow = await db.select('accounts', account.id);
1124
+ const oldestTransaction = await getAccountOldestTransaction(account.id);
1125
+ const newAccount = oldestTransaction == null;
1126
+
1127
+ if (download.error_code) {
1128
+ promises.push(
1129
+ Promise.resolve({
1130
+ accountId: account.id,
1131
+ res: download,
1132
+ }),
1133
+ );
1134
+
1135
+ continue;
1136
+ }
1137
+
1138
+ if (!download.transactions) {
1139
+ promises.push(
1140
+ Promise.resolve({
1141
+ accountId: account.id,
1142
+ res: {
1143
+ error_type: 'ACCOUNT_MISSING',
1144
+ error_code: 'ACCOUNT_MISSING',
1145
+ },
1146
+ }),
1147
+ );
1148
+ continue;
1149
+ }
1150
+
1151
+ promises.push(
1152
+ processBankSyncDownload(download, account.id, acctRow, newAccount)
1153
+ .then(res => ({
1154
+ accountId: account.id,
1155
+ res,
1156
+ }))
1157
+ .catch(err => ({
1158
+ accountId: account.id,
1159
+ res: {
1160
+ error_type: err?.category || 'INTERNAL_ERROR',
1161
+ error_code: err?.code || 'INTERNAL_ERROR',
1162
+ },
1163
+ })),
1164
+ );
1165
+ }
1166
+
1167
+ return await Promise.all(promises);
1168
+ }