mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 15:12:35 -05:00
Compare commits
6 Commits
claude/hid
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3952d2a24 | ||
|
|
fea36466d2 | ||
|
|
1fadfa4e9b | ||
|
|
6ead7ea42c | ||
|
|
d6fc3212b9 | ||
|
|
071611fcc5 |
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
6
upcoming-release-notes/7420.md
Normal file
6
upcoming-release-notes/7420.md
Normal 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.
|
||||
6
upcoming-release-notes/7691.md
Normal file
6
upcoming-release-notes/7691.md
Normal 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
|
||||
6
upcoming-release-notes/7704.md
Normal file
6
upcoming-release-notes/7704.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [alecbakholdin]
|
||||
---
|
||||
|
||||
Fixed cannot read properties of null in throwIfNot200 (reading 'toLowerCase')
|
||||
6
upcoming-release-notes/7723.md
Normal file
6
upcoming-release-notes/7723.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Duplicated transactions are marked as uncleared and unlocked
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user