diff --git a/packages/desktop-client/src/budget/mutations.ts b/packages/desktop-client/src/budget/mutations.ts index 3e30005f9ea..87ad0021b9c 100644 --- a/packages/desktop-client/src/budget/mutations.ts +++ b/packages/desktop-client/src/budget/mutations.ts @@ -647,6 +647,13 @@ type ApplyBudgetActionPayload = args: { category: CategoryEntity['id']; }; + } + | { + type: 'copy-until-year-end'; + month: string; + args: { + category: CategoryEntity['id']; + }; }; export function useBudgetActions() { @@ -776,6 +783,12 @@ export function useBudgetActions() { category: args.category, }); return null; + case 'copy-until-year-end': + await send('budget/copy-until-year-end', { + month, + category: args.category, + }); + return null; default: throw new Error(`Unknown budget action type: ${String(type)}`); } diff --git a/packages/desktop-client/src/components/budget/tracking/BudgetMenu.tsx b/packages/desktop-client/src/components/budget/tracking/BudgetMenu.tsx index 68e6ba87f8b..44a7aebd413 100644 --- a/packages/desktop-client/src/components/budget/tracking/BudgetMenu.tsx +++ b/packages/desktop-client/src/components/budget/tracking/BudgetMenu.tsx @@ -13,11 +13,13 @@ type BudgetMenuProps = Omit< onCopyLastMonthAverage: () => void; onSetMonthsAverage: (numberOfMonths: number) => void; onApplyBudgetTemplate: () => void; + onCopyUntilYearEnd: () => void; }; export function BudgetMenu({ onCopyLastMonthAverage, onSetMonthsAverage, onApplyBudgetTemplate, + onCopyUntilYearEnd, ...props }: BudgetMenuProps) { const { t } = useTranslation(); @@ -39,6 +41,9 @@ export function BudgetMenu({ case 'apply-single-category-template': onApplyBudgetTemplate?.(); break; + case 'copy-until-year-end': + onCopyUntilYearEnd?.(); + break; default: throw new Error(`Unrecognized menu item: ${name}`); } @@ -65,6 +70,10 @@ export function BudgetMenu({ name: 'set-single-12-avg', text: t('Set to yearly average'), }, + { + name: 'copy-until-year-end', + text: t('Copy until year end'), + }, ...(isGoalTemplatesEnabled ? [ { diff --git a/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx b/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx index e7e3e3ee2dd..4980feebd42 100644 --- a/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx +++ b/packages/desktop-client/src/components/budget/tracking/TrackingBudgetComponents.tsx @@ -344,6 +344,14 @@ export const CategoryMonth = memo(function CategoryMonth({ message: t(`Budget template applied.`), }); }} + onCopyUntilYearEnd={() => { + onMenuAction(month, 'copy-until-year-end', { + category: category.id, + }); + showUndoNotification({ + message: t(`Budget copied until year end.`), + }); + }} /> diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetCell.tsx b/packages/desktop-client/src/components/mobile/budget/BudgetCell.tsx index cc34de212bb..c6c5cf06dab 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetCell.tsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetCell.tsx @@ -79,61 +79,84 @@ export function BudgetCell< ); const onOpenCategoryBudgetMenu = useCallback(() => { - const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking'; - const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const; - dispatch( - pushModal({ - modal: { - name: categoryBudgetMenuModal, - options: { - categoryId: category.id, - month, - onEditNotes, - onUpdateBudget: amount => { - onBudgetAction(month, 'budget-amount', { - category: category.id, - amount, - }); - showUndoNotification({ - message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`, - }); - }, - onCopyLastMonthAverage: () => { - onBudgetAction(month, 'copy-single-last', { - category: category.id, - }); - showUndoNotification({ - message: `${category.name} budget has been set to last month's budgeted amount.`, - }); - }, - onSetMonthsAverage: numberOfMonths => { - if ( - numberOfMonths !== 3 && - numberOfMonths !== 6 && - numberOfMonths !== 12 - ) { - return; - } - onBudgetAction(month, `set-single-${numberOfMonths}-avg`, { - category: category.id, - }); - showUndoNotification({ - message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`, - }); - }, - onApplyBudgetTemplate: () => { - onBudgetAction(month, 'apply-single-category-template', { - category: category.id, - }); - showUndoNotification({ - message: `${category.name} budget templates have been applied.`, - pre: categoryNotes ?? undefined, - }); + const sharedOptions = { + categoryId: category.id, + month, + onEditNotes, + onUpdateBudget: (amount: number) => { + onBudgetAction(month, 'budget-amount', { + category: category.id, + amount, + }); + showUndoNotification({ + message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`, + }); + }, + onCopyLastMonthAverage: () => { + onBudgetAction(month, 'copy-single-last', { + category: category.id, + }); + showUndoNotification({ + message: `${category.name} budget has been set to last month's budgeted amount.`, + }); + }, + onSetMonthsAverage: (numberOfMonths: number) => { + if ( + numberOfMonths !== 3 && + numberOfMonths !== 6 && + numberOfMonths !== 12 + ) { + return; + } + onBudgetAction(month, `set-single-${numberOfMonths}-avg`, { + category: category.id, + }); + showUndoNotification({ + message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`, + }); + }, + onApplyBudgetTemplate: () => { + onBudgetAction(month, 'apply-single-category-template', { + category: category.id, + }); + showUndoNotification({ + message: `${category.name} budget templates have been applied.`, + pre: categoryNotes ?? undefined, + }); + }, + }; + + if (budgetType === 'envelope') { + dispatch( + pushModal({ + modal: { + name: 'envelope-budget-menu', + options: sharedOptions, + }, + }), + ); + } else { + dispatch( + pushModal({ + modal: { + name: 'tracking-budget-menu', + options: { + ...sharedOptions, + onCopyUntilYearEnd: () => { + onBudgetAction(month, 'copy-until-year-end', { + category: category.id, + }); + showUndoNotification({ + message: t('{{categoryName}} budget copied until year end.', { + categoryName: category.name, + }), + }); + }, }, }, - }, - }), - ); + }), + ); + } }, [ budgetType, category.id, @@ -145,6 +168,7 @@ export function BudgetCell< showUndoNotification, onEditNotes, format, + t, ]); return ( diff --git a/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx index 453d9e70814..5625d6c911c 100644 --- a/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx +++ b/packages/desktop-client/src/components/modals/TrackingBudgetMenuModal.tsx @@ -42,6 +42,7 @@ export function TrackingBudgetMenuModal({ onCopyLastMonthAverage, onSetMonthsAverage, onApplyBudgetTemplate, + onCopyUntilYearEnd, onEditNotes, month, }: TrackingBudgetMenuModalProps) { @@ -200,6 +201,7 @@ export function TrackingBudgetMenuModal({ onCopyLastMonthAverage={onCopyLastMonthAverage} onSetMonthsAverage={onSetMonthsAverage} onApplyBudgetTemplate={onApplyBudgetTemplate} + onCopyUntilYearEnd={onCopyUntilYearEnd} /> )} diff --git a/packages/desktop-client/src/modals/modalsSlice.ts b/packages/desktop-client/src/modals/modalsSlice.ts index 284e09a3240..71e41d4e8f2 100644 --- a/packages/desktop-client/src/modals/modalsSlice.ts +++ b/packages/desktop-client/src/modals/modalsSlice.ts @@ -356,6 +356,7 @@ export type Modal = onCopyLastMonthAverage: () => void; onSetMonthsAverage: (numberOfMonths: number) => void; onApplyBudgetTemplate: () => void; + onCopyUntilYearEnd: () => void; onEditNotes: (id: NoteEntity['id'], month: string) => void; }; } diff --git a/packages/docs/docs/getting-started/tracking-budget.md b/packages/docs/docs/getting-started/tracking-budget.md index 6d28f058d7f..a5b5f1e673d 100644 --- a/packages/docs/docs/getting-started/tracking-budget.md +++ b/packages/docs/docs/getting-started/tracking-budget.md @@ -66,6 +66,14 @@ This will affect your spent totals as if the spending didn't happen. The spending will only show up in the month that the rollover stops. ::: +## Copy Until Year End + +The **Copy until year end** option in the per-category budget menu copies the current month's budgeted amount to every later month of the same calendar year, overwriting any existing value for that category in those months. + +To use it, click the budget amount for a category to open the budget menu, then select **Copy until year end**. Months in subsequent calendar years are not affected. + +This is useful when you add or change a recurring expense and want to update all future planned months without clicking through each one individually. For example, if you realise in March that your grocery budget should be $300 for the rest of the year, you can set it once and copy it to April through December in one click. + ## Working With the Budget All the non-budgeting features of Actual can be used with the **Tracking Budget** the same as the **Envelope Budget**. diff --git a/packages/loot-core/src/server/budget/actions.test.ts b/packages/loot-core/src/server/budget/actions.test.ts index 79c15ec5ea9..480a349dbe3 100644 --- a/packages/loot-core/src/server/budget/actions.test.ts +++ b/packages/loot-core/src/server/budget/actions.test.ts @@ -5,6 +5,7 @@ import * as db from '#server/db'; import * as sheet from '#server/sheet'; import { + copyUntilYearEnd, coverOverbudgeted, getSheetValue, setBudget, @@ -12,6 +13,122 @@ import { } from './actions'; import * as budget from './base'; +describe('copyUntilYearEnd', () => { + beforeEach(global.emptyDatabase()); + afterEach(global.emptyDatabase()); + + async function setupDatabase() { + 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 sheet.loadSpreadsheet(db); + await budget.createBudget(['2024-01', '2024-02', '2024-03']); + } + + it('copies the current month budget to all future months in the same year', async () => { + await setupDatabase(); + + await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 }); + await setBudget({ category: 'cat1', month: '2024-02', amount: 1000 }); + await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 }); + await sheet.waitOnSpreadsheet(); + + await copyUntilYearEnd({ month: '2024-01', category: 'cat1' }); + await sheet.waitOnSpreadsheet(); + + expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000); + }); + + it('overwrites future months including those with zero budgets', async () => { + await setupDatabase(); + + await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 }); + // 2024-02 intentionally left at 0 + await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 }); + await sheet.waitOnSpreadsheet(); + + await copyUntilYearEnd({ month: '2024-01', category: 'cat1' }); + await sheet.waitOnSpreadsheet(); + + expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000); + }); + + it('does not affect months before or equal to the current month', async () => { + await setupDatabase(); + + await setBudget({ category: 'cat1', month: '2024-01', amount: 1000 }); + await setBudget({ category: 'cat1', month: '2024-02', amount: 5000 }); + await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 }); + await sheet.waitOnSpreadsheet(); + + await copyUntilYearEnd({ month: '2024-02', category: 'cat1' }); + await sheet.waitOnSpreadsheet(); + + expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(1000); + expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000); + }); + + it('copies the current month budget to future months in tracking budget mode', async () => { + await setupDatabase(); + db.runQuery( + `INSERT INTO preferences (id, value) VALUES ('budgetType', 'tracking')`, + ); + + await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 }); + await setBudget({ category: 'cat1', month: '2024-02', amount: 1000 }); + await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 }); + await sheet.waitOnSpreadsheet(); + + await copyUntilYearEnd({ month: '2024-01', category: 'cat1' }); + await sheet.waitOnSpreadsheet(); + + expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000); + }); + + it('does not copy to months beyond the current calendar year', async () => { + await setupDatabase(); + await budget.createBudget(['2024-11', '2024-12', '2025-01']); + + await setBudget({ category: 'cat1', month: '2024-11', amount: 5000 }); + await setBudget({ category: 'cat1', month: '2024-12', amount: 1000 }); + await setBudget({ category: 'cat1', month: '2025-01', amount: 2000 }); + await sheet.waitOnSpreadsheet(); + + await copyUntilYearEnd({ month: '2024-11', category: 'cat1' }); + await sheet.waitOnSpreadsheet(); + + expect(await getSheetValue('budget202411', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202412', 'budget-cat1')).toBe(5000); + expect(await getSheetValue('budget202501', 'budget-cat1')).toBe(2000); // unchanged + }); +}); + describe('coverOverbudgeted', () => { beforeEach(global.emptyDatabase()); afterEach(global.emptyDatabase()); diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index 2516874786a..be7d2324a77 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -591,6 +591,31 @@ export async function transferCategory({ }); } +export async function copyUntilYearEnd({ + month, + category, +}: { + month: string; + category: string; +}): Promise { + const amount = await getSheetValue( + monthUtils.sheetForMonth(month), + 'budget-' + category, + ); + + const yearEnd = monthUtils.getYearEnd(month); + const { createdMonths } = sheet.get().meta(); + const futureMonths = [...(createdMonths as Set)] + .filter(m => m > month && m <= yearEnd) + .sort(); + + await batchMessages(async () => { + for (const futureMonth of futureMonths) { + void setBudget({ category, month: futureMonth, amount }); + } + }); +} + export async function setCategoryCarryover({ startMonth, category, diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index bc854909295..0d752750504 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -39,6 +39,7 @@ export type BudgetHandlers = { 'budget/transfer-available': typeof actions.transferAvailable; 'budget/cover-overbudgeted': typeof actions.coverOverbudgeted; 'budget/transfer-category': typeof actions.transferCategory; + 'budget/copy-until-year-end': typeof actions.copyUntilYearEnd; 'budget/set-carryover': typeof actions.setCategoryCarryover; 'budget/reset-income-carryover': typeof actions.resetIncomeCarryover; 'get-categories': typeof getCategories; @@ -123,6 +124,10 @@ app.method( 'budget/transfer-category', mutator(undoable(actions.transferCategory)), ); +app.method( + 'budget/copy-until-year-end', + mutator(undoable(actions.copyUntilYearEnd)), +); app.method( 'budget/set-carryover', mutator(undoable(actions.setCategoryCarryover)), diff --git a/upcoming-release-notes/7420.md b/upcoming-release-notes/7420.md new file mode 100644 index 00000000000..b5c5a53bd1e --- /dev/null +++ b/upcoming-release-notes/7420.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [nikhilweee] +--- + +Add 'Copy until year end' option to the per-category budget menu in tracking budget mode, which copies the current month's budgeted amount to all remaining months of the same calendar year.