Skip to content

Commit e808724

Browse files
committed
Add BALANCE_OF() function to fetch balance of an arbitrary account
1 parent 3b14fd0 commit e808724

11 files changed

Lines changed: 341 additions & 43 deletions

File tree

packages/desktop-client/src/components/formula/transactionModeFunctions.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ export const transactionModeFunctions: Record<string, FunctionDef> = {
5858
},
5959
],
6060
},
61+
BALANCE_OF: {
62+
name: 'BALANCE_OF',
63+
description: t(
64+
'Running balance for another account (cents) at this transaction, same cutoff as balance. Use a quoted account id for a deterministic match, or a quoted account name. Use the balance variable instead for the current account.',
65+
),
66+
parameters: [
67+
{
68+
name: 'account_id_or_name',
69+
description: t('Quoted account id or exact account name'),
70+
},
71+
],
72+
},
6173
MID: {
6274
name: 'MID',
6375
description: t('Returns substring from specified position.'),

packages/docs/docs/experimental/formulas.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ Rule formulas evaluate with named variables from the transaction context, includ
290290
- Represents the running balance at this transaction
291291
- To convert to dollars: `=balance / 100`
292292

293+
**`BALANCE_OF` function (other accounts):**
294+
295+
- `BALANCE_OF("…")` — Running balance for another account, in **cents**, using the same cutoff as `balance` (same `date`, `sort_order`, and `id` ordering as the current transaction).
296+
- Pass a **quoted account id** (matches an account id in your budget) for a deterministic result, or a **quoted account name** for an exact name match. If the account name is ambiguous (duplicates), the first match is used.
297+
- If the account is not found, the value is **0**.
298+
- For the **current** transaction’s account, use the `balance` variable instead of `BALANCE_OF`.
299+
293300
**Text variables:**
294301

295302
- `notes` — Transaction notes/memo field (string, may be empty)

packages/loot-core/src/server/budget/schedule-template.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ vi.mock('../schedules/app', async () => {
2020
describe('runSchedule', () => {
2121
beforeEach(() => {
2222
vi.clearAllMocks();
23+
vi.mocked(db.getAccounts).mockResolvedValue([]);
2324
});
2425

2526
it('should return correct budget when recurring schedule set', async () => {

packages/loot-core/src/server/budget/schedule-template.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import {
88
getDateWithSkippedWeekend,
99
getNextDate,
1010
} from '../../shared/schedules';
11-
import type { CategoryEntity } from '../../types/models';
11+
import type { CategoryEntity, TransactionEntity } from '../../types/models';
1212
import type { ScheduleTemplate, Template } from '../../types/models/templates';
1313
import * as db from '../db';
14+
import { collectFormulasFromActions } from '../rules/balanceOfFormula';
1415
import { getRuleForSchedule } from '../schedules/app';
16+
import { prefetchBalanceOfForTransaction } from '../transactions/transaction-rules';
1517

1618
import { getSheetValue, isReflectBudget } from './actions';
1719

@@ -74,10 +76,31 @@ async function createScheduleList(
7476

7577
scheduleAmount = Math.round(scheduleAmount);
7678

77-
const { amount: postRuleAmount, subtransactions } = rule.execActions({
79+
const next_date_string = getNextDate(
80+
dateConditions,
81+
monthUtils._parse(current_month),
82+
);
83+
84+
const accounts = (await db.getAccounts()) ?? [];
85+
const accountsMap = new Map(accounts.map(a => [a.id, a]));
86+
const scheduleRuleContext: TransactionEntity = {
7887
amount: scheduleAmount,
7988
category: category.id,
8089
subtransactions: [],
90+
...(next_date_string ? { date: next_date_string } : {}),
91+
id: null,
92+
sort_order: null,
93+
} as TransactionEntity;
94+
const formulaStrings = collectFormulasFromActions(rule.actions);
95+
const balanceOfPrefetched = await prefetchBalanceOfForTransaction(
96+
scheduleRuleContext,
97+
accountsMap,
98+
formulaStrings,
99+
);
100+
101+
const { amount: postRuleAmount, subtransactions } = rule.execActions({
102+
...scheduleRuleContext,
103+
_balanceOfPrefetched: balanceOfPrefetched,
81104
});
82105
const categorySubtransactions = subtransactions?.filter(
83106
t => t.category === category.id,
@@ -91,10 +114,6 @@ async function createScheduleList(
91114
? categorySubtransactions.reduce((acc, t) => acc + t.amount, 0)
92115
: (postRuleAmount ?? scheduleAmount));
93116

94-
const next_date_string = getNextDate(
95-
dateConditions,
96-
monthUtils._parse(current_month),
97-
);
98117
const target_interval = dateConditions.value.interval
99118
? dateConditions.value.interval
100119
: 1;

packages/loot-core/src/server/rules/action.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { currentDay, format, parseDate } from '../../shared/months';
1010
import { FIELD_TYPES } from '../../shared/rules';
1111
import type { TransactionForRules } from '../transactions/transaction-rules';
1212

13+
import { substituteBalanceOfLiterals } from './balanceOfFormula';
1314
import {
1415
CustomFunctionsPlugin,
1516
customFunctionsTranslations,
@@ -324,6 +325,9 @@ export class Action {
324325
};
325326

326327
for (const key of Object.keys(fieldValues)) {
328+
if (key === '_balanceOfPrefetched') {
329+
continue;
330+
}
327331
let cellValue: string | number | boolean;
328332
if (
329333
fieldValues[key] === undefined ||
@@ -337,8 +341,13 @@ export class Action {
337341
hfInstance.addNamedExpression(key, cellValue);
338342
}
339343

344+
const evaluatedFormula = substituteBalanceOfLiterals(
345+
formula,
346+
transaction._balanceOfPrefetched,
347+
);
348+
340349
hfInstance.setCellContents({ sheet: sheetId, col: 0, row: 0 }, [
341-
[formula],
350+
[evaluatedFormula],
342351
]);
343352

344353
const cellAddress = { sheet: sheetId, col: 0, row: 0 };
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type * as db from '../db';
4+
5+
import {
6+
decodeBalanceOfQuotedLiteral,
7+
extractBalanceOfLiterals,
8+
resolveAccountIdForBalanceOf,
9+
substituteBalanceOfLiterals,
10+
} from './balanceOfFormula';
11+
12+
describe('balanceOfFormula', () => {
13+
it('extractBalanceOfLiterals returns distinct decoded literals', () => {
14+
expect(
15+
extractBalanceOfLiterals(
16+
'=BALANCE_OF("Checking") + BALANCE_OF("Checking")',
17+
),
18+
).toEqual(['Checking']);
19+
expect(extractBalanceOfLiterals('=balance_of("Savings")')).toEqual([
20+
'Savings',
21+
]);
22+
});
23+
24+
it('decodeBalanceOfQuotedLiteral unescapes quotes and backslashes', () => {
25+
expect(decodeBalanceOfQuotedLiteral(String.raw`\"x\"`)).toBe('"x"');
26+
});
27+
28+
it('substituteBalanceOfLiterals replaces calls with cent literals', () => {
29+
const map = new Map([
30+
['Checking', 42],
31+
['id-1', 99],
32+
]);
33+
expect(substituteBalanceOfLiterals('=BALANCE_OF("Checking")+1', map)).toBe(
34+
'=42+1',
35+
);
36+
expect(substituteBalanceOfLiterals('=BALANCE_OF("Missing")', map)).toBe(
37+
'=0',
38+
);
39+
});
40+
41+
it('resolveAccountIdForBalanceOf prefers map key then name', () => {
42+
const id = 'acc-1';
43+
const a1: db.DbAccount = {
44+
id,
45+
name: 'Dup',
46+
offbudget: 0,
47+
} as db.DbAccount;
48+
const a2: db.DbAccount = {
49+
id: 'acc-2',
50+
name: 'Other',
51+
offbudget: 0,
52+
} as db.DbAccount;
53+
const map = new Map<string, db.DbAccount>([
54+
[id, a1],
55+
['acc-2', a2],
56+
]);
57+
expect(resolveAccountIdForBalanceOf(id, map)).toBe(id);
58+
expect(resolveAccountIdForBalanceOf('Other', map)).toBe('acc-2');
59+
expect(resolveAccountIdForBalanceOf('Nope', map)).toBe(null);
60+
});
61+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type * as db from '../db';
2+
3+
/** Collect formula strings from serialized or live rule actions. */
4+
export function collectFormulasFromActions(
5+
actions: Array<{ options?: { formula?: string } }>,
6+
): string[] {
7+
const out: string[] = [];
8+
for (const action of actions) {
9+
const f = action.options?.formula;
10+
if (typeof f === 'string') {
11+
out.push(f);
12+
}
13+
}
14+
return out;
15+
}
16+
17+
/** Decode escape sequences inside a double-quoted formula string literal. */
18+
export function decodeBalanceOfQuotedLiteral(inner: string): string {
19+
return inner.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
20+
}
21+
22+
/**
23+
* Distinct decoded string literals from BALANCE_OF("…") calls in a formula.
24+
*/
25+
export function extractBalanceOfLiterals(formula: string): string[] {
26+
const seen = new Set<string>();
27+
const out: string[] = [];
28+
const re = /BALANCE_OF\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/gi;
29+
let m: RegExpExecArray | null;
30+
while ((m = re.exec(formula)) !== null) {
31+
const decoded = decodeBalanceOfQuotedLiteral(m[1]);
32+
if (!seen.has(decoded)) {
33+
seen.add(decoded);
34+
out.push(decoded);
35+
}
36+
}
37+
return out;
38+
}
39+
40+
/**
41+
* Resolve account id: map key (id) first, else first exact name match.
42+
*/
43+
export function resolveAccountIdForBalanceOf(
44+
literal: string,
45+
accountsMap: Map<string, db.DbAccount>,
46+
): string | null {
47+
if (accountsMap.has(literal)) {
48+
return literal;
49+
}
50+
for (const acc of accountsMap.values()) {
51+
if (acc.name === literal) {
52+
return acc.id;
53+
}
54+
}
55+
return null;
56+
}
57+
58+
/**
59+
* Replace each BALANCE_OF("…") with a cent literal so HyperFormula never needs
60+
* runtime DB (prefetch map is keyed by decoded string literals).
61+
*/
62+
export function substituteBalanceOfLiterals(
63+
formula: string,
64+
map: Map<string, number> | null | undefined,
65+
): string {
66+
return formula.replace(
67+
/BALANCE_OF\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/gi,
68+
(_match, inner: string) => {
69+
const key = decodeBalanceOfQuotedLiteral(inner);
70+
const cents = map?.get(key) ?? 0;
71+
return String(cents);
72+
},
73+
);
74+
}

packages/loot-core/src/server/rules/formula-action.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,41 @@ describe('Formula-based rule actions', () => {
111111
expect(result).toBe(300000);
112112
});
113113

114+
it('should support BALANCE_OF with prefetched map', () => {
115+
const action = new Action('set', 'notes', null, {});
116+
const transaction: Partial<TransactionForRules> = {
117+
notes: 'original',
118+
_balanceOfPrefetched: new Map([
119+
['Savings', 50000],
120+
['550e8400-e29b-41d4-a716-446655440000', 1200],
121+
]),
122+
};
123+
const byName = action.executeFormulaSync(
124+
'=BALANCE_OF("Savings") + 100',
125+
transaction,
126+
);
127+
expect(byName).toBe(5010000);
128+
129+
const byId = action.executeFormulaSync(
130+
'=BALANCE_OF("550e8400-e29b-41d4-a716-446655440000")',
131+
transaction,
132+
);
133+
expect(byId).toBe(120000);
134+
});
135+
136+
it('should return 0 for BALANCE_OF when literal missing from prefetch map', () => {
137+
const action = new Action('set', 'amount', null, {});
138+
const transaction: Partial<TransactionForRules> = {
139+
amount: 100,
140+
_balanceOfPrefetched: new Map(),
141+
};
142+
const result = action.executeFormulaSync(
143+
'=BALANCE_OF("Unknown")',
144+
transaction,
145+
);
146+
expect(result).toBe(0);
147+
});
148+
114149
it('should execute formula and convert to number type', () => {
115150
const action = new Action('set', 'amount', null, {
116151
formula: '=500 + 250',

packages/loot-core/src/server/rules/rule.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ function execSplitActions(actions: Action[], transaction) {
4545
}
4646
newTransactions[splitTransactionIndex].parent_amount = transaction.amount;
4747
newTransactions[splitTransactionIndex].balance = transaction.balance;
48+
if (transaction._balanceOfPrefetched) {
49+
newTransactions[splitTransactionIndex]._balanceOfPrefetched =
50+
transaction._balanceOfPrefetched;
51+
}
4852
action.exec(newTransactions[splitTransactionIndex]);
4953
});
5054

0 commit comments

Comments
 (0)