Skip to content
Snippets Groups Projects
  • Julian Dominguez-Schatz's avatar
    2bb7b3c2
    Add rules with splits (#2059) · 2bb7b3c2
    Julian Dominguez-Schatz authored
    * Add split creation UI to rule creation modal
    
    * Support applying splits when rules execute
    
    * fix: deserialize transaction before running rules
    
    According to how rules are run in other places in the app, we should be
    supplying a "deserialized" (i.e., integer-for-amount and ISO date)
    transaction rather than a "serialized" (amount-plus-formatted-date) one.
    This fixes a crash in how split transactions are applied, as well as
    date-based rules not applying correctly previously (any rule with a date
    condition would never match on mobile).
    
    * Add release notes
    
    * Fix missing types pulled in from master
    
    * PR feedback: use `getActions`
    
    * PR feedback: use `flatMap`
    
    * Fix action deletion
    
    * Don't flicker upon split deletion
    
    * Let users specify parent transaction actions (e.g. linking schedules)
    
    * Support empty splits
    
    * Revert adding `no-op` action type
    
    * Support splits by percent
    
    * Fix types
    
    * Fix crash on transactions page when posting a transaction
    
    The crash would probably have occurred in other places too with
    auto-posting schedules :/
    
    * Fix a bug where schedules wouldn't be marked as completed
    
    This was because the query that we previously used didn't select parent
    transactions, so no transaction was marked as being scheduled (since
    only parent transactions have schedule IDs).
    
    * Add feature flag
    
    * Limit set actions within splits to fewer fields
    
    * Fix merge conflict
    
    * Don't run split rules if feature is disabled
    
    * Fix percent-based splits not applying
    
    * Fix crash when editing parent transaction amount
    
    * Auto-format
    
    * Attempt to fix failing tests
    
    * More test/bug fixes
    
    * Add an extra split at the end if there is a remaining amount
    
    * Make sure split has correct values for dynamic remainder
    
    * Remove extraneous console.log
    Add rules with splits (#2059)
    Julian Dominguez-Schatz authored
    * Add split creation UI to rule creation modal
    
    * Support applying splits when rules execute
    
    * fix: deserialize transaction before running rules
    
    According to how rules are run in other places in the app, we should be
    supplying a "deserialized" (i.e., integer-for-amount and ISO date)
    transaction rather than a "serialized" (amount-plus-formatted-date) one.
    This fixes a crash in how split transactions are applied, as well as
    date-based rules not applying correctly previously (any rule with a date
    condition would never match on mobile).
    
    * Add release notes
    
    * Fix missing types pulled in from master
    
    * PR feedback: use `getActions`
    
    * PR feedback: use `flatMap`
    
    * Fix action deletion
    
    * Don't flicker upon split deletion
    
    * Let users specify parent transaction actions (e.g. linking schedules)
    
    * Support empty splits
    
    * Revert adding `no-op` action type
    
    * Support splits by percent
    
    * Fix types
    
    * Fix crash on transactions page when posting a transaction
    
    The crash would probably have occurred in other places too with
    auto-posting schedules :/
    
    * Fix a bug where schedules wouldn't be marked as completed
    
    This was because the query that we previously used didn't select parent
    transactions, so no transaction was marked as being scheduled (since
    only parent transactions have schedule IDs).
    
    * Add feature flag
    
    * Limit set actions within splits to fewer fields
    
    * Fix merge conflict
    
    * Don't run split rules if feature is disabled
    
    * Fix percent-based splits not applying
    
    * Fix crash when editing parent transaction amount
    
    * Auto-format
    
    * Attempt to fix failing tests
    
    * More test/bug fixes
    
    * Add an extra split at the end if there is a remaining amount
    
    * Make sure split has correct values for dynamic remainder
    
    * Remove extraneous console.log
transactions.ts 7.32 KiB
// @ts-strict-ignore
import { v4 as uuidv4 } from 'uuid';

import { type TransactionEntity } from '../types/models';

import { last, diffItems, applyChanges } from './util';

export function isPreviewId(id) {
  return id.indexOf('preview/') !== -1;
}

// The amount might be null when adding a new transaction
function num(n) {
  return typeof n === 'number' ? n : 0;
}

function SplitTransactionError(total, parent) {
  const difference = num(parent.amount) - total;

  return {
    type: 'SplitTransactionError',
    version: 1,
    difference,
  };
}

export function makeChild(parent, data) {
  const prefix = parent.id === 'temp' ? 'temp' : '';

  return {
    amount: 0,
    ...data,
    payee: data.payee || parent.payee,
    id: data.id ? data.id : prefix + uuidv4(),
    account: parent.account,
    date: parent.date,
    cleared: parent.cleared != null ? parent.cleared : null,
    starting_balance_flag:
      parent.starting_balance_flag != null
        ? parent.starting_balance_flag
        : null,
    is_child: true,
    parent_id: parent.id,
    error: null,
  };
}

export function recalculateSplit(trans) {
  // Calculate the new total of split transactions and make sure
  // that it equals the parent amount
  const total = trans.subtransactions.reduce(
    (acc, t) => acc + num(t.amount),
    0,
  );
  return {
    ...trans,
    error:
      total === num(trans.amount) ? null : SplitTransactionError(total, trans),
  };
}

function findParentIndex(transactions, idx) {
  // This relies on transactions being sorted in a way where parents
  // are always before children, which is enforced in the db layer.
  // Walk backwards and find the last parent;
  while (idx >= 0) {
    const trans = transactions[idx];
    if (trans.is_parent) {
      return idx;
    }
    idx--;
  }
  return null;
}

function getSplit(transactions, parentIndex) {
  const split = [transactions[parentIndex]];
  let curr = parentIndex + 1;
  while (curr < transactions.length && transactions[curr].is_child) {
    split.push(transactions[curr]);
    curr++;
  }
  return split;
}

export function ungroupTransactions(transactions: TransactionEntity[]) {
  return transactions.reduce<TransactionEntity[]>((list, parent) => {
    const { subtransactions, ...trans } = parent;
    const _subtransactions = subtransactions || [];

    list.push(trans);

    for (let i = 0; i < _subtransactions.length; i++) {
      list.push(_subtransactions[i]);
    }
    return list;
  }, []);
}

export function groupTransaction(split) {
  return { ...split[0], subtransactions: split.slice(1) };
}

export function ungroupTransaction(split: TransactionEntity | null) {
  if (split == null) {
    return null;
  }
  return ungroupTransactions([split]);
}

export function applyTransactionDiff(
  groupedTrans: Parameters<typeof ungroupTransaction>[0],
  diff: Parameters<typeof applyChanges>[0],
) {
  return groupTransaction(
    applyChanges(diff, ungroupTransaction(groupedTrans) || []),
  );
}

function replaceTransactions(
  transactions: TransactionEntity[],
  id: string,
  func: (transaction: TransactionEntity) => TransactionEntity,
) {
  const idx = transactions.findIndex(t => t.id === id);
  const trans = transactions[idx];
  const transactionsCopy = [...transactions];

  if (idx === -1) {
    throw new Error('Tried to edit unknown transaction id: ' + id);
  }

  if (trans.is_parent || trans.is_child) {
    const parentIndex = findParentIndex(transactions, idx);
    if (parentIndex == null) {
      console.log('Cannot find parent index');
      return { data: [], diff: { deleted: [], updated: [] } };
    }

    const split = getSplit(transactions, parentIndex);
    let grouped: TransactionEntity | { id: string; _deleted: boolean } = func(
      groupTransaction(split),
    );
    const newSplit = ungroupTransaction(grouped);

    let diff;
    if (newSplit == null) {
      // If everything was deleted, just delete the parent which will
      // delete everything
      diff = { deleted: [{ id: split[0].id }], updated: [] };
      grouped = { id: split[0].id, _deleted: true };
      transactionsCopy.splice(parentIndex, split.length);
    } else {
      diff = diffItems(split, newSplit);
      transactionsCopy.splice(parentIndex, split.length, ...newSplit);
    }

    return { data: transactionsCopy, newTransaction: grouped, diff };
  } else {
    const grouped = func(trans);
    const newTrans = ungroupTransaction(grouped) || [];
    if (grouped) {
      grouped.subtransactions = grouped.subtransactions || [];
    }
    transactionsCopy.splice(idx, 1, ...newTrans);

    return {
      data: transactionsCopy,
      newTransaction: grouped || { id: trans.id, _deleted: true },
      diff: diffItems([trans], newTrans),
    };
  }
}

export function addSplitTransaction(
  transactions: TransactionEntity[],
  id: string,
) {
  return replaceTransactions(transactions, id, trans => {
    if (!trans.is_parent) {
      return trans;
    }
    const prevSub = last(trans.subtransactions || []);
    trans.subtransactions?.push(
      makeChild(trans, {
        amount: 0,
        sort_order: num(prevSub && prevSub.sort_order) - 1,
      }),
    );
    return trans;
  });
}

export function updateTransaction(
  transactions: TransactionEntity[],
  transaction: TransactionEntity,
) {
  return replaceTransactions(transactions, transaction.id, trans => {
    if (trans.is_parent) {
      const parent = trans.id === transaction.id ? transaction : trans;
      const sub = trans.subtransactions?.map(t => {
        // Make sure to update the children to reflect the updated
        // properties (if the parent updated)

        let child = t;
        if (trans.id === transaction.id) {
          child = {
            ...t,
            payee: t.payee === trans.payee ? transaction.payee : t.payee,
          };
        } else if (t.id === transaction.id) {
          child = transaction;
        }

        return makeChild(parent, child);
      });

      return recalculateSplit({ ...parent, subtransactions: sub });
    } else {
      return transaction;
    }
  });
}

export function deleteTransaction(
  transactions: TransactionEntity[],
  id: string,
) {
  return replaceTransactions(transactions, id, trans => {
    if (trans.is_parent) {
      if (trans.id === id) {
        return null;
      } else if (trans.subtransactions?.length === 1) {
        return {
          ...trans,
          subtransactions: null,
          is_parent: false,
          error: null,
        };
      } else {
        const sub = trans.subtransactions?.filter(t => t.id !== id);
        return recalculateSplit({ ...trans, subtransactions: sub });
      }
    } else {
      return null;
    }
  });
}

export function splitTransaction(
  transactions: TransactionEntity[],
  id: string,
) {
  return replaceTransactions(transactions, id, trans => {
    if (trans.is_parent || trans.is_child) {
      return trans;
    }

    return {
      ...trans,
      is_parent: true,
      error: num(trans.amount) === 0 ? null : SplitTransactionError(0, trans),
      subtransactions: [makeChild(trans, { amount: 0, sort_order: -1 })],
    };
  });
}

export function realizeTempTransactions(transactions) {
  let parent = transactions.find(t => !t.is_child);
  parent = { ...parent, id: uuidv4() };

  const children = transactions.filter(t => t.is_child);
  return [
    parent,
    ...children.map(child => ({
      ...child,
      id: uuidv4(),
      parent_id: parent.id,
    })),
  ];
}