Skip to content

Commit

Permalink
Refactor to consistently generate valid splits with no errors
Browse files Browse the repository at this point in the history
  • Loading branch information
jfdoming committed Oct 19, 2024
1 parent 6c50125 commit bbe5c8c
Showing 1 changed file with 74 additions and 112 deletions.
186 changes: 74 additions & 112 deletions packages/loot-core/src/server/accounts/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { recurConfigToRSchedule } from '../../shared/schedules';
import {
addSplitTransaction,
groupTransaction,
recalculateSplit,
splitTransaction,
ungroupTransaction,
Expand Down Expand Up @@ -667,135 +668,96 @@ function execNonSplitActions(actions: Action[], transaction) {
return update;
}

export function execActions(actions: Action[], transaction) {
const parentActions = actions.filter(action => !action.options?.splitIndex);
const childActions = actions.filter(action => action.options?.splitIndex);
const totalSplitCount =
actions.reduce(
(prev, cur) => Math.max(prev, cur.options?.splitIndex ?? 0),
0,
) + 1;

let update = execNonSplitActions(parentActions, transaction);
if (totalSplitCount === 1) {
// No splits, no need to do anything else.
return update;
}

if (update.is_child) {
// Rules with splits can't be applied to child transactions.
return update;
}
function getSplitRemainder(transactions) {
const { error } = recalculateSplit(groupTransaction(transactions));
return error ? error.difference : 0;
}

const splitAmountActions = childActions.filter(
function execSplitActions(actions: Action[], transaction) {
const splitAmountActions = actions.filter(
action => action.op === 'set-split-amount',
);
const fixedSplitAmountActions = splitAmountActions.filter(
action => action.options.method === 'fixed-amount',
);
const fixedAmountsBySplit: Record<number, number> = {};
fixedSplitAmountActions.forEach(action => {
const splitIndex = action.options.splitIndex ?? 0;
fixedAmountsBySplit[splitIndex] = action.value;
});
const fixedAmountSplitCount = Object.keys(fixedAmountsBySplit).length;
const totalFixedAmount = Object.values(fixedAmountsBySplit).reduce<number>(
(prev, cur: number) => prev + cur,
0,
);
if (
fixedAmountSplitCount === totalSplitCount &&
totalFixedAmount !== (transaction.amount ?? totalFixedAmount)
) {
// Not all value would be distributed to a split.
return transaction;
}

const { data, newTransaction } = splitTransaction(
ungroupTransaction(update),
// Convert the transaction to a split transaction.
const { data } = splitTransaction(
ungroupTransaction(transaction),
transaction.id,
);
update = recalculateSplit(newTransaction);
data[0] = update;
let newTransactions = data;

for (const action of childActions) {
const splitIndex = action.options?.splitIndex ?? 0;
if (splitIndex >= update.subtransactions.length) {
const { data, newTransaction } = addSplitTransaction(
newTransactions,
transaction.id,
);
update = recalculateSplit(newTransaction);
data[0] = update;
// Add empty splits, and apply non-set-amount actions.
// This also populates any fixed-amount splits.
actions.forEach(action => {
const splitTransactionIndex = (action.options?.splitIndex ?? 0) + 1;
if (splitTransactionIndex >= newTransactions.length) {
const { data } = addSplitTransaction(newTransactions, transaction.id);
newTransactions = data;
}
action.exec(update.subtransactions[splitIndex]);
}
action.exec(newTransactions[splitTransactionIndex]);
});

// Distribute to fixed-percent splits.
const remainingAfterFixedAmounts = getSplitRemainder(newTransactions);
splitAmountActions
.filter(action => action.options.method === 'fixed-percent')
.forEach(action => {
const splitTransactionIndex = (action.options?.splitIndex ?? 0) + 1;
const percent = action.value / 100;
const amount = Math.round(remainingAfterFixedAmounts * percent);
newTransactions[splitTransactionIndex].amount = amount;
});

// Make sure every transaction has an amount.
if (fixedAmountSplitCount !== totalSplitCount) {
// This is the amount that will be distributed to the splits that
// don't have a fixed amount. The last split will get the remainder.
// The amount will be zero if the parent transaction has no amount.
const amountToDistribute =
(transaction.amount ?? totalFixedAmount) - totalFixedAmount;
let remainingAmount = amountToDistribute;

// First distribute the fixed percentages.
splitAmountActions
.filter(action => action.options.method === 'fixed-percent')
.forEach(action => {
const splitIndex = action.options.splitIndex;
const percent = action.value / 100;
const amount = Math.round(amountToDistribute * percent);
update.subtransactions[splitIndex].amount = amount;
remainingAmount -= amount;
});

// Then distribute the remainder.
const remainderSplitAmountActions = splitAmountActions.filter(
action => action.options.method === 'remainder',
// Distribute to remainder splits.
const remainderActions = splitAmountActions.filter(
action => action.options.method === 'remainder',
);
const remainingAfterFixedPercents = getSplitRemainder(newTransactions);
if (remainderActions.length !== 0) {
const amountPerRemainderSplit = Math.round(
remainingAfterFixedPercents / remainderActions.length,
);
let lastNonFixedTransactionIndex = -1;
remainderActions.forEach(action => {
const splitTransactionIndex = (action.options?.splitIndex ?? 0) + 1;
newTransactions[splitTransactionIndex].amount = amountPerRemainderSplit;
lastNonFixedTransactionIndex = Math.max(
lastNonFixedTransactionIndex,
splitTransactionIndex,
);
});

// Check if there is any value left to distribute after all fixed
// (percentage and amount) splits have been distributed.
if (remainingAmount !== 0) {
// If there are no remainder splits explicitly added by the user,
// distribute the remainder to a virtual split that will be
// adjusted for the remainder.
if (remainderSplitAmountActions.length === 0) {
const splitIndex = totalSplitCount;
const { newTransaction } = addSplitTransaction(
newTransactions,
transaction.id,
);
update = recalculateSplit(newTransaction);
update.subtransactions[splitIndex].amount = remainingAmount;
} else {
const amountPerRemainderSplit = Math.round(
remainingAmount / remainderSplitAmountActions.length,
);
let lastNonFixedIndex = -1;
remainderSplitAmountActions.forEach(action => {
const splitIndex = action.options.splitIndex;
update.subtransactions[splitIndex].amount = amountPerRemainderSplit;
remainingAmount -= amountPerRemainderSplit;
lastNonFixedIndex = Math.max(lastNonFixedIndex, splitIndex);
});
// The last remainder split will be adjusted for any leftovers from rounding.
newTransactions[lastNonFixedTransactionIndex].amount -=
getSplitRemainder(newTransactions);
}

// The last non-fixed split will be adjusted for the remainder.
update.subtransactions[lastNonFixedIndex].amount -= remainingAmount;
}
update = recalculateSplit(update);
}
// The split index 0 (transaction index 1) is reserved for "Apply to all" actions.
// Remove that entry from the transaction list.
newTransactions.splice(1, 1);
return recalculateSplit(groupTransaction(newTransactions));
}

export function execActions(actions: Action[], transaction) {
const parentActions = actions.filter(action => !action.options?.splitIndex);
const childActions = actions.filter(action => action.options?.splitIndex);
const totalSplitCount =
actions.reduce(
(prev, cur) => Math.max(prev, cur.options?.splitIndex ?? 0),
0,
) + 1;

const nonSplitResult = execNonSplitActions(parentActions, transaction);
if (totalSplitCount === 1) {
// No splits, no need to do anything else.
return nonSplitResult;
}

// The split index 0 is reserved for "Apply to all" actions.
// Remove that entry from the subtransactions.
update.subtransactions = update.subtransactions.slice(1);
if (nonSplitResult.is_child) {
// Rules with splits can't be applied to child transactions.
return nonSplitResult;
}

return recalculateSplit(update);
return execSplitActions(childActions, nonSplitResult);
}

export class Rule {
Expand Down

0 comments on commit bbe5c8c

Please sign in to comment.