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