@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,1038 @@
1
+ // @ts-strict-ignore
2
+
3
+ import { logger } from '../../platform/server/log';
4
+ import {
5
+ addDays,
6
+ currentDay,
7
+ dayFromDate,
8
+ parseDate,
9
+ subDays,
10
+ } from '../../shared/months';
11
+ import { q } from '../../shared/query';
12
+ import { getApproxNumberThreshold, sortNumbers } from '../../shared/rules';
13
+ import { ungroupTransaction } from '../../shared/transactions';
14
+ import { fastSetMerge, partitionByField } from '../../shared/util';
15
+ import type {
16
+ RuleActionEntity,
17
+ RuleEntity,
18
+ TransactionEntity,
19
+ } from '../../types/models';
20
+ import { aqlQuery, schemaConfig } from '../aql';
21
+ import * as db from '../db';
22
+ import {
23
+ getAccount,
24
+ getCategory,
25
+ getPayee,
26
+ getPayeeByName,
27
+ insertPayee,
28
+ } from '../db';
29
+ import { getMappings } from '../db/mappings';
30
+ import { RuleError } from '../errors';
31
+ import { requiredFields, toDateRepr } from '../models';
32
+ import {
33
+ Action,
34
+ Condition,
35
+ execActions,
36
+ iterateIds,
37
+ migrateIds,
38
+ rankRules,
39
+ Rule,
40
+ RuleIndexer,
41
+ } from '../rules';
42
+ import { addSyncListener, batchMessages } from '../sync';
43
+
44
+ import { batchUpdateTransactions } from '.';
45
+
46
+ // TODO: Detect if it looks like the user is creating a rename rule
47
+ // and prompt to create it in the pre phase instead
48
+ // * We could also make the "create rule" button a dropdown that
49
+ // provides different "templates" like "create renaming rule"
50
+
51
+ export { iterateIds } from '../rules';
52
+
53
+ let allRules;
54
+ let unlistenSync;
55
+ let firstcharIndexer;
56
+ let payeeIndexer;
57
+
58
+ export function resetState() {
59
+ allRules = new Map();
60
+ firstcharIndexer = new RuleIndexer({
61
+ field: 'imported_payee',
62
+ method: 'firstchar',
63
+ });
64
+ payeeIndexer = new RuleIndexer({ field: 'payee' });
65
+ }
66
+
67
+ // Database functions
68
+
69
+ function invert(obj) {
70
+ return Object.fromEntries(
71
+ Object.entries(obj).map(entry => {
72
+ return [entry[1], entry[0]];
73
+ }),
74
+ );
75
+ }
76
+
77
+ const internalFields = schemaConfig.views.transactions.fields;
78
+ const publicFields = invert(schemaConfig.views.transactions.fields);
79
+
80
+ function fromInternalField<T extends { field: string }>(obj: T): T {
81
+ return {
82
+ ...obj,
83
+ field: publicFields[obj.field] || obj.field,
84
+ };
85
+ }
86
+
87
+ function toInternalField<T extends { field: string }>(obj: T): T {
88
+ return {
89
+ ...obj,
90
+ field: internalFields[obj.field] || obj.field,
91
+ };
92
+ }
93
+
94
+ function parseArray(str) {
95
+ let value;
96
+ try {
97
+ value = typeof str === 'string' ? JSON.parse(str) : str;
98
+ } catch {
99
+ throw new RuleError('internal', 'Cannot parse rule json');
100
+ }
101
+
102
+ if (!Array.isArray(value)) {
103
+ throw new RuleError('internal', 'Rule json must be an array');
104
+ }
105
+ return value;
106
+ }
107
+
108
+ export function parseConditionsOrActions(str) {
109
+ return str ? parseArray(str).map(item => fromInternalField(item)) : [];
110
+ }
111
+
112
+ export function serializeConditionsOrActions(arr) {
113
+ return JSON.stringify(arr.map(item => toInternalField(item)));
114
+ }
115
+
116
+ export const ruleModel = {
117
+ validate(rule, { update }: { update?: boolean } = {}) {
118
+ requiredFields('rules', rule, ['conditions', 'actions'], update);
119
+
120
+ if (!update || 'stage' in rule) {
121
+ if (
122
+ rule.stage !== 'pre' &&
123
+ rule.stage !== 'post' &&
124
+ rule.stage !== null
125
+ ) {
126
+ throw new Error('Invalid rule stage: ' + rule.stage);
127
+ }
128
+ }
129
+ if (!update || 'conditionsOp' in rule) {
130
+ if (!['and', 'or'].includes(rule.conditionsOp)) {
131
+ throw new Error('Invalid rule conditionsOp: ' + rule.conditionsOp);
132
+ }
133
+ }
134
+
135
+ return rule;
136
+ },
137
+
138
+ toJS(row) {
139
+ const { conditions, conditions_op, actions, ...fields } = row;
140
+ return {
141
+ ...fields,
142
+ conditionsOp: conditions_op,
143
+ conditions: parseConditionsOrActions(conditions),
144
+ actions: parseConditionsOrActions(actions),
145
+ };
146
+ },
147
+
148
+ fromJS(rule) {
149
+ const { conditions, conditionsOp, actions, ...row } = rule;
150
+ if (conditionsOp) {
151
+ row.conditions_op = conditionsOp;
152
+ }
153
+ if (Array.isArray(conditions)) {
154
+ row.conditions = serializeConditionsOrActions(conditions);
155
+ }
156
+ if (Array.isArray(actions)) {
157
+ row.actions = serializeConditionsOrActions(actions);
158
+ }
159
+ return row;
160
+ },
161
+ };
162
+
163
+ export function makeRule(data) {
164
+ let rule;
165
+ try {
166
+ rule = new Rule(ruleModel.toJS(data));
167
+ } catch (e) {
168
+ logger.warn('Invalid rule', e);
169
+ if (e instanceof RuleError) {
170
+ return null;
171
+ }
172
+ throw e;
173
+ }
174
+
175
+ // This is needed because we map ids on the fly, and they might
176
+ // not be persisted into the db. Mappings allow items to
177
+ // transparently merge with other items
178
+ migrateIds(rule, getMappings());
179
+
180
+ return rule;
181
+ }
182
+
183
+ export async function loadRules() {
184
+ resetState();
185
+
186
+ const rules = await db.all<db.DbRule>(`
187
+ SELECT * FROM rules
188
+ WHERE conditions IS NOT NULL AND actions IS NOT NULL AND tombstone = 0
189
+ `);
190
+
191
+ for (let i = 0; i < rules.length; i++) {
192
+ const desc = rules[i];
193
+ // These are old stages, can be removed before release
194
+ if (desc.stage === 'cleanup' || desc.stage === 'modify') {
195
+ desc.stage = 'pre';
196
+ }
197
+
198
+ const rule = makeRule(desc);
199
+ if (rule) {
200
+ allRules.set(rule.id, rule);
201
+ firstcharIndexer.index(rule);
202
+ payeeIndexer.index(rule);
203
+ }
204
+ }
205
+
206
+ if (unlistenSync) {
207
+ unlistenSync();
208
+ }
209
+ unlistenSync = addSyncListener(onApplySync);
210
+ }
211
+
212
+ export function getRules() {
213
+ // This can simply return the in-memory data
214
+ return [...allRules.values()];
215
+ }
216
+
217
+ export async function insertRule(
218
+ rule: Omit<RuleEntity, 'id'> & { id?: string },
219
+ ) {
220
+ rule = ruleModel.validate(rule);
221
+ return db.insertWithUUID('rules', ruleModel.fromJS(rule));
222
+ }
223
+
224
+ export async function updateRule(rule) {
225
+ rule = ruleModel.validate(rule, { update: true });
226
+ return db.update('rules', ruleModel.fromJS(rule));
227
+ }
228
+
229
+ export async function deleteRule(id: string) {
230
+ const schedule = await db.first<Pick<db.DbSchedule, 'id'>>(
231
+ 'SELECT id FROM schedules WHERE rule = ?',
232
+ [id],
233
+ );
234
+
235
+ if (schedule) {
236
+ return false;
237
+ }
238
+
239
+ await db.delete_('rules', id);
240
+ return true;
241
+ }
242
+
243
+ // Sync projections
244
+
245
+ function onApplySync(oldValues, newValues) {
246
+ newValues.forEach((items, table) => {
247
+ if (table === 'rules') {
248
+ items.forEach(newValue => {
249
+ const oldRule = allRules.get(newValue.id);
250
+
251
+ if (newValue.tombstone === 1) {
252
+ // Deleted, need to remove it from in-memory
253
+ const rule = allRules.get(newValue.id);
254
+ if (rule) {
255
+ allRules.delete(rule.getId());
256
+ firstcharIndexer.remove(rule);
257
+ payeeIndexer.remove(rule);
258
+ }
259
+ } else {
260
+ // Inserted/updated
261
+ const rule = makeRule(newValue);
262
+ if (rule) {
263
+ if (oldRule) {
264
+ firstcharIndexer.remove(oldRule);
265
+ payeeIndexer.remove(oldRule);
266
+ }
267
+ allRules.set(newValue.id, rule);
268
+ firstcharIndexer.index(rule);
269
+ payeeIndexer.index(rule);
270
+ }
271
+ }
272
+ });
273
+ }
274
+ });
275
+
276
+ // If any of the mapping tables have changed, we need to refresh the
277
+ // ids
278
+ const tables = [...newValues.keys()];
279
+ if (tables.find(table => table.indexOf('mapping') !== -1)) {
280
+ getRules().forEach(rule => {
281
+ migrateIds(rule, getMappings());
282
+ });
283
+ }
284
+ }
285
+
286
+ export async function getRuleIdFromScheduleId(
287
+ scheduleId: string,
288
+ ): Promise<string | null> {
289
+ const row = await db.first<Pick<db.DbSchedule, 'rule'>>(
290
+ 'SELECT rule FROM schedules WHERE id = ?',
291
+ [scheduleId],
292
+ );
293
+
294
+ return row?.rule || null;
295
+ }
296
+
297
+ export async function getAllRuleIdsFromSchedules(
298
+ excluding: string,
299
+ ): Promise<string[]> {
300
+ const rows = await db.all<Pick<db.DbSchedule, 'rule'>>(
301
+ 'SELECT rule FROM schedules',
302
+ );
303
+
304
+ // map all rule ids, filter out null/undefined, and de-dupe if needed
305
+ const ruleIds = rows
306
+ .map(r => r.rule)
307
+ .filter((rule): rule is string => !!rule)
308
+ .filter(ruleId => ruleId !== excluding);
309
+
310
+ return ruleIds;
311
+ }
312
+
313
+ // Runner
314
+ export async function runRules(
315
+ trans,
316
+ accounts: Map<string, db.DbAccount> | null = null,
317
+ ) {
318
+ let accountsMap: Map<string, db.DbAccount> = null;
319
+ if (accounts === null) {
320
+ accountsMap = new Map(
321
+ (await db.getAccounts()).map(account => [account.id, account]),
322
+ );
323
+ } else {
324
+ accountsMap = accounts;
325
+ }
326
+
327
+ let finalTrans = await prepareTransactionForRules({ ...trans }, accountsMap);
328
+
329
+ let scheduleRuleID = '';
330
+ // Check if a schedule is attached to this transaction and if so get the rule ID attached to that schedule.
331
+ if (trans.schedule != null) {
332
+ const ruleId = await getRuleIdFromScheduleId(trans.schedule);
333
+ if (ruleId != null) {
334
+ scheduleRuleID = ruleId;
335
+ }
336
+ }
337
+
338
+ const RuleIdsLinkedToSchedules =
339
+ await getAllRuleIdsFromSchedules(scheduleRuleID);
340
+
341
+ const rules = rankRules(
342
+ fastSetMerge(
343
+ firstcharIndexer.getApplicableRules(trans),
344
+ payeeIndexer.getApplicableRules(trans),
345
+ ),
346
+ );
347
+
348
+ for (let i = 0; i < rules.length; i++) {
349
+ // If there is a scheduleRuleID (meaning this transaction came from a schedule) then exclude rules linked to other schedules.
350
+ if (scheduleRuleID !== '') {
351
+ if (rules[i].id === scheduleRuleID) {
352
+ // bypass condition checking to run the rule even if the transaction date falls outside of the schedule's date range.
353
+ const changes = rules[i].execActions(finalTrans);
354
+ finalTrans = Object.assign({}, finalTrans, changes);
355
+ } else if (RuleIdsLinkedToSchedules.includes(rules[i].id)) {
356
+ // skip all other rules that are linked to other schedules.
357
+ continue;
358
+ } else {
359
+ // if a rule is not linked to a schedule, run it.
360
+ finalTrans = rules[i].apply(finalTrans);
361
+ }
362
+ } else {
363
+ // if there is no scheduleRuleID then just run all rules.
364
+ finalTrans = rules[i].apply(finalTrans);
365
+ }
366
+ }
367
+
368
+ return await finalizeTransactionForRules(finalTrans);
369
+ }
370
+
371
+ function conditionSpecialCases(cond: Condition | null): Condition | null {
372
+ if (!cond) {
373
+ return cond;
374
+ }
375
+
376
+ //special cases that require multiple conditions
377
+ if (cond.op === 'is' && cond.field === 'category' && cond.value === null) {
378
+ return new Condition(
379
+ 'and',
380
+ cond.field,
381
+ [
382
+ cond,
383
+ new Condition('is', 'transfer', false, null),
384
+ new Condition('is', 'parent', false, null),
385
+ ],
386
+ {},
387
+ );
388
+ } else if (
389
+ cond.op === 'isNot' &&
390
+ cond.field === 'category' &&
391
+ cond.value === null
392
+ ) {
393
+ return new Condition(
394
+ 'and',
395
+ cond.field,
396
+ [cond, new Condition('is', 'parent', false, null)],
397
+ {},
398
+ );
399
+ }
400
+ return cond;
401
+ }
402
+
403
+ // This does the inverse: finds all the transactions matching a rule
404
+ export function conditionsToAQL(
405
+ conditions,
406
+ { recurDateBounds = 100, applySpecialCases = true } = {},
407
+ ) {
408
+ const errors = [];
409
+
410
+ conditions = conditions
411
+ .map(cond => {
412
+ if (cond instanceof Condition) {
413
+ return cond;
414
+ }
415
+
416
+ try {
417
+ return new Condition(cond.op, cond.field, cond.value, cond.options);
418
+ } catch (e) {
419
+ errors.push(e.type || 'internal');
420
+ logger.log('conditionsToAQL: invalid condition: ' + e.message);
421
+ return null;
422
+ }
423
+ })
424
+ .map(cond => (applySpecialCases ? conditionSpecialCases(cond) : cond))
425
+ .filter(Boolean);
426
+
427
+ // rule -> actualql
428
+ const mapConditionToActualQL = cond => {
429
+ const { type, options } = cond;
430
+ let { field, op, value } = cond;
431
+
432
+ const getValue = value => {
433
+ if (type === 'number') {
434
+ return value.value;
435
+ }
436
+ return value;
437
+ };
438
+
439
+ if (field === 'transfer' && op === 'is') {
440
+ field = 'transfer_id';
441
+ if (value) {
442
+ op = 'isNot';
443
+ value = null;
444
+ } else {
445
+ value = null;
446
+ }
447
+ } else if (field === 'parent' && op === 'is') {
448
+ field = 'is_parent';
449
+ if (value) {
450
+ op = 'true';
451
+ } else {
452
+ op = 'false';
453
+ }
454
+ } else if (field === 'category_group') {
455
+ field = 'category.group';
456
+ }
457
+
458
+ const apply = (field, aqlOp, value) => {
459
+ if (type === 'number') {
460
+ if (options) {
461
+ if (options.outflow) {
462
+ return {
463
+ $and: [
464
+ { amount: { $lt: 0 } },
465
+ { [field]: { $transform: '$neg', [aqlOp]: value } },
466
+ ],
467
+ };
468
+ } else if (options.inflow) {
469
+ return {
470
+ $and: [{ amount: { $gt: 0 } }, { [field]: { [aqlOp]: value } }],
471
+ };
472
+ }
473
+ }
474
+
475
+ return { amount: { [aqlOp]: value } };
476
+ } else if (type === 'string') {
477
+ return {
478
+ [field]: {
479
+ $transform: op !== 'hasTags' ? '$lower' : undefined,
480
+ [aqlOp]: value,
481
+ },
482
+ };
483
+ } else if (type === 'date') {
484
+ return { [field]: { [aqlOp]: value.date } };
485
+ }
486
+ return { [field]: { [aqlOp]: value } };
487
+ };
488
+
489
+ switch (op) {
490
+ case 'isapprox':
491
+ case 'is':
492
+ if (type === 'date') {
493
+ if (value.type === 'recur') {
494
+ const dates = value.schedule
495
+ .occurrences({ take: recurDateBounds })
496
+ .toArray()
497
+ .map(d => dayFromDate(d.date));
498
+
499
+ return {
500
+ $or: dates.map(d => {
501
+ if (op === 'isapprox') {
502
+ return {
503
+ $and: [
504
+ { date: { $gte: subDays(d, 2) } },
505
+ { date: { $lte: addDays(d, 2) } },
506
+ ],
507
+ };
508
+ }
509
+ return { date: d };
510
+ }),
511
+ };
512
+ } else {
513
+ if (op === 'isapprox') {
514
+ const fullDate = parseDate(value.date);
515
+ const high = addDays(fullDate, 2);
516
+ const low = subDays(fullDate, 2);
517
+
518
+ return {
519
+ $and: [{ date: { $gte: low } }, { date: { $lte: high } }],
520
+ };
521
+ } else {
522
+ switch (value.type) {
523
+ case 'date':
524
+ return { date: value.date };
525
+ case 'month': {
526
+ const low = value.date + '-00';
527
+ const high = value.date + '-99';
528
+ return {
529
+ $and: [{ date: { $gte: low } }, { date: { $lte: high } }],
530
+ };
531
+ }
532
+ case 'year': {
533
+ const low = value.date + '-00-00';
534
+ const high = value.date + '-99-99';
535
+ return {
536
+ $and: [{ date: { $gte: low } }, { date: { $lte: high } }],
537
+ };
538
+ }
539
+ default:
540
+ }
541
+ }
542
+ }
543
+ } else if (type === 'number') {
544
+ const number = value.value;
545
+ if (op === 'isapprox') {
546
+ const threshold = getApproxNumberThreshold(number);
547
+
548
+ return {
549
+ $and: [
550
+ apply(field, '$gte', number - threshold),
551
+ apply(field, '$lte', number + threshold),
552
+ ],
553
+ };
554
+ }
555
+ return apply(field, '$eq', number);
556
+ } else if (type === 'string') {
557
+ if (value === '') {
558
+ return {
559
+ $or: [apply(field, '$eq', null), apply(field, '$eq', '')],
560
+ };
561
+ }
562
+ }
563
+ return apply(field, '$eq', value);
564
+ case 'isNot':
565
+ return apply(field, '$ne', value);
566
+
567
+ case 'isbetween':
568
+ // This operator is only applicable to the specific `between`
569
+ // number type so we don't use `apply`
570
+ const [low, high] = sortNumbers(value.num1, value.num2);
571
+ return {
572
+ [field]: [{ $gte: low }, { $lte: high }],
573
+ };
574
+ case 'contains':
575
+ // Running contains with id will automatically reach into
576
+ // the `name` of the referenced table and do a string match
577
+ return apply(
578
+ type === 'id' ? field + '.name' : field,
579
+ '$like',
580
+ '%' + value + '%',
581
+ );
582
+ case 'matches':
583
+ // Running contains with id will automatically reach into
584
+ // the `name` of the referenced table and do a regex match
585
+ return apply(type === 'id' ? field + '.name' : field, '$regexp', value);
586
+ case 'doesNotContain':
587
+ // Running contains with id will automatically reach into
588
+ // the `name` of the referenced table and do a string match
589
+ return apply(
590
+ type === 'id' ? field + '.name' : field,
591
+ '$notlike',
592
+ '%' + value + '%',
593
+ );
594
+ case 'oneOf':
595
+ const values = value;
596
+ if (values.length === 0) {
597
+ // This forces it to match nothing
598
+ return { id: null };
599
+ }
600
+ return { $or: values.map(v => apply(field, '$eq', v)) };
601
+
602
+ case 'hasTags':
603
+ const tagValues = [];
604
+ for (const [_, tag] of value.matchAll(/(?<!#)(#[^#\s]+)/g)) {
605
+ if (!tagValues.find(t => t.tag === tag)) {
606
+ tagValues.push(tag);
607
+ }
608
+ }
609
+
610
+ return {
611
+ $and: tagValues.map(v => {
612
+ const regex = new RegExp(
613
+ `(?<!#)${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s#]|$)`,
614
+ );
615
+ return apply(field, '$regexp', regex.source);
616
+ }),
617
+ };
618
+
619
+ case 'notOneOf':
620
+ const notValues = value;
621
+ if (notValues.length === 0) {
622
+ // This forces it to match nothing
623
+ return { id: null };
624
+ }
625
+ return { $and: notValues.map(v => apply(field, '$ne', v)) };
626
+ case 'gt':
627
+ return apply(field, '$gt', getValue(value));
628
+ case 'gte':
629
+ return apply(field, '$gte', getValue(value));
630
+ case 'lt':
631
+ return apply(field, '$lt', getValue(value));
632
+ case 'lte':
633
+ return apply(field, '$lte', getValue(value));
634
+ case 'true':
635
+ return apply(field, '$eq', true);
636
+ case 'false':
637
+ return apply(field, '$eq', false);
638
+ case 'and':
639
+ return {
640
+ $and: getValue(value).map(subExpr => mapConditionToActualQL(subExpr)),
641
+ };
642
+
643
+ case 'onBudget':
644
+ return { 'account.offbudget': false };
645
+ case 'offBudget':
646
+ return { 'account.offbudget': true };
647
+
648
+ default:
649
+ throw new Error('Unhandled operator: ' + op);
650
+ }
651
+ };
652
+
653
+ const filters = conditions.map(mapConditionToActualQL);
654
+ return { filters, errors };
655
+ }
656
+
657
+ export async function applyActions(
658
+ transactions: TransactionEntity[],
659
+ actions: Array<Action | RuleActionEntity>,
660
+ ) {
661
+ const parsedActions = actions
662
+ .map(action => {
663
+ if (action instanceof Action) {
664
+ return action;
665
+ }
666
+
667
+ try {
668
+ if (action.op === 'set-split-amount') {
669
+ return new Action(action.op, null, action.value, action.options);
670
+ } else if (action.op === 'link-schedule') {
671
+ return new Action(action.op, null, action.value, null);
672
+ } else if (
673
+ action.op === 'prepend-notes' ||
674
+ action.op === 'append-notes'
675
+ ) {
676
+ return new Action(action.op, null, action.value, null);
677
+ } else if (action.op === 'delete-transaction') {
678
+ return new Action(action.op, null, null, null);
679
+ }
680
+
681
+ return new Action(
682
+ action.op,
683
+ action.field,
684
+ action.value,
685
+ action.options,
686
+ );
687
+ } catch (e) {
688
+ logger.log('Action error', e);
689
+ return null;
690
+ }
691
+ })
692
+ .filter(Boolean);
693
+
694
+ if (parsedActions.length !== actions.length) {
695
+ // An error happened while parsing
696
+ return null;
697
+ }
698
+
699
+ const accounts: db.DbAccount[] = await db.getAccounts();
700
+ const accountsMap = new Map(accounts.map(account => [account.id, account]));
701
+ const transactionsForRules = await Promise.all(
702
+ transactions.map(transactions =>
703
+ prepareTransactionForRules(transactions, accountsMap),
704
+ ),
705
+ );
706
+
707
+ const updated = transactionsForRules.flatMap(trans => {
708
+ return ungroupTransaction(execActions(parsedActions, trans));
709
+ });
710
+
711
+ const finalized: TransactionEntity[] = [];
712
+ for (const trans of updated) {
713
+ finalized.push(await finalizeTransactionForRules(trans));
714
+ }
715
+
716
+ return batchUpdateTransactions({ updated: finalized });
717
+ }
718
+
719
+ export function getRulesForPayee(payeeId) {
720
+ const rules = new Set<Rule>();
721
+ iterateIds(getRules(), 'payee', (rule, id) => {
722
+ if (id === payeeId) {
723
+ rules.add(rule);
724
+ }
725
+ });
726
+
727
+ return rankRules([...rules]);
728
+ }
729
+
730
+ function* getIsSetterRules(
731
+ stage,
732
+ condField,
733
+ actionField,
734
+ { condValue, actionValue }: { condValue?: string; actionValue?: string },
735
+ ) {
736
+ const rules = getRules();
737
+ for (let i = 0; i < rules.length; i++) {
738
+ const rule = rules[i];
739
+
740
+ if (
741
+ rule.stage === stage &&
742
+ rule.actions.length === 1 &&
743
+ rule.actions[0].op === 'set' &&
744
+ rule.actions[0].field === actionField &&
745
+ (actionValue === undefined || rule.actions[0].value === actionValue) &&
746
+ rule.conditions.length === 1 &&
747
+ (rule.conditions[0].op === 'is' || rule.conditions[0].op === 'isNot') &&
748
+ rule.conditions[0].field === condField &&
749
+ (condValue === undefined || rule.conditions[0].value === condValue)
750
+ ) {
751
+ yield rule.serialize();
752
+ }
753
+ }
754
+
755
+ return null;
756
+ }
757
+
758
+ function* getOneOfSetterRules(
759
+ stage,
760
+ condField,
761
+ actionField,
762
+ { condValue, actionValue }: { condValue?: string; actionValue: string },
763
+ ) {
764
+ const rules = getRules();
765
+ for (let i = 0; i < rules.length; i++) {
766
+ const rule = rules[i];
767
+
768
+ if (
769
+ rule.stage === stage &&
770
+ rule.actions.length === 1 &&
771
+ rule.actions[0].op === 'set' &&
772
+ rule.actions[0].field === actionField &&
773
+ (actionValue == null || rule.actions[0].value === actionValue) &&
774
+ rule.conditions.length === 1 &&
775
+ (rule.conditions[0].op === 'oneOf' ||
776
+ rule.conditions[0].op === 'oneOf') &&
777
+ rule.conditions[0].field === condField &&
778
+ (condValue == null || rule.conditions[0].value.indexOf(condValue) !== -1)
779
+ ) {
780
+ yield rule.serialize();
781
+ }
782
+ }
783
+
784
+ return null;
785
+ }
786
+
787
+ export async function updatePayeeRenameRule(fromNames: string[], to: string) {
788
+ const renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
789
+ actionValue: to,
790
+ }).next().value;
791
+
792
+ // Note that we don't check for existing rules that set this
793
+ // `imported_payee` to something else. It's important to do
794
+ // that for categories because categories will be changes frequently
795
+ // for the same payee, but renames won't be changed much. It's a use
796
+ // case we could improve in the future, but this is fine for now.
797
+
798
+ if (renameRule) {
799
+ const condition = renameRule.conditions[0];
800
+ const newValue = [
801
+ ...fastSetMerge(
802
+ new Set(condition.value),
803
+ new Set(fromNames.filter(name => name !== '')),
804
+ ),
805
+ ];
806
+ const rule = {
807
+ ...renameRule,
808
+ conditions: [{ ...condition, value: newValue }],
809
+ };
810
+ await updateRule(rule);
811
+ return renameRule.id;
812
+ } else {
813
+ const rule = new Rule({
814
+ stage: 'pre',
815
+ conditionsOp: 'and',
816
+ conditions: [{ op: 'oneOf', field: 'imported_payee', value: fromNames }],
817
+ actions: [{ op: 'set', field: 'payee', value: to }],
818
+ });
819
+ return insertRule(rule.serialize());
820
+ }
821
+ }
822
+
823
+ export function getProbableCategory(transactions) {
824
+ const scores = new Map();
825
+
826
+ transactions.forEach(trans => {
827
+ if (trans.category) {
828
+ scores.set(trans.category, (scores.get(trans.category) || 0) + 1);
829
+ }
830
+ });
831
+
832
+ const winner = transactions.reduce((winner, trans) => {
833
+ const score = scores.get(trans.category);
834
+ if (!winner || score > winner.score) {
835
+ return { score, category: trans.category };
836
+ }
837
+ return winner;
838
+ }, null);
839
+
840
+ return winner.score >= 3 ? winner.category : null;
841
+ }
842
+
843
+ export async function updateCategoryRules(transactions) {
844
+ if (transactions.length === 0) {
845
+ return;
846
+ }
847
+
848
+ const payeeIds = new Set(transactions.map(trans => trans.payee));
849
+ const transIds = new Set(transactions.map(trans => trans.id));
850
+
851
+ // It's going to be quickest to get the oldest date and then query
852
+ // all transactions since then so we can work in memory
853
+ let oldestDate = null;
854
+ for (let i = 0; i < transactions.length; i++) {
855
+ if (oldestDate === null || transactions[i].date < oldestDate) {
856
+ oldestDate = transactions[i].date;
857
+ }
858
+ }
859
+
860
+ // We look 6 months behind to include any other transaction. This
861
+ // makes it so we, 1. don't have to load in all transactions ever
862
+ // and 2. "forget" really old transactions which might be nice and
863
+ // 3. don't have to individually run a query for each payee
864
+ oldestDate = subDays(oldestDate, 180);
865
+
866
+ // Also look 180 days in the future to get any future transactions
867
+ // (this might change when we think about scheduled transactions)
868
+ const register = await db.all<db.DbViewTransaction>(
869
+ `SELECT t.* FROM v_transactions t
870
+ LEFT JOIN accounts a ON a.id = t.account
871
+ LEFT JOIN payees p ON p.id = t.payee
872
+ WHERE date >= ? AND date <= ? AND is_parent = 0 AND a.closed = 0 AND p.learn_categories = 1
873
+ ORDER BY date DESC`,
874
+ [toDateRepr(oldestDate), toDateRepr(addDays(currentDay(), 180))],
875
+ );
876
+
877
+ const allTransactions = partitionByField(register, 'payee');
878
+ const categoriesToSet = new Map();
879
+
880
+ for (const payeeId of payeeIds) {
881
+ // Don't do anything if payee is null
882
+ if (payeeId) {
883
+ const latestTrans = (allTransactions.get(payeeId) || []).slice(0, 5);
884
+
885
+ // Check if one of the latest transactions was one that was
886
+ // updated. We only want to update anything if so.
887
+ if (latestTrans.find(trans => transIds.has(trans.id))) {
888
+ const category = getProbableCategory(latestTrans);
889
+ if (category) {
890
+ categoriesToSet.set(payeeId, category);
891
+ }
892
+ }
893
+ }
894
+ }
895
+
896
+ await batchMessages(async () => {
897
+ for (const [payeeId, category] of categoriesToSet.entries()) {
898
+ const ruleSetters = [
899
+ ...getIsSetterRules(null, 'payee', 'category', {
900
+ condValue: payeeId,
901
+ }),
902
+ ];
903
+
904
+ if (ruleSetters.length > 0) {
905
+ // If there are existing rules, change all of them to the new
906
+ // category (if they aren't already using it). We set all of
907
+ // them because it's possible that multiple rules exist
908
+ // because 2 clients made them independently. Not really a big
909
+ // deal, but to make sure our update gets applied set it to
910
+ // all of them
911
+ for (const rule of ruleSetters) {
912
+ const action = rule.actions[0];
913
+ if (action.value !== category) {
914
+ await updateRule({
915
+ ...rule,
916
+ actions: [{ ...action, value: category }],
917
+ });
918
+ }
919
+ }
920
+ } else {
921
+ // No existing rules, so create one
922
+ const newRule = new Rule({
923
+ stage: null,
924
+ conditionsOp: 'and',
925
+ conditions: [{ op: 'is', field: 'payee', value: payeeId }],
926
+ actions: [{ op: 'set', field: 'category', value: category }],
927
+ });
928
+ await insertRule(newRule.serialize());
929
+ }
930
+ }
931
+ });
932
+ }
933
+
934
+ export type TransactionForRules = TransactionEntity & {
935
+ payee_name?: string;
936
+ _account?: db.DbAccount;
937
+ balance?: number;
938
+ _category_name?: string;
939
+ _account_name?: string;
940
+ };
941
+
942
+ export async function prepareTransactionForRules(
943
+ trans: TransactionEntity,
944
+ accounts: Map<string, db.DbAccount> | null = null,
945
+ ): Promise<TransactionForRules> {
946
+ const r: TransactionForRules = { ...trans };
947
+ if (trans.payee) {
948
+ const payee = await getPayee(trans.payee);
949
+ if (payee) {
950
+ r.payee_name = payee.name;
951
+ }
952
+ }
953
+
954
+ r.balance = 0;
955
+
956
+ if (trans.account) {
957
+ if (accounts !== null && accounts.has(trans.account)) {
958
+ r._account = accounts.get(trans.account);
959
+ r._account_name = r._account?.name || '';
960
+ } else {
961
+ r._account = await getAccount(trans.account);
962
+ r._account_name = r._account?.name || '';
963
+ }
964
+
965
+ const dateBoundary = trans.date ?? currentDay();
966
+ let query = q('transactions')
967
+ .filter({ account: trans.account, is_parent: false })
968
+ .options({ splits: 'inline' });
969
+
970
+ if (trans.id) {
971
+ query = query.filter({ id: { $ne: trans.id } });
972
+ }
973
+
974
+ const sameDayFilter =
975
+ trans.sort_order != null
976
+ ? {
977
+ $and: [
978
+ { date: dateBoundary },
979
+ { sort_order: { $lt: trans.sort_order } },
980
+ ],
981
+ }
982
+ : {
983
+ $and: [
984
+ { date: dateBoundary },
985
+ {
986
+ $or: [
987
+ { sort_order: { $ne: null } }, // ordered items come before null sort_order
988
+ ...(trans.id ? [{ id: { $lt: trans.id } }] : []), // among nulls, tie-break by id
989
+ ],
990
+ },
991
+ ],
992
+ };
993
+
994
+ const { data: balance } = await aqlQuery(
995
+ query
996
+ .filter({ $or: [{ date: { $lt: dateBoundary } }, sameDayFilter] })
997
+ .calculate({ $sum: '$amount' }),
998
+ );
999
+
1000
+ r.balance = balance ?? 0;
1001
+ }
1002
+
1003
+ if (trans.category) {
1004
+ const category = await getCategory(trans.category);
1005
+ if (category) {
1006
+ r._category_name = category.name;
1007
+ }
1008
+ }
1009
+
1010
+ return r;
1011
+ }
1012
+
1013
+ export async function finalizeTransactionForRules(
1014
+ trans: TransactionEntity | TransactionForRules,
1015
+ ): Promise<TransactionEntity> {
1016
+ if ('payee_name' in trans) {
1017
+ if (trans.payee === 'new') {
1018
+ if (trans.payee_name) {
1019
+ let payeeId = (await getPayeeByName(trans.payee_name))?.id;
1020
+ payeeId ??= await insertPayee({
1021
+ name: trans.payee_name,
1022
+ });
1023
+
1024
+ trans.payee = payeeId;
1025
+ } else {
1026
+ trans.payee = null;
1027
+ }
1028
+ }
1029
+
1030
+ delete trans.payee_name;
1031
+ }
1032
+
1033
+ if ('balance' in trans) {
1034
+ delete trans.balance;
1035
+ }
1036
+
1037
+ return trans;
1038
+ }