From f76a07c3cf72b35c2f47d243eeb1b8d764b6514b Mon Sep 17 00:00:00 2001 From: Alberto Gasparin <albertogasparin@gmail.com> Date: Thu, 30 Mar 2023 08:29:07 +1100 Subject: [PATCH] Add "all" or "any" conditions in rules (#811) --- .../e2e/page-models/rules-page.js | 11 ++++ packages/desktop-client/e2e/schedules.test.js | 6 +- .../desktop-client/public/data-file-index.txt | 1 + .../1679728867040_rules_conditions.sql | 5 ++ .../src/components/ManageRules.js | 6 +- .../src/components/modals/EditRule.js | 38 +++++++++++-- .../components/payees/ManagePayeesWithData.js | 1 + .../1679728867040_rules_conditions.sql | 5 ++ .../transaction-rules.test.js.snap | 6 ++ .../loot-core/src/server/accounts/rules.js | 7 ++- .../src/server/accounts/rules.test.js | 57 ++++++++++++++++++- .../src/server/accounts/sync.test.js | 4 ++ .../src/server/accounts/transaction-rules.js | 47 ++++++++++----- .../server/accounts/transaction-rules.test.js | 28 ++++++++- .../loot-core/src/server/aql/schema/index.js | 1 + .../loot-core/src/server/schedules/app.js | 2 + packages/loot-core/src/shared/rules.js | 4 ++ upcoming-release-notes/811.md | 6 ++ 18 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 packages/desktop-client/public/data/migrations/1679728867040_rules_conditions.sql create mode 100644 packages/loot-core/migrations/1679728867040_rules_conditions.sql create mode 100644 upcoming-release-notes/811.md diff --git a/packages/desktop-client/e2e/page-models/rules-page.js b/packages/desktop-client/e2e/page-models/rules-page.js index 40f79d5a5..2f7783d5c 100644 --- a/packages/desktop-client/e2e/page-models/rules-page.js +++ b/packages/desktop-client/e2e/page-models/rules-page.js @@ -36,6 +36,17 @@ export class RulesPage { } async _fillRuleFields(data) { + if (data.conditionsOp) { + await this.page + .getByTestId('conditions-op') + .getByRole('button') + .first() + .click(); + await this.page + .getByRole('option', { exact: true, name: data.conditionsOp }) + .click(); + } + if (data.conditions) { await this._fillEditorFields( data.conditions, diff --git a/packages/desktop-client/e2e/schedules.test.js b/packages/desktop-client/e2e/schedules.test.js index c98ee8964..6b79b95f8 100644 --- a/packages/desktop-client/e2e/schedules.test.js +++ b/packages/desktop-client/e2e/schedules.test.js @@ -65,9 +65,9 @@ test.describe('Schedules', () => { ], conditions: [ 'payee is Home Depot', - 'account is HSBC', - expect.stringMatching(/^date is approx Every month on the/), - 'amount is approx -25.00', + 'and account is HSBC', + expect.stringMatching(/^and date is approx Every month on the/), + 'and amount is approx -25.00', ], }); diff --git a/packages/desktop-client/public/data-file-index.txt b/packages/desktop-client/public/data-file-index.txt index 9120f9492..edb2dce09 100644 --- a/packages/desktop-client/public/data-file-index.txt +++ b/packages/desktop-client/public/data-file-index.txt @@ -15,3 +15,4 @@ migrations/1615745967948_meta.sql migrations/1616167010796_accounts_order.sql migrations/1618975177358_schedules.sql migrations/1632571489012_remove_cache.js +migrations/1679728867040_rules_conditions.sql diff --git a/packages/desktop-client/public/data/migrations/1679728867040_rules_conditions.sql b/packages/desktop-client/public/data/migrations/1679728867040_rules_conditions.sql new file mode 100644 index 000000000..9f12e68f9 --- /dev/null +++ b/packages/desktop-client/public/data/migrations/1679728867040_rules_conditions.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE rules ADD COLUMN conditions_op TEXT DEFAULT 'and'; + +COMMIT; diff --git a/packages/desktop-client/src/components/ManageRules.js b/packages/desktop-client/src/components/ManageRules.js index 3f1d9bd01..9249a8aa6 100644 --- a/packages/desktop-client/src/components/ManageRules.js +++ b/packages/desktop-client/src/components/ManageRules.js @@ -213,7 +213,7 @@ export function ConditionExpression({ op, value, options, - stage, + prefix, style, }) { return ( @@ -232,6 +232,7 @@ export function ConditionExpression({ style, ]} > + {prefix && <Text style={{ color: colors.n3 }}>{prefix} </Text>} <Text style={{ color: colors.p4 }}>{mapField(field, options)}</Text>{' '} <Text style={{ color: colors.n3 }}>{friendlyOp(op)}</Text>{' '} <Value value={value} field={field} /> @@ -363,7 +364,7 @@ let Rule = React.memo( op={cond.op} value={cond.value} options={cond.options} - stage={rule.stage} + prefix={i > 0 ? friendlyOp(rule.conditionsOp) : null} style={i !== 0 && { marginTop: 3 }} /> ))} @@ -687,6 +688,7 @@ function ManageRulesContent({ isModal, payeeId, setLoading }) { function onCreateRule() { let rule = { stage: null, + conditionsOp: 'and', conditions: [ { field: 'payee', diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js index b432c54fa..0e4b4a291 100644 --- a/packages/desktop-client/src/components/modals/EditRule.js +++ b/packages/desktop-client/src/components/modals/EditRule.js @@ -403,6 +403,7 @@ function newInput(item) { } export function ConditionsList({ + conditionsOp, conditions, editorStyle, isSchedule, @@ -413,8 +414,14 @@ export function ConditionsList({ let fields = conditionFields .map(f => f[0]) .filter(f => f !== 'amount-inflow' && f !== 'amount-outflow'); - for (let cond of conditions) { - fields = fields.filter(f => f !== cond.field); + + // suggest a sensible next field: the same if 'or' or different if 'and' + if (conditions.length && conditionsOp === 'or') { + fields = [conditions[0].field]; + } else { + fields = fields.filter( + f => !conditions.some(c => c.field.includes(f) || f.includes(c.field)), + ); } let field = fields[0] || 'payee'; @@ -582,6 +589,7 @@ export default function EditRule({ let [conditions, setConditions] = useState(defaultRule.conditions.map(parse)); let [actions, setActions] = useState(defaultRule.actions.map(parse)); let [stage, setStage] = useState(defaultRule.stage); + let [conditionsOp, setConditionsOp] = useState(defaultRule.conditionsOp); let [transactions, setTransactions] = useState([]); let dispatch = useDispatch(); let scrollableEl = useRef(); @@ -612,8 +620,11 @@ export default function EditRule({ }); if (filters.length > 0) { + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; let { data: transactions } = await runQuery( - q('transactions').filter({ $and: filters }).select('*'), + q('transactions') + .filter({ [conditionsOpKey]: filters }) + .select('*'), ); setTransactions(transactions); } else { @@ -621,7 +632,7 @@ export default function EditRule({ } } run(); - }, [actions, conditions]); + }, [actions, conditions, conditionsOp]); let selectedInst = useSelected('transactions', transactions, []); @@ -671,6 +682,10 @@ export default function EditRule({ setStage(stage); } + function onChangeConditionsOp(name, value) { + setConditionsOp(value); + } + function onRemoveAction(action) { setActions(actions.filter(a => a !== action)); } @@ -689,6 +704,7 @@ export default function EditRule({ let rule = { ...defaultRule, stage, + conditionsOp, conditions: conditions.map(unparse), actions: actions.map(unparse), }; @@ -787,10 +803,22 @@ export default function EditRule({ <View style={{ flexShrink: 0 }}> <View style={{ marginBottom: 30 }}> <Text style={{ color: colors.n4, marginBottom: 15 }}> - If all these conditions match: + If + <FieldSelect + data-testid="conditions-op" + style={{ display: 'inline-flex' }} + fields={[ + ['and', 'all'], + ['or', 'any'], + ]} + value={conditionsOp} + onChange={onChangeConditionsOp} + /> + of these conditions match: </Text> <ConditionsList + conditionsOp={conditionsOp} conditions={conditions} editorStyle={editorStyle} isSchedule={isSchedule} diff --git a/packages/desktop-client/src/components/payees/ManagePayeesWithData.js b/packages/desktop-client/src/components/payees/ManagePayeesWithData.js index 92fc6dda2..d02ffd359 100644 --- a/packages/desktop-client/src/components/payees/ManagePayeesWithData.js +++ b/packages/desktop-client/src/components/payees/ManagePayeesWithData.js @@ -93,6 +93,7 @@ function ManagePayeesWithData({ function onCreateRule(id) { let rule = { stage: null, + conditionsOp: 'and', conditions: [ { field: 'payee', diff --git a/packages/loot-core/migrations/1679728867040_rules_conditions.sql b/packages/loot-core/migrations/1679728867040_rules_conditions.sql new file mode 100644 index 000000000..9f12e68f9 --- /dev/null +++ b/packages/loot-core/migrations/1679728867040_rules_conditions.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE rules ADD COLUMN conditions_op TEXT DEFAULT 'and'; + +COMMIT; diff --git a/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.js.snap b/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.js.snap index f2c1b82d7..05ac017ec 100644 --- a/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.js.snap +++ b/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.js.snap @@ -24,6 +24,7 @@ Array [ "value": "foo", }, ], + "conditionsOp": "and", "id": "id2", "stage": null, }, @@ -49,6 +50,7 @@ Array [ "value": "foo", }, ], + "conditionsOp": "and", "id": "id3", "stage": null, }, @@ -74,6 +76,7 @@ Array [ "value": null, }, ], + "conditionsOp": "and", "id": "id4", "stage": null, }, @@ -104,6 +107,7 @@ Array [ "value": "foo", }, ], + "conditionsOp": "and", "id": "id2", "stage": null, }, @@ -129,6 +133,7 @@ Array [ "value": "foo", }, ], + "conditionsOp": "and", "id": "id3", "stage": null, }, @@ -154,6 +159,7 @@ Array [ "value": null, }, ], + "conditionsOp": "and", "id": "id4", "stage": null, }, diff --git a/packages/loot-core/src/server/accounts/rules.js b/packages/loot-core/src/server/accounts/rules.js index 0243ac77e..580b3e715 100644 --- a/packages/loot-core/src/server/accounts/rules.js +++ b/packages/loot-core/src/server/accounts/rules.js @@ -440,9 +440,10 @@ export class Action { } export class Rule { - constructor({ id, stage, conditions, actions, fieldTypes }) { + constructor({ id, stage, conditionsOp, conditions, actions, fieldTypes }) { this.id = id; this.stage = stage; + this.conditionsOp = conditionsOp; this.conditions = conditions.map( c => new Condition(c.op, c.field, c.value, c.options, fieldTypes), ); @@ -456,7 +457,8 @@ export class Rule { return false; } - return this.conditions.every(condition => { + const method = this.conditionsOp === 'or' ? 'some' : 'every'; + return this.conditions[method](condition => { return condition.eval(object); }); } @@ -488,6 +490,7 @@ export class Rule { return { id: this.id, stage: this.stage, + conditionsOp: this.conditionsOp, conditions: this.conditions.map(c => c.serialize()), actions: this.actions.map(a => a.serialize()), }; diff --git a/packages/loot-core/src/server/accounts/rules.test.js b/packages/loot-core/src/server/accounts/rules.test.js index 74c54c80f..432f3c8dc 100644 --- a/packages/loot-core/src/server/accounts/rules.test.js +++ b/packages/loot-core/src/server/accounts/rules.test.js @@ -326,6 +326,7 @@ describe('Action', () => { describe('Rule', () => { test('executing a rule works', () => { let rule = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'name', value: 'James' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -342,6 +343,7 @@ describe('Rule', () => { expect(rule.apply({ name: 'James2' })).toEqual({ name: 'James2' }); rule = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'name', value: 'James' }], actions: [ { op: 'set', field: 'name', value: 'Sarah' }, @@ -358,8 +360,9 @@ describe('Rule', () => { expect(rule.apply({ name: 'James2' })).toEqual({ name: 'James2' }); }); - test('rule evaluates conditions as AND', () => { + test('rule with `and` conditionsOp evaluates conditions as AND', () => { let rule = new Rule({ + conditionsOp: 'and', conditions: [ { op: 'is', field: 'name', value: 'James' }, { @@ -386,9 +389,48 @@ describe('Rule', () => { expect(rule.exec({ name: 'James', date: '2018-01-15' })).toEqual(null); }); + test('rule with `or` conditionsOp evaluates conditions as OR', () => { + let rule = new Rule({ + conditionsOp: 'or', + conditions: [ + { op: 'is', field: 'name', value: 'James' }, + { + op: 'isapprox', + field: 'date', + value: { + start: '2018-01-12', + frequency: 'monthly', + interval: 3, + }, + }, + ], + actions: [{ op: 'set', field: 'name', value: 'Sarah' }], + fieldTypes, + }); + + expect(rule.exec({ name: 'James', date: '2018-01-12' })).toEqual({ + name: 'Sarah', + }); + expect(rule.exec({ name: 'James2', date: '2018-01-12' })).toEqual({ + name: 'Sarah', + }); + expect(rule.exec({ name: 'James', date: '2018-01-10' })).toEqual({ + name: 'Sarah', + }); + expect(rule.exec({ name: 'James', date: '2018-01-15' })).toEqual({ + name: 'Sarah', + }); + }); + test('rules are deterministically ranked', () => { let rule = (id, conditions) => - new Rule({ id, conditions, actions: [], fieldTypes }); + new Rule({ + id, + conditionsOp: 'and', + conditions, + actions: [], + fieldTypes, + }); let expectOrder = (rules, ids) => expect(rules.map(r => r.getId())).toEqual(ids); @@ -419,7 +461,7 @@ describe('Rule', () => { test('iterateIds finds all the ids', () => { let rule = (id, conditions, actions = []) => - new Rule({ id, conditions, actions, fieldTypes }); + new Rule({ id, conditionsOp: 'and', conditions, actions, fieldTypes }); let rules = [ rule( @@ -459,6 +501,7 @@ describe('RuleIndexer', () => { let indexer = new RuleIndexer({ field: 'name' }); let rule = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'name', value: 'James' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -466,6 +509,7 @@ describe('RuleIndexer', () => { indexer.index(rule); let rule2 = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'foo' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -489,6 +533,7 @@ describe('RuleIndexer', () => { // A condition that references both of the fields let indexer = new RuleIndexer({ field: 'category', method: 'firstchar' }); let rule = new Rule({ + conditionsOp: 'and', conditions: [ { op: 'is', field: 'name', value: 'James' }, { op: 'is', field: 'category', value: 'food' }, @@ -499,6 +544,7 @@ describe('RuleIndexer', () => { indexer.index(rule); let rule2 = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'bars' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -506,6 +552,7 @@ describe('RuleIndexer', () => { indexer.index(rule2); let rule3 = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'date', value: '2020-01-20' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -539,6 +586,7 @@ describe('RuleIndexer', () => { let rule = new Rule({ id: 'id1', + conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'food' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -557,6 +605,7 @@ describe('RuleIndexer', () => { expect(indexer.getApplicableRules({ category: 'food' }).size).toBe(0); rule = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'alcohol' }], actions: [{ op: 'set', field: 'name', value: 'Sarah' }], fieldTypes, @@ -573,6 +622,7 @@ describe('RuleIndexer', () => { let indexer = new RuleIndexer({ field: 'name', method: 'firstchar' }); let rule = new Rule({ + conditionsOp: 'and', conditions: [ { op: 'oneOf', field: 'name', value: ['James', 'Sarah', 'Evy'] }, ], @@ -582,6 +632,7 @@ describe('RuleIndexer', () => { indexer.index(rule); let rule2 = new Rule({ + conditionsOp: 'and', conditions: [{ op: 'is', field: 'name', value: 'Georgia' }], actions: [{ op: 'set', field: 'category', value: 'Food' }], fieldTypes, diff --git a/packages/loot-core/src/server/accounts/sync.test.js b/packages/loot-core/src/server/accounts/sync.test.js index 35430da11..63288426c 100644 --- a/packages/loot-core/src/server/accounts/sync.test.js +++ b/packages/loot-core/src/server/accounts/sync.test.js @@ -400,6 +400,7 @@ describe('Account sync', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: payeeId }], actions: [{ op: 'set', field: 'category', value: catId }], }); @@ -450,6 +451,7 @@ describe('Account sync', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: 'Bakkerij' }], actions: [{ op: 'set', field: 'payee', value: payeeId }], }); @@ -496,6 +498,7 @@ describe('Account sync', () => { // Unless they sync in a rule... await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: 'Bakkerij' }], actions: [{ op: 'set', field: 'payee', value: payeeId2 }], }); @@ -558,6 +561,7 @@ describe('Account sync', () => { // Make sure it still runs rules await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: 'Bakkerij' }], actions: [{ op: 'set', field: 'payee', value: payeeId }], }); diff --git a/packages/loot-core/src/server/accounts/transaction-rules.js b/packages/loot-core/src/server/accounts/transaction-rules.js index eb3e9fb08..a317adc05 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.js +++ b/packages/loot-core/src/server/accounts/transaction-rules.js @@ -90,6 +90,11 @@ export const ruleModel = { throw new Error('Invalid rule stage: ' + rule.stage); } } + if (!update || 'conditionsOp' in rule) { + if (!['and', 'or'].includes(rule.conditionsOp)) { + throw new Error('Invalid rule conditionsOp: ' + rule.conditionsOp); + } + } return rule; }, @@ -109,25 +114,31 @@ export const ruleModel = { return value; } - let rule = { ...row }; - rule.conditions = rule.conditions - ? parseArray(rule.conditions).map(cond => fromInternalField(cond)) - : []; - rule.actions = rule.actions - ? parseArray(rule.actions).map(action => fromInternalField(action)) - : []; - return rule; + let { conditions, conditions_op, actions, ...fields } = row; + return { + ...fields, + conditionsOp: conditions_op, + conditions: conditions + ? parseArray(conditions).map(cond => fromInternalField(cond)) + : [], + actions: actions + ? parseArray(actions).map(action => fromInternalField(action)) + : [], + }; }, fromJS(rule) { - let row = { ...rule }; - if ('conditions' in row) { - let conditions = row.conditions.map(cond => toInternalField(cond)); - row.conditions = JSON.stringify(conditions); + let { conditions, conditionsOp, actions, ...row } = rule; + if (conditionsOp) { + row.conditions_op = conditionsOp; + } + if (Array.isArray(conditions)) { + let value = conditions.map(cond => toInternalField(cond)); + row.conditions = JSON.stringify(value); } - if ('actions' in row) { - let actions = row.actions.map(action => toInternalField(action)); - row.actions = JSON.stringify(actions); + if (Array.isArray(actions)) { + let value = actions.map(action => toInternalField(action)); + row.actions = JSON.stringify(value); } return row; }, @@ -582,6 +593,7 @@ export async function updatePayeeRenameRule(fromNames, to) { } else { let rule = new Rule({ stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'oneOf', field: 'imported_payee', value: fromNames }], actions: [{ op: 'set', field: 'payee', value: to }], fieldTypes: FIELD_TYPES, @@ -690,6 +702,7 @@ export async function updateCategoryRules(transactions) { // No existing rules, so create one let newRule = new Rule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: payeeId }], actions: [{ op: 'set', field: 'category', value: category }], fieldTypes: FIELD_TYPES, @@ -745,6 +758,7 @@ export async function migrateOldRules() { if (equals.length > 0) { rules.push({ stage: null, + conditionsOp: 'and', conditions: [ { op: 'oneOf', @@ -760,6 +774,7 @@ export async function migrateOldRules() { rules = rules.concat( contains.map(payeeRule => ({ stage: null, + conditionsOp: 'and', conditions: [ { op: 'contains', @@ -786,6 +801,7 @@ export async function migrateOldRules() { for (let [catId, payeeIds] of catRules) { rules.push({ stage: null, + conditionsOp: 'and', conditions: [ { op: 'oneOf', @@ -812,6 +828,7 @@ export async function migrateOldRules() { for (let rule of rules) { await insertRule({ stage: rule.stage, + conditionsOp: rule.conditionsOp, conditions: rule.conditions, actions: rule.actions, }); diff --git a/packages/loot-core/src/server/accounts/transaction-rules.test.js b/packages/loot-core/src/server/accounts/transaction-rules.test.js index 92adc41f7..c3fc3a40c 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.test.js +++ b/packages/loot-core/src/server/accounts/transaction-rules.test.js @@ -91,13 +91,19 @@ describe('Transaction rules', () => { test('insert a rule into the database', async () => { await loadRules(); - await insertRule({ stage: 'pre', conditions: [], actions: [] }); + await insertRule({ + stage: 'pre', + conditionsOp: 'and', + conditions: [], + actions: [], + }); expect((await db.all('SELECT * FROM rules')).length).toBe(1); // Make sure it was projected expect(getRules().length).toBe(1); await insertRule({ stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'is', field: 'date', value: '2019-05' }], actions: [ { op: 'set', field: 'notes', value: 'Sarah' }, @@ -134,6 +140,7 @@ describe('Transaction rules', () => { await loadRules(); let id = await insertRule({ stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: 'kroger' }], actions: [ { op: 'set', field: 'notes', value: 'Sarah' }, @@ -185,6 +192,7 @@ describe('Transaction rules', () => { await loadRules(); let id = await insertRule({ stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'kroger' }], actions: [ { op: 'set', field: 'notes', value: 'Sarah' }, @@ -216,12 +224,14 @@ describe('Transaction rules', () => { await loadRules(); await insertRule({ stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'contains', field: 'imported_payee', value: 'lowes' }], actions: [{ op: 'set', field: 'payee', value: 'lowes' }], }); await insertRule({ stage: 'post', + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: 'kroger' }], actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); @@ -266,6 +276,7 @@ describe('Transaction rules', () => { await insertRule({ id: 'one', stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'contains', field: 'imported_payee', value: 'lowes' }], actions: [{ op: 'set', field: 'payee', value: 'lowes_id' }], }); @@ -273,6 +284,7 @@ describe('Transaction rules', () => { await insertRule({ id: 'two', stage: 'pre', + conditionsOp: 'and', conditions: [ { op: 'is', field: 'payee', value: 'lowes_id' }, { op: 'is', field: 'category', value: 'food_id' }, @@ -307,6 +319,7 @@ describe('Transaction rules', () => { await loadRules(); await insertRule({ stage: 'post', + conditionsOp: 'and', conditions: [ { op: 'oneOf', @@ -319,12 +332,14 @@ describe('Transaction rules', () => { await insertRule({ stage: 'pre', + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: '123 kroger' }], actions: [{ op: 'set', field: 'payee', value: 'kroger3' }], }); await insertRule({ stage: null, + conditionsOp: 'and', conditions: [ { op: 'contains', field: 'imported_payee', value: 'kroger' }, ], @@ -333,6 +348,7 @@ describe('Transaction rules', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'kroger4' }], actions: [{ op: 'set', field: 'notes', value: 'got it' }], }); @@ -667,6 +683,7 @@ describe('Learning categories', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'foo' }], actions: [{ op: 'set', field: 'category', value: 'fun' }], }); @@ -691,6 +708,7 @@ describe('Learning categories', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'foo' }], actions: [{ op: 'set', field: 'category', value: 'beer' }], }); @@ -735,6 +753,7 @@ describe('Learning categories', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'foo' }], actions: [{ op: 'set', field: 'category', value: 'beer' }], }); @@ -781,16 +800,19 @@ describe('Learning categories', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'foo' }], actions: [{ op: 'set', field: 'category', value: 'unknown1' }], }); await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'foo' }], actions: [{ op: 'set', field: 'category', value: 'unknown2' }], }); await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: null }], actions: [{ op: 'set', field: 'category', value: 'beer' }], }); @@ -857,11 +879,13 @@ describe('Learning categories', () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: 'foo' }], actions: [{ op: 'set', field: 'category', value: 'unknown1' }], }); await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'oneOf', field: 'payee', value: ['foo', 'bar'] }], actions: [{ op: 'set', field: 'category', value: 'unknown1' }], }); @@ -889,6 +913,7 @@ describe('Learning categories', () => { test('rules are saved with internal field names', async () => { await insertRule({ stage: null, + conditionsOp: 'and', conditions: [{ op: 'is', field: 'imported_payee', value: 'foo' }], actions: [{ op: 'set', field: 'payee', value: 'unknown1' }], }); @@ -918,6 +943,7 @@ describe('Learning categories', () => { test('rules with public field names are loaded correctly', async () => { await db.insertWithUUID('rules', { stage: null, + conditions_op: 'and', conditions: JSON.stringify([ { op: 'is', field: 'imported_payee', value: 'foo' }, ]), diff --git a/packages/loot-core/src/server/aql/schema/index.js b/packages/loot-core/src/server/aql/schema/index.js index 2a2f33887..56561f3c7 100644 --- a/packages/loot-core/src/server/aql/schema/index.js +++ b/packages/loot-core/src/server/aql/schema/index.js @@ -104,6 +104,7 @@ export const schema = { rules: { id: f('id'), stage: f('string'), + conditions_op: f('string'), conditions: f('json'), actions: f('json'), tombstone: f('boolean'), diff --git a/packages/loot-core/src/server/schedules/app.js b/packages/loot-core/src/server/schedules/app.js index f60bb61aa..f8fb0fd45 100644 --- a/packages/loot-core/src/server/schedules/app.js +++ b/packages/loot-core/src/server/schedules/app.js @@ -114,6 +114,7 @@ export async function fixRuleForSchedule(id) { let newId = await insertRule({ stage: null, + conditionsOp: 'and', conditions: [ { op: 'isapprox', field: 'date', value: currentDay() }, { op: 'isapprox', field: 'amount', value: 0 }, @@ -194,6 +195,7 @@ export async function createSchedule({ schedule, conditions = [] } = {}) { let ruleId; ruleId = await insertRule({ stage: null, + conditionsOp: 'and', conditions, actions: [{ op: 'link-schedule', value: scheduleId }], }); diff --git a/packages/loot-core/src/shared/rules.js b/packages/loot-core/src/shared/rules.js index 079dd548b..b4295c6fa 100644 --- a/packages/loot-core/src/shared/rules.js +++ b/packages/loot-core/src/shared/rules.js @@ -102,6 +102,10 @@ export function friendlyOp(op, type) { return 'set'; case 'link-schedule': return 'link schedule'; + case 'and': + return 'and'; + case 'or': + return 'or'; default: return ''; } diff --git a/upcoming-release-notes/811.md b/upcoming-release-notes/811.md new file mode 100644 index 000000000..f51a627ae --- /dev/null +++ b/upcoming-release-notes/811.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [albertogasparin] +--- + +Allow rules to apply to "all" or "any" of the provided conditions -- GitLab