mirror of
https://github.com/actualbudget/actual.git
synced 2026-08-02 06:36:25 -05:00
[AI] Fix budget templates with a priority not allocating funds in tracking budgets (#8549)
* [AI] Fix budget templates with a priority not allocating funds in tracking budgets Tracking budgets never define a `to-budget` sheet cell (only envelope budgets do), so the priority-tier engine always saw $0 available and clamped every priority>0 template to 0. Seed the pool from `total-saved` instead when in tracking mode, since it's the structural analog (budgeted income minus budgeted expenses) and preserves genuine priority-tier competition rather than disabling the clamp outright. Fixes #8422 * Trigger Build * Trigger Build * Trigger Build
This commit is contained in:
@@ -484,6 +484,140 @@ describe('applyMultipleCategoryTemplates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracking budget priority handling (issue #8422)', () => {
|
||||
const cat1: CategoryEntity = {
|
||||
id: 'cat-1',
|
||||
name: 'Groceries',
|
||||
group: 'g1',
|
||||
is_income: false,
|
||||
};
|
||||
const cat2: CategoryEntity = {
|
||||
id: 'cat-2',
|
||||
name: 'Rent',
|
||||
group: 'g1',
|
||||
is_income: false,
|
||||
};
|
||||
|
||||
function setupAqlMultiCategory(
|
||||
cats: CategoryEntity[],
|
||||
templatesById: Record<string, Template[]>,
|
||||
) {
|
||||
vi.mocked(aql.aqlQuery).mockImplementation(async (query: unknown) => {
|
||||
const queryStr = JSON.stringify(query);
|
||||
if (queryStr.includes('hideFraction')) {
|
||||
return { data: [{ value: 'false' }], dependencies: [] };
|
||||
}
|
||||
if (queryStr.includes('defaultCurrencyCode')) {
|
||||
return { data: [{ value: 'USD' }], dependencies: [] };
|
||||
}
|
||||
if (queryStr.includes('goal_def')) {
|
||||
return {
|
||||
data: cats
|
||||
.filter(c => templatesById[c.id])
|
||||
.map(c => ({
|
||||
...c,
|
||||
goal_def: JSON.stringify(templatesById[c.id]),
|
||||
})),
|
||||
dependencies: [],
|
||||
};
|
||||
}
|
||||
if (queryStr.includes('categories')) {
|
||||
return { data: cats, dependencies: [] };
|
||||
}
|
||||
return { data: [], dependencies: [] };
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(statements.getActiveSchedules).mockResolvedValue(
|
||||
[] as Awaited<ReturnType<typeof statements.getActiveSchedules>>,
|
||||
);
|
||||
vi.mocked(db.getCategories).mockResolvedValue([] as DbCategory[]);
|
||||
});
|
||||
|
||||
it('funds a priority>0 template from total-saved since tracking budgets have no to-budget cell', async () => {
|
||||
// Regression test for #8422: `to-budget` is 0 (it doesn't exist for
|
||||
// tracking budgets), but `total-saved` reflects real budgeted income,
|
||||
// so the priority>0 template should still get funded instead of
|
||||
// clamping to 0.
|
||||
setupSheetMock({ 'to-budget': 0, 'total-saved': 100000 });
|
||||
vi.mocked(actions.isTrackingBudget).mockReturnValue(true);
|
||||
setupAqlMultiCategory([cat1], {
|
||||
[cat1.id]: [
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: 100,
|
||||
period: { period: 'month', amount: 1 },
|
||||
starting: '2024-01-01',
|
||||
directive: 'template',
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await applyMultipleCategoryTemplates({
|
||||
month: '2024-01',
|
||||
categoryIds: [cat1.id],
|
||||
});
|
||||
|
||||
expect(result.message).toBe('templates-applied');
|
||||
const budgetCalls = vi
|
||||
.mocked(actions.setBudget)
|
||||
.mock.calls.map(call => call[0]);
|
||||
expect(budgetCalls.find(c => c.category === cat1.id)?.amount).toBe(10000);
|
||||
});
|
||||
|
||||
it('clamps lower-priority categories proportionally once total-saved runs out', async () => {
|
||||
// Same shape as the envelope-budget "funds run out" test, but backed by
|
||||
// total-saved: priority is still meaningful in tracking budgets, it's
|
||||
// just not the identical no-clamp behavior of skipping the check
|
||||
// entirely.
|
||||
setupSheetMock({ 'to-budget': 0, 'total-saved': 15000 });
|
||||
vi.mocked(actions.isTrackingBudget).mockReturnValue(true);
|
||||
setupAqlMultiCategory([cat1, cat2], {
|
||||
[cat1.id]: [
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: 100,
|
||||
period: { period: 'month', amount: 1 },
|
||||
starting: '2024-01-01',
|
||||
directive: 'template',
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
[cat2.id]: [
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: 100,
|
||||
period: { period: 'month', amount: 1 },
|
||||
starting: '2024-01-01',
|
||||
directive: 'template',
|
||||
priority: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await applyMultipleCategoryTemplates({
|
||||
month: '2024-01',
|
||||
categoryIds: [cat1.id, cat2.id],
|
||||
});
|
||||
|
||||
const budgetCalls = vi
|
||||
.mocked(actions.setBudget)
|
||||
.mock.calls.map(call => call[0]);
|
||||
const cat1Amount = Number(
|
||||
budgetCalls.find(c => c.category === cat1.id)?.amount ?? 0,
|
||||
);
|
||||
const cat2Amount = Number(
|
||||
budgetCalls.find(c => c.category === cat2.id)?.amount ?? 0,
|
||||
);
|
||||
expect(cat1Amount).toBe(10000);
|
||||
expect(cat2Amount).toBe(5000);
|
||||
expect(cat1Amount + cat2Amount).toBe(15000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTemplate (force=false)', () => {
|
||||
const cat1: CategoryEntity = {
|
||||
id: 'cat-1',
|
||||
|
||||
@@ -233,7 +233,7 @@ async function computeTemplates(
|
||||
const templateContexts: CategoryTemplateContext[] = [];
|
||||
let availBudget = await getSheetValue(
|
||||
monthUtils.sheetForMonth(month),
|
||||
`to-budget`,
|
||||
isTracking ? `total-saved` : `to-budget`,
|
||||
);
|
||||
const prioritiesSet = new Set<number>();
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Fix budget templates/automations with a priority not allocating funds properly in tracking budgets
|
||||
Reference in New Issue
Block a user