@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,1095 @@
1
+ // @ts-strict-ignore
2
+ import {
3
+ Action,
4
+ Condition,
5
+ iterateIds,
6
+ parseDateString,
7
+ rankRules,
8
+ Rule,
9
+ RuleIndexer,
10
+ } from '.';
11
+
12
+ describe('Condition', () => {
13
+ test('parses date formats correctly', () => {
14
+ expect(parseDateString('2020-08-10')).toEqual({
15
+ type: 'date',
16
+ date: '2020-08-10',
17
+ });
18
+ expect(parseDateString('2020-08')).toEqual({
19
+ type: 'month',
20
+ date: '2020-08',
21
+ });
22
+ expect(parseDateString('2020')).toEqual({
23
+ type: 'year',
24
+ date: '2020',
25
+ });
26
+
27
+ // Invalid dates
28
+ expect(parseDateString('2020-0')).toBe(null);
29
+ expect(parseDateString('2020-14-01')).toBe(null);
30
+ expect(parseDateString('2020-05-53')).toBe(null);
31
+ });
32
+
33
+ test('ops handles null fields', () => {
34
+ let cond = new Condition('contains', 'notes', 'foo', null);
35
+ expect(cond.eval({ notes: null })).toBe(false);
36
+
37
+ cond = new Condition('matches', 'notes', '^fo*$', null);
38
+ expect(cond.eval({ notes: null })).toBe(false);
39
+
40
+ cond = new Condition('oneOf', 'imported_payee', ['foo'], null);
41
+ expect(cond.eval({ imported_payee: null })).toBe(false);
42
+
43
+ ['gt', 'gte', 'lt', 'lte', 'isapprox'].forEach(op => {
44
+ const cond = new Condition(op, 'date', '2020-01-01', null);
45
+ expect(cond.eval({ date: null })).toBe(false);
46
+ });
47
+
48
+ cond = new Condition('is', 'payee', null, null);
49
+ expect(cond.eval({ payee: null })).toBe(true);
50
+
51
+ cond = new Condition('is', 'notes', '', null);
52
+ expect(cond.eval({ notes: null })).toBe(true);
53
+ });
54
+
55
+ test('ops handles undefined fields', () => {
56
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => null);
57
+
58
+ let cond = new Condition('is', 'payee', null, null);
59
+ // null is strict and won't match undefined
60
+ expect(cond.eval({ notes: 'James' })).toBe(false);
61
+
62
+ cond = new Condition('contains', 'notes', 'foo', null);
63
+ expect(cond.eval({ date: '2020-01-01' })).toBe(false);
64
+
65
+ cond = new Condition('matches', 'notes', '^fo*$', null);
66
+ expect(cond.eval({ date: '2020-01-01' })).toBe(false);
67
+
68
+ spy.mockRestore();
69
+ });
70
+
71
+ test('date restricts operators for each type', () => {
72
+ expect(() => {
73
+ new Condition('isapprox', 'date', '2020-08', null);
74
+ }).toThrow('Invalid date value for');
75
+ expect(() => {
76
+ new Condition('gt', 'date', '2020-08', null);
77
+ }).toThrow('Invalid date value for');
78
+ expect(() => {
79
+ new Condition('gte', 'date', '2020-08', null);
80
+ }).toThrow('Invalid date value for');
81
+ expect(() => {
82
+ new Condition('lt', 'date', '2020-08', null);
83
+ }).toThrow('Invalid date value for');
84
+ expect(() => {
85
+ new Condition('lte', 'date', '2020-08', null);
86
+ }).toThrow('Invalid date value for');
87
+ });
88
+
89
+ test('date conditions work with `is` operator', () => {
90
+ let cond = new Condition('is', 'date', '2020-08-10', null);
91
+ expect(cond.eval({ date: '2020-08-05' })).toBe(false);
92
+ expect(cond.eval({ date: '2020-08-10' })).toBe(true);
93
+
94
+ cond = new Condition('is', 'date', '2020-08', null);
95
+ expect(cond.eval({ date: '2020-08-05' })).toBe(true);
96
+ expect(cond.eval({ date: '2020-08-10' })).toBe(true);
97
+ expect(cond.eval({ date: '2020-09-10' })).toBe(false);
98
+
99
+ cond = new Condition('is', 'date', '2020', null);
100
+ expect(cond.eval({ date: '2020-08-05' })).toBe(true);
101
+ expect(cond.eval({ date: '2020-08-10' })).toBe(true);
102
+ expect(cond.eval({ date: '2020-09-10' })).toBe(true);
103
+ expect(cond.eval({ date: '2019-09-10' })).toBe(false);
104
+
105
+ // Approximate dates
106
+ cond = new Condition('isapprox', 'date', '2020-08-07', null);
107
+ expect(cond.eval({ date: '2020-08-04' })).toBe(false);
108
+ expect(cond.eval({ date: '2020-08-05' })).toBe(true);
109
+ expect(cond.eval({ date: '2020-08-09' })).toBe(true);
110
+ expect(cond.eval({ date: '2020-08-10' })).toBe(false);
111
+ });
112
+
113
+ test('recurring date conditions work with `is` operator', () => {
114
+ let cond = new Condition(
115
+ 'is',
116
+ 'date',
117
+ {
118
+ start: '2019-01-01',
119
+ frequency: 'monthly',
120
+ patterns: [{ type: 'day', value: 15 }],
121
+ },
122
+ null,
123
+ );
124
+ expect(cond.eval({ date: '2018-03-15' })).toBe(false);
125
+ expect(cond.eval({ date: '2019-03-15' })).toBe(true);
126
+ expect(cond.eval({ date: '2020-05-15' })).toBe(true);
127
+ expect(cond.eval({ date: '2020-06-15' })).toBe(true);
128
+ expect(cond.eval({ date: '2020-06-10' })).toBe(false);
129
+
130
+ cond = new Condition(
131
+ 'is',
132
+ 'date',
133
+ {
134
+ start: '2018-01-12',
135
+ frequency: 'monthly',
136
+ interval: 3,
137
+ },
138
+ null,
139
+ );
140
+ expect(cond.eval({ date: '2019-01-12' })).toBe(true);
141
+ expect(cond.eval({ date: '2019-04-12' })).toBe(true);
142
+ expect(cond.eval({ date: '2020-07-12' })).toBe(true);
143
+ expect(cond.eval({ date: '2020-06-12' })).toBe(false);
144
+
145
+ // Approximate dates
146
+ cond = new Condition(
147
+ 'isapprox',
148
+ 'date',
149
+ {
150
+ start: '2019-01-01',
151
+ frequency: 'monthly',
152
+ patterns: [{ type: 'day', value: 15 }],
153
+ },
154
+ null,
155
+ );
156
+ expect(cond.eval({ date: '2019-03-12' })).toBe(false);
157
+ expect(cond.eval({ date: '2019-03-13' })).toBe(true);
158
+ expect(cond.eval({ date: '2019-03-15' })).toBe(true);
159
+ expect(cond.eval({ date: '2019-03-17' })).toBe(true);
160
+ expect(cond.eval({ date: '2019-03-18' })).toBe(false);
161
+ expect(cond.eval({ date: '2019-04-15' })).toBe(true);
162
+ expect(cond.eval({ date: '2019-05-15' })).toBe(true);
163
+ expect(cond.eval({ date: '2019-05-17' })).toBe(true);
164
+ });
165
+
166
+ test('date conditions work with comparison operators', () => {
167
+ let cond = new Condition('gt', 'date', '2020-08-10', null);
168
+ expect(cond.eval({ date: '2020-08-11' })).toBe(true);
169
+ expect(cond.eval({ date: '2020-08-10' })).toBe(false);
170
+
171
+ cond = new Condition('gte', 'date', '2020-08-10', null);
172
+ expect(cond.eval({ date: '2020-08-11' })).toBe(true);
173
+ expect(cond.eval({ date: '2020-08-10' })).toBe(true);
174
+ expect(cond.eval({ date: '2020-08-09' })).toBe(false);
175
+
176
+ cond = new Condition('lt', 'date', '2020-08-10', null);
177
+ expect(cond.eval({ date: '2020-08-09' })).toBe(true);
178
+ expect(cond.eval({ date: '2020-08-10' })).toBe(false);
179
+
180
+ cond = new Condition('lte', 'date', '2020-08-10', null);
181
+ expect(cond.eval({ date: '2020-08-09' })).toBe(true);
182
+ expect(cond.eval({ date: '2020-08-10' })).toBe(true);
183
+ expect(cond.eval({ date: '2020-08-11' })).toBe(false);
184
+ });
185
+
186
+ test('id works with all operators', () => {
187
+ let cond = new Condition('is', 'payee', 'foo', null);
188
+ expect(cond.eval({ payee: 'foo' })).toBe(true);
189
+ expect(cond.eval({ payee: 'FOO' })).toBe(true);
190
+ expect(cond.eval({ payee: 'foo2' })).toBe(false);
191
+
192
+ cond = new Condition('oneOf', 'payee', ['foo', 'bar'], null);
193
+ expect(cond.eval({ payee: 'foo' })).toBe(true);
194
+ expect(cond.eval({ payee: 'FOO' })).toBe(true);
195
+ expect(cond.eval({ payee: 'Bar' })).toBe(true);
196
+ expect(cond.eval({ payee: 'bar2' })).toBe(false);
197
+ });
198
+
199
+ test('string works with all operators', () => {
200
+ let cond = new Condition('is', 'notes', 'foo', null);
201
+ expect(cond.eval({ notes: 'foo' })).toBe(true);
202
+ expect(cond.eval({ notes: 'FOO' })).toBe(true);
203
+ expect(cond.eval({ notes: 'foo2' })).toBe(false);
204
+
205
+ cond = new Condition('oneOf', 'imported_payee', ['foo', 'bar'], null);
206
+ expect(cond.eval({ imported_payee: 'foo' })).toBe(true);
207
+ expect(cond.eval({ imported_payee: 'FOO' })).toBe(true);
208
+ expect(cond.eval({ imported_payee: 'Bar' })).toBe(true);
209
+ expect(cond.eval({ imported_payee: 'bar2' })).toBe(false);
210
+
211
+ cond = new Condition('contains', 'notes', 'foo', null);
212
+ expect(cond.eval({ notes: 'bar foo baz' })).toBe(true);
213
+ expect(cond.eval({ notes: 'bar FOOb' })).toBe(true);
214
+ expect(cond.eval({ notes: 'foo' })).toBe(true);
215
+ expect(cond.eval({ notes: 'foob' })).toBe(true);
216
+ expect(cond.eval({ notes: 'bfoo' })).toBe(true);
217
+ expect(cond.eval({ notes: 'bfo' })).toBe(false);
218
+ expect(cond.eval({ notes: 'f o o' })).toBe(false);
219
+
220
+ cond = new Condition('matches', 'notes', '^fo*$', null);
221
+ expect(cond.eval({ notes: 'bar foo baz' })).toBe(false);
222
+ expect(cond.eval({ notes: 'bar FOOb' })).toBe(false);
223
+ expect(cond.eval({ notes: 'foo' })).toBe(true);
224
+ expect(cond.eval({ notes: 'FOOOO' })).toBe(true);
225
+ expect(cond.eval({ notes: 'foob' })).toBe(false);
226
+ expect(cond.eval({ notes: 'bfoo' })).toBe(false);
227
+ expect(cond.eval({ notes: 'bfo' })).toBe(false);
228
+ expect(cond.eval({ notes: 'f o o' })).toBe(false);
229
+ });
230
+
231
+ test('matches handles invalid regex', () => {
232
+ const cond = new Condition('matches', 'notes', 'fo**', null);
233
+ expect(cond.eval({ notes: 'foo' })).toBe(false);
234
+ });
235
+
236
+ test('number validates value', () => {
237
+ new Condition('isapprox', 'amount', 34, null);
238
+
239
+ expect(() => {
240
+ new Condition('isapprox', 'amount', 'hello', null);
241
+ }).toThrow('Value must be a number or between amount');
242
+
243
+ expect(() => {
244
+ new Condition('is', 'amount', { num1: 0, num2: 10 }, null);
245
+ }).toThrow('Invalid number value for');
246
+
247
+ new Condition('isbetween', 'amount', { num1: 0, num2: 10 }, null);
248
+
249
+ expect(() => {
250
+ new Condition('isbetween', 'amount', 34.22, null);
251
+ }).toThrow('Invalid between value for');
252
+ expect(() => {
253
+ new Condition('isbetween', 'amount', { num1: 0 }, null);
254
+ }).toThrow('Value must be a number or between amount');
255
+ });
256
+
257
+ test('number works with all operators', () => {
258
+ let cond = new Condition('is', 'amount', 155, null);
259
+ expect(cond.eval({ amount: 155 })).toBe(true);
260
+ expect(cond.eval({ amount: 167 })).toBe(false);
261
+
262
+ cond = new Condition('isapprox', 'amount', 1535, null);
263
+ expect(cond.eval({ amount: 1540 })).toBe(true);
264
+ expect(cond.eval({ amount: 1300 })).toBe(false);
265
+ expect(cond.eval({ amount: 1650 })).toBe(true);
266
+ expect(cond.eval({ amount: 1800 })).toBe(false);
267
+
268
+ cond = new Condition('isbetween', 'amount', { num1: 32, num2: 86 }, null);
269
+ expect(cond.eval({ amount: 30 })).toBe(false);
270
+ expect(cond.eval({ amount: 32 })).toBe(true);
271
+ expect(cond.eval({ amount: 80 })).toBe(true);
272
+ expect(cond.eval({ amount: 86 })).toBe(true);
273
+ expect(cond.eval({ amount: 90 })).toBe(false);
274
+
275
+ cond = new Condition('isbetween', 'amount', { num1: -16, num2: -20 }, null);
276
+ expect(cond.eval({ amount: -18 })).toBe(true);
277
+ expect(cond.eval({ amount: -12 })).toBe(false);
278
+
279
+ cond = new Condition('gt', 'amount', 1.55, null);
280
+ expect(cond.eval({ amount: 1.55 })).toBe(false);
281
+ expect(cond.eval({ amount: 1.67 })).toBe(true);
282
+ expect(cond.eval({ amount: 1.5 })).toBe(false);
283
+
284
+ cond = new Condition('gte', 'amount', 1.55, null);
285
+ expect(cond.eval({ amount: 1.55 })).toBe(true);
286
+ expect(cond.eval({ amount: 1.67 })).toBe(true);
287
+ expect(cond.eval({ amount: 1.5 })).toBe(false);
288
+
289
+ cond = new Condition('lt', 'amount', 1.55, null);
290
+ expect(cond.eval({ amount: 1.55 })).toBe(false);
291
+ expect(cond.eval({ amount: 1.67 })).toBe(false);
292
+ expect(cond.eval({ amount: 1.5 })).toBe(true);
293
+
294
+ cond = new Condition('lte', 'amount', 1.55, null);
295
+ expect(cond.eval({ amount: 1.55 })).toBe(true);
296
+ expect(cond.eval({ amount: 1.67 })).toBe(false);
297
+ expect(cond.eval({ amount: 1.5 })).toBe(true);
298
+ });
299
+ });
300
+
301
+ describe('Action', () => {
302
+ test('`set` operator sets a field', () => {
303
+ const action = new Action('set', 'notes', 'James', null);
304
+ const item = { notes: 'Sarah' };
305
+ action.exec(item);
306
+ expect(item.notes).toBe('James');
307
+
308
+ expect(() => {
309
+ new Action('set', 'foo', 'James', null);
310
+ }).toThrow(/invalid field/i);
311
+
312
+ expect(() => {
313
+ new Action(null, 'notes', 'James', null);
314
+ }).toThrow(/invalid action operation/i);
315
+ });
316
+
317
+ test('empty account values result in error', () => {
318
+ expect(() => {
319
+ new Action('set', 'account', '', null);
320
+ }).toThrow(/Field cannot be empty/i);
321
+ });
322
+
323
+ describe('templating', () => {
324
+ test('should use available fields', () => {
325
+ const action = new Action('set', 'notes', '', {
326
+ template: 'Hey {{notes}}! You just payed {{amount}}',
327
+ });
328
+ const item = { notes: 'Sarah', amount: 10 };
329
+ action.exec(item);
330
+ expect(item.notes).toBe('Hey Sarah! You just payed 10');
331
+ });
332
+
333
+ test('should create actions with balance math operations', () => {
334
+ // This test ensures the template validation doesn't fail when using balance
335
+ expect(() => {
336
+ new Action('set', 'notes', '', {
337
+ template: '{{ floor (div (mul balance (div 7.99 100)) 12) }}',
338
+ });
339
+ }).not.toThrow();
340
+ });
341
+
342
+ test('should not escape text', () => {
343
+ const action = new Action('set', 'notes', '', {
344
+ template: '{{notes}}',
345
+ });
346
+ const note = 'Sarah !@#$%^&*()<> Special';
347
+ const item = { notes: note };
348
+ action.exec(item);
349
+ expect(item.notes).toBe(note);
350
+ });
351
+
352
+ describe('regex helper', () => {
353
+ function testHelper(template: string, expected: unknown) {
354
+ test(template, () => {
355
+ const action = new Action('set', 'notes', '', { template });
356
+ const item = { notes: 'Sarah Condition' };
357
+ action.exec(item);
358
+ expect(item.notes).toBe(expected);
359
+ });
360
+ }
361
+
362
+ testHelper('{{regex notes "/[aeuio]/g" "a"}}', 'Sarah Candataan');
363
+ testHelper('{{regex notes "/[aeuio]/" ""}}', 'Srah Condition');
364
+ // capture groups
365
+ testHelper('{{regex notes "/^.+ (.+)$/" "$1"}}', 'Condition');
366
+ // no match
367
+ testHelper('{{regex notes "/Klaas/" "Jantje"}}', 'Sarah Condition');
368
+ // no regex format (/.../flags)
369
+ testHelper('{{regex notes "Sarah" "Jantje"}}', 'Jantje Condition');
370
+
371
+ // should not use regex when not in regex format
372
+ testHelper('{{replace notes "[a-z]" "a"}}', 'Sarah Condition');
373
+ // should use regex when in regex format
374
+ testHelper('{{replace notes "/[a-z]/g" "a"}}', 'Saaaa Caaaaaaaa');
375
+ // should replace once with non regex
376
+ testHelper('{{replace notes "a" "b"}}', 'Sbrah Condition');
377
+
378
+ // should not use regex when not in regex format
379
+ testHelper('{{replaceAll notes "[a-z]" "a"}}', 'Sarah Condition');
380
+ // should use regex when in regex format
381
+ testHelper('{{replaceAll notes "/[a-z]/g" "a"}}', 'Saaaa Caaaaaaaa');
382
+ // should replace all with non regex
383
+ testHelper('{{replaceAll notes "a" "b"}}', 'Sbrbh Condition');
384
+ });
385
+
386
+ describe('math helpers', () => {
387
+ function testHelper(
388
+ template: string,
389
+ expected: unknown,
390
+ field = 'amount',
391
+ ) {
392
+ test(template, () => {
393
+ const action = new Action('set', field, '', { template });
394
+ const item = { [field]: 10 };
395
+ action.exec(item);
396
+ expect(item[field]).toBe(expected);
397
+ });
398
+ }
399
+
400
+ testHelper('{{add amount 5}}', 15);
401
+ testHelper('{{add amount 5 10}}', 25);
402
+ testHelper('{{sub amount 5}}', 5);
403
+ testHelper('{{sub amount 5 10}}', -5);
404
+ testHelper('{{mul amount 5}}', 50);
405
+ testHelper('{{mul amount 5 10}}', 500);
406
+ testHelper('{{div amount 5}}', 2);
407
+ testHelper('{{div amount 5 10}}', 0.2);
408
+ testHelper('{{mod amount 3}}', 1);
409
+ testHelper('{{mod amount 6 5}}', 4);
410
+ testHelper('{{floor (div amount 3)}}', 3);
411
+ testHelper('{{ceil (div amount 3)}}', 4);
412
+ testHelper('{{round (div amount 3)}}', 3);
413
+ testHelper('{{round (div amount 4)}}', 3);
414
+ testHelper('{{abs -5}}', 5);
415
+ testHelper('{{abs 5}}', 5);
416
+ testHelper('{{min amount 5 500}}', 5);
417
+ testHelper('{{max amount 5 500}}', 500);
418
+ testHelper('{{fixed (div 10 4) 2}}', '2.50', 'notes');
419
+ });
420
+
421
+ describe('date helpers', () => {
422
+ function testHelper(template: string, expected: unknown) {
423
+ test(template, () => {
424
+ const action = new Action('set', 'notes', '', { template });
425
+ const item = { notes: '' };
426
+ action.exec(item);
427
+ expect(item.notes).toBe(expected);
428
+ });
429
+ }
430
+
431
+ testHelper('{{day "2002-07-25"}}', '25');
432
+ testHelper('{{month "2002-07-25"}}', '7');
433
+ testHelper('{{year "2002-07-25"}}', '2002');
434
+ testHelper('{{format "2002-07-25" "MM yyyy d"}}', '07 2002 25');
435
+ testHelper('{{day undefined}}', '');
436
+ testHelper('{{month undefined}}', '');
437
+ testHelper('{{year undefined}}', '');
438
+ testHelper('{{day}}', '');
439
+ testHelper('{{month}}', '');
440
+ testHelper('{{year}}', '');
441
+ testHelper('{{format undefined undefined}}', '');
442
+ testHelper('{{format}}', '');
443
+ testHelper('{{addDays "2002-07-25" 5}}', '2002-07-30');
444
+ testHelper('{{addDays}}', '');
445
+ testHelper('{{subDays "2002-07-25" 5}}', '2002-07-20');
446
+ testHelper('{{subDays}}', '');
447
+ testHelper('{{addMonths "2002-07-25" 5}}', '2002-12-25');
448
+ testHelper('{{addMonths}}', '');
449
+ testHelper('{{subMonths "2002-07-25" 5}}', '2002-02-25');
450
+ testHelper('{{subMonths}}', '');
451
+ testHelper('{{addYears "2002-07-25" 5}}', '2007-07-25');
452
+ testHelper('{{addYears}}', '');
453
+ testHelper('{{subYears "2002-07-25" 5}}', '1997-07-25');
454
+ testHelper('{{subYears}}', '');
455
+ testHelper('{{addWeeks "2002-07-25" 1}}', '2002-08-01');
456
+ testHelper('{{addWeeks}}', '');
457
+ testHelper('{{subWeeks "2002-07-25" 1}}', '2002-07-18');
458
+ testHelper('{{subWeeks}}', '');
459
+ testHelper('{{setDay "2002-07-25" 1}}', '2002-07-01');
460
+ testHelper('{{setDay "2002-07-25" 32}}', '2002-08-01');
461
+ testHelper('{{setDay "2002-07-25" 0}}', '2002-06-30');
462
+ testHelper('{{setDay}}', '');
463
+ });
464
+
465
+ describe('other helpers', () => {
466
+ function testHelper(template: string, expected: unknown) {
467
+ test(template, () => {
468
+ const action = new Action('set', 'notes', '', { template });
469
+ const item = { notes: '' };
470
+ action.exec(item);
471
+ expect(item.notes).toBe(expected);
472
+ });
473
+ }
474
+
475
+ testHelper('{{concat "Sarah" "Trops"}}', 'SarahTrops');
476
+ testHelper('{{concat "Sarah" "Trops" 12 "Wow"}}', 'SarahTrops12Wow');
477
+ });
478
+
479
+ test('should have access to balance variable', () => {
480
+ const action = new Action('set', 'notes', '', {
481
+ template: 'Balance: {{balance}}, Amount: {{amount}}',
482
+ });
483
+ const item = { notes: '', amount: 5000, balance: 100000 };
484
+ action.exec(item);
485
+ expect(item.notes).toBe('Balance: 100000, Amount: 5000');
486
+ });
487
+
488
+ test('should allow math operations on balance', () => {
489
+ const action = new Action('set', 'notes', '', {
490
+ template: 'New balance: {{add balance amount}}',
491
+ });
492
+ const item = { notes: '', amount: 5000, balance: 100000 };
493
+ action.exec(item);
494
+ expect(item.notes).toBe('New balance: 105000');
495
+ });
496
+
497
+ test('should handle undefined balance gracefully in number fields', () => {
498
+ const action = new Action('set', 'amount', '', {
499
+ template: '{{ floor (div (mul balance (div 7.99 100)) 12) }}',
500
+ });
501
+ const item = { amount: 0 }; // No balance field
502
+ action.exec(item);
503
+ // Should default to 0 instead of NaN when balance is undefined
504
+ expect(item.amount).toBe(0);
505
+ });
506
+
507
+ test('should calculate with balance in number fields', () => {
508
+ const action = new Action('set', 'amount', '', {
509
+ template: '{{ floor (div (mul balance (div 7.99 100)) 12) }}',
510
+ });
511
+ const item = { amount: 0, balance: 1200 };
512
+ action.exec(item);
513
+ // (1200 * 7.99) / 12 = 7.99 -> floor = 7
514
+ expect(item.amount).toBe(7);
515
+ });
516
+
517
+ test('{{debug}} should log the item', () => {
518
+ const action = new Action('set', 'notes', '', {
519
+ template: '{{debug notes}}',
520
+ });
521
+ const item = { notes: 'Sarah' };
522
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => null);
523
+ action.exec(item);
524
+ expect(spy).toHaveBeenCalledWith('Sarah');
525
+ spy.mockRestore();
526
+ });
527
+ });
528
+ });
529
+
530
+ describe('Rule', () => {
531
+ test('executing a rule works', () => {
532
+ let rule = new Rule({
533
+ conditionsOp: 'and',
534
+ conditions: [{ op: 'is', field: 'notes', value: 'James' }],
535
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
536
+ });
537
+
538
+ // This matches
539
+ expect(rule.exec({ notes: 'James' })).toEqual({ notes: 'Sarah' });
540
+ // It returns updates, not the whole object
541
+ expect(rule.exec({ notes: 'James', date: '2018-10-01' })).toEqual({
542
+ notes: 'Sarah',
543
+ });
544
+ // This does not match
545
+ expect(rule.exec({ notes: 'James2' })).toEqual(null);
546
+ expect(rule.apply({ notes: 'James2' })).toEqual({ notes: 'James2' });
547
+
548
+ rule = new Rule({
549
+ conditionsOp: 'and',
550
+ conditions: [{ op: 'is', field: 'notes', value: 'James' }],
551
+ actions: [
552
+ { op: 'set', field: 'notes', value: 'Sarah' },
553
+ { op: 'set', field: 'category', value: 'Sarah' },
554
+ ],
555
+ });
556
+
557
+ expect(rule.exec({ notes: 'James' })).toEqual({
558
+ notes: 'Sarah',
559
+ category: 'Sarah',
560
+ });
561
+ expect(rule.exec({ notes: 'James2' })).toEqual(null);
562
+ expect(rule.apply({ notes: 'James2' })).toEqual({ notes: 'James2' });
563
+ });
564
+
565
+ test('rule with `and` conditionsOp evaluates conditions as AND', () => {
566
+ const rule = new Rule({
567
+ conditionsOp: 'and',
568
+ conditions: [
569
+ { op: 'is', field: 'notes', value: 'James' },
570
+ {
571
+ op: 'isapprox',
572
+ field: 'date',
573
+ value: {
574
+ start: '2018-01-12',
575
+ frequency: 'monthly',
576
+ interval: 3,
577
+ },
578
+ },
579
+ ],
580
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
581
+ });
582
+
583
+ expect(rule.exec({ notes: 'James', date: '2018-01-12' })).toEqual({
584
+ notes: 'Sarah',
585
+ });
586
+ expect(rule.exec({ notes: 'James2', date: '2018-01-12' })).toEqual(null);
587
+ expect(rule.exec({ notes: 'James', date: '2018-01-10' })).toEqual({
588
+ notes: 'Sarah',
589
+ });
590
+ expect(rule.exec({ notes: 'James', date: '2018-01-15' })).toEqual(null);
591
+ });
592
+
593
+ test('rule with `or` conditionsOp evaluates conditions as OR', () => {
594
+ const rule = new Rule({
595
+ conditionsOp: 'or',
596
+ conditions: [
597
+ { op: 'is', field: 'notes', value: 'James' },
598
+ {
599
+ op: 'isapprox',
600
+ field: 'date',
601
+ value: {
602
+ start: '2018-01-12',
603
+ frequency: 'monthly',
604
+ interval: 3,
605
+ },
606
+ },
607
+ ],
608
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
609
+ });
610
+
611
+ expect(rule.exec({ notes: 'James', date: '2018-01-12' })).toEqual({
612
+ notes: 'Sarah',
613
+ });
614
+ expect(rule.exec({ notes: 'James2', date: '2018-01-12' })).toEqual({
615
+ notes: 'Sarah',
616
+ });
617
+ expect(rule.exec({ notes: 'James', date: '2018-01-10' })).toEqual({
618
+ notes: 'Sarah',
619
+ });
620
+ expect(rule.exec({ notes: 'James', date: '2018-01-15' })).toEqual({
621
+ notes: 'Sarah',
622
+ });
623
+ });
624
+
625
+ describe('split actions', () => {
626
+ test('splits can change the payee', () => {
627
+ const rule = new Rule({
628
+ conditionsOp: 'and',
629
+ conditions: [{ op: 'is', field: 'payee', value: '123' }],
630
+ actions: [
631
+ {
632
+ op: 'set-split-amount',
633
+ field: 'amount',
634
+ value: 100,
635
+ options: { splitIndex: 1, method: 'fixed-amount' },
636
+ },
637
+ {
638
+ op: 'set',
639
+ field: 'payee',
640
+ value: '456',
641
+ options: { splitIndex: 1 },
642
+ },
643
+ ],
644
+ });
645
+
646
+ expect(rule.exec({ payee: '123' })).toMatchObject({
647
+ subtransactions: [{ payee: '456' }],
648
+ });
649
+ });
650
+
651
+ const fixedAmountRule = new Rule({
652
+ conditionsOp: 'and',
653
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'James' }],
654
+ actions: [
655
+ {
656
+ op: 'set-split-amount',
657
+ field: 'amount',
658
+ value: 100,
659
+ options: { splitIndex: 1, method: 'fixed-amount' },
660
+ },
661
+ {
662
+ op: 'set-split-amount',
663
+ field: 'amount',
664
+ value: 100,
665
+ options: { splitIndex: 2, method: 'fixed-amount' },
666
+ },
667
+ ],
668
+ });
669
+
670
+ test('basic fixed-amount', () => {
671
+ expect(
672
+ fixedAmountRule.exec({ imported_payee: 'James', amount: 200 }),
673
+ ).toMatchObject({
674
+ subtransactions: [{ amount: 100 }, { amount: 100 }],
675
+ });
676
+ });
677
+
678
+ test('basic fixed-percent', () => {
679
+ const rule = new Rule({
680
+ conditionsOp: 'and',
681
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'James' }],
682
+ actions: [
683
+ {
684
+ op: 'set-split-amount',
685
+ field: 'amount',
686
+ value: 50,
687
+ options: { splitIndex: 1, method: 'fixed-percent' },
688
+ },
689
+ {
690
+ op: 'set-split-amount',
691
+ field: 'amount',
692
+ value: 50,
693
+ options: { splitIndex: 2, method: 'fixed-percent' },
694
+ },
695
+ ],
696
+ });
697
+
698
+ expect(rule.exec({ imported_payee: 'James', amount: 200 })).toMatchObject(
699
+ {
700
+ subtransactions: [{ amount: 100 }, { amount: 100 }],
701
+ },
702
+ );
703
+ });
704
+
705
+ test('basic remainder', () => {
706
+ const rule = new Rule({
707
+ conditionsOp: 'and',
708
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'James' }],
709
+ actions: [
710
+ {
711
+ op: 'set-split-amount',
712
+ field: 'amount',
713
+ options: { splitIndex: 1, method: 'remainder' },
714
+ },
715
+ {
716
+ op: 'set-split-amount',
717
+ field: 'amount',
718
+ options: { splitIndex: 2, method: 'remainder' },
719
+ },
720
+ ],
721
+ });
722
+
723
+ expect(rule.exec({ imported_payee: 'James', amount: 200 })).toMatchObject(
724
+ {
725
+ subtransactions: [{ amount: 100 }, { amount: 100 }],
726
+ },
727
+ );
728
+ });
729
+
730
+ const prioritizationRule = new Rule({
731
+ conditionsOp: 'and',
732
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'James' }],
733
+ actions: [
734
+ {
735
+ op: 'set-split-amount',
736
+ field: 'amount',
737
+ value: 100,
738
+ options: { splitIndex: 1, method: 'fixed-amount' },
739
+ },
740
+ {
741
+ op: 'set-split-amount',
742
+ field: 'amount',
743
+ value: 50,
744
+ options: { splitIndex: 2, method: 'fixed-percent' },
745
+ },
746
+ {
747
+ op: 'set-split-amount',
748
+ field: 'amount',
749
+ options: { splitIndex: 3, method: 'remainder' },
750
+ },
751
+ ],
752
+ });
753
+
754
+ test('percent is of the post-fixed-amount total', () => {
755
+ // Percent is a percent of the amount pre-remainder
756
+ expect(
757
+ prioritizationRule.exec({ imported_payee: 'James', amount: 200 }),
758
+ ).toMatchObject({
759
+ subtransactions: [{ amount: 100 }, { amount: 50 }, { amount: 50 }],
760
+ });
761
+ });
762
+
763
+ test('remainder/percent goes negative if less than expected after fixed amounts', () => {
764
+ // Remainder/percent goes negative if less than expected after fixed amounts
765
+ expect(
766
+ prioritizationRule.exec({ imported_payee: 'James', amount: 50 }),
767
+ ).toMatchObject({
768
+ subtransactions: [{ amount: 100 }, { amount: -25 }, { amount: -25 }],
769
+ });
770
+ });
771
+
772
+ test('remainder zeroes out if nothing left', () => {
773
+ const rule = new Rule({
774
+ conditionsOp: 'and',
775
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'James' }],
776
+ actions: [
777
+ {
778
+ op: 'set-split-amount',
779
+ field: 'amount',
780
+ value: 100,
781
+ options: { splitIndex: 1, method: 'fixed-amount' },
782
+ },
783
+ {
784
+ op: 'set-split-amount',
785
+ field: 'amount',
786
+ value: 100,
787
+ options: { splitIndex: 2, method: 'fixed-percent' },
788
+ },
789
+ {
790
+ op: 'set-split-amount',
791
+ field: 'amount',
792
+ options: { splitIndex: 3, method: 'remainder' },
793
+ },
794
+ ],
795
+ });
796
+
797
+ expect(rule.exec({ imported_payee: 'James', amount: 150 })).toMatchObject(
798
+ {
799
+ subtransactions: [{ amount: 100 }, { amount: 50 }, { amount: 0 }],
800
+ },
801
+ );
802
+ });
803
+
804
+ test('remainder rounds correctly and only if necessary', () => {
805
+ const rule = new Rule({
806
+ conditionsOp: 'and',
807
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'James' }],
808
+ actions: [
809
+ {
810
+ op: 'set-split-amount',
811
+ field: 'amount',
812
+ options: { splitIndex: 1, method: 'remainder' },
813
+ },
814
+ {
815
+ op: 'set-split-amount',
816
+ field: 'amount',
817
+ options: { splitIndex: 2, method: 'remainder' },
818
+ },
819
+ ],
820
+ });
821
+
822
+ expect(
823
+ rule.exec({ imported_payee: 'James', amount: -2397 }),
824
+ ).toMatchObject({
825
+ subtransactions: [{ amount: -1198 }, { amount: -1199 }],
826
+ });
827
+
828
+ expect(rule.exec({ imported_payee: 'James', amount: 123 })).toMatchObject(
829
+ {
830
+ subtransactions: [{ amount: 62 }, { amount: 61 }],
831
+ },
832
+ );
833
+
834
+ expect(rule.exec({ imported_payee: 'James', amount: 100 })).toMatchObject(
835
+ {
836
+ subtransactions: [{ amount: 50 }, { amount: 50 }],
837
+ },
838
+ );
839
+ });
840
+
841
+ test('generate errors when fixed amounts exceed the total', () => {
842
+ expect(
843
+ fixedAmountRule.exec({ imported_payee: 'James', amount: 100 }),
844
+ ).toMatchObject({
845
+ error: {
846
+ difference: -100,
847
+ type: 'SplitTransactionError',
848
+ version: 1,
849
+ },
850
+ subtransactions: [{ amount: 100 }, { amount: 100 }],
851
+ });
852
+ });
853
+
854
+ test('generate errors when fixed amounts undershoot the total', () => {
855
+ expect(
856
+ fixedAmountRule.exec({ imported_payee: 'James', amount: 300 }),
857
+ ).toMatchObject({
858
+ error: {
859
+ difference: 100,
860
+ type: 'SplitTransactionError',
861
+ version: 1,
862
+ },
863
+ subtransactions: [{ amount: 100 }, { amount: 100 }],
864
+ });
865
+ });
866
+ });
867
+
868
+ test('rules are deterministically ranked', () => {
869
+ const rule = (id, conditions) =>
870
+ new Rule({
871
+ id,
872
+ conditionsOp: 'and',
873
+ conditions,
874
+ actions: [],
875
+ });
876
+ const expectOrder = (rules, ids) =>
877
+ expect(rules.map(r => r.getId())).toEqual(ids);
878
+
879
+ let rules = [
880
+ rule('id1', [{ op: 'contains', field: 'notes', value: 'sar' }]),
881
+ rule('id2', [{ op: 'contains', field: 'notes', value: 'jim' }]),
882
+ rule('id3', [{ op: 'is', field: 'notes', value: 'James' }]),
883
+ ];
884
+
885
+ expectOrder(rankRules(rules), ['id1', 'id2', 'id3']);
886
+
887
+ rules = [
888
+ rule('id1', [{ op: 'contains', field: 'notes', value: 'sar' }]),
889
+ rule('id2', [
890
+ { op: 'oneOf', field: 'imported_payee', value: ['jim', 'sar'] },
891
+ ]),
892
+ rule('id3', [{ op: 'is', field: 'notes', value: 'James' }]),
893
+ rule('id4', [
894
+ { op: 'is', field: 'notes', value: 'James' },
895
+ { op: 'gt', field: 'amount', value: 5 },
896
+ ]),
897
+ rule('id5', [
898
+ { op: 'is', field: 'notes', value: 'James' },
899
+ { op: 'gt', field: 'amount', value: 5 },
900
+ { op: 'lt', field: 'amount', value: 10 },
901
+ ]),
902
+ ];
903
+ expectOrder(rankRules(rules), ['id1', 'id4', 'id5', 'id2', 'id3']);
904
+ });
905
+
906
+ test('iterateIds finds all the ids', () => {
907
+ const rule = (id, conditions, actions = []) =>
908
+ new Rule({ id, conditionsOp: 'and', conditions, actions });
909
+
910
+ const rules = [
911
+ rule(
912
+ 'first',
913
+ [{ op: 'is', field: 'payee', value: 'id1' }],
914
+ [{ op: 'set', field: 'notes', value: 'sar' }],
915
+ ),
916
+ rule('second', [{ op: 'oneOf', field: 'payee', value: ['id2', 'id3'] }]),
917
+ rule(
918
+ 'third',
919
+ [{ op: 'is', field: 'notes', value: 'James' }],
920
+ [{ op: 'set', field: 'payee', value: 'id3' }],
921
+ ),
922
+ rule('fourth', [
923
+ { op: 'is', field: 'notes', value: 'James' },
924
+ { op: 'gt', field: 'amount', value: 5 },
925
+ ]),
926
+ rule('fifth', [
927
+ { op: 'is', field: 'category', value: 'id5' },
928
+ { op: 'gt', field: 'amount', value: 5 },
929
+ { op: 'lt', field: 'amount', value: 10 },
930
+ ]),
931
+ ];
932
+
933
+ const foundRules = [];
934
+ iterateIds(rules, 'payee', rule => {
935
+ foundRules.push(rule.getId());
936
+ });
937
+ expect(foundRules).toEqual(['first', 'second', 'second', 'third']);
938
+ });
939
+ });
940
+
941
+ describe('RuleIndexer', () => {
942
+ test('indexing a single field works', () => {
943
+ const indexer = new RuleIndexer({ field: 'notes' });
944
+
945
+ const rule = new Rule({
946
+ conditionsOp: 'and',
947
+ conditions: [{ op: 'is', field: 'notes', value: 'James' }],
948
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
949
+ });
950
+ indexer.index(rule);
951
+
952
+ const rule2 = new Rule({
953
+ conditionsOp: 'and',
954
+ conditions: [{ op: 'is', field: 'category', value: 'foo' }],
955
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
956
+ });
957
+ indexer.index(rule2);
958
+
959
+ // rule2 always gets returned because it's not indexed and always
960
+ // needs to be run
961
+ expect(indexer.getApplicableRules({ notes: 'James' })).toEqual(
962
+ new Set([rule, rule2]),
963
+ );
964
+ expect(indexer.getApplicableRules({ notes: 'James2' })).toEqual(
965
+ new Set([rule2]),
966
+ );
967
+ expect(indexer.getApplicableRules({ amount: 15 })).toEqual(
968
+ new Set([rule2]),
969
+ );
970
+ });
971
+
972
+ test('indexing using the firstchar method works', () => {
973
+ // A condition that references both of the fields
974
+ const indexer = new RuleIndexer({ field: 'category', method: 'firstchar' });
975
+ const rule = new Rule({
976
+ conditionsOp: 'and',
977
+ conditions: [
978
+ { op: 'is', field: 'notes', value: 'James' },
979
+ { op: 'is', field: 'category', value: 'food' },
980
+ ],
981
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
982
+ });
983
+ indexer.index(rule);
984
+
985
+ const rule2 = new Rule({
986
+ conditionsOp: 'and',
987
+ conditions: [{ op: 'is', field: 'category', value: 'bars' }],
988
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
989
+ });
990
+ indexer.index(rule2);
991
+
992
+ const rule3 = new Rule({
993
+ conditionsOp: 'and',
994
+ conditions: [{ op: 'is', field: 'date', value: '2020-01-20' }],
995
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
996
+ });
997
+ indexer.index(rule3);
998
+
999
+ expect(indexer.rules.size).toBe(3);
1000
+ expect(indexer.rules.get('f').size).toBe(1);
1001
+ expect(indexer.rules.get('b').size).toBe(1);
1002
+ expect(indexer.rules.get('*').size).toBe(1);
1003
+
1004
+ expect(
1005
+ indexer.getApplicableRules({ notes: 'James', category: 'food' }),
1006
+ ).toEqual(new Set([rule, rule3]));
1007
+ expect(
1008
+ indexer.getApplicableRules({ notes: 'James', category: 'f' }),
1009
+ ).toEqual(new Set([rule, rule3]));
1010
+ expect(
1011
+ indexer.getApplicableRules({ notes: 'James', category: 'foo' }),
1012
+ ).toEqual(new Set([rule, rule3]));
1013
+ expect(
1014
+ indexer.getApplicableRules({ notes: 'James', category: 'bars' }),
1015
+ ).toEqual(new Set([rule2, rule3]));
1016
+ expect(indexer.getApplicableRules({ notes: 'James' })).toEqual(
1017
+ new Set([rule3]),
1018
+ );
1019
+ });
1020
+
1021
+ test('re-indexing a field works', () => {
1022
+ const indexer = new RuleIndexer({ field: 'category', method: 'firstchar' });
1023
+
1024
+ let rule = new Rule({
1025
+ id: 'id1',
1026
+ conditionsOp: 'and',
1027
+ conditions: [{ op: 'is', field: 'category', value: 'food' }],
1028
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
1029
+ });
1030
+ indexer.index(rule);
1031
+
1032
+ expect(indexer.rules.get('f').size).toBe(1);
1033
+ expect(indexer.rules.get('*')).toBe(undefined);
1034
+ expect(indexer.getApplicableRules({ category: 'alco' }).size).toBe(0);
1035
+ expect(indexer.getApplicableRules({ category: 'food' }).size).toBe(1);
1036
+
1037
+ indexer.remove(rule);
1038
+
1039
+ expect(indexer.rules.get('f').size).toBe(0);
1040
+ expect(indexer.getApplicableRules({ category: 'alco' }).size).toBe(0);
1041
+ expect(indexer.getApplicableRules({ category: 'food' }).size).toBe(0);
1042
+
1043
+ rule = new Rule({
1044
+ conditionsOp: 'and',
1045
+ conditions: [{ op: 'is', field: 'category', value: 'alcohol' }],
1046
+ actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
1047
+ });
1048
+ indexer.index(rule);
1049
+
1050
+ expect(indexer.rules.get('f').size).toBe(0);
1051
+ expect(indexer.rules.get('a').size).toBe(1);
1052
+ expect(indexer.getApplicableRules({ category: 'alco' }).size).toBe(1);
1053
+ expect(indexer.getApplicableRules({ category: 'food' }).size).toBe(0);
1054
+ });
1055
+
1056
+ test('indexing works with the oneOf operator', () => {
1057
+ const indexer = new RuleIndexer({
1058
+ field: 'imported_payee',
1059
+ method: 'firstchar',
1060
+ });
1061
+
1062
+ const rule = new Rule({
1063
+ conditionsOp: 'and',
1064
+ conditions: [
1065
+ {
1066
+ op: 'oneOf',
1067
+ field: 'imported_payee',
1068
+ value: ['James', 'Sarah', 'Evy'],
1069
+ },
1070
+ ],
1071
+ actions: [{ op: 'set', field: 'category', value: 'Food' }],
1072
+ });
1073
+ indexer.index(rule);
1074
+
1075
+ const rule2 = new Rule({
1076
+ conditionsOp: 'and',
1077
+ conditions: [{ op: 'is', field: 'imported_payee', value: 'Georgia' }],
1078
+ actions: [{ op: 'set', field: 'category', value: 'Food' }],
1079
+ });
1080
+ indexer.index(rule2);
1081
+
1082
+ expect(indexer.getApplicableRules({ imported_payee: 'James' })).toEqual(
1083
+ new Set([rule]),
1084
+ );
1085
+ expect(indexer.getApplicableRules({ imported_payee: 'Evy' })).toEqual(
1086
+ new Set([rule]),
1087
+ );
1088
+ expect(indexer.getApplicableRules({ imported_payee: 'Charlotte' })).toEqual(
1089
+ new Set([]),
1090
+ );
1091
+ expect(indexer.getApplicableRules({ imported_payee: 'Georgia' })).toEqual(
1092
+ new Set([rule2]),
1093
+ );
1094
+ });
1095
+ });