diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx
index 4d42814e53d..e7ebb209470 100644
--- a/packages/desktop-client/src/components/modals/EditRule.jsx
+++ b/packages/desktop-client/src/components/modals/EditRule.jsx
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
+import { v4 as uuid } from 'uuid';
+
import {
initiallyLoadPayees,
setUndoEnabled,
@@ -26,10 +28,11 @@ import {
amountToInteger,
} from 'loot-core/src/shared/util';
+import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { useSelected, SelectedProvider } from '../../hooks/useSelected';
-import { SvgAdd, SvgSubtract } from '../../icons/v0';
+import { SvgDelete, SvgAdd, SvgSubtract } from '../../icons/v0';
import { SvgInformationOutline } from '../../icons/v1';
-import { theme } from '../../style';
+import { styles, theme } from '../../style';
import { Button } from '../common/Button';
import { Modal } from '../common/Modal';
import { Select } from '../common/Select';
@@ -122,6 +125,19 @@ export function OpSelect({
);
}
+function SplitAmountMethodSelect({ options, style, value, onChange }) {
+ return (
+
+
+ );
+}
+
function EditorButtons({ onAdd, onDelete }) {
return (
<>
@@ -310,8 +326,25 @@ const actionFields = [
'date',
'amount',
].map(field => [field, mapField(field)]);
+const parentOnlyFields = ['amount', 'cleared', 'account', 'date'];
+const splitActionFields = actionFields.filter(
+ ([field]) => !parentOnlyFields.includes(field),
+);
+const splitAmountTypes = [
+ ['fixed-amount', 'a fixed amount'],
+ ['fixed-percent', 'a fixed percentage'],
+ ['remainder', 'an equal portion of the remainder'],
+];
function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
- const { field, op, value, type, error, inputKey = 'initial' } = action;
+ const {
+ field,
+ op,
+ value,
+ type,
+ error,
+ inputKey = 'initial',
+ options,
+ } = action;
return (
@@ -324,7 +357,7 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
@@ -340,6 +373,30 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
/>
>
+ ) : op === 'set-split-amount' ? (
+ <>
+
+ allocate
+
+
+
+
+
+ {options.method !== 'remainder' && (
+ onChange('value', v)}
+ />
+ )}
+
+ >
) : op === 'link-schedule' ? (
<>
-
+
);
@@ -587,6 +641,9 @@ function ConditionsList({
);
}
+const getActions = splits => splits.flatMap(s => s.actions);
+const getUnparsedActions = splits => getActions(splits).map(unparse);
+
// TODO:
// * Dont touch child transactions?
@@ -606,17 +663,32 @@ const conditionFields = [
]);
export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
+ const splitsEnabled = useFeatureFlag('splitsInRules');
const [conditions, setConditions] = useState(
defaultRule.conditions.map(parse),
);
- const [actions, setActions] = useState(defaultRule.actions.map(parse));
+ const [actionSplits, setActionSplits] = useState(() => {
+ const parsedActions = defaultRule.actions.map(parse);
+ return parsedActions.reduce(
+ (acc, action) => {
+ const splitIndex = action.options?.splitIndex ?? 0;
+ acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
+ acc[splitIndex].actions.push(action);
+ return acc;
+ },
+ // The pre-split group is always there
+ [{ id: uuid(), actions: [] }],
+ );
+ });
const [stage, setStage] = useState(defaultRule.stage);
const [conditionsOp, setConditionsOp] = useState(defaultRule.conditionsOp);
const [transactions, setTransactions] = useState([]);
const dispatch = useDispatch();
const scrollableEl = useRef();
- const isSchedule = actions.some(action => action.op === 'link-schedule');
+ const isSchedule = getActions(actionSplits).some(
+ action => action.op === 'link-schedule',
+ );
useEffect(() => {
dispatch(initiallyLoadPayees());
@@ -643,9 +715,11 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
if (filters.length > 0) {
const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and';
+ const parentOnlyCondition =
+ actionSplits.length > 1 ? { is_child: false } : {};
const { data: transactions } = await runQuery(
q('transactions')
- .filter({ [conditionsOpKey]: filters })
+ .filter({ [conditionsOpKey]: filters, ...parentOnlyCondition })
.select('*'),
);
setTransactions(transactions);
@@ -654,49 +728,71 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
}
}
run();
- }, [actions, conditions, conditionsOp]);
+ }, [actionSplits, conditions, conditionsOp]);
const selectedInst = useSelected('transactions', transactions, []);
function addInitialAction() {
- addAction(-1);
+ addActionToSplitAfterIndex(0, -1);
}
- function addAction(index) {
- let fields = actionFields.map(f => f[0]);
- for (const action of actions) {
- fields = fields.filter(f => f !== action.field);
+ function addActionToSplitAfterIndex(splitIndex, actionIndex) {
+ let newAction;
+ if (splitIndex && !actionSplits[splitIndex]?.actions?.length) {
+ actionSplits[splitIndex] = { id: uuid(), actions: [] };
+ newAction = {
+ op: 'set-split-amount',
+ options: { method: 'remainder', splitIndex },
+ value: null,
+ };
+ } else {
+ const fieldsArray = splitIndex === 0 ? actionFields : splitActionFields;
+ let fields = fieldsArray.map(f => f[0]);
+ for (const action of actionSplits[splitIndex].actions) {
+ fields = fields.filter(f => f !== action.field);
+ }
+ const field = fields[0] || 'category';
+ newAction = {
+ type: FIELD_TYPES.get(field),
+ field,
+ op: 'set',
+ value: null,
+ options: { splitIndex },
+ };
}
- const field = fields[0] || 'category';
- const copy = [...actions];
- copy.splice(index + 1, 0, {
- type: FIELD_TYPES.get(field),
- field,
- op: 'set',
- value: null,
- });
- setActions(copy);
+ const actionsCopy = [...actionSplits[splitIndex].actions];
+ actionsCopy.splice(actionIndex + 1, 0, newAction);
+ const copy = [...actionSplits];
+ copy[splitIndex] = { ...actionSplits[splitIndex], actions: actionsCopy };
+ setActionSplits(copy);
}
function onChangeAction(action, field, value) {
- setActions(
- updateValue(actions, action, () => {
- const a = { ...action };
- a[field] = value;
-
- if (field === 'field') {
- a.type = FIELD_TYPES.get(a.field);
- a.value = null;
- return newInput(a);
- } else if (field === 'op') {
- a.value = null;
- a.inputKey = '' + Math.random();
- return newInput(a);
- }
+ setActionSplits(
+ actionSplits.map(({ id, actions }) => ({
+ id,
+ actions: updateValue(actions, action, () => {
+ const a = { ...action };
+ if (field === 'method') {
+ a.options = { ...a.options, method: value };
+ } else {
+ a[field] = value;
+
+ if (field === 'field') {
+ a.type = FIELD_TYPES.get(a.field);
+ a.value = null;
+ return newInput(a);
+ } else if (field === 'op') {
+ a.value = null;
+ a.inputKey = '' + Math.random();
+ return newInput(a);
+ }
+ }
- return a;
- }),
+ return a;
+ }),
+ })),
);
}
@@ -709,16 +805,51 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
}
function onRemoveAction(action) {
- setActions(actions.filter(a => a !== action));
+ setActionSplits(splits =>
+ splits.map(({ id, actions }) => ({
+ id,
+ actions: actions.filter(a => a !== action),
+ })),
+ );
+ }
+
+ function onRemoveSplit(splitIndexToRemove) {
+ setActionSplits(splits => {
+ const copy = [];
+ splits.forEach(({ id }, index) => {
+ if (index === splitIndexToRemove) {
+ return;
+ }
+ copy.push({ id, actions: [] });
+ });
+ getActions(splits).forEach(action => {
+ const currentSplitIndex = action.options?.splitIndex ?? 0;
+ if (currentSplitIndex === splitIndexToRemove) {
+ return;
+ }
+ const newSplitIndex =
+ currentSplitIndex > splitIndexToRemove
+ ? currentSplitIndex - 1
+ : currentSplitIndex;
+ copy[newSplitIndex].actions.push({
+ ...action,
+ options: { ...action.options, splitIndex: newSplitIndex },
+ });
+ });
+ return copy;
+ });
}
function onApply() {
+ const selectedTransactions = transactions.filter(({ id }) =>
+ selectedInst.items.has(id),
+ );
send('rule-apply-actions', {
- transactionIds: [...selectedInst.items],
- actions: actions.map(unparse),
+ transactions: selectedTransactions,
+ actions: getUnparsedActions(actionSplits),
}).then(() => {
// This makes it refetch the transactions
- setActions([...actions]);
+ setActionSplits([...actionSplits]);
});
}
@@ -728,7 +859,7 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
stage,
conditionsOp,
conditions: conditions.map(unparse),
- actions: actions.map(unparse),
+ actions: getUnparsedActions(actionSplits),
};
const method = rule.id ? 'rule-update' : 'rule-add';
@@ -740,7 +871,7 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
}
if (error.actionErrors) {
- setActions(applyErrors(actions, error.actionErrors));
+ setActionSplits(applyErrors(actionSplits, error.actionErrors));
}
} else {
// If adding a rule, we got back an id
@@ -759,6 +890,11 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
borderRadius: 4,
};
+ // Enable editing existing split rules even if the feature has since been disabled.
+ const showSplitButton = splitsEnabled
+ ? actionSplits.length > 0
+ : actionSplits.length > 1;
+
return (
- {actions.length === 0 ? (
+ {actionSplits.length === 0 && (
- ) : (
-
- {actions.map((action, i) => (
-
- {
- onChangeAction(action, name, value);
- }}
- onDelete={() => onRemoveAction(action)}
- onAdd={() => addAction(i)}
- />
-
- ))}
-
+ )}
+
+ {actionSplits.map(({ id, actions }, splitIndex) => (
+ 1
+ ? {
+ borderColor: theme.tableBorder,
+ borderWidth: '1px',
+ borderRadius: '5px',
+ padding: '5px',
+ }
+ : {}
+ }
+ >
+ {actionSplits.length > 1 && (
+
+
+ {splitIndex === 0
+ ? 'Before split'
+ : `Split ${splitIndex}`}
+
+ {splitIndex && (
+
+ )}
+
+ )}
+
+ {actions.map((action, actionIndex) => (
+
+ {
+ onChangeAction(action, name, value);
+ }}
+ onDelete={() => onRemoveAction(action)}
+ onAdd={() =>
+ addActionToSplitAfterIndex(
+ splitIndex,
+ actionIndex,
+ )
+ }
+ />
+
+ ))}
+
+
+ {actions.length === 0 && (
+
+ )}
+
+ ))}
+
+ {showSplitButton && (
+
)}
@@ -905,7 +1123,10 @@ export function EditRule({ modalProps, defaultRule, onSave: originalOnSave }) {
SimpleFIN sync
+ Splits in rules
) : (
ReactNode;
style?: CSSProperties;
getStyle?: (value) => CSSProperties;
diff --git a/packages/desktop-client/src/components/spreadsheet/useFormat.ts b/packages/desktop-client/src/components/spreadsheet/useFormat.ts
index 28d0c1f0bd1..d1599dd6a71 100644
--- a/packages/desktop-client/src/components/spreadsheet/useFormat.ts
+++ b/packages/desktop-client/src/components/spreadsheet/useFormat.ts
@@ -4,9 +4,15 @@ import { useSelector } from 'react-redux';
import { selectNumberFormat } from 'loot-core/src/client/selectors';
import { integerToCurrency } from 'loot-core/src/shared/util';
+export type FormatType =
+ | 'string'
+ | 'number'
+ | 'financial'
+ | 'financial-with-sign';
+
function format(
value: unknown,
- type = 'string',
+ type: FormatType = 'string',
formatter?: Intl.NumberFormat,
): string {
switch (type) {
@@ -49,7 +55,7 @@ export function useFormat() {
const numberFormat = useSelector(selectNumberFormat);
return useCallback(
- (value: unknown, type = 'string') =>
+ (value: unknown, type: FormatType = 'string') =>
format(value, type, numberFormat.formatter),
[numberFormat],
);
diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx
index d1c98ec48f2..5a51dd0f9f2 100644
--- a/packages/desktop-client/src/components/table.tsx
+++ b/packages/desktop-client/src/components/table.tsx
@@ -40,7 +40,7 @@ import {
mergeConditionalPrivacyFilterProps,
} from './PrivacyFilter';
import { type Binding } from './spreadsheet';
-import { useFormat } from './spreadsheet/useFormat';
+import { type FormatType, useFormat } from './spreadsheet/useFormat';
import { useSheetValue } from './spreadsheet/useSheetValue';
import { Tooltip, IntersectionBoundary } from './tooltips';
@@ -660,7 +660,7 @@ export function SelectCell({
type SheetCellValueProps = {
binding: Binding;
- type: string;
+ type: FormatType;
getValueStyle?: (value: string | number) => CSSProperties;
formatExpr?: (value) => string;
unformatExpr?: (value: string) => unknown;
diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx
index aa939b53990..bda688058a8 100644
--- a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx
+++ b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx
@@ -992,19 +992,23 @@ function TransactionEditUnconnected(props) {
return null;
}
- const onEdit = async transaction => {
- let newTransaction = transaction;
+ const onEdit = async serializedTransaction => {
+ const transaction = deserializeTransaction(
+ serializedTransaction,
+ null,
+ dateFormat,
+ );
+
// Run the rules to auto-fill in any data. Right now we only do
// this on new transactions because that's how desktop works.
if (isTemporary(transaction)) {
const afterRules = await send('rules-run', { transaction });
const diff = getChangedValues(transaction, afterRules);
- newTransaction = { ...transaction };
if (diff) {
Object.keys(diff).forEach(field => {
- if (newTransaction[field] == null) {
- newTransaction[field] = diff[field];
+ if (transaction[field] == null) {
+ transaction[field] = diff[field];
}
});
}
@@ -1012,7 +1016,7 @@ function TransactionEditUnconnected(props) {
const { data: newTransactions } = updateTransaction(
transactions,
- deserializeTransaction(newTransaction, null, dateFormat),
+ transaction,
);
setTransactions(newTransactions);
};
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index 69e53337c4c..8fbe74a8707 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -35,6 +35,8 @@ import {
updateTransaction,
deleteTransaction,
addSplitTransaction,
+ groupTransaction,
+ ungroupTransactions,
} from 'loot-core/src/shared/transactions';
import {
integerToCurrency,
@@ -689,6 +691,7 @@ function PayeeIcons({
const Transaction = memo(function Transaction(props) {
const {
transaction: originalTransaction,
+ subtransactions,
editing,
showAccount,
showBalance,
@@ -827,7 +830,8 @@ const Transaction = memo(function Transaction(props) {
// Run the transaction through the formatting so that we know
// it's always showing the formatted result
setTransaction(serializeTransaction(deserialized, showZeroInDeposit));
- onSave(deserialized);
+
+ onSave(deserialized, subtransactions);
}
}
@@ -1473,9 +1477,10 @@ function NewTransaction({
const error = transactions[0].error;
const isDeposit = transactions[0].amount > 0;
- const emptyChildTransactions = transactions.filter(
- t => t.parent_id === transactions[0].id && t.amount === 0,
+ const childTransactions = transactions.filter(
+ t => t.parent_id === transactions[0].id,
);
+ const emptyChildTransactions = childTransactions.filter(t => t.amount === 0);
return (
{
}, [props.onAdd, newNavigator.onEdit]);
const onSave = useCallback(
- async transaction => {
+ async (transaction, subtransactions = null) => {
savePending.current = true;
+ let groupedTransaction = subtransactions
+ ? groupTransaction([transaction, ...subtransactions])
+ : transaction;
+
if (isTemporaryId(transaction.id)) {
if (props.onApplyRules) {
- transaction = await props.onApplyRules(transaction);
+ groupedTransaction = await props.onApplyRules(groupedTransaction);
}
const newTrans = latestState.current.newTransactions;
- setNewTransactions(updateTransaction(newTrans, transaction).data);
+ // Future refactor: we shouldn't need to iterate through the entire
+ // transaction list to ungroup, just the new transactions.
+ setNewTransactions(
+ ungroupTransactions(
+ updateTransaction(newTrans, groupedTransaction).data,
+ ),
+ );
} else {
- props.onSave(transaction);
+ props.onSave(groupedTransaction);
}
},
[props.onSave],
diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts
index 08338bb3c0a..f5568619e87 100644
--- a/packages/desktop-client/src/hooks/useFeatureFlag.ts
+++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts
@@ -10,6 +10,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record = {
goalTemplatesEnabled: false,
customReports: false,
simpleFinSync: false,
+ splitsInRules: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {
diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts
index 96b33b52512..68f0d67bffb 100644
--- a/packages/loot-core/src/server/accounts/rules.ts
+++ b/packages/loot-core/src/server/accounts/rules.ts
@@ -12,9 +12,16 @@ import {
} from '../../shared/months';
import { sortNumbers, getApproxNumberThreshold } from '../../shared/rules';
import { recurConfigToRSchedule } from '../../shared/schedules';
+import {
+ addSplitTransaction,
+ recalculateSplit,
+ splitTransaction,
+ ungroupTransaction,
+} from '../../shared/transactions';
import { fastSetMerge } from '../../shared/util';
import { RuleConditionEntity } from '../../types/models';
import { RuleError } from '../errors';
+import * as prefs from '../prefs';
import { Schedule as RSchedule } from '../util/rschedule';
function assert(test, type, msg) {
@@ -418,9 +425,8 @@ export class Condition {
}
}
-type ActionOperator = 'set' | 'link-schedule';
-
-const ACTION_OPS: ActionOperator[] = ['set', 'link-schedule'];
+const ACTION_OPS = ['set', 'set-split-amount', 'link-schedule'] as const;
+type ActionOperator = (typeof ACTION_OPS)[number];
export class Action {
field;
@@ -442,6 +448,9 @@ export class Action {
assert(typeName, 'internal', `Invalid field for action: ${field}`);
this.field = field;
this.type = typeName;
+ } else if (op === 'set-split-amount') {
+ this.field = null;
+ this.type = 'number';
} else if (op === 'link-schedule') {
this.field = null;
this.type = 'id';
@@ -458,6 +467,14 @@ export class Action {
case 'set':
object[this.field] = this.value;
break;
+ case 'set-split-amount':
+ switch (this.options.method) {
+ case 'fixed-amount':
+ object.amount = this.value;
+ break;
+ default:
+ }
+ break;
case 'link-schedule':
object.schedule = this.value;
break;
@@ -476,6 +493,142 @@ export class Action {
}
}
+function execNonSplitActions(actions: Action[], transaction) {
+ const update = transaction;
+ actions.forEach(action => action.exec(update));
+ 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 (!prefs.getPrefs()?.['flags.splitsInRules'] || totalSplitCount === 1) {
+ return update;
+ }
+
+ if (update.is_child) {
+ // Rules with splits can't be applied to child transactions.
+ return update;
+ }
+
+ const splitAmountActions = childActions.filter(
+ action => action.op === 'set-split-amount',
+ );
+ const fixedSplitAmountActions = splitAmountActions.filter(
+ action => action.options.method === 'fixed-amount',
+ );
+ const fixedAmountsBySplit: Record = {};
+ 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(
+ (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),
+ 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;
+ newTransactions = data;
+ }
+ action.exec(update.subtransactions[splitIndex]);
+ }
+
+ // 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',
+ );
+
+ // 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 non-fixed split will be adjusted for the remainder.
+ update.subtransactions[lastNonFixedIndex].amount -= remainingAmount;
+ }
+ update = recalculateSplit(update);
+ }
+ }
+
+ // The split index 0 is reserved for "Before split" actions.
+ // Remove that entry from the subtransactions.
+ update.subtransactions = update.subtransactions.slice(1);
+
+ return update;
+}
+
export class Rule {
actions;
conditions;
@@ -520,15 +673,23 @@ export class Rule {
});
}
- execActions() {
- const changes = {};
- this.actions.forEach(action => action.exec(changes));
+ execActions(object) {
+ const result = execActions(this.actions, {
+ ...object,
+ subtransactions: object.subtransactions,
+ });
+ const changes = Object.keys(result).reduce((prev, cur) => {
+ if (result[cur] !== object[cur]) {
+ prev[cur] = result[cur];
+ }
+ return prev;
+ }, {});
return changes;
}
exec(object) {
if (this.evalConditions(object)) {
- return this.execActions();
+ return this.execActions(object);
}
return null;
}
diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts
index ccf2d09aba7..5a3ccfba8bc 100644
--- a/packages/loot-core/src/server/accounts/sync.ts
+++ b/packages/loot-core/src/server/accounts/sync.ts
@@ -487,7 +487,7 @@ export async function reconcileExternalTransactions(acctId, transactions) {
transactionsStep1.push({
payee_name,
trans,
- subtransactions,
+ subtransactions: trans.subtransactions || subtransactions,
match,
fuzzyDataset,
});
@@ -650,7 +650,7 @@ export async function reconcileTransactions(acctId, transactions) {
transactionsStep1.push({
payee_name,
trans,
- subtransactions,
+ subtransactions: trans.subtransactions || subtransactions,
match,
fuzzyDataset,
});
@@ -783,8 +783,12 @@ export async function addTransactions(
};
// Add split transactions if they are given
- if (subtransactions && subtransactions.length > 0) {
- added.push(...makeSplitTransaction(finalTransaction, subtransactions));
+ const updatedSubtransactions =
+ finalTransaction.subtransactions || subtransactions;
+ if (updatedSubtransactions && updatedSubtransactions.length > 0) {
+ added.push(
+ ...makeSplitTransaction(finalTransaction, updatedSubtransactions),
+ );
} else {
added.push(finalTransaction);
}
diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts
index a8afebff64f..926b6797cab 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.ts
@@ -11,6 +11,7 @@ import {
sortNumbers,
getApproxNumberThreshold,
} from '../../shared/rules';
+import { ungroupTransaction } from '../../shared/transactions';
import { partitionByField, fastSetMerge } from '../../shared/util';
import {
type TransactionEntity,
@@ -32,6 +33,7 @@ import {
rankRules,
migrateIds,
iterateIds,
+ execActions,
} from './rules';
import { batchUpdateTransactions } from './transactions';
@@ -492,8 +494,8 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
return { filters, errors };
}
-export function applyActions(
- transactionIds: string[],
+export async function applyActions(
+ transactions: TransactionEntity[],
actions: Array,
) {
const parsedActions = actions
@@ -526,12 +528,8 @@ export function applyActions(
return null;
}
- const updated = transactionIds.map(id => {
- const update = { id };
- for (const action of parsedActions) {
- action.exec(update);
- }
- return update;
+ const updated = transactions.flatMap(trans => {
+ return ungroupTransaction(execActions(parsedActions, trans));
});
return batchUpdateTransactions({ updated });
diff --git a/packages/loot-core/src/server/rules/app.ts b/packages/loot-core/src/server/rules/app.ts
index 62dd517d4dd..8fb059c5a94 100644
--- a/packages/loot-core/src/server/rules/app.ts
+++ b/packages/loot-core/src/server/rules/app.ts
@@ -129,8 +129,8 @@ app.method(
app.method(
'rule-apply-actions',
mutator(
- undoable(async function ({ transactionIds, actions }) {
- return rules.applyActions(transactionIds, actions);
+ undoable(async function ({ transactions, actions }) {
+ return rules.applyActions(transactions, actions);
}),
),
);
diff --git a/packages/loot-core/src/server/rules/types/handlers.ts b/packages/loot-core/src/server/rules/types/handlers.ts
index 95dfce095b8..429acddc91d 100644
--- a/packages/loot-core/src/server/rules/types/handlers.ts
+++ b/packages/loot-core/src/server/rules/types/handlers.ts
@@ -31,7 +31,7 @@ export interface RulesHandlers {
) => Promise<{ someDeletionsFailed: boolean }>;
'rule-apply-actions': (arg: {
- transactionIds: string[];
+ transactions: TransactionEntity[];
actions: Array;
}) => Promise;
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index b1e40f373fa..9eb16dade64 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -159,6 +159,13 @@ export function sortNumbers(num1, num2) {
}
export function parse(item) {
+ if (item.op === 'set-split-amount') {
+ if (item.options.method === 'fixed-amount') {
+ return { ...item, value: item.value && integerToAmount(item.value) };
+ }
+ return item;
+ }
+
switch (item.type) {
case 'number': {
let parsed = item.value;
@@ -186,6 +193,22 @@ export function parse(item) {
}
export function unparse({ error, inputKey, ...item }) {
+ if (item.op === 'set-split-amount') {
+ if (item.options.method === 'fixed-amount') {
+ return {
+ ...item,
+ value: item.value && amountToInteger(item.value),
+ };
+ }
+ if (item.options.method === 'fixed-percent') {
+ return {
+ ...item,
+ value: item.value && parseFloat(item.value),
+ };
+ }
+ return item;
+ }
+
switch (item.type) {
case 'number': {
let unparsed = item.value;
diff --git a/packages/loot-core/src/shared/schedules.ts b/packages/loot-core/src/shared/schedules.ts
index d8628ddfa0d..174b8102c39 100644
--- a/packages/loot-core/src/shared/schedules.ts
+++ b/packages/loot-core/src/shared/schedules.ts
@@ -43,9 +43,9 @@ export function getHasTransactionsQuery(schedules) {
});
return q('transactions')
+ .options({ splits: 'grouped' })
.filter({ $or: filters })
.orderBy({ date: 'desc' })
- .groupBy('schedule')
.select(['schedule', 'date']);
}
diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts
index b642454e4e7..532047a223c 100644
--- a/packages/loot-core/src/shared/transactions.ts
+++ b/packages/loot-core/src/shared/transactions.ts
@@ -97,7 +97,7 @@ export function ungroupTransactions(transactions: TransactionEntity[]) {
}, []);
}
-function groupTransaction(split) {
+export function groupTransaction(split) {
return { ...split[0], subtransactions: split.slice(1) };
}
diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts
index ce2b384f665..66fa53f99a2 100644
--- a/packages/loot-core/src/types/models/rule.d.ts
+++ b/packages/loot-core/src/types/models/rule.d.ts
@@ -46,10 +46,21 @@ export interface SetRuleActionEntity {
field: string;
op: 'set';
value: unknown;
- options?: unknown;
+ options?: {
+ splitIndex?: number;
+ };
type?: string;
}
+export interface SetSplitAmountRuleActionEntity {
+ op: 'set-split-amount';
+ value: number;
+ options?: {
+ splitIndex?: number;
+ method: 'fixed-amount' | 'fixed-percent' | 'remainder';
+ };
+}
+
export interface LinkScheduleRuleActionEntity {
op: 'link-schedule';
value: ScheduleEntity;
diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts
index f483d672ef9..e924b329013 100644
--- a/packages/loot-core/src/types/prefs.d.ts
+++ b/packages/loot-core/src/types/prefs.d.ts
@@ -6,7 +6,8 @@ export type FeatureFlag =
| 'reportBudget'
| 'goalTemplatesEnabled'
| 'customReports'
- | 'simpleFinSync';
+ | 'simpleFinSync'
+ | 'splitsInRules';
export type LocalPrefs = Partial<
{
diff --git a/upcoming-release-notes/2059.md b/upcoming-release-notes/2059.md
new file mode 100644
index 00000000000..1518c16d612
--- /dev/null
+++ b/upcoming-release-notes/2059.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [jfdoming]
+---
+
+Support automatically splitting transactions with rules