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