Skip to content
Snippets Groups Projects
transaction-rules.test.ts 25.79 KiB
// @ts-strict-ignore
import { q } from '../../shared/query';
import { runQuery } from '../aql';
import * as db from '../db';
import { loadMappings } from '../db/mappings';

import {
  getRules,
  loadRules,
  insertRule,
  updateRule,
  deleteRule,
  makeRule,
  runRules,
  conditionsToAQL,
  resetState,
  getProbableCategory,
  updateCategoryRules,
} from './transaction-rules';

// TODO: write tests to make sure payee renaming is "pre" and category
// setting is "null" stage

beforeEach(async () => {
  await global.emptyDatabase()();
  resetState();
  await loadMappings();
});

async function getMatchingTransactions(conds) {
  const { filters } = conditionsToAQL(conds);
  const { data } = await runQuery(
    q('transactions').filter({ $and: filters }).select('*'),
  );
  return data;
}

describe('Transaction rules', () => {
  test('makeRule validates rule data', () => {
    const spy = jest.spyOn(console, 'warn').mockImplementation();

    // Parse errors
    expect(makeRule({ conditions: '{', actions: '[]' })).toBe(null);
    expect(makeRule({ conditions: '[]', actions: '{' })).toBe(null);
    expect(makeRule({ conditions: '{}', actions: '{}' })).toBe(null);

    // This is valid
    expect(makeRule({ conditions: '[]', actions: '[]' })).not.toBe(null);

    // condition has invalid operator
    expect(
      makeRule({
        conditions: JSON.stringify([
          { op: 'noop', field: 'date', value: '2019-05' },
        ]),
        actions: JSON.stringify([
          { op: 'set', field: 'name', value: 'Sarah' },
          { op: 'set', field: 'category', value: 'Sarah' },
        ]),
      }),
    ).toBe(null);

    // setting an invalid field
    expect(
      makeRule({
        conditions: JSON.stringify([
          { op: 'is', field: 'date', value: '2019-05' },
        ]),
        actions: JSON.stringify([
          { op: 'set', field: 'notes', value: 'Sarah' },
          { op: 'set', field: 'invalid', value: 'Sarah' },
        ]),
      }),
    ).toBe(null);

    // condition has valid operator & setting valid fields
    expect(
      makeRule({
        conditions: JSON.stringify([
          { op: 'is', field: 'date', value: '2019-05' },
        ]),
        actions: JSON.stringify([
          { op: 'set', field: 'notes', value: 'Sarah' },
          { op: 'set', field: 'category', value: 'Sarah' },
        ]),
      }),
    ).not.toBe(null);

    spy.mockRestore();
  });

  test('insert a rule into the database', async () => {
    await loadRules();
    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' },
        { op: 'set', field: 'category', value: 'food' },
      ],
    });
    expect((await db.all('SELECT * FROM rules')).length).toBe(2);
    expect(getRules().length).toBe(2);

    const spy = jest.spyOn(console, 'warn').mockImplementation();

    // Try to insert an invalid rule (don't use `insertRule` because
    // that will validate the input)
    await db.insertWithUUID('rules', { conditions: '{', actions: '}' });
    // It will be in the database
    expect((await db.all('SELECT * FROM rules')).length).toBe(3);
    // But it will be ignored
    expect(getRules().length).toBe(2);

    spy.mockRestore();

    // Finally make sure the rule is actually in place and runs
    const transaction = runRules({
      date: '2019-05-10',
      notes: '',
      category: null,
    });
    expect(transaction.date).toBe('2019-05-10');
    expect(transaction.notes).toBe('Sarah');
    expect(transaction.category).toBe('food');
  });

  test('update a rule in the database', async () => {
    await loadRules();
    const id = await insertRule({
      stage: 'pre',
      conditionsOp: 'and',
      conditions: [{ op: 'is', field: 'imported_payee', value: 'kroger' }],
      actions: [
        { op: 'set', field: 'notes', value: 'Sarah' },
        { op: 'set', field: 'category', value: 'food' },
      ],
    });
    expect(getRules().length).toBe(1);

    let transaction = runRules({
      imported_payee: 'Kroger',
      notes: '',
      category: null,
    });
    expect(transaction.imported_payee).toBe('Kroger');
    expect(transaction.notes).toBe('Sarah');
    expect(transaction.category).toBe('food');

    // Change the action
    await updateRule({
      id,
      actions: [{ op: 'set', field: 'category', value: 'bars' }],
    });
    expect(getRules().length).toBe(1);

    transaction = runRules({
      imported_payee: 'Kroger',
      notes: '',
      category: null,
    });
    expect(transaction.imported_payee).toBe('Kroger');
    expect(transaction.notes).toBe('');
    expect(transaction.category).toBe('bars');

    // If changing the condition, make sure the rule is re-indexed
    await updateRule({
      id,
      conditions: [{ op: 'is', field: 'imported_payee', value: 'ABC' }],
    });
    transaction = runRules({
      imported_payee: 'ABC',
      notes: '',
      category: null,
    });
    expect(transaction.category).toBe('bars');
    expect(getRules().length).toBe(1);
  });

  test('delete a rule in the database', async () => {
    await loadRules();
    const id = await insertRule({
      stage: 'pre',
      conditionsOp: 'and',
      conditions: [{ op: 'is', field: 'payee', value: 'kroger' }],
      actions: [
        { op: 'set', field: 'notes', value: 'Sarah' },
        { op: 'set', field: 'category', value: 'food' },
      ],
    });
    expect(getRules().length).toBe(1);

    let transaction = runRules({
      payee: 'Kroger',
      notes: '',
      category: null,
    });
    expect(transaction.payee).toBe('Kroger');
    expect(transaction.category).toBe('food');

    await deleteRule({ id });
    expect(getRules().length).toBe(0);
    transaction = runRules({
      payee: 'Kroger',
      notes: '',
      category: null,
    });
    expect(transaction.payee).toBe('Kroger');
    expect(transaction.category).toBe(null);
  });

  test('loadRules loads all the rules', async () => {
    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' }],
    });

    resetState();

    expect(getRules().length).toBe(0);
    await loadRules();
    expect(getRules().length).toBe(2);

    let transaction = runRules({
      imported_payee: 'blah Lowes blah',
      payee: null,
      category: null,
    });
    expect(transaction.payee).toBe('lowes');

    transaction = runRules({
      imported_payee: 'kroger',
      category: null,
    });
    expect(transaction.notes).toBe('Sarah');
  });

  test('ids in rules are migrated as mapping changes', async () => {
    await loadRules();

    await db.insertPayee({ id: 'home_id', name: 'home' });
    await db.insertPayee({ id: 'lowes_id', name: 'lowes' });
    await db.insertCategoryGroup({ name: 'group' });
    await db.insertCategory({
      id: 'food_id',
      name: 'food',
      cat_group: 'group',
    });
    await db.insertCategory({
      id: 'beer_id',
      name: 'beer',
      cat_group: 'group',
    });

    await insertRule({
      id: 'one',
      stage: 'pre',
      conditionsOp: 'and',
      conditions: [{ op: 'contains', field: 'imported_payee', value: 'lowes' }],
      actions: [{ op: 'set', field: 'payee', value: 'lowes_id' }],
    });

    await insertRule({
      id: 'two',
      stage: 'pre',
      conditionsOp: 'and',
      conditions: [
        { op: 'is', field: 'payee', value: 'lowes_id' },
        { op: 'is', field: 'category', value: 'food_id' },
      ],
      actions: [{ op: 'set', field: 'notes', value: 'Sarah' }],
    });

    let rule1 = getRules().find(r => r.id === 'one');
    let rule2 = getRules().find(r => r.id === 'two');

    expect(rule1.actions[0].value).toBe('lowes_id');
    expect(rule2.conditions[0].value).toBe('lowes_id');
    await db.mergePayees('home_id', ['lowes_id']);
    expect(rule1.actions[0].value).toBe('home_id');
    expect(rule2.conditions[0].value).toBe('home_id');

    expect(rule2.conditions[1].value).toBe('food_id');
    await db.deleteCategory({ id: 'food_id' }, 'beer_id');
    expect(rule2.conditions[1].value).toBe('beer_id');

    await loadRules();

    // Make sure mappings work when loading fresh
    rule1 = getRules().find(r => r.id === 'one');
    rule2 = getRules().find(r => r.id === 'two');
    expect(rule1.actions[0].value).toBe('home_id');
    expect(rule2.conditions[0].value).toBe('home_id');
    expect(rule2.conditions[1].value).toBe('beer_id');
  });

  test('runRules runs all the rules in each phase', async () => {
    await loadRules();
    await insertRule({
      stage: 'post',
      conditionsOp: 'and',
      conditions: [
        {
          op: 'oneOf',
          field: 'payee',
          value: ['kroger', 'kroger1', 'kroger2', 'kroger3', 'kroger4'],
        },
      ],
      actions: [{ op: 'set', field: 'notes', value: 'got it2' }],
    });

    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' },
      ],
      actions: [{ op: 'set', field: 'payee', value: 'kroger4' }],
    });

    await insertRule({
      stage: null,
      conditionsOp: 'and',
      conditions: [{ op: 'is', field: 'payee', value: 'kroger4' }],
      actions: [{ op: 'set', field: 'notes', value: 'got it' }],
    });

    expect(
      runRules({
        imported_payee: '123 kroger',
        date: '2020-08-11',
        amount: 50,
      }),
    ).toEqual({
      date: '2020-08-11',
      imported_payee: '123 kroger',
      payee: 'kroger4',
      amount: 50,
      notes: 'got it2',
    });
  });

  test('transactions can be queried by rule', async () => {
    await loadRules();
    const account = await db.insertAccount({ name: 'bank' });
    const categoryGroupId = await db.insertCategoryGroup({ name: 'general' });
    const categoryId = await db.insertCategory({
      name: 'food',
      cat_group: categoryGroupId,
    });
    const krogerId = await db.insertPayee({ name: 'kroger' });
    const lowesId = await db.insertPayee({
      name: 'lowes',
      category: categoryId,
    });

    await db.insertTransaction({
      id: '1',
      date: '2020-10-01',
      account,
      payee: krogerId,
      notes: 'barr',
      amount: 353,
    });
    await db.insertTransaction({
      id: '2',
      date: '2020-10-15',
      account,
      payee: krogerId,
      notes: 'fooo',
      amount: 453,
    });
    await db.insertTransaction({
      id: '3',
      date: '2020-10-15',
      account,
      payee: lowesId,
      notes: 'FooO',
      amount: -322,
    });
    await db.insertTransaction({
      id: '4',
      date: '2020-10-16',
      account,
      payee: lowesId,
      notes: null,
      amount: 101,
    });
    await db.insertTransaction({
      id: '5',
      date: '2020-10-16',
      account,
      payee: lowesId,
      notes: '',
      amount: 124,
    });

    let transactions = await getMatchingTransactions([
      { field: 'date', op: 'is', value: '2020-10-15' },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['2', '3']);

    transactions = await getMatchingTransactions([
      { field: 'payee', op: 'is', value: lowesId },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['4', '5', '3']);

    transactions = await getMatchingTransactions([
      { field: 'amount', op: 'is', value: 353 },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['1']);

    transactions = await getMatchingTransactions([
      { field: 'notes', op: 'is', value: 'FooO' },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['2', '3']);

    transactions = await getMatchingTransactions([
      { field: 'notes', op: 'contains', value: 'oo' },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['2', '3']);

    transactions = await getMatchingTransactions([
      { field: 'notes', op: 'oneOf', value: ['fooo', 'barr'] },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['2', '3', '1']);

    transactions = await getMatchingTransactions([
      { field: 'notes', op: 'is', value: '' },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['4', '5']);

    transactions = await getMatchingTransactions([
      { field: 'amount', op: 'gt', value: 300 },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['2', '1']);

    transactions = await getMatchingTransactions([
      { field: 'amount', op: 'gt', value: 400 },
      { field: 'amount', op: 'lt', value: 500 },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['2']);

    transactions = await getMatchingTransactions([
      { field: 'amount', op: 'gt', value: 300, options: { inflow: true } },
      { field: 'amount', op: 'lt', value: 400, options: { inflow: true } },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['1']);

    // If `inflow` is true, it should never return outflow transactions
    transactions = await getMatchingTransactions([
      { field: 'amount', op: 'gt', value: -1000, options: { inflow: true } },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['4', '5', '2', '1']);

    // Same thing for `outflow`: never return `inflow` transactions
    transactions = await getMatchingTransactions([
      { field: 'amount', op: 'gt', value: 300, options: { outflow: true } },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['3']);

    transactions = await getMatchingTransactions([
      { field: 'date', op: 'gt', value: '2020-10-10' },
    ]);
    expect(transactions.map(t => t.id)).toEqual(['4', '5', '2', '3']);

    // todo: isapprox
  });
});

describe('Learning categories', () => {
  function expectCategoryRule(rule, category, expectedPayee) {
    expect(rule.conditions.length).toBe(1);
    expect(rule.conditions[0].op).toBe('is');
    expect(rule.conditions[0].field).toBe('payee');
    expect(rule.conditions[0].value).toBe(expectedPayee);
    expect(rule.actions.length).toBe(1);
    expect(rule.actions[0].op).toBe('set');
    expect(rule.actions[0].field).toBe('category');
    expect(rule.actions[0].value).toBe(category);
  }

  async function insertTransaction(
    transaction,
    expectedCategory,
    expectedRuleCount = 1,
    expectedPayee = 'foo',
  ) {
    await db.insertTransaction(transaction);
    await updateCategoryRules([transaction]);
    expect(getRules().length).toBe(expectedRuleCount);

    if (expectedRuleCount > 0) {
      expectCategoryRule(
        getRules()[expectedRuleCount - 1],
        expectedCategory,
        expectedPayee,
      );
    }
  }

  async function loadData() {
    await loadRules();
    await db.insertAccount({ id: 'acct', name: 'acct' });
    await db.insertCategoryGroup({ id: 'catg', name: 'catg' });
    await db.insertCategory({ id: 'food', name: 'food', cat_group: 'catg' });
    await db.insertCategory({ id: 'beer', name: 'beer', cat_group: 'catg' });
    await db.insertCategory({ id: 'fun', name: 'fun', cat_group: 'catg' });
    await db.insertPayee({ id: 'foo', name: 'foo' });
    await db.insertPayee({ id: 'bar', name: 'bar' });
  }

  test('getProbableCategory estimates a category winner', () => {
    let winner = getProbableCategory([{ category: 'foo' }]);
    // It needs at least 3 transactions
    expect(winner).toBe(null);

    winner = getProbableCategory([
      { category: 'foo' },
      { category: 'foo' },
      { category: 'foo' },
    ]);
    expect(winner).toBe('foo');

    winner = getProbableCategory([
      { category: 'bar' },
      { category: 'foo' },
      { category: 'foo' },
      { category: 'foo' },
    ]);
    expect(winner).toBe('foo');
    winner = getProbableCategory([
      { category: 'bar' },
      { category: 'bar' },
      { category: 'bar' },
      { category: 'foo' },
      { category: 'foo' },
      { category: 'foo' },
    ]);
    expect(winner).toBe('bar');
  });

  test('creates rule when inserting transactions', async () => {
    await loadData();

    await insertTransaction(
      {
        id: 'one',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      null,
      0,
    );

    await insertTransaction(
      {
        id: 'two',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      null,
      0,
    );

    await insertTransaction(
      {
        id: 'three',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      'food',
    );
  });

  test('leaves existing rule alone if probable category is ambiguous', async () => {
    await loadData();

    await insertTransaction(
      {
        id: 'one',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      null,
      0,
    );

    await insertTransaction(
      {
        id: 'two',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'beer',
      },
      null,
      0,
    );

    await insertTransaction(
      {
        id: 'three',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'beer',
      },
      null,
      0,
    );

    await insertRule({
      stage: null,
      conditionsOp: 'and',
      conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
      actions: [{ op: 'set', field: 'category', value: 'fun' }],
    });

    // Even though the system couldn't figure out the category to set,
    // it should leave the existing rule alone
    await insertTransaction(
      {
        id: 'four',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'bills',
      },
      'fun',
      1,
    );
  });

  test('updates an existing rule', async () => {
    await loadData();

    await insertRule({
      stage: null,
      conditionsOp: 'and',
      conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
      actions: [{ op: 'set', field: 'category', value: 'beer' }],
    });

    await insertTransaction(
      {
        id: 'one',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      'beer',
      1,
    );
    await insertTransaction(
      {
        id: 'two',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      'beer',
      1,
    );
    await insertTransaction(
      {
        id: 'three',
        date: '2016-12-01',
        account: 'acct',
        payee: 'foo',
        category: 'food',
      },
      'food',
      1,
    );
  });

  test('works with multiple payees', async () => {
    await loadData();

    await insertRule({
      stage: null,
      conditionsOp: 'and',
      conditions: [{ op: 'is', field: 'payee', value: 'foo' }],
      actions: [{ op: 'set', field: 'category', value: 'beer' }],
    });

    // Use a new payee, so the category should be remembered
    await insertTransaction(
      {
        id: 'three',
        date: '2016-12-03',
        account: 'acct',
        payee: 'bar',
        category: 'fun',
      },
      'beer',
      1,
    );
    await insertTransaction(
      {
        id: 'four',
        date: '2016-12-03',
        account: 'acct',
        payee: 'bar',
        category: 'fun',
      },
      'beer',
      1,
    );
    await insertTransaction(
      {
        id: 'five',
        date: '2016-12-03',
        account: 'acct',
        payee: 'bar',
        category: 'fun',
      },
      'fun',
      2,
      'bar',
    );
  });

  test('updates rules correctly even if multiple rules exist', async () => {
    await loadData();

    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' }],
    });

    let trans = {
      date: '2016-12-01',
      account: 'acct',
      payee: 'foo',
      category: 'food',
    };
    await db.insertTransaction({ ...trans, id: 'one' });
    await db.insertTransaction({ ...trans, id: 'two' });
    await db.insertTransaction({ ...trans, id: 'three' });
    await updateCategoryRules([{ ...trans, id: 'three' }]);
    expect(getRules()).toMatchSnapshot();

    trans = {
      date: '2016-12-02',
      account: 'acct',
      payee: 'foo',
      category: 'beer',
    };
    await db.insertTransaction({ ...trans, id: 'four' });
    await db.insertTransaction({ ...trans, id: 'five' });
    await db.insertTransaction({ ...trans, id: 'six' });
    await updateCategoryRules([{ ...trans, id: 'three' }]);
    expect(getRules()).toMatchSnapshot();

    const rules = getRules();
    const getPayees = cat => {
      const arr = rules
        .filter(rule => rule.actions[0].value === cat)
        .map(r => r.conditions.map(c => c.value));
      return Array.prototype.concat.apply([], arr);
    };

    // The `foo` payee has been removed from all rules and added to
    // the correct one
    expect(getPayees('unknown1')).toEqual([]);
    expect(getPayees('unknown2')).toEqual([]);
    expect(getPayees('food')).toEqual([]);
    expect(getPayees('beer')).toEqual(['foo', 'foo', null]);
  });

  test('avoids remembering categories for `null` payee', async () => {
    await loadData();

    expect(getRules().length).toBe(0);
    const trans = {
      date: '2016-12-01',
      account: 'acct',
      payee: null,
      category: 'food',
    };
    await db.insertTransaction({ ...trans, id: 'one' });
    await db.insertTransaction({ ...trans, id: 'two' });
    await db.insertTransaction({ ...trans, id: 'three' });
    await updateCategoryRules([{ ...trans, id: 'three' }]);
    expect(getRules().length).toBe(0);
  });

  test('adding transaction with `null` payee never changes rules', async () => {
    await loadData();

    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' }],
    });

    expect(getRules().length).toBe(2);
    const trans = {
      date: '2016-12-01',
      account: 'acct',
      payee: null,
      category: 'food',
    };
    await db.insertTransaction({ ...trans, id: 'one' });
    await db.insertTransaction({ ...trans, id: 'two' });
    await db.insertTransaction({ ...trans, id: 'three' });
    await updateCategoryRules([{ ...trans, id: 'three' }]);

    // This should not have changed the category! This is tested
    // because this was a bug when rules were released
    const rules = getRules();
    expect(rules.length).toBe(2);
    expect(rules[0].actions[0].value).toBe('unknown1');
    expect(rules[1].actions[0].value).toBe('unknown1');
  });

  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' }],
    });

    // The rule that the system sees should use the new public names
    let [rule] = getRules();
    expect(rule.conditions[0].field).toBe('imported_payee');
    expect(rule.actions[0].field).toBe('payee');

    // Internally, it should still be stored with the internal names
    // so that it's backwards compatible
    const rawRule = await db.first('SELECT * FROM rules');
    rawRule.conditions = JSON.parse(rawRule.conditions);
    rawRule.actions = JSON.parse(rawRule.actions);
    expect(rawRule.conditions[0].field).toBe('imported_description');
    expect(rawRule.actions[0].field).toBe('description');

    await loadRules();

    // Make sure reloading everything from the db still uses the new
    // public names
    [rule] = getRules();
    expect(rule.conditions[0].field).toBe('imported_payee');
    expect(rule.actions[0].field).toBe('payee');
  });

  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' },
      ]),
      actions: JSON.stringify([{ op: 'set', field: 'payee', value: 'payee1' }]),
    });

    await loadRules();

    // This rule internally has been stored with the public names.
    // Making this work now allows us to switch to it by default in
    // the future
    const rawRule = await db.first('SELECT * FROM rules');
    rawRule.conditions = JSON.parse(rawRule.conditions);
    rawRule.actions = JSON.parse(rawRule.actions);
    expect(rawRule.conditions[0].field).toBe('imported_payee');
    expect(rawRule.actions[0].field).toBe('payee');

    const [rule] = getRules();
    expect(rule.conditions[0].field).toBe('imported_payee');
    expect(rule.actions[0].field).toBe('payee');
  });

  // TODO: write tests for split transactions
});