@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,994 @@
1
+ // @ts-strict-ignore
2
+ import { q } from '../../shared/query';
3
+ import { aqlQuery } from '../aql';
4
+ import * as db from '../db';
5
+ import { loadMappings } from '../db/mappings';
6
+
7
+ import {
8
+ conditionsToAQL,
9
+ deleteRule,
10
+ getProbableCategory,
11
+ getRules,
12
+ insertRule,
13
+ loadRules,
14
+ makeRule,
15
+ resetState,
16
+ runRules,
17
+ updateCategoryRules,
18
+ updateRule,
19
+ } from './transaction-rules';
20
+
21
+ // TODO: write tests to make sure payee renaming is "pre" and category
22
+ // setting is "null" stage
23
+
24
+ beforeEach(async () => {
25
+ await global.emptyDatabase()();
26
+ resetState();
27
+ await loadMappings();
28
+ });
29
+
30
+ async function getMatchingTransactions(conds) {
31
+ const { filters } = conditionsToAQL(conds);
32
+ const { data } = await aqlQuery(
33
+ q('transactions').filter({ $and: filters }).select('*'),
34
+ );
35
+ return data;
36
+ }
37
+
38
+ describe('Transaction rules', () => {
39
+ test('makeRule validates rule data', () => {
40
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => null);
41
+
42
+ // Parse errors
43
+ expect(makeRule({ conditions: '{', actions: '[]' })).toBe(null);
44
+ expect(makeRule({ conditions: '[]', actions: '{' })).toBe(null);
45
+ expect(makeRule({ conditions: '{}', actions: '{}' })).toBe(null);
46
+
47
+ // This is valid
48
+ expect(makeRule({ conditions: '[]', actions: '[]' })).not.toBe(null);
49
+
50
+ // condition has invalid operator
51
+ expect(
52
+ makeRule({
53
+ conditions: JSON.stringify([
54
+ { op: 'noop', field: 'date', value: '2019-05' },
55
+ ]),
56
+ actions: JSON.stringify([
57
+ { op: 'set', field: 'name', value: 'Sarah' },
58
+ { op: 'set', field: 'category', value: 'Sarah' },
59
+ ]),
60
+ }),
61
+ ).toBe(null);
62
+
63
+ // setting an invalid field
64
+ expect(
65
+ makeRule({
66
+ conditions: JSON.stringify([
67
+ { op: 'is', field: 'date', value: '2019-05' },
68
+ ]),
69
+ actions: JSON.stringify([
70
+ { op: 'set', field: 'notes', value: 'Sarah' },
71
+ { op: 'set', field: 'invalid', value: 'Sarah' },
72
+ ]),
73
+ }),
74
+ ).toBe(null);
75
+
76
+ // condition has valid operator & setting valid fields
77
+ expect(
78
+ makeRule({
79
+ conditions: JSON.stringify([
80
+ { op: 'is', field: 'date', value: '2019-05' },
81
+ ]),
82
+ actions: JSON.stringify([
83
+ { op: 'set', field: 'notes', value: 'Sarah' },
84
+ { op: 'set', field: 'category', value: 'Sarah' },
85
+ ]),
86
+ }),
87
+ ).not.toBe(null);
88
+
89
+ spy.mockRestore();
90
+ });
91
+
92
+ test('insert a rule into the database', async () => {
93
+ await loadRules();
94
+ await insertRule({
95
+ stage: 'pre',
96
+ conditionsOp: 'and',
97
+ conditions: [],
98
+ actions: [],
99
+ });
100
+ expect((await db.all<db.DbRule>('SELECT * FROM rules')).length).toBe(1);
101
+ // Make sure it was projected
102
+ expect(getRules().length).toBe(1);
103
+
104
+ await insertRule({
105
+ stage: 'pre',
106
+ conditionsOp: 'and',
107
+ conditions: [{ op: 'is', field: 'date', value: '2019-05' }],
108
+ actions: [
109
+ { op: 'set', field: 'notes', value: 'Sarah' },
110
+ { op: 'set', field: 'category', value: 'food' },
111
+ ],
112
+ });
113
+ expect((await db.all<db.DbRule>('SELECT * FROM rules')).length).toBe(2);
114
+ expect(getRules().length).toBe(2);
115
+
116
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => null);
117
+
118
+ // Try to insert an invalid rule (don't use `insertRule` because
119
+ // that will validate the input)
120
+ await db.insertWithUUID('rules', { conditions: '{', actions: '}' });
121
+ // It will be in the database
122
+ expect((await db.all<db.DbRule>('SELECT * FROM rules')).length).toBe(3);
123
+ // But it will be ignored
124
+ expect(getRules().length).toBe(2);
125
+
126
+ spy.mockRestore();
127
+
128
+ // Finally make sure the rule is actually in place and runs
129
+ const transaction = await runRules({
130
+ date: '2019-05-10',
131
+ notes: '',
132
+ category: null,
133
+ });
134
+ expect(transaction.date).toBe('2019-05-10');
135
+ expect(transaction.notes).toBe('Sarah');
136
+ expect(transaction.category).toBe('food');
137
+ });
138
+
139
+ test('update a rule in the database', async () => {
140
+ await loadRules();
141
+ const id = await insertRule({
142
+ stage: 'pre',
143
+ conditionsOp: 'and',
144
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'kroger' }],
145
+ actions: [
146
+ { op: 'set', field: 'notes', value: 'Sarah' },
147
+ { op: 'set', field: 'category', value: 'food' },
148
+ ],
149
+ });
150
+ expect(getRules().length).toBe(1);
151
+
152
+ let transaction = await runRules({
153
+ imported_payee: 'Kroger',
154
+ notes: '',
155
+ category: null,
156
+ });
157
+ expect(transaction.imported_payee).toBe('Kroger');
158
+ expect(transaction.notes).toBe('Sarah');
159
+ expect(transaction.category).toBe('food');
160
+
161
+ // Change the action
162
+ await updateRule({
163
+ id,
164
+ actions: [{ op: 'set', field: 'category', value: 'bars' }],
165
+ });
166
+ expect(getRules().length).toBe(1);
167
+
168
+ transaction = await runRules({
169
+ imported_payee: 'Kroger',
170
+ notes: '',
171
+ category: null,
172
+ });
173
+ expect(transaction.imported_payee).toBe('Kroger');
174
+ expect(transaction.notes).toBe('');
175
+ expect(transaction.category).toBe('bars');
176
+
177
+ // If changing the condition, make sure the rule is re-indexed
178
+ await updateRule({
179
+ id,
180
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'ABC' }],
181
+ });
182
+ transaction = await runRules({
183
+ imported_payee: 'ABC',
184
+ notes: '',
185
+ category: null,
186
+ });
187
+ expect(transaction.category).toBe('bars');
188
+ expect(getRules().length).toBe(1);
189
+ });
190
+
191
+ test('delete a rule in the database', async () => {
192
+ await loadRules();
193
+ const id = await insertRule({
194
+ stage: 'pre',
195
+ conditionsOp: 'and',
196
+ conditions: [{ op: 'is', field: 'payee', value: 'kroger' }],
197
+ actions: [
198
+ { op: 'set', field: 'notes', value: 'Sarah' },
199
+ { op: 'set', field: 'category', value: 'food' },
200
+ ],
201
+ });
202
+ expect(getRules().length).toBe(1);
203
+
204
+ let transaction = await runRules({
205
+ payee: 'Kroger',
206
+ notes: '',
207
+ category: null,
208
+ });
209
+ expect(transaction.payee).toBe('Kroger');
210
+ expect(transaction.category).toBe('food');
211
+
212
+ await deleteRule(id);
213
+ expect(getRules().length).toBe(0);
214
+ transaction = await runRules({
215
+ payee: 'Kroger',
216
+ notes: '',
217
+ category: null,
218
+ });
219
+ expect(transaction.payee).toBe('Kroger');
220
+ expect(transaction.category).toBe(null);
221
+ });
222
+
223
+ test('loadRules loads all the rules', async () => {
224
+ await loadRules();
225
+ await insertRule({
226
+ stage: 'pre',
227
+ conditionsOp: 'and',
228
+ conditions: [{ op: 'contains', field: 'imported_payee', value: 'lowes' }],
229
+ actions: [{ op: 'set', field: 'payee', value: 'lowes' }],
230
+ });
231
+
232
+ await insertRule({
233
+ stage: 'post',
234
+ conditionsOp: 'and',
235
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'kroger' }],
236
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
237
+ });
238
+
239
+ resetState();
240
+
241
+ expect(getRules().length).toBe(0);
242
+ await loadRules();
243
+ expect(getRules().length).toBe(2);
244
+
245
+ let transaction = await runRules({
246
+ imported_payee: 'blah Lowes blah',
247
+ payee: null,
248
+ category: null,
249
+ });
250
+ expect(transaction.payee).toBe('lowes');
251
+
252
+ transaction = await runRules({
253
+ imported_payee: 'kroger',
254
+ category: null,
255
+ });
256
+ expect(transaction.notes).toBe('Sarah');
257
+ });
258
+
259
+ test('ids in rules are migrated as mapping changes', async () => {
260
+ await loadRules();
261
+
262
+ await db.insertPayee({ id: 'home_id', name: 'home' });
263
+ await db.insertPayee({ id: 'lowes_id', name: 'lowes' });
264
+ await db.insertCategoryGroup({ name: 'group' });
265
+ await db.insertCategory({
266
+ id: 'food_id',
267
+ name: 'food',
268
+ cat_group: 'group',
269
+ });
270
+ await db.insertCategory({
271
+ id: 'beer_id',
272
+ name: 'beer',
273
+ cat_group: 'group',
274
+ });
275
+
276
+ await insertRule({
277
+ id: 'one',
278
+ stage: 'pre',
279
+ conditionsOp: 'and',
280
+ conditions: [{ op: 'contains', field: 'imported_payee', value: 'lowes' }],
281
+ actions: [{ op: 'set', field: 'payee', value: 'lowes_id' }],
282
+ });
283
+
284
+ await insertRule({
285
+ id: 'two',
286
+ stage: 'pre',
287
+ conditionsOp: 'and',
288
+ conditions: [
289
+ { op: 'is', field: 'payee', value: 'lowes_id' },
290
+ { op: 'is', field: 'category', value: 'food_id' },
291
+ ],
292
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
293
+ });
294
+
295
+ let rule1 = getRules().find(r => r.id === 'one');
296
+ let rule2 = getRules().find(r => r.id === 'two');
297
+
298
+ expect(rule1.actions[0].value).toBe('lowes_id');
299
+ expect(rule2.conditions[0].value).toBe('lowes_id');
300
+ await db.mergePayees('home_id', ['lowes_id']);
301
+ expect(rule1.actions[0].value).toBe('home_id');
302
+ expect(rule2.conditions[0].value).toBe('home_id');
303
+
304
+ expect(rule2.conditions[1].value).toBe('food_id');
305
+ await db.deleteCategory({ id: 'food_id' }, 'beer_id');
306
+ expect(rule2.conditions[1].value).toBe('beer_id');
307
+
308
+ await loadRules();
309
+
310
+ // Make sure mappings work when loading fresh
311
+ rule1 = getRules().find(r => r.id === 'one');
312
+ rule2 = getRules().find(r => r.id === 'two');
313
+ expect(rule1.actions[0].value).toBe('home_id');
314
+ expect(rule2.conditions[0].value).toBe('home_id');
315
+ expect(rule2.conditions[1].value).toBe('beer_id');
316
+ });
317
+
318
+ test('await runRules runs all the rules in each phase', async () => {
319
+ await loadRules();
320
+ await insertRule({
321
+ stage: 'post',
322
+ conditionsOp: 'and',
323
+ conditions: [
324
+ {
325
+ op: 'oneOf',
326
+ field: 'payee',
327
+ value: ['kroger', 'kroger1', 'kroger2', 'kroger3', 'kroger4'],
328
+ },
329
+ ],
330
+ actions: [{ op: 'set', field: 'notes', value: 'got it2' }],
331
+ });
332
+
333
+ await insertRule({
334
+ stage: 'pre',
335
+ conditionsOp: 'and',
336
+ conditions: [{ op: 'is', field: 'imported_payee', value: '123 kroger' }],
337
+ actions: [{ op: 'set', field: 'payee', value: 'kroger3' }],
338
+ });
339
+
340
+ await insertRule({
341
+ stage: null,
342
+ conditionsOp: 'and',
343
+ conditions: [
344
+ { op: 'contains', field: 'imported_payee', value: 'kroger' },
345
+ ],
346
+ actions: [{ op: 'set', field: 'payee', value: 'kroger4' }],
347
+ });
348
+
349
+ await insertRule({
350
+ stage: null,
351
+ conditionsOp: 'and',
352
+ conditions: [{ op: 'is', field: 'payee', value: 'kroger4' }],
353
+ actions: [{ op: 'set', field: 'notes', value: 'got it' }],
354
+ });
355
+
356
+ expect(
357
+ await runRules({
358
+ imported_payee: '123 kroger',
359
+ date: '2020-08-11',
360
+ amount: 50,
361
+ }),
362
+ ).toEqual({
363
+ date: '2020-08-11',
364
+ imported_payee: '123 kroger',
365
+ payee: 'kroger4',
366
+ amount: 50,
367
+ notes: 'got it2',
368
+ });
369
+ });
370
+
371
+ test('transactions can be queried by rule', async () => {
372
+ await loadRules();
373
+ const account = await db.insertAccount({ name: 'bank' });
374
+ const categoryGroupId = await db.insertCategoryGroup({ name: 'general' });
375
+ const foodCategoryId = await db.insertCategory({
376
+ name: 'food',
377
+ cat_group: categoryGroupId,
378
+ });
379
+ const krogerId = await db.insertPayee({ name: 'kroger' });
380
+ const lowesId = await db.insertPayee({
381
+ name: 'lowes',
382
+ category: foodCategoryId,
383
+ });
384
+
385
+ await db.insertTransaction({
386
+ id: '1',
387
+ date: '2020-10-01',
388
+ account,
389
+ payee: krogerId,
390
+ category: foodCategoryId,
391
+ notes: 'barr',
392
+ amount: 353,
393
+ });
394
+ await db.insertTransaction({
395
+ id: '2',
396
+ date: '2020-10-15',
397
+ account,
398
+ payee: krogerId,
399
+ notes: 'fooo',
400
+ amount: 453,
401
+ });
402
+ await db.insertTransaction({
403
+ id: '3',
404
+ date: '2020-10-15',
405
+ account,
406
+ payee: lowesId,
407
+ notes: 'FooO',
408
+ amount: -322,
409
+ });
410
+ await db.insertTransaction({
411
+ id: '4',
412
+ date: '2020-10-16',
413
+ account,
414
+ payee: lowesId,
415
+ notes: null,
416
+ amount: 101,
417
+ });
418
+ await db.insertTransaction({
419
+ id: '5',
420
+ date: '2020-10-16',
421
+ account,
422
+ payee: lowesId,
423
+ category: foodCategoryId,
424
+ notes: '',
425
+ amount: 124,
426
+ });
427
+
428
+ let transactions = await getMatchingTransactions([
429
+ { field: 'date', op: 'is', value: '2020-10-15' },
430
+ ]);
431
+ expect(transactions.map(t => t.id)).toEqual(['2', '3']);
432
+
433
+ transactions = await getMatchingTransactions([
434
+ { field: 'payee', op: 'is', value: lowesId },
435
+ ]);
436
+ expect(transactions.map(t => t.id)).toEqual(['4', '5', '3']);
437
+
438
+ transactions = await getMatchingTransactions([
439
+ { field: 'amount', op: 'is', value: 353 },
440
+ ]);
441
+ expect(transactions.map(t => t.id)).toEqual(['1']);
442
+
443
+ transactions = await getMatchingTransactions([
444
+ { field: 'notes', op: 'is', value: 'FooO' },
445
+ ]);
446
+ expect(transactions.map(t => t.id)).toEqual(['2', '3']);
447
+
448
+ transactions = await getMatchingTransactions([
449
+ { field: 'notes', op: 'contains', value: 'oo' },
450
+ ]);
451
+ expect(transactions.map(t => t.id)).toEqual(['2', '3']);
452
+
453
+ transactions = await getMatchingTransactions([
454
+ { field: 'notes', op: 'is', value: 'barr' },
455
+ ]);
456
+ expect(transactions.map(t => t.id)).toEqual(['1']);
457
+
458
+ transactions = await getMatchingTransactions([
459
+ { field: 'notes', op: 'is', value: '' },
460
+ ]);
461
+ expect(transactions.map(t => t.id)).toEqual(['4', '5']);
462
+
463
+ transactions = await getMatchingTransactions([
464
+ { field: 'amount', op: 'gt', value: 300 },
465
+ ]);
466
+ expect(transactions.map(t => t.id)).toEqual(['2', '1']);
467
+
468
+ transactions = await getMatchingTransactions([
469
+ { field: 'amount', op: 'gt', value: 400 },
470
+ { field: 'amount', op: 'lt', value: 500 },
471
+ ]);
472
+ expect(transactions.map(t => t.id)).toEqual(['2']);
473
+
474
+ transactions = await getMatchingTransactions([
475
+ { field: 'amount', op: 'gt', value: 300, options: { inflow: true } },
476
+ { field: 'amount', op: 'lt', value: 400, options: { inflow: true } },
477
+ ]);
478
+ expect(transactions.map(t => t.id)).toEqual(['1']);
479
+
480
+ // If `inflow` is true, it should never return outflow transactions
481
+ transactions = await getMatchingTransactions([
482
+ { field: 'amount', op: 'gt', value: -1000, options: { inflow: true } },
483
+ ]);
484
+ expect(transactions.map(t => t.id)).toEqual(['4', '5', '2', '1']);
485
+
486
+ // Same thing for `outflow`: never return `inflow` transactions
487
+ transactions = await getMatchingTransactions([
488
+ { field: 'amount', op: 'gt', value: 300, options: { outflow: true } },
489
+ ]);
490
+ expect(transactions.map(t => t.id)).toEqual(['3']);
491
+
492
+ transactions = await getMatchingTransactions([
493
+ { field: 'date', op: 'gt', value: '2020-10-10' },
494
+ ]);
495
+ expect(transactions.map(t => t.id)).toEqual(['4', '5', '2', '3']);
496
+
497
+ //Condition special cases
498
+ //is category null
499
+ transactions = await getMatchingTransactions([
500
+ { field: 'category', op: 'is', value: null },
501
+ ]);
502
+ expect(transactions.map(t => t.id)).toEqual(['4', '2', '3']);
503
+
504
+ //category is not X
505
+ transactions = await getMatchingTransactions([
506
+ { field: 'category', op: 'isNot', value: null },
507
+ ]);
508
+ expect(transactions.map(t => t.id)).toEqual(['5', '1']);
509
+
510
+ // todo: isapprox
511
+ });
512
+
513
+ test('and sub expression builds $and condition', async () => {
514
+ const conds = [{ field: 'category', op: 'is', value: null }];
515
+ const { filters } = conditionsToAQL(conds);
516
+ expect(filters).toStrictEqual([
517
+ {
518
+ $and: [
519
+ { category: { $eq: null } },
520
+ { transfer_id: { $eq: null } },
521
+ { is_parent: { $eq: false } },
522
+ ],
523
+ },
524
+ ]);
525
+ });
526
+ });
527
+
528
+ describe('Learning categories', () => {
529
+ function expectCategoryRule(rule, category, expectedPayee) {
530
+ expect(rule.conditions.length).toBe(1);
531
+ expect(rule.conditions[0].op).toBe('is');
532
+ expect(rule.conditions[0].field).toBe('payee');
533
+ expect(rule.conditions[0].value).toBe(expectedPayee);
534
+ expect(rule.actions.length).toBe(1);
535
+ expect(rule.actions[0].op).toBe('set');
536
+ expect(rule.actions[0].field).toBe('category');
537
+ expect(rule.actions[0].value).toBe(category);
538
+ }
539
+
540
+ async function insertTransaction(
541
+ transaction,
542
+ expectedCategory,
543
+ expectedRuleCount = 1,
544
+ expectedPayee = 'foo',
545
+ ) {
546
+ await db.insertTransaction(transaction);
547
+ await updateCategoryRules([transaction]);
548
+ expect(getRules().length).toBe(expectedRuleCount);
549
+
550
+ if (expectedRuleCount > 0) {
551
+ expectCategoryRule(
552
+ getRules()[expectedRuleCount - 1],
553
+ expectedCategory,
554
+ expectedPayee,
555
+ );
556
+ }
557
+ }
558
+
559
+ async function loadData() {
560
+ await loadRules();
561
+ await db.insertAccount({ id: 'acct', name: 'acct' });
562
+ await db.insertCategoryGroup({ id: 'catg', name: 'catg' });
563
+ await db.insertCategory({ id: 'food', name: 'food', cat_group: 'catg' });
564
+ await db.insertCategory({ id: 'beer', name: 'beer', cat_group: 'catg' });
565
+ await db.insertCategory({ id: 'fun', name: 'fun', cat_group: 'catg' });
566
+ await db.insertPayee({ id: 'foo', name: 'foo' });
567
+ await db.insertPayee({ id: 'bar', name: 'bar' });
568
+ }
569
+
570
+ test('getProbableCategory estimates a category winner', () => {
571
+ let winner = getProbableCategory([{ category: 'foo' }]);
572
+ // It needs at least 3 transactions
573
+ expect(winner).toBe(null);
574
+
575
+ winner = getProbableCategory([
576
+ { category: 'foo' },
577
+ { category: 'foo' },
578
+ { category: 'foo' },
579
+ ]);
580
+ expect(winner).toBe('foo');
581
+
582
+ winner = getProbableCategory([
583
+ { category: 'bar' },
584
+ { category: 'foo' },
585
+ { category: 'foo' },
586
+ { category: 'foo' },
587
+ ]);
588
+ expect(winner).toBe('foo');
589
+
590
+ winner = getProbableCategory([
591
+ { category: 'bar' },
592
+ { category: 'bar' },
593
+ { category: 'bar' },
594
+ { category: 'foo' },
595
+ { category: 'foo' },
596
+ { category: 'foo' },
597
+ ]);
598
+ expect(winner).toBe('bar');
599
+ });
600
+
601
+ test('creates rule when inserting transactions', async () => {
602
+ await loadData();
603
+
604
+ await insertTransaction(
605
+ {
606
+ id: 'one',
607
+ date: '2016-12-01',
608
+ account: 'acct',
609
+ payee: 'foo',
610
+ category: 'food',
611
+ },
612
+ null,
613
+ 0,
614
+ );
615
+
616
+ await insertTransaction(
617
+ {
618
+ id: 'two',
619
+ date: '2016-12-01',
620
+ account: 'acct',
621
+ payee: 'foo',
622
+ category: 'food',
623
+ },
624
+ null,
625
+ 0,
626
+ );
627
+
628
+ await insertTransaction(
629
+ {
630
+ id: 'three',
631
+ date: '2016-12-01',
632
+ account: 'acct',
633
+ payee: 'foo',
634
+ category: 'food',
635
+ },
636
+ 'food',
637
+ );
638
+ });
639
+
640
+ test('leaves existing rule alone if probable category is ambiguous', async () => {
641
+ await loadData();
642
+
643
+ await insertTransaction(
644
+ {
645
+ id: 'one',
646
+ date: '2016-12-01',
647
+ account: 'acct',
648
+ payee: 'foo',
649
+ category: 'food',
650
+ },
651
+ null,
652
+ 0,
653
+ );
654
+
655
+ await insertTransaction(
656
+ {
657
+ id: 'two',
658
+ date: '2016-12-01',
659
+ account: 'acct',
660
+ payee: 'foo',
661
+ category: 'beer',
662
+ },
663
+ null,
664
+ 0,
665
+ );
666
+
667
+ await insertTransaction(
668
+ {
669
+ id: 'three',
670
+ date: '2016-12-01',
671
+ account: 'acct',
672
+ payee: 'foo',
673
+ category: 'beer',
674
+ },
675
+ null,
676
+ 0,
677
+ );
678
+
679
+ await insertRule({
680
+ stage: null,
681
+ conditionsOp: 'and',
682
+ conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
683
+ actions: [{ op: 'set', field: 'category', value: 'fun' }],
684
+ });
685
+
686
+ // Even though the system couldn't figure out the category to set,
687
+ // it should leave the existing rule alone
688
+ await insertTransaction(
689
+ {
690
+ id: 'four',
691
+ date: '2016-12-01',
692
+ account: 'acct',
693
+ payee: 'foo',
694
+ category: 'bills',
695
+ },
696
+ 'fun',
697
+ 1,
698
+ );
699
+ });
700
+
701
+ test('updates an existing rule', async () => {
702
+ await loadData();
703
+
704
+ await insertRule({
705
+ stage: null,
706
+ conditionsOp: 'and',
707
+ conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
708
+ actions: [{ op: 'set', field: 'category', value: 'beer' }],
709
+ });
710
+
711
+ await insertTransaction(
712
+ {
713
+ id: 'one',
714
+ date: '2016-12-01',
715
+ account: 'acct',
716
+ payee: 'foo',
717
+ category: 'food',
718
+ },
719
+ 'beer',
720
+ 1,
721
+ );
722
+ await insertTransaction(
723
+ {
724
+ id: 'two',
725
+ date: '2016-12-01',
726
+ account: 'acct',
727
+ payee: 'foo',
728
+ category: 'food',
729
+ },
730
+ 'beer',
731
+ 1,
732
+ );
733
+ await insertTransaction(
734
+ {
735
+ id: 'three',
736
+ date: '2016-12-01',
737
+ account: 'acct',
738
+ payee: 'foo',
739
+ category: 'food',
740
+ },
741
+ 'food',
742
+ 1,
743
+ );
744
+ });
745
+
746
+ test('works with multiple payees', async () => {
747
+ await loadData();
748
+
749
+ await insertRule({
750
+ stage: null,
751
+ conditionsOp: 'and',
752
+ conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
753
+ actions: [{ op: 'set', field: 'category', value: 'beer' }],
754
+ });
755
+
756
+ // Use a new payee, so the category should be remembered
757
+ await insertTransaction(
758
+ {
759
+ id: 'three',
760
+ date: '2016-12-03',
761
+ account: 'acct',
762
+ payee: 'bar',
763
+ category: 'fun',
764
+ },
765
+ 'beer',
766
+ 1,
767
+ );
768
+ await insertTransaction(
769
+ {
770
+ id: 'four',
771
+ date: '2016-12-03',
772
+ account: 'acct',
773
+ payee: 'bar',
774
+ category: 'fun',
775
+ },
776
+ 'beer',
777
+ 1,
778
+ );
779
+ await insertTransaction(
780
+ {
781
+ id: 'five',
782
+ date: '2016-12-03',
783
+ account: 'acct',
784
+ payee: 'bar',
785
+ category: 'fun',
786
+ },
787
+ 'fun',
788
+ 2,
789
+ 'bar',
790
+ );
791
+ });
792
+
793
+ test('updates rules correctly even if multiple rules exist', async () => {
794
+ await loadData();
795
+
796
+ await insertRule({
797
+ stage: null,
798
+ conditionsOp: 'and',
799
+ conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
800
+ actions: [{ op: 'set', field: 'category', value: 'unknown1' }],
801
+ });
802
+ await insertRule({
803
+ stage: null,
804
+ conditionsOp: 'and',
805
+ conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
806
+ actions: [{ op: 'set', field: 'category', value: 'unknown2' }],
807
+ });
808
+ await insertRule({
809
+ stage: null,
810
+ conditionsOp: 'and',
811
+ conditions: [{ op: 'is', field: 'payee', value: null }],
812
+ actions: [{ op: 'set', field: 'category', value: 'beer' }],
813
+ });
814
+
815
+ let trans = {
816
+ date: '2016-12-01',
817
+ account: 'acct',
818
+ payee: 'foo',
819
+ category: 'food',
820
+ };
821
+ await db.insertTransaction({ ...trans, id: 'one' });
822
+ await db.insertTransaction({ ...trans, id: 'two' });
823
+ await db.insertTransaction({ ...trans, id: 'three' });
824
+ await updateCategoryRules([{ ...trans, id: 'three' }]);
825
+ expect(getRules()).toMatchSnapshot();
826
+
827
+ trans = {
828
+ date: '2016-12-02',
829
+ account: 'acct',
830
+ payee: 'foo',
831
+ category: 'beer',
832
+ };
833
+ await db.insertTransaction({ ...trans, id: 'four' });
834
+ await db.insertTransaction({ ...trans, id: 'five' });
835
+ await db.insertTransaction({ ...trans, id: 'six' });
836
+ await updateCategoryRules([{ ...trans, id: 'three' }]);
837
+ expect(getRules()).toMatchSnapshot();
838
+
839
+ const rules = getRules();
840
+ const getPayees = cat => {
841
+ const arr = rules
842
+ .filter(rule => rule.actions[0].value === cat)
843
+ .map(r => r.conditions.map(c => c.value));
844
+ return Array.prototype.concat.apply([], arr);
845
+ };
846
+
847
+ // The `foo` payee has been removed from all rules and added to
848
+ // the correct one
849
+ expect(getPayees('unknown1')).toEqual([]);
850
+ expect(getPayees('unknown2')).toEqual([]);
851
+ expect(getPayees('food')).toEqual([]);
852
+ expect(getPayees('beer')).toEqual(['foo', 'foo', null]);
853
+ });
854
+
855
+ test('avoids remembering categories for `null` payee', async () => {
856
+ await loadData();
857
+
858
+ expect(getRules().length).toBe(0);
859
+ const trans = {
860
+ date: '2016-12-01',
861
+ account: 'acct',
862
+ payee: null,
863
+ category: 'food',
864
+ };
865
+ await db.insertTransaction({ ...trans, id: 'one' });
866
+ await db.insertTransaction({ ...trans, id: 'two' });
867
+ await db.insertTransaction({ ...trans, id: 'three' });
868
+ await updateCategoryRules([{ ...trans, id: 'three' }]);
869
+ expect(getRules().length).toBe(0);
870
+ });
871
+
872
+ test('avoids remembering categories for payees specified by the user', async () => {
873
+ await loadData();
874
+
875
+ expect(getRules().length).toBe(0);
876
+ await db.insertPayee({
877
+ id: 'supermarket_id',
878
+ name: 'supermarket',
879
+ learn_categories: 0,
880
+ });
881
+
882
+ const trans = {
883
+ date: '2016-12-01',
884
+ account: 'acct',
885
+ payee: 'supermarket_id',
886
+ category: 'food',
887
+ };
888
+ await db.insertTransaction({ ...trans, id: 'one' });
889
+ await db.insertTransaction({ ...trans, id: 'two' });
890
+ await db.insertTransaction({ ...trans, id: 'three' });
891
+ await updateCategoryRules([{ ...trans, id: 'three' }]);
892
+ expect(getRules().length).toBe(0);
893
+ });
894
+
895
+ test('adding transaction with `null` payee never changes rules', async () => {
896
+ await loadData();
897
+
898
+ await insertRule({
899
+ stage: null,
900
+ conditionsOp: 'and',
901
+ conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
902
+ actions: [{ op: 'set', field: 'category', value: 'unknown1' }],
903
+ });
904
+ await insertRule({
905
+ stage: null,
906
+ conditionsOp: 'and',
907
+ conditions: [{ op: 'oneOf', field: 'payee', value: ['foo', 'bar'] }],
908
+ actions: [{ op: 'set', field: 'category', value: 'unknown1' }],
909
+ });
910
+
911
+ expect(getRules().length).toBe(2);
912
+ const trans = {
913
+ date: '2016-12-01',
914
+ account: 'acct',
915
+ payee: null,
916
+ category: 'food',
917
+ };
918
+ await db.insertTransaction({ ...trans, id: 'one' });
919
+ await db.insertTransaction({ ...trans, id: 'two' });
920
+ await db.insertTransaction({ ...trans, id: 'three' });
921
+ await updateCategoryRules([{ ...trans, id: 'three' }]);
922
+
923
+ // This should not have changed the category! This is tested
924
+ // because this was a bug when rules were released
925
+ const rules = getRules();
926
+ expect(rules.length).toBe(2);
927
+ expect(rules[0].actions[0].value).toBe('unknown1');
928
+ expect(rules[1].actions[0].value).toBe('unknown1');
929
+ });
930
+
931
+ test('rules are saved with internal field names', async () => {
932
+ await insertRule({
933
+ stage: null,
934
+ conditionsOp: 'and',
935
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'foo' }],
936
+ actions: [{ op: 'set', field: 'payee', value: 'unknown1' }],
937
+ });
938
+
939
+ // The rule that the system sees should use the new public names
940
+ let [rule] = getRules();
941
+ expect(rule.conditions[0].field).toBe('imported_payee');
942
+ expect(rule.actions[0].field).toBe('payee');
943
+
944
+ // Internally, it should still be stored with the internal names
945
+ // so that it's backwards compatible
946
+ const rawRule = await db.first<db.DbRule>('SELECT * FROM rules');
947
+ const parsedRule = {
948
+ ...rawRule,
949
+ conditions: JSON.parse(rawRule.conditions),
950
+ actions: JSON.parse(rawRule.actions),
951
+ };
952
+ expect(parsedRule.conditions[0].field).toBe('imported_description');
953
+ expect(parsedRule.actions[0].field).toBe('description');
954
+
955
+ await loadRules();
956
+
957
+ // Make sure reloading everything from the db still uses the new
958
+ // public names
959
+ [rule] = getRules();
960
+ expect(rule.conditions[0].field).toBe('imported_payee');
961
+ expect(rule.actions[0].field).toBe('payee');
962
+ });
963
+
964
+ test('rules with public field names are loaded correctly', async () => {
965
+ await db.insertWithUUID('rules', {
966
+ stage: null,
967
+ conditions_op: 'and',
968
+ conditions: JSON.stringify([
969
+ { op: 'is', field: 'imported_payee', value: 'foo' },
970
+ ]),
971
+ actions: JSON.stringify([{ op: 'set', field: 'payee', value: 'payee1' }]),
972
+ });
973
+
974
+ await loadRules();
975
+
976
+ // This rule internally has been stored with the public names.
977
+ // Making this work now allows us to switch to it by default in
978
+ // the future
979
+ const rawRule = await db.first<db.DbRule>('SELECT * FROM rules');
980
+ const parsedRule = {
981
+ ...rawRule,
982
+ conditions: JSON.parse(rawRule.conditions),
983
+ actions: JSON.parse(rawRule.actions),
984
+ };
985
+ expect(parsedRule.conditions[0].field).toBe('imported_payee');
986
+ expect(parsedRule.actions[0].field).toBe('payee');
987
+
988
+ const [rule] = getRules();
989
+ expect(rule.conditions[0].field).toBe('imported_payee');
990
+ expect(rule.actions[0].field).toBe('payee');
991
+ });
992
+
993
+ // TODO: write tests for split transactions
994
+ });