Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4f0437f
[AI] Add 'Copy to future months' budget option
nikhilweee Apr 8, 2026
14be73f
[AI] Rename release note to PR #7420
nikhilweee Apr 8, 2026
1a8e865
Merge remote-tracking branch 'origin/master' into copy-to-future-months
nikhilweee Apr 8, 2026
46d3c21
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 12, 2026
ce63562
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 13, 2026
7010197
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 17, 2026
736fac7
[AI] Skip empty budget months in copyToFutureMonths
nikhilweee Apr 17, 2026
3762f88
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 17, 2026
ee07335
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 20, 2026
f02a2f3
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 21, 2026
92d4725
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 22, 2026
60bef0d
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 23, 2026
40377e0
Merge branch 'master' into copy-to-future-months
nikhilweee Apr 26, 2026
f39a312
[AI] Rename to 'Copy until year end', limit to tracking budget, add y…
nikhilweee May 2, 2026
d88e283
Merge branch 'master' into copy-to-future-months
nikhilweee May 2, 2026
efd96b6
[AI] Address CodeRabbit feedback: i18n undo notification, clarify docs
nikhilweee May 5, 2026
b4c273a
[AI] Fix typecheck: branch dispatch by budgetType for proper modal na…
nikhilweee May 5, 2026
9f223dc
[AI] Fix lint: add t to useCallback deps
nikhilweee May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/desktop-client/src/budget/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,13 @@ type ApplyBudgetActionPayload =
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-until-year-end';
month: string;
args: {
category: CategoryEntity['id'];
};
};

export function useBudgetActions() {
Expand Down Expand Up @@ -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)}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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}`);
}
Expand All @@ -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
? [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.`),
});
}}
/>
</Popover>
</View>
Expand Down
130 changes: 77 additions & 53 deletions packages/desktop-client/src/components/mobile/budget/BudgetCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
},
},
},
}),
);
}),
);
}
}, [
budgetType,
category.id,
Expand All @@ -145,6 +168,7 @@ export function BudgetCell<
showUndoNotification,
onEditNotes,
format,
t,
]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
Expand Down Expand Up @@ -200,6 +201,7 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
onCopyUntilYearEnd={onCopyUntilYearEnd}
/>
)}
</>
Expand Down
1 change: 1 addition & 0 deletions packages/desktop-client/src/modals/modalsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/docs/getting-started/tracking-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
117 changes: 117 additions & 0 deletions packages/loot-core/src/server/budget/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,130 @@ import * as db from '#server/db';
import * as sheet from '#server/sheet';

import {
copyUntilYearEnd,
coverOverbudgeted,
getSheetValue,
setBudget,
setCategoryCarryover,
} 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());
Expand Down
Loading
Loading