Skip to content
Snippets Groups Projects
transaction-rules.ts 20.61 KiB
// @ts-strict-ignore
import {
  currentDay,
  addDays,
  subDays,
  parseDate,
  dayFromDate,
} from '../../shared/months';
import {
  FIELD_TYPES,
  sortNumbers,
  getApproxNumberThreshold,
} from '../../shared/rules';
import { partitionByField, fastSetMerge } from '../../shared/util';
import {
  type TransactionEntity,
  type RuleActionEntity,
  type RuleEntity,
} from '../../types/models';
import { schemaConfig } from '../aql';
import * as db from '../db';
import { getMappings } from '../db/mappings';
import { RuleError } from '../errors';
import { requiredFields, toDateRepr } from '../models';
import { batchMessages, addSyncListener } from '../sync';

import {
  Condition,
  Action,
  Rule,
  RuleIndexer,
  rankRules,
  migrateIds,
  iterateIds,
} from './rules';
import { batchUpdateTransactions } from './transactions';

// TODO: Detect if it looks like the user is creating a rename rule
// and prompt to create it in the pre phase instead
// * We could also make the "create rule" button a dropdown that
//   provides different "templates" like "create renaming rule"

export { iterateIds } from './rules';

let allRules;
let unlistenSync;
let firstcharIndexer;
let payeeIndexer;

export function resetState() {
  allRules = new Map();
  firstcharIndexer = new RuleIndexer({
    field: 'imported_payee',
    method: 'firstchar',
  });
  payeeIndexer = new RuleIndexer({ field: 'payee' });
}

// Database functions

function invert(obj) {
  return Object.fromEntries(
    Object.entries(obj).map(entry => {
      return [entry[1], entry[0]];
    }),
  );
}

const internalFields = schemaConfig.views.transactions.fields;
const publicFields = invert(schemaConfig.views.transactions.fields);

function fromInternalField<T extends { field: string }>(obj: T): T {
  return {
    ...obj,
    field: publicFields[obj.field] || obj.field,
  };
}

function toInternalField<T extends { field: string }>(obj: T): T {
  return {
    ...obj,
    field: internalFields[obj.field] || obj.field,
  };
}

function parseArray(str) {
  let value;
  try {
    value = typeof str === 'string' ? JSON.parse(str) : str;
  } catch (e) {
    throw new RuleError('internal', 'Cannot parse rule json');
  }

  if (!Array.isArray(value)) {
    throw new RuleError('internal', 'Rule json must be an array');
  }
  return value;
}

export function parseConditionsOrActions(str) {
  return str ? parseArray(str).map(item => fromInternalField(item)) : [];
}

export function serializeConditionsOrActions(arr) {
  return JSON.stringify(arr.map(item => toInternalField(item)));
}

export const ruleModel = {
  validate(rule, { update }: { update?: boolean } = {}) {
    requiredFields('rules', rule, ['conditions', 'actions'], update);

    if (!update || 'stage' in rule) {
      if (
        rule.stage !== 'pre' &&
        rule.stage !== 'post' &&
        rule.stage !== null
      ) {
        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;
  },

  toJS(row) {
    const { conditions, conditions_op, actions, ...fields } = row;
    return {
      ...fields,
      conditionsOp: conditions_op,
      conditions: parseConditionsOrActions(conditions),
      actions: parseConditionsOrActions(actions),
    };
  },

  fromJS(rule) {
    const { conditions, conditionsOp, actions, ...row } = rule;
    if (conditionsOp) {
      row.conditions_op = conditionsOp;
    }
    if (Array.isArray(conditions)) {
      row.conditions = serializeConditionsOrActions(conditions);
    }
    if (Array.isArray(actions)) {
      row.actions = serializeConditionsOrActions(actions);
    }
    return row;
  },
};

export function makeRule(data) {
  let rule;
  try {
    rule = new Rule({
      ...ruleModel.toJS(data),
      fieldTypes: FIELD_TYPES,
    });
  } catch (e) {
    console.warn('Invalid rule', e);
    if (e instanceof RuleError) {
      return null;
    }
    throw e;
  }

  // This is needed because we map ids on the fly, and they might
  // not be persisted into the db. Mappings allow items to
  // transparently merge with other items
  migrateIds(rule, getMappings());

  return rule;
}

export async function loadRules() {
  resetState();

  const rules = await db.all(`
    SELECT * FROM rules
      WHERE conditions IS NOT NULL AND actions IS NOT NULL AND tombstone = 0
  `);

  for (let i = 0; i < rules.length; i++) {
    const desc = rules[i];
    // These are old stages, can be removed before release
    if (desc.stage === 'cleanup' || desc.stage === 'modify') {
      desc.stage = 'pre';
    }

    const rule = makeRule(desc);
    if (rule) {
      allRules.set(rule.id, rule);
      firstcharIndexer.index(rule);
      payeeIndexer.index(rule);
    }
  }

  if (unlistenSync) {
    unlistenSync();
  }
  unlistenSync = addSyncListener(onApplySync);
}

export function getRules() {
  // This can simply return the in-memory data
  return [...allRules.values()];
}

export async function insertRule(
  rule: Omit<RuleEntity, 'id'> & { id?: string },
) {
  rule = ruleModel.validate(rule);
  return db.insertWithUUID('rules', ruleModel.fromJS(rule));
}

export async function updateRule(rule) {
  rule = ruleModel.validate(rule, { update: true });
  return db.update('rules', ruleModel.fromJS(rule));
}

export async function deleteRule<T extends { id: string }>(rule: T) {
  const schedule = await db.first('SELECT id FROM schedules WHERE rule = ?', [
    rule.id,
  ]);

  if (schedule) {
    return false;
  }

  return db.delete_('rules', rule.id);
}

// Sync projections

function onApplySync(oldValues, newValues) {
  newValues.forEach((items, table) => {
    if (table === 'rules') {
      items.forEach(newValue => {
        const oldRule = allRules.get(newValue.id);

        if (newValue.tombstone === 1) {
          // Deleted, need to remove it from in-memory
          const rule = allRules.get(newValue.id);
          if (rule) {
            allRules.delete(rule.getId());
            firstcharIndexer.remove(rule);
            payeeIndexer.remove(rule);
          }
        } else {
          // Inserted/updated
          const rule = makeRule(newValue);
          if (rule) {
            if (oldRule) {
              firstcharIndexer.remove(oldRule);
              payeeIndexer.remove(oldRule);
            }
            allRules.set(newValue.id, rule);
            firstcharIndexer.index(rule);
            payeeIndexer.index(rule);
          }
        }
      });
    }
  });

  // If any of the mapping tables have changed, we need to refresh the
  // ids
  const tables = [...newValues.keys()];
  if (tables.find(table => table.indexOf('mapping') !== -1)) {
    getRules().forEach(rule => {
      migrateIds(rule, getMappings());
    });
  }
}

// Runner
export function runRules(trans) {
  let finalTrans = { ...trans };

  const rules = rankRules(
    fastSetMerge(
      firstcharIndexer.getApplicableRules(trans),
      payeeIndexer.getApplicableRules(trans),
    ),
  );

  for (let i = 0; i < rules.length; i++) {
    finalTrans = rules[i].apply(finalTrans);
  }

  return finalTrans;
}

// This does the inverse: finds all the transactions matching a rule
export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
  const errors = [];

  conditions = conditions
    .map(cond => {
      if (cond instanceof Condition) {
        return cond;
      }

      try {
        return new Condition(
          cond.op,
          cond.field,
          cond.value,
          cond.options,
          FIELD_TYPES,
        );
      } catch (e) {
        errors.push(e.type || 'internal');
        console.log('conditionsToAQL: invalid condition: ' + e.message);
        return null;
      }
    })
    .filter(Boolean);

  // rule -> actualql
  const filters = conditions.map(cond => {
    const { type, field, op, value, options } = cond;

    const getValue = value => {
      if (type === 'number') {
        return value.value;
      }
      return value;
    };

    const apply = (field, op, value) => {
      if (type === 'number') {
        if (options) {
          if (options.outflow) {
            return {
              $and: [
                { amount: { $lt: 0 } },
                { [field]: { $transform: '$neg', [op]: value } },
              ],
            };
          } else if (options.inflow) {
            return {
              $and: [{ amount: { $gt: 0 } }, { [field]: { [op]: value } }],
            };
          }
        }
        return { amount: { [op]: value } };
      } else if (type === 'string') {
        return { [field]: { $transform: '$lower', [op]: value } };
      } else if (type === 'date') {
        return { [field]: { [op]: value.date } };
      }
      return { [field]: { [op]: value } };
    };

    switch (op) {
      case 'isapprox':
      case 'is':
        if (type === 'date') {
          if (value.type === 'recur') {
            const dates = value.schedule
              .occurrences({ take: recurDateBounds })
              .toArray()
              .map(d => dayFromDate(d.date));

            return {
              $or: dates.map(d => {
                if (op === 'isapprox') {
                  return {
                    $and: [
                      { date: { $gte: subDays(d, 2) } },
                      { date: { $lte: addDays(d, 2) } },
                    ],
                  };
                }
                return { date: d };
              }),
            };
          } else {
            if (op === 'isapprox') {
              const fullDate = parseDate(value.date);
              const high = addDays(fullDate, 2);
              const low = subDays(fullDate, 2);

              return {
                $and: [{ date: { $gte: low } }, { date: { $lte: high } }],
              };
            } else {
              switch (value.type) {
                case 'date':
                  return { date: value.date };
                case 'month': {
                  const low = value.date + '-00';
                  const high = value.date + '-99';
                  return {
                    $and: [{ date: { $gte: low } }, { date: { $lte: high } }],
                  };
                }
                case 'year': {
                  const low = value.date + '-00-00';
                  const high = value.date + '-99-99';
                  return {
                    $and: [{ date: { $gte: low } }, { date: { $lte: high } }],
                  };
                }
                default:
              }
            }
          }
        } else if (type === 'number') {
          const number = value.value;
          if (op === 'isapprox') {
            const threshold = getApproxNumberThreshold(number);

            return {
              $and: [
                apply(field, '$gte', number - threshold),
                apply(field, '$lte', number + threshold),
              ],
            };
          }
          return apply(field, '$eq', number);
        } else if (type === 'string') {
          if (value === '') {
            return {
              $or: [apply(field, '$eq', null), apply(field, '$eq', '')],
            };
          }
        }
        return apply(field, '$eq', value);
      case 'isNot':
        return apply(field, '$ne', value);

      case 'isbetween':
        // This operator is only applicable to the specific `between`
        // number type so we don't use `apply`
        const [low, high] = sortNumbers(value.num1, value.num2);
        return {
          [field]: [{ $gte: low }, { $lte: high }],
        };
      case 'contains':
        // Running contains with id will automatically reach into
        // the `name` of the referenced table and do a string match
        return apply(
          type === 'id' ? field + '.name' : field,
          '$like',
          '%' + value + '%',
        );
      case 'doesNotContain':
        // Running contains with id will automatically reach into
        // the `name` of the referenced table and do a string match
        return apply(
          type === 'id' ? field + '.name' : field,
          '$notlike',
          '%' + value + '%',
        );
      case 'oneOf':
        const values = value;
        if (values.length === 0) {
          // This forces it to match nothing
          return { id: null };
        }
        return { $or: values.map(v => apply(field, '$eq', v)) };
      case 'notOneOf':
        const notValues = value;
        if (notValues.length === 0) {
          // This forces it to match nothing
          return { id: null };
        }
        return { $and: notValues.map(v => apply(field, '$ne', v)) };
      case 'gt':
        return apply(field, '$gt', getValue(value));
      case 'gte':
        return apply(field, '$gte', getValue(value));
      case 'lt':
        return apply(field, '$lt', getValue(value));
      case 'lte':
        return apply(field, '$lte', getValue(value));
      case 'true':
        return apply(field, '$eq', true);
      case 'false':
        return apply(field, '$eq', false);
      default:
        throw new Error('Unhandled operator: ' + op);
    }
  });

  return { filters, errors };
}

export function applyActions(
  transactionIds: string[],
  actions: Array<Action | RuleActionEntity>,
) {
  const parsedActions = actions
    .map(action => {
      if (action instanceof Action) {
        return action;
      }

      try {
        if (action.op === 'link-schedule') {
          return new Action(action.op, null, action.value, null, FIELD_TYPES);
        }

        return new Action(
          action.op,
          action.field,
          action.value,
          action.options,
          FIELD_TYPES,
        );
      } catch (e) {
        console.log('Action error', e);
        return null;
      }
    })
    .filter(Boolean);

  if (parsedActions.length !== actions.length) {
    // An error happened while parsing
    return null;
  }

  const updated = transactionIds.map(id => {
    const update = { id };
    for (const action of parsedActions) {
      action.exec(update);
    }
    return update;
  });

  return batchUpdateTransactions({ updated });
}

export function getRulesForPayee(payeeId) {
  const rules = new Set();
  iterateIds(getRules(), 'payee', (rule, id) => {
    if (id === payeeId) {
      rules.add(rule);
    }
  });

  return rankRules([...rules]);
}

function* getIsSetterRules(
  stage,
  condField,
  actionField,
  { condValue, actionValue }: { condValue?: string; actionValue?: string },
) {
  const rules = getRules();
  for (let i = 0; i < rules.length; i++) {
    const rule = rules[i];
    if (
      rule.stage === stage &&
      rule.actions.length === 1 &&
      rule.actions[0].op === 'set' &&
      rule.actions[0].field === actionField &&
      (actionValue === undefined || rule.actions[0].value === actionValue) &&
      rule.conditions.length === 1 &&
      (rule.conditions[0].op === 'is' || rule.conditions[0].op === 'isNot') &&
      rule.conditions[0].field === condField &&
      (condValue === undefined || rule.conditions[0].value === condValue)
    ) {
      yield rule.serialize();
    }
  }

  return null;
}

function* getOneOfSetterRules(
  stage,
  condField,
  actionField,
  { condValue, actionValue }: { condValue?: string; actionValue: string },
) {
  const rules = getRules();
  for (let i = 0; i < rules.length; i++) {
    const rule = rules[i];

    if (
      rule.stage === stage &&
      rule.actions.length === 1 &&
      rule.actions[0].op === 'set' &&
      rule.actions[0].field === actionField &&
      (actionValue == null || rule.actions[0].value === actionValue) &&
      rule.conditions.length === 1 &&
      (rule.conditions[0].op === 'oneOf' ||
        rule.conditions[0].op === 'oneOf') &&
      rule.conditions[0].field === condField &&
      (condValue == null || rule.conditions[0].value.indexOf(condValue) !== -1)
    ) {
      yield rule.serialize();
    }
  }

  return null;
}

export async function updatePayeeRenameRule(fromNames: string[], to: string) {
  const renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
    actionValue: to,
  }).next().value;

  // Note that we don't check for existing rules that set this
  // `imported_payee` to something else. It's important to do
  // that for categories because categories will be changes frequently
  // for the same payee, but renames won't be changed much. It's a use
  // case we could improve in the future, but this is fine for now.

  if (renameRule) {
    const condition = renameRule.conditions[0];
    const newValue = [
      ...fastSetMerge(
        new Set(condition.value),
        new Set(fromNames.filter(name => name !== '')),
      ),
    ];
    const rule = {
      ...renameRule,
      conditions: [{ ...condition, value: newValue }],
    };
    await updateRule(rule);
    return renameRule.id;
  } else {
    const 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,
    });
    return insertRule(rule.serialize());
  }
}

export function getProbableCategory(transactions) {
  const scores = new Map();

  transactions.forEach(trans => {
    if (trans.category) {
      scores.set(trans.category, (scores.get(trans.category) || 0) + 1);
    }
  });

  const winner = transactions.reduce((winner, trans) => {
    const score = scores.get(trans.category);
    if (!winner || score > winner.score) {
      return { score, category: trans.category };
    }
    return winner;
  }, null);

  return winner.score >= 3 ? winner.category : null;
}

export async function updateCategoryRules(transactions) {
  if (transactions.length === 0) {
    return;
  }

  const payeeIds = new Set(transactions.map(trans => trans.payee));
  const transIds = new Set(transactions.map(trans => trans.id));

  // It's going to be quickest to get the oldest date and then query
  // all transactions since then so we can work in memory
  let oldestDate = null;
  for (let i = 0; i < transactions.length; i++) {
    if (oldestDate === null || transactions[i].date < oldestDate) {
      oldestDate = transactions[i].date;
    }
  }

  // We look 6 months behind to include any other transaction. This
  // makes it so we, 1. don't have to load in all transactions ever
  // and 2. "forget" really old transactions which might be nice and
  // 3. don't have to individually run a query for each payee
  oldestDate = subDays(oldestDate, 180);

  // Also look 180 days in the future to get any future transactions
  // (this might change when we think about scheduled transactions)
  const register: TransactionEntity[] = await db.all(
    `SELECT t.* FROM v_transactions t
     LEFT JOIN accounts a ON a.id = t.account
     WHERE date >= ? AND date <= ? AND is_parent = 0 AND a.closed = 0
     ORDER BY date DESC`,
    [toDateRepr(oldestDate), toDateRepr(addDays(currentDay(), 180))],
  );

  const allTransactions = partitionByField(register, 'payee');
  const categoriesToSet = new Map();
  for (const payeeId of payeeIds) {
    // Don't do anything if payee is null
    if (payeeId) {
      const latestTrans = (allTransactions.get(payeeId) || []).slice(0, 5);

      // Check if one of the latest transactions was one that was
      // updated. We only want to update anything if so.
      if (latestTrans.find(trans => transIds.has(trans.id))) {
        const category = getProbableCategory(latestTrans);
        if (category) {
          categoriesToSet.set(payeeId, category);
        }
      }
    }
  }

  await batchMessages(async () => {
    for (const [payeeId, category] of categoriesToSet.entries()) {
      const ruleSetters = [
        ...getIsSetterRules(null, 'payee', 'category', {
          condValue: payeeId,
        }),
      ];

      if (ruleSetters.length > 0) {
        // If there are existing rules, change all of them to the new
        // category (if they aren't already using it). We set all of
        // them because it's possible that multiple rules exist
        // because 2 clients made them independently. Not really a big
        // deal, but to make sure our update gets applied set it to
        // all of them
        for (const rule of ruleSetters) {
          const action = rule.actions[0];
          if (action.value !== category) {
            await updateRule({
              ...rule,
              actions: [{ ...action, value: category }],
            });
          }
        }
      } else {
        // No existing rules, so create one
        const newRule = new Rule({
          stage: null,
          conditionsOp: 'and',
          conditions: [{ op: 'is', field: 'payee', value: payeeId }],
          actions: [{ op: 'set', field: 'category', value: category }],
          fieldTypes: FIELD_TYPES,
        });
        await insertRule(newRule.serialize());
      }
    }
  });
}