Compare commits

..

6 Commits

Author SHA1 Message Date
Nikhil Verma
e3952d2a24 [AI] Add option to 'Copy [budget] to future months' (#7420)
* [AI] Add 'Copy to future months' budget option

Adds a new per-category budget menu option that copies the current
month's budgeted amount to all future months that already exist in
the budget. Works for both envelope and tracking budget types, and
on both desktop (inline popover) and mobile (modal) views.

* [AI] Rename release note to PR #7420

* [AI] Skip empty budget months in copyToFutureMonths

Only copy the budget value to future months that already have a
non-zero budget set for the category. Months with no budget (zero)
are left untouched.

* [AI] Rename to 'Copy until year end', limit to tracking budget, add year-end cap

* [AI] Address CodeRabbit feedback: i18n undo notification, clarify docs

* [AI] Fix typecheck: branch dispatch by budgetType for proper modal narrowing

* [AI] Fix lint: add t to useCallback deps
2026-05-06 18:19:40 +00:00
Michael Clark
fea36466d2 fix rss feed (#7732) 2026-05-06 18:18:30 +00:00
youngcw
1fadfa4e9b [AI] Fix #2155: duplicated transactions are marked as uncleared (#7723)
* init

* note
2026-05-06 17:43:30 +00:00
Aaron
6ead7ea42c docs: add Enable Actual to community repos (#7697)
Co-authored-by: Aaron <aaron@noreply.squaresine.com>
2026-05-06 00:31:41 +00:00
Matt Fiddaman
d6fc3212b9 re-sort preview transactions after rule application (#7691)
* resort schedules after rule application

* note
2026-05-05 23:48:26 +00:00
Alec Bakholdin
071611fcc5 Cannot read properties of null (reading 'toLowerCase') (#7704)
* added null safety in throwIfNot200

* release notes

* updated release notes

---------

Co-authored-by: Alec Bakholdin <alecbakholdin.com>
2026-05-05 23:03:02 +00:00
24 changed files with 312 additions and 296 deletions

View File

@@ -1,192 +0,0 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '#output';
import { registerCategoriesCommand } from './categories';
import { registerCategoryGroupsCommand } from './category-groups';
vi.mock('@actual-app/api', () => ({
getCategories: vi.fn().mockResolvedValue([]),
createCategory: vi.fn().mockResolvedValue('new-id'),
updateCategory: vi.fn().mockResolvedValue(undefined),
deleteCategory: vi.fn().mockResolvedValue(undefined),
getCategoryGroups: vi.fn().mockResolvedValue([]),
createCategoryGroup: vi.fn().mockResolvedValue('new-group-id'),
updateCategoryGroup: vi.fn().mockResolvedValue(undefined),
deleteCategoryGroup: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('#connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('#output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerCategoriesCommand(program);
registerCategoryGroupsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('categories list', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it('filters out hidden categories by default', async () => {
vi.mocked(api.getCategories).mockResolvedValue([
{ id: '1', name: 'Visible', group_id: 'g1', hidden: false },
{ id: '2', name: 'Hidden', group_id: 'g1', hidden: true },
]);
await run(['categories', 'list']);
expect(printOutput).toHaveBeenCalledWith(
[{ id: '1', name: 'Visible', group_id: 'g1', hidden: false }],
undefined,
);
});
it('includes hidden categories when --include-hidden is passed', async () => {
vi.mocked(api.getCategories).mockResolvedValue([
{ id: '1', name: 'Visible', group_id: 'g1', hidden: false },
{ id: '2', name: 'Hidden', group_id: 'g1', hidden: true },
]);
await run(['categories', 'list', '--include-hidden']);
expect(printOutput).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: '2', hidden: true }),
]),
undefined,
);
});
it('passes format option to printOutput', async () => {
vi.mocked(api.getCategories).mockResolvedValue([]);
await run(['--format', 'csv', 'categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
});
describe('category-groups list', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it('filters out hidden groups and hidden child categories by default', async () => {
vi.mocked(api.getCategoryGroups).mockResolvedValue([
{
id: 'g1',
name: 'Visible Group',
is_income: false,
hidden: false,
categories: [
{ id: 'c1', name: 'Visible Cat', group_id: 'g1', hidden: false },
{ id: 'c2', name: 'Hidden Cat', group_id: 'g1', hidden: true },
],
},
{
id: 'g2',
name: 'Hidden Group',
is_income: false,
hidden: true,
categories: [],
},
]);
await run(['category-groups', 'list']);
expect(printOutput).toHaveBeenCalledWith(
[
{
id: 'g1',
name: 'Visible Group',
is_income: false,
hidden: false,
categories: [
{ id: 'c1', name: 'Visible Cat', group_id: 'g1', hidden: false },
],
},
],
undefined,
);
});
it('includes hidden groups and categories when --include-hidden is passed', async () => {
vi.mocked(api.getCategoryGroups).mockResolvedValue([
{
id: 'g1',
name: 'Visible Group',
is_income: false,
hidden: false,
categories: [
{ id: 'c2', name: 'Hidden Cat', group_id: 'g1', hidden: true },
],
},
{
id: 'g2',
name: 'Hidden Group',
is_income: false,
hidden: true,
categories: [],
},
]);
await run(['category-groups', 'list', '--include-hidden']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
id: string;
}>;
expect(output).toHaveLength(2);
expect(output.map(g => g.id)).toEqual(['g1', 'g2']);
});
});

View File

@@ -12,17 +12,13 @@ export function registerCategoriesCommand(program: Command) {
categories
.command('list')
.description('List categories (excludes hidden by default)')
.option('--include-hidden', 'Include hidden categories', false)
.action(async cmdOpts => {
.description('List all categories')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const allCategories = await api.getCategories();
const result = allCategories.filter(
c => cmdOpts.includeHidden || !c.hidden,
);
const result = await api.getCategories();
printOutput(result, opts.format);
},
{ mutates: false },

View File

@@ -12,22 +12,13 @@ export function registerCategoryGroupsCommand(program: Command) {
groups
.command('list')
.description('List category groups (excludes hidden by default)')
.option('--include-hidden', 'Include hidden groups and categories', false)
.action(async cmdOpts => {
.description('List all category groups')
.action(async () => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const allGroups = await api.getCategoryGroups();
const result = cmdOpts.includeHidden
? allGroups
: allGroups
.filter(g => !g.hidden)
.map(g => ({
...g,
categories: g.categories?.filter(c => !c.hidden),
}));
const result = await api.getCategoryGroups();
printOutput(result, opts.format);
},
{ mutates: false },

View File

@@ -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)}`);
}

View File

@@ -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
? [
{

View File

@@ -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>

View File

@@ -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 (

View File

@@ -73,21 +73,6 @@ export function ConfirmTransactionEditModal({
out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciledTransfer' ? (
<Block>
<Trans>
This transfer has a linked transaction in another account that
is reconciled. Duplicating it may bring that account's
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciled' ? (
<Block>
<Trans>
Duplicating reconciled transactions may bring your
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'editReconciled' ? (
<Block>
<Trans>

View File

@@ -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}
/>
)}
</>

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { send } from '@actual-app/core/platform/client/connection';
import * as monthUtils from '@actual-app/core/shared/months';
import { computeSchedulePreviewTransactions } from '@actual-app/core/shared/schedules';
import { ungroupTransactions } from '@actual-app/core/shared/transactions';
import type { IntegerAmount } from '@actual-app/core/shared/util';
@@ -100,6 +101,13 @@ export function usePreviewTransactions({
),
}));
// re-sort in case rule actions have changed the dates
withDefaults.sort(
(a, b) =>
monthUtils.parseDate(b.date).getTime() -
monthUtils.parseDate(a.date).getTime() || a.amount - b.amount,
);
const ungroupedTransactions = ungroupTransactions(withDefaults);
setPreviewTransactions(ungroupedTransactions);

View File

@@ -297,7 +297,11 @@ export function useTransactionBatchActions() {
added: transactions.reduce(
(newTransactions: TransactionEntity[], trans: TransactionEntity) => {
return newTransactions.concat(
realizeTempTransactions(ungroupTransaction(trans)),
realizeTempTransactions(ungroupTransaction(trans)).map(t => ({
...t,
cleared: false,
reconciled: false,
})),
);
},
[],
@@ -309,11 +313,7 @@ export function useTransactionBatchActions() {
onSuccess?.(ids);
};
await checkForReconciledTransactions(
ids,
'batchDuplicateWithReconciled',
onConfirmDuplicate,
);
await onConfirmDuplicate(ids);
};
const onBatchDelete = async ({ ids, onSuccess }: BatchDeleteProps) => {
@@ -445,7 +445,6 @@ export function useTransactionBatchActions() {
> = {
batchDeleteWithReconciled: 'batchDeleteWithReconciledTransfer',
batchEditWithReconciled: 'batchEditWithReconciledTransfer',
batchDuplicateWithReconciled: 'batchDuplicateWithReconciledTransfer',
};
const checkForReconciledTransactions = async (

View File

@@ -32,8 +32,6 @@ export type ConfirmTransactionEditReason =
| 'batchDeleteWithReconciledTransfer'
| 'batchEditWithReconciled'
| 'batchEditWithReconciledTransfer'
| 'batchDuplicateWithReconciled'
| 'batchDuplicateWithReconciledTransfer'
| 'editReconciled'
| 'unlockReconciled'
| 'deleteReconciled';
@@ -356,6 +354,7 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}

View File

@@ -59,7 +59,7 @@ This release introduces powerful new reporting capabilities as well as numerous
- [#7269](https://github.com/actualbudget/actual/pull/7269) Show confirmation dialog when editing/duplicating/deleting transfers where the other half is reconciled — thanks @matt-fidd
- [#7270](https://github.com/actualbudget/actual/pull/7270) Fix transaction quick search incorrectly treating "?" and "%" as wildcards, causing all transactions to be returned instead of only those matching the literal character — thanks @eduardopio03
- [#7283](https://github.com/actualbudget/actual/pull/7283) Standardise ledger scrolling when using keyboard shortcuts — thanks @JSkinnerUK
- [#7284](https://github.com/actualbudget/actual/pull/7284) Handle normalisation of some common non-latin diacritic characters, ł, ø, ß, œ. — thanks @JSkinnerUK
- [#7284](https://github.com/actualbudget/actual/pull/7284) Handle normalisation of some common non-latin diacritic characters. — thanks @JSkinnerUK
- [#7296](https://github.com/actualbudget/actual/pull/7296) Fix Net Worth graph showing a time-interval less than specified — thanks @emiltb
- [#7304](https://github.com/actualbudget/actual/pull/7304) Fix UUID showing when switching filter operators — thanks @sk10727-a11y
- [#7324](https://github.com/actualbudget/actual/pull/7324) Fixes transaction query by tag when tag starts with $ — thanks @gust0717

View File

@@ -13,6 +13,7 @@ for it to be added, your project must have a proper README file.
The following are implementations of bank syncing using the Actual API. For instructions on using them, see the respective repositories.
- **Akahu and Up bank sync to Actual Budget** - https://github.com/tim-smart/actualbudget-sync
- **Enable Actual: Import transactions from European banks using Enable Banking** - https://github.com/2manyvcos/enable-actual
- **ICS Cards Holland CVS exporter** - https://github.com/IeuanK/ICS-Exporter/
- **Lunch Flow: Import transactions from GoCardless, MX, Finicity, Finverse, and more** - https://github.com/lunchflow/actual-flow
- **MoneyMan an israel banks importer** - https://github.com/daniel-hauser/moneyman

View File

@@ -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**.

View File

@@ -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());

View File

@@ -591,6 +591,31 @@ export async function transferCategory({
});
}
export async function copyUntilYearEnd({
month,
category,
}: {
month: string;
category: string;
}): Promise<void> {
const amount = await getSheetValue(
monthUtils.sheetForMonth(month),
'budget-' + category,
);
const yearEnd = monthUtils.getYearEnd(month);
const { createdMonths } = sheet.get().meta();
const futureMonths = [...(createdMonths as Set<string>)]
.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,

View File

@@ -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)),

View File

@@ -11,7 +11,7 @@ function throwIfNot200(res: Response, text: string) {
throw new PostError(res.status === 500 ? 'internal' : text);
}
const contentType = res.headers.get('Content-Type');
const contentType = res.headers.get('Content-Type') ?? '';
if (contentType.toLowerCase().indexOf('application/json') !== -1) {
const json = JSON.parse(text);
throw new PostError(json.reason);

View File

@@ -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.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [matt-fidd]
---
Fix schedules not appearing on the mobile view when the date is changed by rules

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [alecbakholdin]
---
Fixed cannot read properties of null in throwIfNot200 (reading 'toLowerCase')

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [youngcw]
---
Duplicated transactions are marked as uncleared and unlocked

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: []
---
CLI: `categories list` and `category-groups list` now exclude hidden entries by default. Pass `--include-hidden` to include them.