From 53db33a2b2727d449b53cc60dc552c6210860f92 Mon Sep 17 00:00:00 2001 From: James Skinner <56730344+JSkinnerUK@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:29:12 +0000 Subject: [PATCH] Fix leftover balance usage in budget covering logic (#7131) (#7272) * Fix leftover balance usage in budget covering logic (#7131) * [autofix.ci] apply automated fixes * Add regression unit tests for `coverOverbudgeted` fixes * Update release note for a more user-facing sentence --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../src/server/budget/actions.test.ts | 99 +++++++++++++++++++ .../loot-core/src/server/budget/actions.ts | 10 +- upcoming-release-notes/7272.md | 6 ++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 packages/loot-core/src/server/budget/actions.test.ts create mode 100644 upcoming-release-notes/7272.md diff --git a/packages/loot-core/src/server/budget/actions.test.ts b/packages/loot-core/src/server/budget/actions.test.ts new file mode 100644 index 0000000000..88426d797d --- /dev/null +++ b/packages/loot-core/src/server/budget/actions.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import * as db from '../db'; +import * as sheet from '../sheet'; + +import { + coverOverbudgeted, + getSheetValue, + setBudget, + setCategoryCarryover, +} from './actions'; +import * as budget from './base'; + +describe('coverOverbudgeted', () => { + beforeEach(global.emptyDatabase()); + afterEach(global.emptyDatabase()); + + it('fully covers the overbudget when category has sufficient leftover', async () => { + // Setup: cat1 has 100 leftover, overbudget is 90 + await prepareDatabase(); + + await coverOverbudgeted({ + month: '2024-02', + category: 'cat1', + currencyCode: 'USD', + }); + await sheet.waitOnSpreadsheet(); + + const sheetName = 'budget202402'; + expect(await getSheetValue(sheetName, 'to-budget')).toBe(0); + expect(await getSheetValue(sheetName, 'leftover-cat1')).toBe(10); + }); + + it('partially covers the overbudget when category leftover is insufficient', async () => { + // Setup: cat3 has 10 leftover, overbudget is 90 + await prepareDatabase(); + + await coverOverbudgeted({ + month: '2024-02', + category: 'cat3', + currencyCode: 'USD', + }); + await sheet.waitOnSpreadsheet(); + + const sheetName = 'budget202402'; + expect(await getSheetValue(sheetName, 'to-budget')).toBe(-80); + expect(await getSheetValue(sheetName, 'leftover-cat3')).toBe(0); + }); +}); + +// Setup: 2024-02, is 90 overbudgeted +// with balances of cat1 = 100, cat2 = -20, cat3 = 10 +async function prepareDatabase() { + await db.insertCategoryGroup({ + id: 'income-group', + name: 'Income', + is_income: 1, + }); + await db.insertCategory({ + id: 'income-cat', + name: 'Income', + cat_group: 'income-group', + is_income: 1, + }); + + await db.insertCategoryGroup({ id: 'group1', name: 'group1', is_income: 0 }); + await db.insertCategory({ + id: 'cat1', + name: 'cat1', + cat_group: 'group1', + is_income: 0, + }); + await db.insertCategory({ + id: 'cat2', + name: 'cat2', + cat_group: 'group1', + is_income: 0, + }); + await db.insertCategory({ + id: 'cat3', + name: 'cat3', + cat_group: 'group1', + is_income: 0, + }); + + await setBudget({ category: 'cat1', month: '2024-01', amount: 100 }); + await setBudget({ category: 'cat2', month: '2024-01', amount: -20 }); + await setBudget({ category: 'cat3', month: '2024-01', amount: 10 }); + + await sheet.loadSpreadsheet(db); + await budget.createBudget(['2024-01', '2024-02']); + + await setCategoryCarryover({ + startMonth: '2024-01', + category: 'cat2', + flag: true, + }); + await sheet.waitOnSpreadsheet(); +} diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index 01de568cc0..2b9c956823 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -520,6 +520,10 @@ export async function coverOverbudgeted({ }): Promise { const sheetName = monthUtils.sheetForMonth(month); const categoryBudget = await getSheetValue(sheetName, 'budget-' + category); + const categoryLeftover = await getSheetValue( + sheetName, + 'leftover-' + category, + ); // Cover provided amount (can be partial) or full overbudgeted amount. const amountToCover = amount @@ -527,12 +531,12 @@ export async function coverOverbudgeted({ -amount : await getSheetValue(sheetName, 'to-budget'); - if (amountToCover >= 0 || categoryBudget <= 0) { + if (amountToCover >= 0 || categoryLeftover <= 0) { return; } - // Don't allow the budget of the covering category to go negative. - const coverableAmount = Math.min(Math.abs(amountToCover), categoryBudget); + // Don't exceed the available balance of the covering category. + const coverableAmount = Math.min(Math.abs(amountToCover), categoryLeftover); await batchMessages(async () => { await setBudget({ diff --git a/upcoming-release-notes/7272.md b/upcoming-release-notes/7272.md new file mode 100644 index 0000000000..c0bd41fe6d --- /dev/null +++ b/upcoming-release-notes/7272.md @@ -0,0 +1,6 @@ +--- +category: Bugfixes +authors: [JSkinnerUK] +--- + +Fixed an issue that prevented category's leftover from being able to cover an overbudgeted amount.