mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-11 22:49:59 +00:00
[AI] feat(budget analysis report): allow selecting future months in date range (#8722)
* [AI] feat(budget-report): allow selecting future months in date range Extend the allMonths picker list 12 months past the current month so users can select future months for long-term goal planning. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * [AI] fix(budget-report): preserve running balance for future months with no budget data Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * [AI] fix(budget-report): fix balance zeroing for future months with no budget data Use monthBalance as the next month's running balance instead of carryoverToNextMonth, which is 0 for months where no category has any budget or spending data. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * [AI] fix(budget-report): fix import path in budget-analysis-spreadsheet test * [AI] fix: added in release note Assisted-by: ClaudeCode:claude-opus-5 * [AI] fix(budget-report): address CodeRabbit review findings Addresses both review comments from CodeRabbit on PR #8722: 1. "Keep later-dated transaction months selectable" — the month-range endpoint now takes the later of the latest transaction month and December of next year, so existing future-dated transactions are no longer hidden from the picker. This PR had replaced latestMonth with a hardcoded December-of-next-year, which dropped transactions dated beyond it. 2. "Preserve the no-carryover reset when propagating balances" — running-balance propagation goes back to carrying over only what actually carries over, instead of the full month balance. Using monthBalance had re-included negative balances for categories with carryover disabled, so an overspend both dragged forward through later months and still surfaced as an overspending adjustment. A month with no budget data at all now passes the running balance through untouched, which fixes the original future-month bug without defeating the carryover reset. Extracts summarizeMonthCategories, getNextRunningBalance and getLastSelectableMonth so this logic is unit-testable, and replaces the self-referential range tests with ones that exercise the real functions. Assisted-by: ClaudeCode:claude-opus-5 * [AI] test(budget-report): cover never-budgeted months in data detection CodeRabbit's follow-up review on PR #8722 suggested setting hasBudgetData from cell presence rather than cell value. That change would be wrong here: envelope-budget-month builds its response by iterating the category list, so it emits a cell for every category in every month and normalizes missing sheet values to 0 (see loot-core/src/server/budget/app.ts). Cells are always present, so presence-based detection would report every month as populated and re-introduce the running-balance bug the previous commit fixed. The zero-value case it worries about is not reachable in a way that changes the result: leftover already folds in the previous month's balance, so a month with a real balance carrying in always has a non-zero cell and is detected correctly. When all three are zero there is nothing to carry, and both detection strategies agree. Adds a regression test pinning the never-budgeted month to hasBudgetData=false, and documents why detection is value-based so the suggestion is not re-applied later. Assisted-by: ClaudeCode:claude-opus-5 --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
008c609424
commit
30e671b3ec
@@ -33,7 +33,10 @@ import { Header } from '#components/reports/Header';
|
||||
import { LoadingIndicator } from '#components/reports/LoadingIndicator';
|
||||
import { calculateTimeRange } from '#components/reports/reportRanges';
|
||||
import { buildBudgetAnalysisCsv } from '#components/reports/spreadsheets/budget-analysis-export';
|
||||
import { createBudgetAnalysisSpreadsheet } from '#components/reports/spreadsheets/budget-analysis-spreadsheet';
|
||||
import {
|
||||
createBudgetAnalysisSpreadsheet,
|
||||
getLastSelectableMonth,
|
||||
} from '#components/reports/spreadsheets/budget-analysis-spreadsheet';
|
||||
import { useReport } from '#components/reports/useReport';
|
||||
import { fromDateRepr } from '#components/reports/util';
|
||||
import { useDashboardWidget } from '#hooks/useDashboardWidget';
|
||||
@@ -223,8 +226,13 @@ function BudgetAnalysisInternal({ widget }: BudgetAnalysisInternalProps) {
|
||||
earliestMonth = yearAgo;
|
||||
}
|
||||
|
||||
// Extend the selectable range to December of next year so users can
|
||||
// plan ahead (e.g. June 2026 → December 2027), without hiding
|
||||
// transactions dated beyond that.
|
||||
const lastMonth = getLastSelectableMonth(currentMonth, latestMonth);
|
||||
|
||||
const allMonthsData = monthUtils
|
||||
.rangeInclusive(earliestMonth, latestMonth)
|
||||
.rangeInclusive(earliestMonth, lastMonth)
|
||||
.map(month => ({
|
||||
name: month,
|
||||
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
|
||||
|
||||
+219
-1
@@ -1,6 +1,13 @@
|
||||
import { rangeInclusive } from '@actual-app/core/shared/months';
|
||||
import type { CategoryEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import { isBaseCategory } from './budget-analysis-spreadsheet';
|
||||
import {
|
||||
getLastSelectableMonth,
|
||||
getNextRunningBalance,
|
||||
isBaseCategory,
|
||||
summarizeMonthCategories,
|
||||
} from './budget-analysis-spreadsheet';
|
||||
import type { BudgetMonthCell } from './budgetMonthCell';
|
||||
|
||||
const makeCategory = (
|
||||
overrides: Partial<CategoryEntity> & Pick<CategoryEntity, 'id' | 'name'>,
|
||||
@@ -74,4 +81,215 @@ describe('createBudgetAnalysisSpreadsheet', () => {
|
||||
expect(result).toContain(hiddenExpense);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastSelectableMonth', () => {
|
||||
it('extends the range to December of next year', () => {
|
||||
expect(getLastSelectableMonth('2026-06', '2026-06')).toBe('2027-12');
|
||||
});
|
||||
|
||||
it('ends in December regardless of the current month', () => {
|
||||
for (const month of ['2024-01', '2024-06', '2024-12']) {
|
||||
expect(getLastSelectableMonth(month, month)).toBe('2025-12');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a later transaction month selectable', () => {
|
||||
// A transaction dated past December of next year must not be cut off.
|
||||
expect(getLastSelectableMonth('2026-08', '2028-03')).toBe('2028-03');
|
||||
});
|
||||
|
||||
it('prefers December of next year when the latest transaction is earlier', () => {
|
||||
expect(getLastSelectableMonth('2026-08', '2026-11')).toBe('2027-12');
|
||||
});
|
||||
|
||||
it('keeps the boundary month itself', () => {
|
||||
expect(getLastSelectableMonth('2026-08', '2027-12')).toBe('2027-12');
|
||||
});
|
||||
|
||||
it('produces a range that spans from the earliest month to the endpoint', () => {
|
||||
const range = rangeInclusive(
|
||||
'2026-06',
|
||||
getLastSelectableMonth('2026-06', '2026-06'),
|
||||
);
|
||||
|
||||
expect(range[0]).toBe('2026-06');
|
||||
expect(range[range.length - 1]).toBe('2027-12');
|
||||
});
|
||||
|
||||
it('lists future months before the current month once reversed', () => {
|
||||
const last = getLastSelectableMonth('2026-06', '2026-06');
|
||||
const allMonths = rangeInclusive('2026-06', last).reverse();
|
||||
|
||||
expect(allMonths[0]).toBe(last);
|
||||
expect(allMonths[allMonths.length - 1]).toBe('2026-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeMonthCategories', () => {
|
||||
const cells = (
|
||||
values: Record<string, number | boolean>,
|
||||
): BudgetMonthCell[] =>
|
||||
Object.entries(values).map(([name, value]) => ({
|
||||
name: `budget202601!${name}`,
|
||||
value,
|
||||
})) as BudgetMonthCell[];
|
||||
|
||||
it('reports no budget data when every cell is empty', () => {
|
||||
const result = summarizeMonthCategories([], [visibleExpense]);
|
||||
|
||||
expect(result).toEqual({
|
||||
budgeted: 0,
|
||||
spent: 0,
|
||||
carryoverToNextMonth: 0,
|
||||
overspendingThisMonth: 0,
|
||||
hasBudgetData: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no budget data for a never-budgeted month', () => {
|
||||
// `envelope-budget-month` emits a cell for every category in every
|
||||
// month, so an untouched future month arrives as present-but-zero
|
||||
// cells rather than as missing ones. Presence alone cannot distinguish
|
||||
// it from a real month.
|
||||
const result = summarizeMonthCategories(
|
||||
cells({
|
||||
'budget-c1': 0,
|
||||
'sum-amount-c1': 0,
|
||||
'leftover-c1': 0,
|
||||
'carryover-c1': false,
|
||||
}),
|
||||
[visibleExpense],
|
||||
);
|
||||
|
||||
expect(result.hasBudgetData).toBe(false);
|
||||
});
|
||||
|
||||
it('carries a positive balance to the next month', () => {
|
||||
const result = summarizeMonthCategories(
|
||||
cells({
|
||||
'budget-c1': 10000,
|
||||
'sum-amount-c1': -4000,
|
||||
'leftover-c1': 6000,
|
||||
'carryover-c1': false,
|
||||
}),
|
||||
[visibleExpense],
|
||||
);
|
||||
|
||||
expect(result.carryoverToNextMonth).toBe(6000);
|
||||
expect(result.overspendingThisMonth).toBe(0);
|
||||
expect(result.hasBudgetData).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an overspend without carryover as an overspending adjustment', () => {
|
||||
const result = summarizeMonthCategories(
|
||||
cells({
|
||||
'budget-c1': 0,
|
||||
'sum-amount-c1': -10000,
|
||||
'leftover-c1': -10000,
|
||||
'carryover-c1': false,
|
||||
}),
|
||||
[visibleExpense],
|
||||
);
|
||||
|
||||
expect(result.carryoverToNextMonth).toBe(0);
|
||||
expect(result.overspendingThisMonth).toBe(-10000);
|
||||
expect(result.hasBudgetData).toBe(true);
|
||||
});
|
||||
|
||||
it('carries a negative balance forward when carryover is enabled', () => {
|
||||
const result = summarizeMonthCategories(
|
||||
cells({
|
||||
'budget-c1': 0,
|
||||
'sum-amount-c1': -10000,
|
||||
'leftover-c1': -10000,
|
||||
'carryover-c1': true,
|
||||
}),
|
||||
[visibleExpense],
|
||||
);
|
||||
|
||||
expect(result.carryoverToNextMonth).toBe(-10000);
|
||||
expect(result.overspendingThisMonth).toBe(0);
|
||||
});
|
||||
|
||||
it('only counts the categories it is given', () => {
|
||||
const result = summarizeMonthCategories(
|
||||
cells({
|
||||
'budget-c1': 10000,
|
||||
'leftover-c1': 10000,
|
||||
'budget-c2': 5000,
|
||||
'leftover-c2': 5000,
|
||||
}),
|
||||
[visibleExpense],
|
||||
);
|
||||
|
||||
expect(result.budgeted).toBe(10000);
|
||||
expect(result.carryoverToNextMonth).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextRunningBalance', () => {
|
||||
it('hands off only what carries over when the month has data', () => {
|
||||
expect(
|
||||
getNextRunningBalance({
|
||||
hasBudgetData: true,
|
||||
carryoverToNextMonth: 6000,
|
||||
runningBalance: 20000,
|
||||
}),
|
||||
).toBe(6000);
|
||||
});
|
||||
|
||||
it('resets a non-carryover overspend instead of dragging it forward', () => {
|
||||
// The overspend surfaces as next month's overspending adjustment, so it
|
||||
// must not also reduce the running balance.
|
||||
expect(
|
||||
getNextRunningBalance({
|
||||
hasBudgetData: true,
|
||||
carryoverToNextMonth: 0,
|
||||
runningBalance: 20000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('passes the running balance through months with no budget data', () => {
|
||||
expect(
|
||||
getNextRunningBalance({
|
||||
hasBudgetData: false,
|
||||
carryoverToNextMonth: 0,
|
||||
runningBalance: 20000,
|
||||
}),
|
||||
).toBe(20000);
|
||||
});
|
||||
|
||||
it('preserves a balance across a run of empty future months', () => {
|
||||
let runningBalance = 15000;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
runningBalance = getNextRunningBalance({
|
||||
hasBudgetData: false,
|
||||
carryoverToNextMonth: 0,
|
||||
runningBalance,
|
||||
});
|
||||
}
|
||||
|
||||
expect(runningBalance).toBe(15000);
|
||||
});
|
||||
|
||||
it('does not carry a non-carryover overspend into later empty months', () => {
|
||||
// Overspend of -10000 in a category without carryover enabled.
|
||||
let runningBalance = getNextRunningBalance({
|
||||
hasBudgetData: true,
|
||||
carryoverToNextMonth: 0,
|
||||
runningBalance: 0,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
runningBalance = getNextRunningBalance({
|
||||
hasBudgetData: false,
|
||||
carryoverToNextMonth: 0,
|
||||
runningBalance,
|
||||
});
|
||||
}
|
||||
|
||||
expect(runningBalance).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+146
-63
@@ -43,6 +43,134 @@ export function isBaseCategory(
|
||||
return !cat.is_income && (showHiddenCategories || !cat.hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Last month the date pickers offer.
|
||||
*
|
||||
* The range runs through December of next year so the report can be used for
|
||||
* planning ahead, but it never cuts off existing data: if a transaction is
|
||||
* dated even further out, that month stays selectable.
|
||||
*/
|
||||
export function getLastSelectableMonth(
|
||||
currentMonth: string,
|
||||
latestMonth: string,
|
||||
): string {
|
||||
const futureMonth = `${Number(currentMonth.slice(0, 4)) + 1}-12`;
|
||||
return latestMonth > futureMonth ? latestMonth : futureMonth;
|
||||
}
|
||||
|
||||
export type MonthCategoryTotals = {
|
||||
budgeted: number;
|
||||
spent: number;
|
||||
/**
|
||||
* Sum of category balances that roll into the next month: positive balances
|
||||
* always roll over, negative ones only when the category has carryover
|
||||
* ("rollover overspending") enabled.
|
||||
*/
|
||||
carryoverToNextMonth: number;
|
||||
/**
|
||||
* Sum of negative balances for categories without carryover enabled. These
|
||||
* are zeroed out at the month boundary and surface as next month's
|
||||
* overspending adjustment instead of reducing the running balance.
|
||||
*/
|
||||
overspendingThisMonth: number;
|
||||
/**
|
||||
* Whether the month returned any budget data at all. Months in the future
|
||||
* that have never been budgeted come back with every cell empty; there is
|
||||
* nothing to carry over from them, so the running balance must pass through
|
||||
* untouched rather than being reset to zero.
|
||||
*/
|
||||
hasBudgetData: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduce a month's raw budget cells down to the totals the report needs.
|
||||
*/
|
||||
export function summarizeMonthCategories(
|
||||
monthData: BudgetMonthCell[],
|
||||
categoriesToInclude: CategoryEntity[],
|
||||
): MonthCategoryTotals {
|
||||
let budgeted = 0;
|
||||
let spent = 0;
|
||||
let carryoverToNextMonth = 0;
|
||||
let overspendingThisMonth = 0;
|
||||
let hasBudgetData = false;
|
||||
|
||||
for (const cat of categoriesToInclude) {
|
||||
// Find the budget, spent, balance, and carryover flag for this category
|
||||
const budgetCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`budget-${cat.id}`),
|
||||
);
|
||||
const spentCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`sum-amount-${cat.id}`),
|
||||
);
|
||||
const balanceCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`leftover-${cat.id}`),
|
||||
);
|
||||
const carryoverCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`carryover-${cat.id}`),
|
||||
);
|
||||
|
||||
const catBudgeted = (budgetCell?.value as number) || 0;
|
||||
const catSpent = (spentCell?.value as number) || 0;
|
||||
const catBalance = (balanceCell?.value as number) || 0;
|
||||
const hasCarryover = Boolean(carryoverCell?.value);
|
||||
|
||||
// Detect by value, not by cell presence: `envelope-budget-month` builds
|
||||
// its response from the category list, so it emits a cell for every
|
||||
// category in every month and fills missing sheet values with 0. Every
|
||||
// month therefore has all its cells, and presence tells us nothing.
|
||||
// A zero here is unambiguous: `leftover` already folds in the previous
|
||||
// month's balance, so a month with a real balance carrying in cannot
|
||||
// report zero across all three.
|
||||
if (catBudgeted !== 0 || catSpent !== 0 || catBalance !== 0) {
|
||||
hasBudgetData = true;
|
||||
}
|
||||
|
||||
budgeted += catBudgeted;
|
||||
spent += catSpent;
|
||||
|
||||
// Add to next month's carryover if:
|
||||
// - Balance is positive (always carries over), OR
|
||||
// - Balance is negative AND carryover is enabled
|
||||
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
|
||||
carryoverToNextMonth += catBalance;
|
||||
} else if (catBalance < 0 && !hasCarryover) {
|
||||
// If balance is negative and carryover is NOT enabled,
|
||||
// this will be zeroed out and becomes next month's overspending adjustment
|
||||
overspendingThisMonth += catBalance; // Keep as negative
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
budgeted,
|
||||
spent,
|
||||
carryoverToNextMonth,
|
||||
overspendingThisMonth,
|
||||
hasBudgetData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The balance that seeds the following month.
|
||||
*
|
||||
* Months with budget data hand off only what actually carries over, so
|
||||
* overspending in a category without carryover enabled is reset rather than
|
||||
* dragged forward. Months with no data at all (typically future months that
|
||||
* have never been budgeted) have nothing to carry over and must leave the
|
||||
* running balance untouched.
|
||||
*/
|
||||
export function getNextRunningBalance({
|
||||
hasBudgetData,
|
||||
carryoverToNextMonth,
|
||||
runningBalance,
|
||||
}: {
|
||||
hasBudgetData: boolean;
|
||||
carryoverToNextMonth: number;
|
||||
runningBalance: number;
|
||||
}): number {
|
||||
return hasBudgetData ? carryoverToNextMonth : runningBalance;
|
||||
}
|
||||
|
||||
export function createBudgetAnalysisSpreadsheet({
|
||||
conditions = [],
|
||||
conditionsOp = 'and',
|
||||
@@ -174,7 +302,6 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
|
||||
// Track running balance that respects carryover flags
|
||||
// Get the balance from the month before the start period to initialize properly
|
||||
let runningBalance = 0;
|
||||
const monthBeforeStart = monthUtils.subMonths(
|
||||
monthUtils.getMonth(startDate),
|
||||
1,
|
||||
@@ -184,22 +311,10 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
});
|
||||
|
||||
// Calculate the carryover from the previous month
|
||||
for (const cat of categoriesToInclude) {
|
||||
const balanceCell = prevMonthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`leftover-${cat.id}`),
|
||||
);
|
||||
const carryoverCell = prevMonthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`carryover-${cat.id}`),
|
||||
);
|
||||
|
||||
const catBalance = (balanceCell?.value as number) || 0;
|
||||
const hasCarryover = Boolean(carryoverCell?.value);
|
||||
|
||||
// Add to running balance if it would carry over
|
||||
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
|
||||
runningBalance += catBalance;
|
||||
}
|
||||
}
|
||||
let runningBalance = summarizeMonthCategories(
|
||||
prevMonthData,
|
||||
categoriesToInclude,
|
||||
).carryoverToNextMonth;
|
||||
|
||||
// Track totals across all months
|
||||
let totalBudgeted = 0;
|
||||
@@ -215,48 +330,13 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
// This uses the same calculations as the budget page
|
||||
const monthData = await send('envelope-budget-month', { month });
|
||||
|
||||
let budgeted = 0;
|
||||
let spent = 0;
|
||||
let overspendingThisMonth = 0;
|
||||
|
||||
// Track what will carry over to next month
|
||||
let carryoverToNextMonth = 0;
|
||||
|
||||
// Sum up values for categories we're interested in
|
||||
for (const cat of categoriesToInclude) {
|
||||
// Find the budget, spent, balance, and carryover flag for this category
|
||||
const budgetCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`budget-${cat.id}`),
|
||||
);
|
||||
const spentCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`sum-amount-${cat.id}`),
|
||||
);
|
||||
const balanceCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`leftover-${cat.id}`),
|
||||
);
|
||||
const carryoverCell = monthData.find((cell: BudgetMonthCell) =>
|
||||
cell.name.endsWith(`carryover-${cat.id}`),
|
||||
);
|
||||
|
||||
const catBudgeted = (budgetCell?.value as number) || 0;
|
||||
const catSpent = (spentCell?.value as number) || 0;
|
||||
const catBalance = (balanceCell?.value as number) || 0;
|
||||
const hasCarryover = Boolean(carryoverCell?.value);
|
||||
|
||||
budgeted += catBudgeted;
|
||||
spent += catSpent;
|
||||
|
||||
// Add to next month's carryover if:
|
||||
// - Balance is positive (always carries over), OR
|
||||
// - Balance is negative AND carryover is enabled
|
||||
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
|
||||
carryoverToNextMonth += catBalance;
|
||||
} else if (catBalance < 0 && !hasCarryover) {
|
||||
// If balance is negative and carryover is NOT enabled,
|
||||
// this will be zeroed out and becomes next month's overspending adjustment
|
||||
overspendingThisMonth += catBalance; // Keep as negative
|
||||
}
|
||||
}
|
||||
const {
|
||||
budgeted,
|
||||
spent,
|
||||
carryoverToNextMonth,
|
||||
overspendingThisMonth,
|
||||
hasBudgetData,
|
||||
} = summarizeMonthCategories(monthData, categoriesToInclude);
|
||||
|
||||
// Apply overspending adjustment from previous month (negative value)
|
||||
const overspendingAdjustment = overspendingFromPrevMonth;
|
||||
@@ -272,13 +352,16 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
intervalData.push({
|
||||
date: month,
|
||||
budgeted,
|
||||
spent, // Display as positive
|
||||
spent,
|
||||
balance: monthBalance,
|
||||
overspendingAdjustment: Math.abs(overspendingAdjustment), // Display as positive
|
||||
overspendingAdjustment: Math.abs(overspendingAdjustment),
|
||||
});
|
||||
|
||||
// Update running balance for next month
|
||||
runningBalance = carryoverToNextMonth;
|
||||
runningBalance = getNextRunningBalance({
|
||||
hasBudgetData,
|
||||
carryoverToNextMonth,
|
||||
runningBalance,
|
||||
});
|
||||
// Save this month's overspending to apply in next month
|
||||
overspendingFromPrevMonth = overspendingThisMonth;
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [tabedzki]
|
||||
---
|
||||
|
||||
Allow selecting future months in the Budget Analysis report date range, so you can plan ahead.
|
||||
Reference in New Issue
Block a user