Adds balance variable to rule templates (#5925)

* Adds balance variable to rule templates

Enables access to the account balance within rule templates. This allows for more complex rule creation based on the current account balance.

Calculates the account balance up to the transaction being processed, including transactions on the same date with lower sort order.

Handles cases where the balance is undefined gracefully, defaulting to 0 to prevent errors.

## AI disclaimer
This PR contains code that was partially or fully generated by AI and may contain errors. All suggestions for improvement are welcome.

* Add release notes

* indexed sql params not supported

* [autofix.ci] apply automated fixes

* Skip parent transactions of splits

* Uses aql for account balance

Updates transaction rule preparation to use aql instead of  sql calculating it from past transactions.

The balance is defaulted to 0 if no account is set.

Refactor account balance calculation to build a proper query with date and sort_order filters

* Add block scoping to switch cases and ensure correct fallthrough handling in Action type conversions

* Corrects transaction rule sorting order

Reverses the sort order comparison in transaction rules
to ensure correct identification of prior transactions with the same date.

This ensures that the correct balance is used when
calculating balance-based rule conditions.

fixup! Corrects transaction rule sorting order

* Improves transaction rule balance calculation

Uses a more efficient query for calculating the account balance
up to a transaction when applying rules, improving performance.
This change reduces the complexity of the balance calculation.

* Apply coderabbit lessons learned

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Brown II
2025-10-18 11:22:55 -04:00
committed by GitHub
parent 0af2c6c2fb
commit 94332016e8
4 changed files with 106 additions and 4 deletions

View File

@@ -83,10 +83,13 @@ export class Action {
// Handlebars always returns a string, so we need to convert
switch (this.type) {
case 'number':
object[this.field] = parseFloat(object[this.field]);
case 'number': {
const numValue = parseFloat(object[this.field]);
// If the result is NaN, default to 0 to avoid database insertion errors
object[this.field] = isNaN(numValue) ? 0 : numValue;
break;
case 'date':
}
case 'date': {
const parsed = parseDate(object[this.field]);
if (parsed && dateFns.isValid(parsed)) {
object[this.field] = format(parsed, 'yyyy-MM-dd');
@@ -100,6 +103,7 @@ export class Action {
object[this.field] = '9999-12-31';
}
break;
}
case 'boolean':
object[this.field] = object[this.field] === 'true';
break;

View File

@@ -330,6 +330,15 @@ describe('Action', () => {
expect(item.notes).toBe('Hey Sarah! You just payed 10');
});
test('should create actions with balance math operations', () => {
// This test ensures the template validation doesn't fail when using balance
expect(() => {
new Action('set', 'notes', '', {
template: '{{ floor (div (mul balance (div 7.99 100)) 12) }}',
});
}).not.toThrow();
});
test('should not escape text', () => {
const action = new Action('set', 'notes', '', {
template: '{{notes}}',
@@ -467,6 +476,44 @@ describe('Action', () => {
testHelper('{{concat "Sarah" "Trops" 12 "Wow"}}', 'SarahTrops12Wow');
});
test('should have access to balance variable', () => {
const action = new Action('set', 'notes', '', {
template: 'Balance: {{balance}}, Amount: {{amount}}',
});
const item = { notes: '', amount: 5000, balance: 100000 };
action.exec(item);
expect(item.notes).toBe('Balance: 100000, Amount: 5000');
});
test('should allow math operations on balance', () => {
const action = new Action('set', 'notes', '', {
template: 'New balance: {{add balance amount}}',
});
const item = { notes: '', amount: 5000, balance: 100000 };
action.exec(item);
expect(item.notes).toBe('New balance: 105000');
});
test('should handle undefined balance gracefully in number fields', () => {
const action = new Action('set', 'amount', '', {
template: '{{ floor (div (mul balance (div 7.99 100)) 12) }}',
});
const item = { amount: 0 }; // No balance field
action.exec(item);
// Should default to 0 instead of NaN when balance is undefined
expect(item.amount).toBe(0);
});
test('should calculate with balance in number fields', () => {
const action = new Action('set', 'amount', '', {
template: '{{ floor (div (mul balance (div 7.99 100)) 12) }}',
});
const item = { amount: 0, balance: 1200 };
action.exec(item);
// (1200 * 7.99) / 12 = 7.99 -> floor = 7
expect(item.amount).toBe(7);
});
test('{{debug}} should log the item', () => {
const action = new Action('set', 'notes', '', {
template: '{{debug notes}}',

View File

@@ -8,6 +8,7 @@ import {
parseDate,
dayFromDate,
} from '../../shared/months';
import { q } from '../../shared/query';
import { sortNumbers, getApproxNumberThreshold } from '../../shared/rules';
import { ungroupTransaction } from '../../shared/transactions';
import { partitionByField, fastSetMerge } from '../../shared/util';
@@ -16,7 +17,7 @@ import {
type RuleActionEntity,
type RuleEntity,
} from '../../types/models';
import { schemaConfig } from '../aql';
import { aqlQuery, schemaConfig } from '../aql';
import * as db from '../db';
import { getPayee, getPayeeByName, insertPayee, getAccount } from '../db';
import { getMappings } from '../db/mappings';
@@ -925,6 +926,7 @@ export async function updateCategoryRules(transactions) {
export type TransactionForRules = TransactionEntity & {
payee_name?: string;
_account?: db.DbAccount;
balance?: number;
};
export async function prepareTransactionForRules(
@@ -939,12 +941,51 @@ export async function prepareTransactionForRules(
}
}
r.balance = 0;
if (trans.account) {
if (accounts !== null && accounts.has(trans.account)) {
r._account = accounts.get(trans.account);
} else {
r._account = await getAccount(trans.account);
}
const dateBoundary = trans.date ?? currentDay();
let query = q('transactions')
.filter({ account: trans.account, is_parent: false })
.options({ splits: 'inline' });
if (trans.id) {
query = query.filter({ id: { $ne: trans.id } });
}
const sameDayFilter =
trans.sort_order != null
? {
$and: [
{ date: dateBoundary },
{ sort_order: { $lt: trans.sort_order } },
],
}
: {
$and: [
{ date: dateBoundary },
{
$or: [
{ sort_order: { $ne: null } }, // ordered items come before null sort_order
...(trans.id ? [{ id: { $lt: trans.id } }] : []), // among nulls, tie-break by id
],
},
],
};
const { data: balance } = await aqlQuery(
query
.filter({ $or: [{ date: { $lt: dateBoundary } }, sameDayFilter] })
.calculate({ $sum: '$amount' }),
);
r.balance = balance ?? 0;
}
return r;
@@ -970,5 +1011,9 @@ export async function finalizeTransactionForRules(
delete trans.payee_name;
}
if ('balance' in trans) {
delete trans.balance;
}
return trans;
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [StephenBrown2]
---
Enables access to the account balance within rule templates.