[AI] feat: add an experimental balance forecast report (#7310)
* [AI] feat: add balance forecast backend * [AI] feat: add balance forecast report UI * [AI] feat: gate balance forecast behind an experimental flag * [AI] Include account-less schedules in balance forecast via explicit flag - Add includeAccountlessSchedules to forecast/generate and normalize schedules without an account into FORECAST_UNASSIGNED_ACCOUNT_ID - When enabled, append synthetic bucket and rule stub; skip transfer legs for unassigned schedules - Balance forecast UI sets the flag when widget meta has no account filter - Add loot-core tests for include vs exclude behavior * [AI] Improve balance forecast chart refresh UX Keep forecast charts stable during refetches and let the Y-axis scale to forecast data so balance changes remain visible. * [AI] Document balance forecast report Add experimental user documentation and navigation links for the new balance forecast report. * [AI] Link balance forecast experimental flag to feedback issue #7669 * docs: add PR release notes * [AI] chore: rerun CI
@@ -25,6 +25,75 @@ export class ReportsPage {
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async goToBalanceForecastPage() {
|
||||
const gridItems = this.pageContent.locator('.react-grid-item');
|
||||
const count = await gridItems.count();
|
||||
|
||||
let targetItem: Locator | null = null;
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const item = gridItems.nth(i);
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
const heading = item.getByRole('heading', { name: /^Balance Forecast/i });
|
||||
if (await heading.isVisible()) {
|
||||
targetItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
await this.page.evaluate(() => {
|
||||
window.scrollTo(0, document.documentElement.scrollHeight);
|
||||
});
|
||||
const refreshedCount = await gridItems.count();
|
||||
for (let i = refreshedCount - 1; i >= 0; i--) {
|
||||
const item = gridItems.nth(i);
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
const heading = item.getByRole('heading', {
|
||||
name: /^Balance Forecast/i,
|
||||
});
|
||||
if (await heading.isVisible()) {
|
||||
targetItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
throw new Error('No Balance Forecast dashboard card found in the grid');
|
||||
}
|
||||
|
||||
const cardNavigateButton = targetItem.getByRole('button', {
|
||||
name: /^Balance Forecast/i,
|
||||
});
|
||||
await Promise.all([
|
||||
this.page.waitForURL(/\/reports\/forecast\//),
|
||||
cardNavigateButton.click(),
|
||||
]);
|
||||
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Monthly' })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async selectForecastGranularity(granularity: string) {
|
||||
await this.pageContent.getByRole('button', { name: 'Monthly' }).click();
|
||||
const option = this.page.getByRole('button', { name: granularity });
|
||||
await option.waitFor({ state: 'visible' });
|
||||
await option.click();
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: granularity })
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
async addWidget(widgetName: string) {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: widgetName }).click();
|
||||
}
|
||||
|
||||
async goToCustomReportPage() {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
|
||||
@@ -42,17 +42,22 @@ export class SettingsPage {
|
||||
}
|
||||
|
||||
async enableExperimentalFeature(featureName: string) {
|
||||
if (await this.advancedSettingsButton.isVisible()) {
|
||||
await this.advancedSettingsButton.click();
|
||||
}
|
||||
await this.advancedSettingsButton.waitFor({
|
||||
state: 'visible',
|
||||
timeout: 2000,
|
||||
});
|
||||
await this.advancedSettingsButton.click();
|
||||
|
||||
if (await this.experimentalSettingsButton.isVisible()) {
|
||||
await this.experimentalSettingsButton.click();
|
||||
}
|
||||
await this.experimentalSettingsButton.waitFor({
|
||||
state: 'visible',
|
||||
timeout: 2000,
|
||||
});
|
||||
await this.experimentalSettingsButton.click();
|
||||
|
||||
const featureCheckbox = this.page.getByRole('checkbox', {
|
||||
name: featureName,
|
||||
});
|
||||
await featureCheckbox.waitFor({ state: 'visible' });
|
||||
if (!(await featureCheckbox.isChecked())) {
|
||||
await featureCheckbox.click();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,28 @@ test.describe.parallel('Reports', () => {
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test.describe('balance forecast', () => {
|
||||
test.beforeEach(async () => {
|
||||
const settingsPage = await navigation.goToSettingsPage();
|
||||
await settingsPage.enableExperimentalFeature('Balance Forecast Report');
|
||||
|
||||
reportsPage = await navigation.goToReportsPage();
|
||||
await reportsPage.waitToLoad();
|
||||
await reportsPage.addWidget('Balance forecast');
|
||||
await reportsPage.goToBalanceForecastPage();
|
||||
});
|
||||
|
||||
test('loads balance forecast report with monthly granularity', async () => {
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('switches to daily granularity', async () => {
|
||||
await reportsPage.selectForecastGranularity('Daily');
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.parallel('custom reports', () => {
|
||||
let customReportPage: CustomReportPage;
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -20,8 +20,10 @@ import { useLocale } from '#hooks/useLocale';
|
||||
import { getLiveRange } from './getLiveRange';
|
||||
import {
|
||||
calculateTimeRange,
|
||||
getFullFutureRange,
|
||||
getFullRange,
|
||||
getLatestRange,
|
||||
getNextRange,
|
||||
validateEnd,
|
||||
validateStart,
|
||||
} from './reportRanges';
|
||||
@@ -31,6 +33,8 @@ type HeaderProps = {
|
||||
end: TimeFrame['end'];
|
||||
mode?: TimeFrame['mode'];
|
||||
show1Month?: boolean;
|
||||
showFutureRange?: boolean;
|
||||
hideModeToggle?: boolean;
|
||||
allMonths: Array<{ name: string; pretty: string }>;
|
||||
earliestTransaction: string;
|
||||
latestTransaction: string;
|
||||
@@ -66,11 +70,200 @@ type HeaderProps = {
|
||||
}
|
||||
);
|
||||
|
||||
type RangePresetProps = {
|
||||
show1Month?: boolean;
|
||||
earliestTransaction: string;
|
||||
latestTransaction: string;
|
||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
|
||||
allMonths: Array<{ name: string; pretty: string }>;
|
||||
onChangeDates: HeaderProps['onChangeDates'];
|
||||
};
|
||||
|
||||
type PastRangePresetsProps = RangePresetProps & {
|
||||
convertToMonth: (
|
||||
start: string,
|
||||
end: string,
|
||||
currentMode: TimeFrame['mode'],
|
||||
mode: TimeFrame['mode'],
|
||||
) => [string, string, TimeFrame['mode']];
|
||||
};
|
||||
|
||||
type FutureRangePresetsProps = Pick<
|
||||
RangePresetProps,
|
||||
'show1Month' | 'latestTransaction' | 'onChangeDates'
|
||||
>;
|
||||
|
||||
function PastRangePresets({
|
||||
show1Month,
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
firstDayOfWeekIdx,
|
||||
allMonths,
|
||||
onChangeDates,
|
||||
convertToMonth,
|
||||
}: PastRangePresetsProps) {
|
||||
return (
|
||||
<>
|
||||
{show1Month && (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(0))}
|
||||
>
|
||||
<Trans>1 month</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(2))}
|
||||
>
|
||||
<Trans>3 months</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(5))}
|
||||
>
|
||||
<Trans>6 months</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(11))}
|
||||
>
|
||||
<Trans>1 year</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Year to date',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
true,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'yearToDate',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Year to date</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Last month',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
false,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'lastMonth',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Last month</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Last year',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
false,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'lastYear',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Last year</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Prior year to date',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
false,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'priorYearToDate',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Prior year to date</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...getFullRange(
|
||||
allMonths[allMonths.length - 1].name,
|
||||
allMonths[0].name,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>All time</Trans>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureRangePresets({
|
||||
show1Month,
|
||||
latestTransaction,
|
||||
onChangeDates,
|
||||
}: FutureRangePresetsProps) {
|
||||
return (
|
||||
<>
|
||||
{show1Month && (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getNextRange(0))}
|
||||
>
|
||||
<Trans>Next month</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="bare" onPress={() => onChangeDates(...getNextRange(2))}>
|
||||
<Trans>Next 3 months</Trans>
|
||||
</Button>
|
||||
<Button variant="bare" onPress={() => onChangeDates(...getNextRange(5))}>
|
||||
<Trans>Next 6 months</Trans>
|
||||
</Button>
|
||||
<Button variant="bare" onPress={() => onChangeDates(...getNextRange(11))}>
|
||||
<Trans>Next year</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getFullFutureRange(latestTransaction))}
|
||||
>
|
||||
<Trans>All future</Trans>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
start,
|
||||
end,
|
||||
mode,
|
||||
show1Month,
|
||||
showFutureRange,
|
||||
hideModeToggle,
|
||||
allMonths,
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
@@ -122,7 +315,7 @@ export function Header({
|
||||
}}
|
||||
>
|
||||
<SpaceBetween gap={isNarrowWidth ? 5 : undefined}>
|
||||
{mode && (
|
||||
{mode && !hideModeToggle && (
|
||||
<Button
|
||||
variant={mode === 'static' ? 'normal' : 'primary'}
|
||||
onPress={() => {
|
||||
@@ -177,122 +370,23 @@ export function Header({
|
||||
</SpaceBetween>
|
||||
|
||||
<SpaceBetween gap={3}>
|
||||
{show1Month && (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(0))}
|
||||
>
|
||||
<Trans>1 month</Trans>
|
||||
</Button>
|
||||
{showFutureRange ? (
|
||||
<FutureRangePresets
|
||||
show1Month={show1Month}
|
||||
latestTransaction={latestTransaction}
|
||||
onChangeDates={onChangeDates}
|
||||
/>
|
||||
) : (
|
||||
<PastRangePresets
|
||||
show1Month={show1Month}
|
||||
earliestTransaction={earliestTransaction}
|
||||
latestTransaction={latestTransaction}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
allMonths={allMonths}
|
||||
onChangeDates={onChangeDates}
|
||||
convertToMonth={convertToMonth}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(2))}
|
||||
>
|
||||
<Trans>3 months</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(5))}
|
||||
>
|
||||
<Trans>6 months</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onChangeDates(...getLatestRange(11))}
|
||||
>
|
||||
<Trans>1 year</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Year to date',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
true,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'yearToDate',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Year to date</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Last month',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
false,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'lastMonth',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Last month</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Last year',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
false,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'lastYear',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Last year</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...convertToMonth(
|
||||
...getLiveRange(
|
||||
'Prior year to date',
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
false,
|
||||
firstDayOfWeekIdx,
|
||||
),
|
||||
'priorYearToDate',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>Prior year to date</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
onChangeDates(
|
||||
...getFullRange(
|
||||
allMonths[allMonths.length - 1].name,
|
||||
allMonths[0].name,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>All time</Trans>
|
||||
</Button>
|
||||
|
||||
{filters && (
|
||||
<FilterButton
|
||||
compact={isNarrowWidth}
|
||||
|
||||
@@ -57,6 +57,7 @@ import './overview.scss';
|
||||
import { DashboardSelector } from './DashboardSelector';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
import { AgeOfMoneyCard } from './reports/AgeOfMoneyCard';
|
||||
import { BalanceForecastCard } from './reports/BalanceForecastCard';
|
||||
import { BudgetAnalysisCard } from './reports/BudgetAnalysisCard';
|
||||
import { CalendarCard } from './reports/CalendarCard';
|
||||
import { CashFlowCard } from './reports/CashFlowCard';
|
||||
@@ -87,6 +88,7 @@ export function Overview({ dashboard }: OverviewProps) {
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
const ageOfMoneyReportEnabled = useFeatureFlag('ageOfMoneyReport');
|
||||
const budgetAnalysisReportEnabled = useFeatureFlag('budgetAnalysisReport');
|
||||
const balanceForecastReportEnabled = useFeatureFlag('balanceForecastReport');
|
||||
|
||||
const formulaMode = useFeatureFlag('formulaMode');
|
||||
|
||||
@@ -597,6 +599,14 @@ export function Overview({ dashboard }: OverviewProps) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(balanceForecastReportEnabled
|
||||
? [
|
||||
{
|
||||
name: 'balance-forecast-card' as const,
|
||||
text: t('Balance forecast'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'markdown-card' as const,
|
||||
text: t('Text widget'),
|
||||
@@ -858,6 +868,21 @@ export function Overview({ dashboard }: OverviewProps) {
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : widget.type === 'balance-forecast-card' &&
|
||||
balanceForecastReportEnabled ? (
|
||||
<BalanceForecastCard
|
||||
widgetId={item.i}
|
||||
isEditing={isEditing}
|
||||
accounts={accounts}
|
||||
meta={widget.meta}
|
||||
onMetaChange={newMeta =>
|
||||
onMetaChange(item, newMeta)
|
||||
}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : widget.type === 'markdown-card' ? (
|
||||
<MarkdownCard
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Route, Routes } from 'react-router';
|
||||
import { useFeatureFlag } from '#hooks/useFeatureFlag';
|
||||
|
||||
import { AgeOfMoney } from './reports/AgeOfMoney';
|
||||
import { BalanceForecast } from './reports/BalanceForecast';
|
||||
import { BudgetAnalysis } from './reports/BudgetAnalysis';
|
||||
import { Calendar } from './reports/Calendar';
|
||||
import { CashFlow } from './reports/CashFlow';
|
||||
@@ -18,6 +19,7 @@ import { ReportsDashboardRouter } from './ReportsDashboardRouter';
|
||||
|
||||
export function ReportRouter() {
|
||||
const ageOfMoneyReportEnabled = useFeatureFlag('ageOfMoneyReport');
|
||||
const balanceForecastReportEnabled = useFeatureFlag('balanceForecastReport');
|
||||
const budgetAnalysisReportEnabled = useFeatureFlag('budgetAnalysisReport');
|
||||
const sankeyReportEnabled = useFeatureFlag('sankeyReport');
|
||||
|
||||
@@ -53,6 +55,12 @@ export function ReportRouter() {
|
||||
<Route path="/calendar/:id" element={<Calendar />} />
|
||||
<Route path="/formula" element={<Formula />} />
|
||||
<Route path="/formula/:id" element={<Formula />} />
|
||||
{balanceForecastReportEnabled && (
|
||||
<>
|
||||
<Route path="/forecast" element={<BalanceForecast />} />
|
||||
<Route path="/forecast/:id" element={<BalanceForecast />} />
|
||||
</>
|
||||
)}
|
||||
{sankeyReportEnabled && (
|
||||
<>
|
||||
<Route path="/sankey" element={<Sankey />} />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getDashboardWidgetItems } from './getDashboardWidgetItems';
|
||||
|
||||
function getNames(items: ReturnType<typeof getDashboardWidgetItems>) {
|
||||
return items.filter(item => item !== Menu.line).map(item => item.name);
|
||||
}
|
||||
|
||||
describe('getDashboardWidgetItems', () => {
|
||||
it('includes the balance forecast card only when the flag is enabled', () => {
|
||||
const disabled = getDashboardWidgetItems({
|
||||
t: value => value,
|
||||
customReports: [],
|
||||
formulaMode: false,
|
||||
crossoverReportEnabled: false,
|
||||
budgetAnalysisReportEnabled: false,
|
||||
balanceForecastReportEnabled: false,
|
||||
});
|
||||
|
||||
const enabled = getDashboardWidgetItems({
|
||||
t: value => value,
|
||||
customReports: [],
|
||||
formulaMode: false,
|
||||
crossoverReportEnabled: false,
|
||||
budgetAnalysisReportEnabled: false,
|
||||
balanceForecastReportEnabled: true,
|
||||
});
|
||||
|
||||
expect(getNames(disabled)).not.toContain('balance-forecast-card');
|
||||
expect(getNames(enabled)).toContain('balance-forecast-card');
|
||||
});
|
||||
|
||||
it('keeps custom report entries after a divider', () => {
|
||||
const items = getDashboardWidgetItems({
|
||||
t: value => value,
|
||||
customReports: [{ id: 'abc', name: 'Custom Budget Review' }],
|
||||
formulaMode: false,
|
||||
crossoverReportEnabled: false,
|
||||
budgetAnalysisReportEnabled: false,
|
||||
balanceForecastReportEnabled: false,
|
||||
});
|
||||
|
||||
expect(items).toContain(Menu.line);
|
||||
expect(getNames(items)).toContain('custom-report-abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import type { MenuItem } from '@actual-app/components/menu';
|
||||
|
||||
type CustomReportSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
type GetDashboardWidgetItemsParams = {
|
||||
t: Translate;
|
||||
customReports: CustomReportSummary[];
|
||||
formulaMode: boolean;
|
||||
crossoverReportEnabled: boolean;
|
||||
budgetAnalysisReportEnabled: boolean;
|
||||
balanceForecastReportEnabled: boolean;
|
||||
};
|
||||
|
||||
type DashboardWidgetMenuName =
|
||||
| 'balance-forecast-card'
|
||||
| 'budget-analysis-card'
|
||||
| 'calendar-card'
|
||||
| 'cash-flow-card'
|
||||
| 'crossover-card'
|
||||
| 'custom-report'
|
||||
| 'formula-card'
|
||||
| 'markdown-card'
|
||||
| 'net-worth-card'
|
||||
| 'spending-card'
|
||||
| 'summary-card'
|
||||
| `custom-report-${string}`;
|
||||
|
||||
function findItemIndex(
|
||||
items: MenuItem<DashboardWidgetMenuName>[],
|
||||
name: string,
|
||||
) {
|
||||
const index = items.findIndex(
|
||||
item => item !== Menu.line && item.name === name,
|
||||
);
|
||||
return index === -1 ? items.length : index;
|
||||
}
|
||||
|
||||
export function getDashboardWidgetItems({
|
||||
t,
|
||||
customReports,
|
||||
formulaMode,
|
||||
crossoverReportEnabled,
|
||||
budgetAnalysisReportEnabled,
|
||||
balanceForecastReportEnabled,
|
||||
}: GetDashboardWidgetItemsParams): MenuItem<DashboardWidgetMenuName>[] {
|
||||
const items: MenuItem<DashboardWidgetMenuName>[] = [
|
||||
{
|
||||
name: 'cash-flow-card',
|
||||
text: t('Cash flow graph'),
|
||||
},
|
||||
{
|
||||
name: 'net-worth-card',
|
||||
text: t('Net worth graph'),
|
||||
},
|
||||
{
|
||||
name: 'spending-card',
|
||||
text: t('Spending analysis'),
|
||||
},
|
||||
{
|
||||
name: 'markdown-card',
|
||||
text: t('Text widget'),
|
||||
},
|
||||
{
|
||||
name: 'summary-card',
|
||||
text: t('Summary card'),
|
||||
},
|
||||
{
|
||||
name: 'calendar-card',
|
||||
text: t('Calendar card'),
|
||||
},
|
||||
{
|
||||
name: 'custom-report',
|
||||
text: t('New custom report'),
|
||||
},
|
||||
];
|
||||
|
||||
if (crossoverReportEnabled) {
|
||||
items.splice(2, 0, {
|
||||
name: 'crossover-card',
|
||||
text: t('Crossover point'),
|
||||
});
|
||||
}
|
||||
|
||||
if (budgetAnalysisReportEnabled) {
|
||||
items.splice(findItemIndex(items, 'markdown-card'), 0, {
|
||||
name: 'budget-analysis-card',
|
||||
text: t('Budget analysis'),
|
||||
});
|
||||
}
|
||||
|
||||
if (balanceForecastReportEnabled) {
|
||||
items.splice(findItemIndex(items, 'markdown-card'), 0, {
|
||||
name: 'balance-forecast-card',
|
||||
text: t('Balance forecast'),
|
||||
});
|
||||
}
|
||||
|
||||
if (formulaMode) {
|
||||
items.splice(findItemIndex(items, 'custom-report'), 0, {
|
||||
name: 'formula-card',
|
||||
text: t('Formula card'),
|
||||
});
|
||||
}
|
||||
|
||||
if (customReports.length) {
|
||||
items.push(Menu.line);
|
||||
items.push(
|
||||
...customReports.map(report => ({
|
||||
name: `custom-report-${report.id}`,
|
||||
text: report.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -174,6 +174,27 @@ export function getLatestRange(offset: number) {
|
||||
return [start, end, 'sliding-window'] as const;
|
||||
}
|
||||
|
||||
export function getNextRange(offset: number) {
|
||||
const start = monthUtils.currentMonth();
|
||||
const end = monthUtils.addMonths(start, offset);
|
||||
|
||||
return [start, end, 'static'] as const;
|
||||
}
|
||||
|
||||
export function getFullFutureRange(latestMonth?: string) {
|
||||
const start = monthUtils.currentMonth();
|
||||
const defaultEnd = monthUtils.addMonths(start, 24);
|
||||
const latestMonthValue = latestMonth
|
||||
? monthUtils.monthFromDate(latestMonth)
|
||||
: undefined;
|
||||
const end =
|
||||
latestMonthValue && monthUtils.isAfter(latestMonthValue, start)
|
||||
? latestMonthValue
|
||||
: defaultEnd;
|
||||
|
||||
return [start, end, 'static'] as const;
|
||||
}
|
||||
|
||||
export function calculateTimeRange(
|
||||
timeFrame?: Partial<TimeFrame>,
|
||||
defaultTimeFrame?: TimeFrame,
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
// oxlint-disable typescript-paths/absolute-parent-import
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Select } from '@actual-app/components/select';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
import type {
|
||||
BalanceForecastWidget,
|
||||
RuleConditionEntity,
|
||||
TimeFrame,
|
||||
} from '@actual-app/core/types/models';
|
||||
import * as d from 'date-fns';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { Page, PageHeader } from '#components/Page';
|
||||
import { PrivacyFilter } from '#components/PrivacyFilter';
|
||||
import { Container } from '#components/reports/Container';
|
||||
import { getCustomTick } from '#components/reports/getCustomTick';
|
||||
import { Header } from '#components/reports/Header';
|
||||
import { LoadingIndicator } from '#components/reports/LoadingIndicator';
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
import { useBalanceForecast } from '#hooks/useBalanceForecast';
|
||||
import { useDashboardWidget } from '#hooks/useDashboardWidget';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { useLocale } from '#hooks/useLocale';
|
||||
import { usePrivacyMode } from '#hooks/usePrivacyMode';
|
||||
import { useRuleConditionFilters } from '#hooks/useRuleConditionFilters';
|
||||
import { addNotification } from '#notifications/notificationsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
import {
|
||||
buildBalanceForecastChartData,
|
||||
countForecastScheduledOccurrences,
|
||||
} from './balanceForecastChartData';
|
||||
|
||||
export function BalanceForecast() {
|
||||
const params = useParams();
|
||||
const { data: widget, isLoading } = useDashboardWidget<BalanceForecastWidget>(
|
||||
{
|
||||
id: params.id,
|
||||
type: 'balance-forecast-card',
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <BalanceForecastInner key={widget?.id ?? 'new'} widget={widget} />;
|
||||
}
|
||||
|
||||
type BalanceForecastInnerProps = {
|
||||
widget?: BalanceForecastWidget;
|
||||
};
|
||||
|
||||
function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const privacyMode = usePrivacyMode();
|
||||
const locale = useLocale();
|
||||
const dispatch = useDispatch();
|
||||
const { data: accounts = [] } = useAccounts();
|
||||
|
||||
const {
|
||||
conditions,
|
||||
conditionsOp,
|
||||
onApply: onApplyFilter,
|
||||
onDelete: onDeleteFilter,
|
||||
onUpdate: onUpdateFilter,
|
||||
onConditionsOpChange,
|
||||
} = useRuleConditionFilters<RuleConditionEntity>(
|
||||
widget?.meta?.conditions,
|
||||
widget?.meta?.conditionsOp,
|
||||
);
|
||||
|
||||
const [allMonths, setAllMonths] = useState<Array<{
|
||||
name: string;
|
||||
pretty: string;
|
||||
}> | null>(null);
|
||||
|
||||
const currentMonth = monthUtils.currentMonth();
|
||||
const [start, setStart] = useState(
|
||||
widget?.meta?.timeFrame?.start ?? widget?.meta?.startDate ?? currentMonth,
|
||||
);
|
||||
const [end, setEnd] = useState(
|
||||
widget?.meta?.timeFrame?.end ??
|
||||
widget?.meta?.endDate ??
|
||||
monthUtils.addMonths(currentMonth, 11),
|
||||
);
|
||||
const [mode, setMode] = useState<TimeFrame['mode']>(
|
||||
widget?.meta?.timeFrame?.mode ?? 'static',
|
||||
);
|
||||
const [granularity, setGranularity] = useState<'Daily' | 'Monthly'>(
|
||||
widget?.meta?.granularity ?? 'Monthly',
|
||||
);
|
||||
const selectedAccountIds = useMemo(
|
||||
() => widget?.meta?.accounts ?? accounts.map(a => a.id),
|
||||
[accounts, widget?.meta?.accounts],
|
||||
);
|
||||
const hasMonthOptions = allMonths != null;
|
||||
const startDate = start + '-01';
|
||||
const endDate = monthUtils.lastDayOfMonth(end);
|
||||
const {
|
||||
data: forecastData,
|
||||
error,
|
||||
isFetching,
|
||||
isPlaceholderData,
|
||||
isPending: isLoading,
|
||||
} = useBalanceForecast({
|
||||
accountIds: widget ? selectedAccountIds : undefined,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
startDate,
|
||||
endDate,
|
||||
includeAccountlessSchedules: widget?.meta?.accounts === undefined,
|
||||
enabled: hasMonthOptions,
|
||||
});
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: error
|
||||
? t('Failed to load forecast')
|
||||
: null;
|
||||
const normalizedForecastData = forecastData ?? null;
|
||||
const hasFilters = conditions.length > 0;
|
||||
const committedChartRange = useRef({ start, end });
|
||||
|
||||
async function onSaveWidget() {
|
||||
if (!widget) {
|
||||
throw new Error('No widget that could be saved.');
|
||||
}
|
||||
|
||||
await send('dashboard-update-widget', {
|
||||
id: widget.id,
|
||||
meta: {
|
||||
...widget.meta,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
granularity,
|
||||
timeFrame: {
|
||||
start,
|
||||
end,
|
||||
mode,
|
||||
},
|
||||
},
|
||||
});
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Dashboard widget successfully saved.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const earliestTransaction =
|
||||
!allMonths || allMonths.length === 0
|
||||
? currentMonth
|
||||
: (allMonths[allMonths.length - 1]?.name ?? currentMonth);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadMonths() {
|
||||
const currentMonthLocal = monthUtils.currentMonth();
|
||||
|
||||
const earliestTransactionResponse = await send(
|
||||
'get-earliest-transaction',
|
||||
);
|
||||
|
||||
const earliestMonth = earliestTransactionResponse
|
||||
? monthUtils.monthFromDate(d.parseISO(earliestTransactionResponse.date))
|
||||
: monthUtils.subMonths(currentMonthLocal, 12);
|
||||
|
||||
let futureEndMonth = monthUtils.addMonths(currentMonthLocal, 24);
|
||||
if (end > futureEndMonth) {
|
||||
futureEndMonth = end;
|
||||
}
|
||||
if (normalizedForecastData?.forecastEndDate) {
|
||||
const forecastEndMonth = monthUtils.monthFromDate(
|
||||
normalizedForecastData.forecastEndDate,
|
||||
);
|
||||
if (forecastEndMonth > futureEndMonth) {
|
||||
futureEndMonth = forecastEndMonth;
|
||||
}
|
||||
}
|
||||
|
||||
const allMonthsArray = monthUtils
|
||||
.rangeInclusive(earliestMonth, futureEndMonth)
|
||||
.map(month => ({
|
||||
name: month,
|
||||
pretty: monthUtils.format(month, 'MMMM, yyyy', locale),
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAllMonths(prev => {
|
||||
if (
|
||||
prev &&
|
||||
prev.length === allMonthsArray.length &&
|
||||
prev.every(
|
||||
(p, i) =>
|
||||
p.name === allMonthsArray[i]?.name &&
|
||||
p.pretty === allMonthsArray[i]?.pretty,
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return allMonthsArray;
|
||||
});
|
||||
}
|
||||
void loadMonths();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale, end, normalizedForecastData?.forecastEndDate]);
|
||||
|
||||
const onChangeDates = (
|
||||
newStart: string,
|
||||
newEnd: string,
|
||||
newMode?: TimeFrame['mode'],
|
||||
) => {
|
||||
setStart(newStart);
|
||||
setEnd(newEnd);
|
||||
if (newMode) {
|
||||
setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
const chartRange = isPlaceholderData
|
||||
? committedChartRange.current
|
||||
: { start, end };
|
||||
useEffect(() => {
|
||||
if (normalizedForecastData && !isPlaceholderData) {
|
||||
committedChartRange.current = { start, end };
|
||||
}
|
||||
}, [end, isPlaceholderData, normalizedForecastData, start]);
|
||||
|
||||
const chartData = buildBalanceForecastChartData({
|
||||
forecastData: normalizedForecastData,
|
||||
start: chartRange.start,
|
||||
end: chartRange.end,
|
||||
granularity,
|
||||
});
|
||||
const isUpdatingForecast = isFetching && isPlaceholderData;
|
||||
|
||||
const scheduledOccurrenceCount = countForecastScheduledOccurrences(
|
||||
normalizedForecastData,
|
||||
);
|
||||
|
||||
if (!allMonths) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (isLoading && !normalizedForecastData) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const lowestPoint = forecastData?.lowestBalance;
|
||||
const hasNegativeBalance = chartData.some(d => d.balance < 0);
|
||||
const todayReferenceDate =
|
||||
granularity === 'Daily'
|
||||
? monthUtils.currentDay()
|
||||
: monthUtils.currentMonth();
|
||||
const showsTodayReferenceLine = chartData.some(
|
||||
dataPoint => dataPoint.date === todayReferenceDate,
|
||||
);
|
||||
|
||||
return (
|
||||
<Page
|
||||
header={<PageHeader title={<Trans>Balance Forecast</Trans>} />}
|
||||
padding={0}
|
||||
>
|
||||
<Header
|
||||
allMonths={allMonths}
|
||||
start={start}
|
||||
end={end}
|
||||
earliestTransaction={earliestTransaction}
|
||||
latestTransaction={
|
||||
forecastData?.forecastEndDate
|
||||
? monthUtils.monthFromDate(forecastData.forecastEndDate)
|
||||
: (allMonths[0]?.name ?? monthUtils.addMonths(currentMonth, 24))
|
||||
}
|
||||
mode={mode}
|
||||
onChangeDates={onChangeDates}
|
||||
filters={conditions}
|
||||
onApply={onApplyFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
conditionsOp={conditionsOp}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
showFutureRange
|
||||
hideModeToggle
|
||||
inlineContent={
|
||||
<Select
|
||||
value={granularity}
|
||||
onChange={setGranularity}
|
||||
options={[
|
||||
['Monthly', t('Monthly')],
|
||||
['Daily', t('Daily')],
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{widget && (
|
||||
<Button variant="primary" onPress={onSaveWidget}>
|
||||
<Trans>Save widget</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
padding: 20,
|
||||
paddingTop: 0,
|
||||
flex: '1 0 auto',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{errorMessage ? (
|
||||
<div style={{ color: theme.errorText, marginBottom: 20 }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : lowestPoint ? (
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
paddingTop: 20,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
...styles.largeText,
|
||||
fontWeight: 400,
|
||||
marginBottom: 5,
|
||||
color:
|
||||
lowestPoint.balance < 0 ? theme.errorText : theme.pageText,
|
||||
}}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{format(lowestPoint.balance, 'financial')}
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
<View style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Lowest Point</Trans>: {lowestPoint.date}
|
||||
{lowestPoint.accountName && <> ({lowestPoint.accountName})</>}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 300 }}>
|
||||
{errorMessage ? null : chartData.length > 0 ? (
|
||||
<>
|
||||
<Container>
|
||||
{(width, height) => (
|
||||
<ResponsiveContainer>
|
||||
<LineChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, left: 5, bottom: 10 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: theme.pageText }}
|
||||
tickLine={{ stroke: theme.pageText }}
|
||||
interval={
|
||||
granularity === 'Daily'
|
||||
? Math.ceil(chartData.length / 10)
|
||||
: 0
|
||||
}
|
||||
tickFormatter={value => {
|
||||
if (granularity === 'Daily') {
|
||||
return d.format(
|
||||
monthUtils.parseDate(value),
|
||||
'MMM d',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
domain={['auto', 'auto']}
|
||||
tickFormatter={value =>
|
||||
getCustomTick(
|
||||
format(value, 'financial-no-decimals'),
|
||||
privacyMode,
|
||||
)
|
||||
}
|
||||
tick={{ fill: theme.pageText }}
|
||||
tickLine={{ stroke: theme.pageText }}
|
||||
tickSize={0}
|
||||
/>
|
||||
<Tooltip
|
||||
isAnimationActive={false}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
|
||||
backgroundColor: theme.menuBackground,
|
||||
color: theme.menuItemText,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<strong>{payload[0].payload.date}</strong>
|
||||
</div>
|
||||
<div>
|
||||
{format(
|
||||
payload[0].value as number,
|
||||
'financial',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
{showsTodayReferenceLine && (
|
||||
<ReferenceLine
|
||||
x={todayReferenceDate}
|
||||
stroke={theme.noticeText}
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: t('Today'),
|
||||
fill: theme.noticeText,
|
||||
fontSize: 12,
|
||||
position: 'insideTop',
|
||||
offset: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
stroke={
|
||||
hasNegativeBalance
|
||||
? theme.errorText
|
||||
: theme.noticeText
|
||||
}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
opacity={isUpdatingForecast ? 0.45 : 1}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Container>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 12,
|
||||
color: theme.pageTextLight,
|
||||
}}
|
||||
>
|
||||
{scheduledOccurrenceCount === 0 ? (
|
||||
<Trans>
|
||||
This range shows posted transactions only; no scheduled
|
||||
occurrences fall in it.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans count={scheduledOccurrenceCount}>
|
||||
{{ count: scheduledOccurrenceCount }} scheduled transactions
|
||||
included in this date range
|
||||
</Trans>
|
||||
)}
|
||||
{isUpdatingForecast ? (
|
||||
<>
|
||||
{' '}
|
||||
<Trans>Updating...</Trans>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: theme.pageTextLight }}>
|
||||
<Trans>
|
||||
No transactions are included in this report. Adjust your
|
||||
filters, accounts, or date range to see a balance projection.
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!errorMessage && (
|
||||
<div
|
||||
style={{ marginTop: 20, fontSize: 12, color: theme.pageTextLight }}
|
||||
>
|
||||
{hasFilters ? (
|
||||
<Trans>
|
||||
This forecast shows the running total of matching posted
|
||||
transactions, plus upcoming scheduled transactions in the
|
||||
future.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This forecast shows your running balance from posted
|
||||
transactions, plus upcoming scheduled transactions in the
|
||||
future.
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// oxlint-disable typescript-paths/absolute-parent-import
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Block } from '@actual-app/components/block';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BalanceForecastWidget,
|
||||
} from '@actual-app/core/types/models';
|
||||
import {
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from 'recharts';
|
||||
|
||||
import { PrivacyFilter } from '#components/PrivacyFilter';
|
||||
import { Container } from '#components/reports/Container';
|
||||
import { DateRange } from '#components/reports/DateRange';
|
||||
import { LoadingIndicator } from '#components/reports/LoadingIndicator';
|
||||
import { ReportCard } from '#components/reports/ReportCard';
|
||||
import { ReportCardName } from '#components/reports/ReportCardName';
|
||||
import { calculateTimeRange } from '#components/reports/reportRanges';
|
||||
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
|
||||
import { useBalanceForecast } from '#hooks/useBalanceForecast';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
|
||||
import {
|
||||
buildBalanceForecastChartData,
|
||||
countForecastScheduledOccurrences,
|
||||
} from './balanceForecastChartData';
|
||||
|
||||
type BalanceForecastCardProps = {
|
||||
widgetId: string;
|
||||
isEditing?: boolean;
|
||||
accounts: AccountEntity[];
|
||||
meta?: BalanceForecastWidget['meta'];
|
||||
onMetaChange: (newMeta: BalanceForecastWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function BalanceForecastCard({
|
||||
widgetId,
|
||||
isEditing,
|
||||
accounts,
|
||||
meta,
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: BalanceForecastCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useDashboardWidgetCopyMenu(onCopy);
|
||||
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||
|
||||
const defaultTimeFrame = {
|
||||
start: monthUtils.currentMonth(),
|
||||
end: monthUtils.addMonths(monthUtils.currentMonth(), 11),
|
||||
mode: 'static' as const,
|
||||
};
|
||||
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, defaultTimeFrame);
|
||||
|
||||
const selectedAccountIds = useMemo(
|
||||
() => meta?.accounts ?? accounts.map(a => a.id),
|
||||
[accounts, meta?.accounts],
|
||||
);
|
||||
|
||||
const startDate = start + '-01';
|
||||
const endDate = monthUtils.lastDayOfMonth(end);
|
||||
|
||||
const {
|
||||
data: forecastData,
|
||||
error,
|
||||
isFetching,
|
||||
isPlaceholderData,
|
||||
isPending: isLoading,
|
||||
} = useBalanceForecast({
|
||||
accountIds: selectedAccountIds,
|
||||
conditions: meta?.conditions,
|
||||
conditionsOp: meta?.conditionsOp,
|
||||
startDate,
|
||||
endDate,
|
||||
includeAccountlessSchedules: meta?.accounts === undefined,
|
||||
});
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: error
|
||||
? t('Failed to load forecast')
|
||||
: null;
|
||||
const normalizedForecastData = forecastData ?? null;
|
||||
const committedChartRange = useRef({ start, end });
|
||||
|
||||
const onCardHover = () => setIsCardHovered(true);
|
||||
const onCardHoverEnd = () => setIsCardHovered(false);
|
||||
|
||||
const lowestPoint = forecastData?.lowestBalance;
|
||||
const hasNegative = lowestPoint && lowestPoint.balance < 0;
|
||||
|
||||
const chartRange = isPlaceholderData
|
||||
? committedChartRange.current
|
||||
: { start, end };
|
||||
useEffect(() => {
|
||||
if (normalizedForecastData && !isPlaceholderData) {
|
||||
committedChartRange.current = { start, end };
|
||||
}
|
||||
}, [end, isPlaceholderData, normalizedForecastData, start]);
|
||||
|
||||
const chartData = buildBalanceForecastChartData({
|
||||
forecastData: normalizedForecastData,
|
||||
start: chartRange.start,
|
||||
end: chartRange.end,
|
||||
granularity: 'Monthly',
|
||||
});
|
||||
const isUpdatingForecast = isFetching && isPlaceholderData;
|
||||
const todayReferenceDate = monthUtils.currentMonth();
|
||||
const showsTodayReferenceLine = chartData.some(
|
||||
dataPoint => dataPoint.date === todayReferenceDate,
|
||||
);
|
||||
|
||||
const scheduledOccurrenceCount = countForecastScheduledOccurrences(
|
||||
normalizedForecastData,
|
||||
);
|
||||
const hasFilters = (meta?.conditions?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
disableClick={nameMenuOpen}
|
||||
to={`/reports/forecast/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
name: 'rename',
|
||||
text: t('Rename'),
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
break;
|
||||
case 'remove':
|
||||
onRemove();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized selection: ${item}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flex: 1 }}
|
||||
onPointerEnter={onCardHover}
|
||||
onPointerLeave={onCardHoverEnd}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', padding: 20 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ReportCardName
|
||||
name={meta?.name || t('Balance Forecast')}
|
||||
isEditing={nameMenuOpen}
|
||||
onChange={newName => {
|
||||
onMetaChange({
|
||||
...meta,
|
||||
name: newName,
|
||||
});
|
||||
setNameMenuOpen(false);
|
||||
}}
|
||||
onClose={() => setNameMenuOpen(false)}
|
||||
/>
|
||||
<DateRange start={start} end={end} />
|
||||
</View>
|
||||
{lowestPoint && (
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Block
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
fontWeight: 500,
|
||||
marginBottom: 5,
|
||||
color: hasNegative ? theme.errorText : theme.pageText,
|
||||
}}
|
||||
>
|
||||
<PrivacyFilter activationFilters={[!isCardHovered]}>
|
||||
<Trans>Lowest</Trans>:{' '}
|
||||
{format(lowestPoint.balance, 'financial')}
|
||||
</PrivacyFilter>
|
||||
</Block>
|
||||
<PrivacyFilter activationFilters={[!isCardHovered]}>
|
||||
<Block style={{ fontSize: 12, color: theme.pageTextLight }}>
|
||||
{lowestPoint.date}
|
||||
</Block>
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isLoading && !normalizedForecastData ? (
|
||||
<LoadingIndicator />
|
||||
) : errorMessage ? (
|
||||
<View style={{ height: 120, padding: 20 }}>
|
||||
<Block
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.errorText,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</Block>
|
||||
</View>
|
||||
) : forecastData && forecastData.dataPoints.length > 0 ? (
|
||||
<>
|
||||
<Container style={{ height: 'auto', flex: 1 }}>
|
||||
{(width, height) => (
|
||||
<ResponsiveContainer>
|
||||
<LineChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
>
|
||||
<Tooltip
|
||||
isAnimationActive={false}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
|
||||
backgroundColor: theme.menuBackground,
|
||||
color: theme.menuItemText,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<strong>{payload[0].payload.date}</strong>
|
||||
</div>
|
||||
<div>
|
||||
{format(
|
||||
payload[0].value as number,
|
||||
'financial',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
{showsTodayReferenceLine && (
|
||||
<ReferenceLine
|
||||
x={todayReferenceDate}
|
||||
stroke={theme.noticeText}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
stroke={hasNegative ? theme.errorText : theme.noticeText}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
opacity={isUpdatingForecast ? 0.45 : 1}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Container>
|
||||
<Block
|
||||
style={{
|
||||
padding: '0 20px 16px',
|
||||
fontSize: 12,
|
||||
color: theme.pageTextLight,
|
||||
}}
|
||||
>
|
||||
{scheduledOccurrenceCount === 0 ? (
|
||||
hasFilters ? (
|
||||
<Trans>
|
||||
Filtered running total only; no scheduled occurrences in
|
||||
this range
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>No scheduled transactions in this range</Trans>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Trans count={scheduledOccurrenceCount}>
|
||||
{{ count: scheduledOccurrenceCount }} scheduled transactions
|
||||
included
|
||||
</Trans>
|
||||
{hasFilters ? (
|
||||
<>
|
||||
{' '}
|
||||
<Trans>(filtered running total)</Trans>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{isUpdatingForecast ? (
|
||||
<>
|
||||
{' '}
|
||||
<Trans>Updating...</Trans>
|
||||
</>
|
||||
) : null}
|
||||
</Block>
|
||||
</>
|
||||
) : (
|
||||
<View style={{ height: 120, padding: 20 }}>
|
||||
<Block
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextLight,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
No transactions are included in this report. Adjust your
|
||||
filters, accounts, or date range to see a balance projection.
|
||||
</Trans>
|
||||
</Block>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBalanceForecastChartData,
|
||||
countForecastScheduledOccurrences,
|
||||
} from './balanceForecastChartData';
|
||||
|
||||
describe('buildBalanceForecastChartData', () => {
|
||||
it('combines balances across accounts for monthly data', () => {
|
||||
const chartData = buildBalanceForecastChartData({
|
||||
forecastData: {
|
||||
dataPoints: [
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 1000,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 500,
|
||||
accountId: 'savings',
|
||||
accountName: 'Savings',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-04-01',
|
||||
balance: 900,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-04-01',
|
||||
balance: 700,
|
||||
accountId: 'savings',
|
||||
accountName: 'Savings',
|
||||
transactions: [],
|
||||
},
|
||||
],
|
||||
lowestBalance: {
|
||||
date: '2024-03-01',
|
||||
balance: 1500,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate: '2024-03-01',
|
||||
forecastEndDate: '2024-04-30',
|
||||
},
|
||||
start: '2024-03',
|
||||
end: '2024-04',
|
||||
granularity: 'Monthly',
|
||||
});
|
||||
|
||||
expect(chartData).toEqual([
|
||||
{ date: '2024-03', balance: 1500 },
|
||||
{ date: '2024-04', balance: 1600 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries monthly balances through the full end month for daily data', () => {
|
||||
const chartData = buildBalanceForecastChartData({
|
||||
forecastData: {
|
||||
dataPoints: [
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 1000,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-04-01',
|
||||
balance: 1200,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
],
|
||||
lowestBalance: {
|
||||
date: '2024-03-01',
|
||||
balance: 1000,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate: '2024-03-01',
|
||||
forecastEndDate: '2024-04-30',
|
||||
},
|
||||
start: '2024-03',
|
||||
end: '2024-04',
|
||||
granularity: 'Daily',
|
||||
});
|
||||
|
||||
expect(chartData[0]).toEqual({ date: '2024-03-01', balance: 1000 });
|
||||
expect(chartData[30]).toEqual({ date: '2024-03-31', balance: 1000 });
|
||||
expect(chartData[31]).toEqual({ date: '2024-04-01', balance: 1200 });
|
||||
expect(chartData.at(-1)).toEqual({ date: '2024-04-30', balance: 1200 });
|
||||
expect(chartData).toHaveLength(61);
|
||||
});
|
||||
|
||||
it('uses the latest same-day balance for each account', () => {
|
||||
const chartData = buildBalanceForecastChartData({
|
||||
forecastData: {
|
||||
dataPoints: [
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 1000,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 850,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 500,
|
||||
accountId: 'savings',
|
||||
accountName: 'Savings',
|
||||
transactions: [],
|
||||
},
|
||||
],
|
||||
lowestBalance: {
|
||||
date: '2024-03-01',
|
||||
balance: 1350,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate: '2024-03-01',
|
||||
forecastEndDate: '2024-03-31',
|
||||
},
|
||||
start: '2024-03',
|
||||
end: '2024-03',
|
||||
granularity: 'Daily',
|
||||
});
|
||||
|
||||
expect(chartData[0]).toEqual({ date: '2024-03-01', balance: 1350 });
|
||||
expect(chartData.at(-1)).toEqual({ date: '2024-03-31', balance: 1350 });
|
||||
});
|
||||
|
||||
it('uses the latest balance in each month for monthly data', () => {
|
||||
const chartData = buildBalanceForecastChartData({
|
||||
forecastData: {
|
||||
dataPoints: [
|
||||
{
|
||||
date: '2024-03-01',
|
||||
balance: 1000,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-03-31',
|
||||
balance: 900,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-04-01',
|
||||
balance: 950,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
{
|
||||
date: '2024-04-30',
|
||||
balance: 1100,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [],
|
||||
},
|
||||
],
|
||||
lowestBalance: {
|
||||
date: '2024-03-31',
|
||||
balance: 900,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate: '2024-03-01',
|
||||
forecastEndDate: '2024-04-30',
|
||||
},
|
||||
start: '2024-03',
|
||||
end: '2024-04',
|
||||
granularity: 'Monthly',
|
||||
});
|
||||
|
||||
expect(chartData).toEqual([
|
||||
{ date: '2024-03', balance: 900 },
|
||||
{ date: '2024-04', balance: 1100 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countForecastScheduledOccurrences', () => {
|
||||
it('counts a transfer schedule once across both account legs', () => {
|
||||
const count = countForecastScheduledOccurrences({
|
||||
dataPoints: [
|
||||
{
|
||||
date: '2024-03-20',
|
||||
balance: -250,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [
|
||||
{
|
||||
amount: -250,
|
||||
payee: 'Transfer to savings',
|
||||
scheduleId: 'schedule-transfer',
|
||||
scheduleName: 'Transfer to savings',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2024-03-20',
|
||||
balance: 250,
|
||||
accountId: 'savings',
|
||||
accountName: 'Savings',
|
||||
transactions: [
|
||||
{
|
||||
amount: 250,
|
||||
payee: 'Transfer from checking',
|
||||
scheduleId: 'schedule-transfer',
|
||||
scheduleName: 'Transfer to savings',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2024-04-20',
|
||||
balance: 0,
|
||||
accountId: 'checking',
|
||||
accountName: 'Checking',
|
||||
transactions: [
|
||||
{
|
||||
amount: -250,
|
||||
payee: 'Transfer to savings',
|
||||
scheduleId: 'schedule-transfer',
|
||||
scheduleName: 'Transfer to savings',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
lowestBalance: {
|
||||
date: '2024-03-20',
|
||||
balance: 0,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate: '2024-03-01',
|
||||
forecastEndDate: '2024-04-30',
|
||||
});
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
import type { ForecastResult } from '@actual-app/core/types/models/forecast';
|
||||
import * as d from 'date-fns';
|
||||
|
||||
type Granularity = 'Daily' | 'Monthly';
|
||||
type ChartDataPoint = { date: string; balance: number };
|
||||
|
||||
function getCombinedBalanceByDate(forecastData: ForecastResult) {
|
||||
const balancesByDateAndAccount: Record<string, Record<string, number>> = {};
|
||||
|
||||
for (const dataPoint of forecastData.dataPoints) {
|
||||
if (!balancesByDateAndAccount[dataPoint.date]) {
|
||||
balancesByDateAndAccount[dataPoint.date] = {};
|
||||
}
|
||||
|
||||
balancesByDateAndAccount[dataPoint.date][dataPoint.accountId] =
|
||||
dataPoint.balance;
|
||||
}
|
||||
|
||||
const combinedBalanceByDate: Record<string, number> = {};
|
||||
for (const [date, balancesByAccount] of Object.entries(
|
||||
balancesByDateAndAccount,
|
||||
)) {
|
||||
combinedBalanceByDate[date] = Object.values(balancesByAccount).reduce(
|
||||
(sum, balance) => sum + balance,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return combinedBalanceByDate;
|
||||
}
|
||||
|
||||
function getCombinedBalanceByMonth(forecastData: ForecastResult) {
|
||||
const balancesByMonthAndAccount: Record<string, Record<string, number>> = {};
|
||||
|
||||
for (const dataPoint of forecastData.dataPoints) {
|
||||
const month = dataPoint.date.substring(0, 7);
|
||||
|
||||
if (!balancesByMonthAndAccount[month]) {
|
||||
balancesByMonthAndAccount[month] = {};
|
||||
}
|
||||
|
||||
balancesByMonthAndAccount[month][dataPoint.accountId] = dataPoint.balance;
|
||||
}
|
||||
|
||||
const combinedBalanceByMonth: Record<string, number> = {};
|
||||
for (const [month, balancesByAccount] of Object.entries(
|
||||
balancesByMonthAndAccount,
|
||||
)) {
|
||||
combinedBalanceByMonth[month] = Object.values(balancesByAccount).reduce(
|
||||
(sum, balance) => sum + balance,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return combinedBalanceByMonth;
|
||||
}
|
||||
|
||||
export function buildBalanceForecastChartData({
|
||||
forecastData,
|
||||
start,
|
||||
end,
|
||||
granularity,
|
||||
}: {
|
||||
forecastData: ForecastResult | null;
|
||||
start: string;
|
||||
end: string;
|
||||
granularity: Granularity;
|
||||
}) {
|
||||
if (!forecastData || forecastData.dataPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (granularity === 'Daily') {
|
||||
const result: ChartDataPoint[] = [];
|
||||
let runningBalance = 0;
|
||||
const combinedBalanceByDate = getCombinedBalanceByDate(forecastData);
|
||||
|
||||
const startDate = monthUtils.parseDate(start + '-01');
|
||||
const endDate = monthUtils.parseDate(monthUtils.lastDayOfMonth(end));
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
const dayStr = d.format(current, 'yyyy-MM-dd');
|
||||
if (combinedBalanceByDate[dayStr] !== undefined) {
|
||||
runningBalance = combinedBalanceByDate[dayStr];
|
||||
}
|
||||
result.push({ date: dayStr, balance: runningBalance });
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: ChartDataPoint[] = [];
|
||||
let runningBalance = 0;
|
||||
const combinedBalanceByMonth = getCombinedBalanceByMonth(forecastData);
|
||||
|
||||
for (
|
||||
let month = start;
|
||||
month <= end;
|
||||
month = monthUtils.addMonths(month, 1)
|
||||
) {
|
||||
if (combinedBalanceByMonth[month] !== undefined) {
|
||||
runningBalance = combinedBalanceByMonth[month];
|
||||
}
|
||||
|
||||
result.push({ date: month, balance: runningBalance });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function countForecastScheduledOccurrences(
|
||||
forecastData: ForecastResult | null | undefined,
|
||||
): number {
|
||||
if (!forecastData?.dataPoints.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const occurrenceKeys = new Set<string>();
|
||||
|
||||
for (const dataPoint of forecastData.dataPoints) {
|
||||
for (const transaction of dataPoint.transactions) {
|
||||
occurrenceKeys.add(`${dataPoint.date}:${transaction.scheduleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return occurrenceKeys.size;
|
||||
}
|
||||
@@ -198,6 +198,12 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Sankey report</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle
|
||||
flag="balanceForecastReport"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/7669"
|
||||
>
|
||||
<Trans>Balance Forecast Report</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle
|
||||
flag="ageOfMoneyReport"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/7006"
|
||||
|
||||
49
packages/desktop-client/src/hooks/useBalanceForecast.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import type { RuleConditionEntity } from '@actual-app/core/types/models';
|
||||
import type { ForecastResult } from '@actual-app/core/types/models/forecast';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
type UseBalanceForecastParams = {
|
||||
accountIds?: string[];
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
includeAccountlessSchedules?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function useBalanceForecast({
|
||||
accountIds,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
startDate,
|
||||
endDate,
|
||||
includeAccountlessSchedules,
|
||||
enabled = true,
|
||||
}: UseBalanceForecastParams) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'balance-forecast',
|
||||
{
|
||||
accountIds: accountIds ?? null,
|
||||
conditions: conditions ?? null,
|
||||
conditionsOp: conditionsOp ?? 'and',
|
||||
startDate,
|
||||
endDate,
|
||||
includeAccountlessSchedules: includeAccountlessSchedules ?? false,
|
||||
},
|
||||
],
|
||||
queryFn: async (): Promise<ForecastResult> =>
|
||||
send('forecast/generate', {
|
||||
accountIds,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
startDate,
|
||||
endDate,
|
||||
includeAccountlessSchedules,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
formulaMode: false,
|
||||
currency: false,
|
||||
ageOfMoneyReport: false,
|
||||
balanceForecastReport: false,
|
||||
customThemes: false,
|
||||
budgetAnalysisReport: false,
|
||||
payeeLocations: false,
|
||||
enableBanking: false,
|
||||
|
||||
@@ -212,6 +212,7 @@ const sidebars = {
|
||||
'experimental/monthly-cleanup',
|
||||
'experimental/rule-templating',
|
||||
'experimental/formulas',
|
||||
'experimental/balance-forecast-report',
|
||||
'experimental/crossover-point-report',
|
||||
'experimental/budget-analysis-report',
|
||||
],
|
||||
|
||||
46
packages/docs/docs/experimental/balance-forecast-report.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Balance Forecast Report
|
||||
|
||||
:::warning
|
||||
This is an **experimental feature**. That means we're still working on finishing it. There may be bugs, missing functionality or incomplete documentation, and we may decide to remove the feature in a future release. If you have any feedback, please [open an issue](https://github.com/actualbudget/actual/issues) or post a message in the Discord.
|
||||
:::
|
||||
|
||||
## What it is
|
||||
|
||||
The Balance Forecast report projects future account balances from your posted transaction history and upcoming scheduled transactions. Use it to spot possible shortfalls, compare date ranges, and see how scheduled income, bills, and transfers may affect your balances over time.
|
||||
|
||||

|
||||
|
||||
## How balances are predicted
|
||||
|
||||
The report starts by resolving the selected accounts, filters, and forecast date range. It then calculates a starting balance from posted transactions before the forecast begins.
|
||||
|
||||
For future dates, Actual expands scheduled transactions into simulated occurrences up to the forecast end date. Schedule rules are applied to those simulated transactions, and transfer schedules generate matching transfer legs when both sides can be assigned to accounts.
|
||||
|
||||
For each forecast day, Actual updates the running balance with posted transactions on that day plus simulated scheduled transactions on that day. Monthly granularity shows the same running balance grouped by month.
|
||||
|
||||
## Important information
|
||||
|
||||
- The forecast is only as accurate as your schedules and the assumptions they represent.
|
||||
- Account filters limit the forecast to the selected accounts.
|
||||
- Report filters affect which posted transactions and scheduled transactions are included.
|
||||
- Schedules without an account can be included when forecasting the total budget balance without an explicit account filter. They cannot be assigned to a specific real account.
|
||||
- Transfers are included when the forecast can resolve the relevant account information.
|
||||
|
||||
## Display options
|
||||
|
||||
- **Start / End**: pick the forecast date range.
|
||||
- **Quick ranges**: choose future presets from the report header.
|
||||
- **Granularity**: switch between monthly and daily views.
|
||||
- **Filters**: use the Filter button to narrow the transactions and schedules included in the forecast.
|
||||
- **Save widget**: save the current report settings back to the dashboard widget.
|
||||
|
||||
## Quick troubleshooting
|
||||
|
||||
- **Forecast looks flat**: check whether the selected range is too long or whether the balance changes are small relative to the overall account balance.
|
||||
- **Schedules are missing**: verify that the schedules are active and have enough account information for the selected account scope.
|
||||
- **Balance looks wrong**: check account filters, report filters, transfer schedules, and whether the relevant future transactions are scheduled.
|
||||
|
||||
## Related
|
||||
|
||||
- [Reports index](/docs/reports/index.md) — other report types and tips.
|
||||
- [Schedules](/docs/schedules) — manage the scheduled transactions used by the forecast.
|
||||
@@ -26,6 +26,7 @@ The following are available as experimental features:
|
||||
|
||||
- [Crossover Point report](/docs/experimental/crossover-point-report)
|
||||
- [Budget Analysis report](/docs/experimental/budget-analysis-report)
|
||||
- [Balance Forecast report](/docs/experimental/balance-forecast-report)
|
||||
|
||||
## Cash Flow Graph
|
||||
|
||||
|
||||
BIN
packages/docs/static/img/experimental/balance-forecast-report/balance-forecast-report-overview.png
vendored
Normal file
|
After Width: | Height: | Size: 155 KiB |
548
packages/loot-core/src/server/forecast/app.test.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import MockDate from 'mockdate';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import * as db from '#server/db';
|
||||
import { loadMappings } from '#server/db/mappings';
|
||||
import {
|
||||
createSchedule as createScheduleBase,
|
||||
getRuleForSchedule,
|
||||
} from '#server/schedules/app';
|
||||
import { loadRules, updateRule } from '#server/transactions/transaction-rules';
|
||||
import type { RuleConditionEntity } from '#types/models';
|
||||
|
||||
import { generateForecast } from './app';
|
||||
import { FORECAST_UNASSIGNED_ACCOUNT_ID } from './forecast-schedules';
|
||||
|
||||
const { emptyDatabase } = global as typeof globalThis & {
|
||||
emptyDatabase: () => () => Promise<void>;
|
||||
};
|
||||
const createSchedule = createScheduleBase as (args: {
|
||||
conditions: RuleConditionEntity[];
|
||||
}) => Promise<string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await emptyDatabase()();
|
||||
await loadMappings();
|
||||
await loadRules();
|
||||
MockDate.set(new Date(2024, 2, 10, 12));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
MockDate.reset();
|
||||
});
|
||||
|
||||
describe('forecast app', () => {
|
||||
it('shows the real running balance for historical months', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
|
||||
await db.insertTransaction({
|
||||
id: 'starting-deposit',
|
||||
account: accountId,
|
||||
amount: 1000,
|
||||
date: '2024-01-05',
|
||||
});
|
||||
await db.insertTransaction({
|
||||
id: 'march-spend',
|
||||
account: accountId,
|
||||
amount: -200,
|
||||
date: '2024-03-20',
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [accountId],
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-04-30',
|
||||
});
|
||||
|
||||
const balanceByDate = Object.fromEntries(
|
||||
result.dataPoints.map(({ date, balance }) => [date, balance]),
|
||||
);
|
||||
|
||||
expect(balanceByDate['2024-01-01']).toBe(0);
|
||||
expect(balanceByDate['2024-01-05']).toBe(1000);
|
||||
expect(balanceByDate['2024-03-19']).toBe(1000);
|
||||
expect(balanceByDate['2024-03-20']).toBe(800);
|
||||
expect(balanceByDate['2024-04-30']).toBe(800);
|
||||
expect(result.dataPoints).toHaveLength(121);
|
||||
expect(result.lowestBalance).toEqual({
|
||||
date: '2024-01-01',
|
||||
balance: 0,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit empty account selection as no accounts', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
|
||||
await db.insertTransaction({
|
||||
id: 'starting-deposit',
|
||||
account: accountId,
|
||||
amount: 1000,
|
||||
date: '2024-01-05',
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [],
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
});
|
||||
|
||||
expect(result.dataPoints).toEqual([]);
|
||||
expect(result.lowestBalance).toEqual({
|
||||
date: '2024-01-01',
|
||||
balance: 0,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores reconstructed schedule occurrences before today', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
const scheduleConditions = [
|
||||
{ op: 'is', field: 'account', value: accountId },
|
||||
{ op: 'is', field: 'amount', value: -100 },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-01-15',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[];
|
||||
|
||||
await createSchedule({ conditions: scheduleConditions });
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [accountId],
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-04-30',
|
||||
});
|
||||
|
||||
const dataPointByDate = Object.fromEntries(
|
||||
result.dataPoints.map(dataPoint => [dataPoint.date, dataPoint]),
|
||||
);
|
||||
|
||||
expect(dataPointByDate['2024-03-14']).toMatchObject({
|
||||
balance: 0,
|
||||
transactions: [],
|
||||
});
|
||||
expect(dataPointByDate['2024-03-15']).toMatchObject({
|
||||
balance: -100,
|
||||
transactions: [{ amount: -100 }],
|
||||
});
|
||||
expect(dataPointByDate['2024-04-14']).toMatchObject({
|
||||
balance: -100,
|
||||
transactions: [],
|
||||
});
|
||||
expect(dataPointByDate['2024-04-15']).toMatchObject({
|
||||
balance: -200,
|
||||
transactions: [{ amount: -100 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('forecasts transfer schedules for both source and destination accounts', async () => {
|
||||
const checkingId = await db.insertAccount({
|
||||
id: 'checking',
|
||||
name: 'Checking',
|
||||
});
|
||||
const savingsId = await db.insertAccount({
|
||||
id: 'savings',
|
||||
name: 'Savings',
|
||||
});
|
||||
const transferPayeeId = await db.insertPayee({
|
||||
id: 'transfer-payee',
|
||||
name: 'Transfer to savings',
|
||||
transfer_acct: savingsId,
|
||||
});
|
||||
|
||||
await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'account', value: checkingId },
|
||||
{ op: 'is', field: 'payee', value: transferPayeeId },
|
||||
{ op: 'is', field: 'amount', value: -250 },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-20',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
const combinedResult = await generateForecast({
|
||||
accountIds: [checkingId, savingsId],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-04-30',
|
||||
});
|
||||
const savingsOnlyResult = await generateForecast({
|
||||
accountIds: [savingsId],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-04-30',
|
||||
});
|
||||
|
||||
const combinedDataPointByAccountAndDate = new Map(
|
||||
combinedResult.dataPoints.map(dataPoint => [
|
||||
`${dataPoint.accountId}:${dataPoint.date}`,
|
||||
dataPoint,
|
||||
]),
|
||||
);
|
||||
const savingsOnlyDataPointByDate = Object.fromEntries(
|
||||
savingsOnlyResult.dataPoints.map(dataPoint => [
|
||||
dataPoint.date,
|
||||
dataPoint,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
combinedDataPointByAccountAndDate.get('checking:2024-03-20'),
|
||||
).toMatchObject({
|
||||
balance: -250,
|
||||
transactions: [{ amount: -250, scheduleId: expect.any(String) }],
|
||||
});
|
||||
expect(
|
||||
combinedDataPointByAccountAndDate.get('savings:2024-03-20'),
|
||||
).toMatchObject({
|
||||
balance: 250,
|
||||
transactions: [{ amount: 250, scheduleId: expect.any(String) }],
|
||||
});
|
||||
expect(combinedResult.lowestBalance.balance).toBe(0);
|
||||
expect(savingsOnlyDataPointByDate['2024-03-20']).toMatchObject({
|
||||
balance: 250,
|
||||
transactions: [{ amount: 250, scheduleId: expect.any(String) }],
|
||||
});
|
||||
expect(
|
||||
combinedResult.dataPoints.every(dataPoint =>
|
||||
[checkingId, savingsId].includes(dataPoint.accountId),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
savingsOnlyResult.dataPoints.every(
|
||||
dataPoint => dataPoint.accountId === savingsId,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches payee filters for destination-only transfer forecasts', async () => {
|
||||
const checkingId = await db.insertAccount({
|
||||
id: 'checking',
|
||||
name: 'Checking',
|
||||
});
|
||||
const savingsId = await db.insertAccount({
|
||||
id: 'savings',
|
||||
name: 'Savings',
|
||||
});
|
||||
const transferToSavingsPayeeId = await db.insertPayee({
|
||||
id: 'transfer-to-savings',
|
||||
name: 'Transfer to savings',
|
||||
transfer_acct: savingsId,
|
||||
});
|
||||
const transferToCheckingPayeeId = await db.insertPayee({
|
||||
id: 'transfer-to-checking',
|
||||
name: 'Transfer to checking',
|
||||
transfer_acct: checkingId,
|
||||
});
|
||||
|
||||
await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'account', value: checkingId },
|
||||
{ op: 'is', field: 'payee', value: transferToSavingsPayeeId },
|
||||
{ op: 'is', field: 'amount', value: -250 },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-20',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [savingsId],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-04-30',
|
||||
conditions: [
|
||||
{ op: 'is', field: 'payee', value: transferToCheckingPayeeId },
|
||||
],
|
||||
});
|
||||
|
||||
const dataPointByDate = Object.fromEntries(
|
||||
result.dataPoints.map(dataPoint => [dataPoint.date, dataPoint]),
|
||||
);
|
||||
|
||||
expect(dataPointByDate['2024-03-20']).toMatchObject({
|
||||
accountId: savingsId,
|
||||
balance: 250,
|
||||
transactions: [
|
||||
{
|
||||
amount: 250,
|
||||
payee: 'Checking',
|
||||
scheduleId: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(dataPointByDate['2024-04-20']).toMatchObject({
|
||||
accountId: savingsId,
|
||||
balance: 500,
|
||||
transactions: [
|
||||
{
|
||||
amount: 250,
|
||||
payee: 'Checking',
|
||||
scheduleId: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('applies standard historical report filters to posted transactions', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
const groceryPayeeId = await db.insertPayee({
|
||||
id: 'payee-grocery',
|
||||
name: 'Grocery Store',
|
||||
});
|
||||
const rentPayeeId = await db.insertPayee({
|
||||
id: 'payee-rent',
|
||||
name: 'Landlord',
|
||||
});
|
||||
|
||||
await db.insertTransaction({
|
||||
id: 'grocery',
|
||||
account: accountId,
|
||||
payee: groceryPayeeId,
|
||||
amount: -25,
|
||||
date: '2024-03-10',
|
||||
});
|
||||
await db.insertTransaction({
|
||||
id: 'rent',
|
||||
account: accountId,
|
||||
payee: rentPayeeId,
|
||||
amount: -100,
|
||||
date: '2024-03-11',
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-03-31',
|
||||
conditions: [{ op: 'is', field: 'payee', value: groceryPayeeId }],
|
||||
});
|
||||
|
||||
const balanceByDate = Object.fromEntries(
|
||||
result.dataPoints.map(({ date, balance }) => [date, balance]),
|
||||
);
|
||||
|
||||
expect(balanceByDate['2024-03-09']).toBe(0);
|
||||
expect(balanceByDate['2024-03-10']).toBe(-25);
|
||||
expect(balanceByDate['2024-03-11']).toBe(-25);
|
||||
expect(balanceByDate['2024-03-31']).toBe(-25);
|
||||
});
|
||||
|
||||
it('filters future schedule occurrences using rule-derived fields like category', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
const groupId = await db.insertCategoryGroup({ name: 'Bills' });
|
||||
const categoryId = await db.insertCategory({
|
||||
name: 'Utilities',
|
||||
cat_group: groupId,
|
||||
});
|
||||
|
||||
const scheduleId = await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'account', value: accountId },
|
||||
{ op: 'is', field: 'amount', value: -75 },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-15',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
const scheduleRule = await getRuleForSchedule(scheduleId);
|
||||
await updateRule({
|
||||
...scheduleRule.serialize(),
|
||||
actions: [
|
||||
{ op: 'link-schedule', value: scheduleId },
|
||||
{ op: 'set', field: 'category', value: categoryId },
|
||||
],
|
||||
});
|
||||
await loadRules();
|
||||
|
||||
const result = await generateForecast({
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-04-30',
|
||||
conditions: [{ op: 'is', field: 'category', value: categoryId }],
|
||||
});
|
||||
|
||||
const dataPointByDate = Object.fromEntries(
|
||||
result.dataPoints.map(dataPoint => [dataPoint.date, dataPoint]),
|
||||
);
|
||||
|
||||
expect(dataPointByDate['2024-03-15']).toMatchObject({
|
||||
balance: -75,
|
||||
transactions: [{ amount: -75, scheduleId }],
|
||||
});
|
||||
expect(dataPointByDate['2024-04-15']).toMatchObject({
|
||||
balance: -150,
|
||||
transactions: [{ amount: -75, scheduleId }],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not over-restrict accounts when filters use mixed OR conditions', async () => {
|
||||
const checkingId = await db.insertAccount({
|
||||
id: 'checking',
|
||||
name: 'Checking',
|
||||
});
|
||||
const savingsId = await db.insertAccount({
|
||||
id: 'savings',
|
||||
name: 'Savings',
|
||||
});
|
||||
const groceryPayeeId = await db.insertPayee({
|
||||
id: 'payee-grocery',
|
||||
name: 'Grocery Store',
|
||||
});
|
||||
const salaryPayeeId = await db.insertPayee({
|
||||
id: 'payee-salary',
|
||||
name: 'Employer',
|
||||
});
|
||||
|
||||
await db.insertTransaction({
|
||||
id: 'checking-grocery',
|
||||
account: checkingId,
|
||||
payee: groceryPayeeId,
|
||||
amount: -30,
|
||||
date: '2024-03-10',
|
||||
});
|
||||
await db.insertTransaction({
|
||||
id: 'savings-salary',
|
||||
account: savingsId,
|
||||
payee: salaryPayeeId,
|
||||
amount: 200,
|
||||
date: '2024-03-11',
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-03-31',
|
||||
conditionsOp: 'or',
|
||||
conditions: [
|
||||
{ op: 'is', field: 'account', value: savingsId },
|
||||
{ op: 'is', field: 'payee', value: groceryPayeeId },
|
||||
],
|
||||
});
|
||||
|
||||
const balanceByAccountAndDate = new Map(
|
||||
result.dataPoints.map(dataPoint => [
|
||||
`${dataPoint.accountId}:${dataPoint.date}`,
|
||||
dataPoint.balance,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(balanceByAccountAndDate.get('checking:2024-03-10')).toBe(-30);
|
||||
expect(balanceByAccountAndDate.get('savings:2024-03-11')).toBe(200);
|
||||
});
|
||||
|
||||
it('uses the requested range for empty-account results', async () => {
|
||||
const result = await generateForecast({
|
||||
accountIds: ['missing-account'],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-03-31',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
dataPoints: [],
|
||||
lowestBalance: {
|
||||
date: '2024-03-01',
|
||||
balance: 0,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate: '2024-03-01',
|
||||
forecastEndDate: '2024-03-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes account-less schedules when includeAccountlessSchedules is true', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
|
||||
await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'amount', value: -75 },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-15',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [accountId],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-03-31',
|
||||
includeAccountlessSchedules: true,
|
||||
});
|
||||
|
||||
const combinedBalance = (date: string) =>
|
||||
result.dataPoints
|
||||
.filter(dataPoint => dataPoint.date === date)
|
||||
.reduce((sum, dataPoint) => sum + dataPoint.balance, 0);
|
||||
|
||||
expect(combinedBalance('2024-03-15')).toBe(-75);
|
||||
expect(result.lowestBalance).toMatchObject({
|
||||
date: '2024-03-15',
|
||||
balance: -75,
|
||||
});
|
||||
expect(
|
||||
result.dataPoints.some(
|
||||
dataPoint => dataPoint.accountId === FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes account-less schedules when includeAccountlessSchedules is false', async () => {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
|
||||
await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'amount', value: -75 },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-15',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [accountId],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-03-31',
|
||||
includeAccountlessSchedules: false,
|
||||
});
|
||||
|
||||
const combinedBalance = (date: string) =>
|
||||
result.dataPoints
|
||||
.filter(dataPoint => dataPoint.date === date)
|
||||
.reduce((sum, dataPoint) => sum + dataPoint.balance, 0);
|
||||
|
||||
expect(combinedBalance('2024-03-15')).toBe(0);
|
||||
expect(
|
||||
result.dataPoints.every(
|
||||
dataPoint => dataPoint.accountId !== FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
148
packages/loot-core/src/server/forecast/app.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createApp } from '#server/app';
|
||||
import * as db from '#server/db';
|
||||
import type { RuleConditionEntity } from '#types/models';
|
||||
import type { ForecastResult } from '#types/models/forecast';
|
||||
|
||||
import { resolveForecastAccounts } from './forecast-accounts';
|
||||
import type {
|
||||
AccountWithComputedBalance,
|
||||
DbAccountForRules,
|
||||
} from './forecast-accounts';
|
||||
import { buildFilterInfo, getTransactions } from './forecast-filters';
|
||||
import {
|
||||
buildForecastDateContext,
|
||||
createEmptyForecastResult,
|
||||
projectForecastData,
|
||||
} from './forecast-projection';
|
||||
import {
|
||||
buildFutureScheduleOccurrences,
|
||||
FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
getNormalizedSchedules,
|
||||
} from './forecast-schedules';
|
||||
|
||||
export type ForecastRequestParams = {
|
||||
accountIds?: string[];
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
includeAccountlessSchedules?: boolean;
|
||||
};
|
||||
|
||||
function createUnassignedForecastAccount(): AccountWithComputedBalance {
|
||||
return {
|
||||
id: FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
name: '',
|
||||
closed: 0,
|
||||
offbudget: 0,
|
||||
balance_current: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createUnassignedRuleAccountStub(): DbAccountForRules {
|
||||
return {
|
||||
id: FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
name: '',
|
||||
offbudget: 0,
|
||||
closed: 0,
|
||||
tombstone: 0,
|
||||
sort_order: -1,
|
||||
bankName: '',
|
||||
bankId: '',
|
||||
} as DbAccountForRules;
|
||||
}
|
||||
|
||||
export async function generateForecast({
|
||||
accountIds,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
startDate,
|
||||
endDate,
|
||||
includeAccountlessSchedules,
|
||||
}: ForecastRequestParams): Promise<ForecastResult> {
|
||||
const includeUnassigned = includeAccountlessSchedules ?? false;
|
||||
const dateContext = buildForecastDateContext(startDate, endDate);
|
||||
const { filterInfo, plainConditions, resolvedConditionsOp } = buildFilterInfo(
|
||||
conditions,
|
||||
conditionsOp,
|
||||
);
|
||||
let accounts = await resolveForecastAccounts({
|
||||
accountIds,
|
||||
plainConditions,
|
||||
resolvedConditionsOp,
|
||||
canRestrictAccounts: filterInfo.canRestrictAccounts,
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return createEmptyForecastResult(
|
||||
dateContext.forecastStartDate,
|
||||
dateContext.forecastEndDate,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
includeUnassigned &&
|
||||
!accounts.some(account => account.id === FORECAST_UNASSIGNED_ACCOUNT_ID)
|
||||
) {
|
||||
accounts = [...accounts, createUnassignedForecastAccount()];
|
||||
}
|
||||
|
||||
const accountIdsToQuery = accounts.map(account => account.id);
|
||||
const accountsById = new Map(accounts.map(account => [account.id, account]));
|
||||
const [schedulesRaw, transactions, ruleAccounts] = await Promise.all([
|
||||
getNormalizedSchedules(),
|
||||
getTransactions(accountIdsToQuery, filterInfo),
|
||||
db.getAccounts(),
|
||||
]);
|
||||
const schedules = includeUnassigned
|
||||
? schedulesRaw
|
||||
: schedulesRaw.filter(
|
||||
schedule => schedule._account !== FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
);
|
||||
|
||||
const ruleAccountsById = new Map(
|
||||
ruleAccounts.map(account => [account.id, account]),
|
||||
);
|
||||
if (includeUnassigned) {
|
||||
ruleAccountsById.set(
|
||||
FORECAST_UNASSIGNED_ACCOUNT_ID,
|
||||
createUnassignedRuleAccountStub(),
|
||||
);
|
||||
}
|
||||
|
||||
const futureOccurrences = await buildFutureScheduleOccurrences(
|
||||
schedules,
|
||||
dateContext.endDateObj,
|
||||
accountsById,
|
||||
ruleAccountsById,
|
||||
);
|
||||
const { dataPoints, lowestBalance } = projectForecastData({
|
||||
accounts,
|
||||
transactions,
|
||||
futureOccurrences,
|
||||
filterInfo,
|
||||
dateContext,
|
||||
});
|
||||
|
||||
return {
|
||||
dataPoints,
|
||||
lowestBalance,
|
||||
forecastStartDate: dateContext.forecastStartDate,
|
||||
forecastEndDate: dateContext.forecastEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
export type ForecastHandlers = {
|
||||
'forecast/generate': (params: {
|
||||
accountIds?: string[];
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
includeAccountlessSchedules?: boolean;
|
||||
}) => Promise<ForecastResult>;
|
||||
};
|
||||
|
||||
export const app = createApp<ForecastHandlers>();
|
||||
|
||||
app.method('forecast/generate', generateForecast);
|
||||
183
packages/loot-core/src/server/forecast/forecast-accounts.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { aqlQuery } from '#server/aql';
|
||||
import * as db from '#server/db';
|
||||
import { q } from '#shared/query';
|
||||
import type { AccountEntity, RuleConditionEntity } from '#types/models';
|
||||
|
||||
type AccountCondition = Extract<RuleConditionEntity, { field: 'account' }>;
|
||||
type AccountMatchable = Pick<AccountEntity, 'id' | 'name' | 'offbudget'>;
|
||||
|
||||
export type DbAccountForRules = Awaited<
|
||||
ReturnType<typeof db.getAccounts>
|
||||
>[number];
|
||||
|
||||
export type AccountWithComputedBalance = {
|
||||
id: string;
|
||||
name: string;
|
||||
closed: number;
|
||||
offbudget: number;
|
||||
balance_current: number;
|
||||
};
|
||||
|
||||
function getAccountName(account: AccountMatchable) {
|
||||
return account.name.toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesAccountCondition(
|
||||
account: AccountMatchable,
|
||||
condition: AccountCondition,
|
||||
) {
|
||||
switch (condition.op) {
|
||||
case 'is':
|
||||
return account.id === condition.value;
|
||||
case 'isNot':
|
||||
return account.id !== condition.value;
|
||||
case 'oneOf':
|
||||
return condition.value.includes(account.id);
|
||||
case 'notOneOf':
|
||||
return !condition.value.includes(account.id);
|
||||
case 'contains':
|
||||
return getAccountName(account).includes(
|
||||
String(condition.value).toLowerCase(),
|
||||
);
|
||||
case 'doesNotContain':
|
||||
return !getAccountName(account).includes(
|
||||
String(condition.value).toLowerCase(),
|
||||
);
|
||||
case 'matches': {
|
||||
try {
|
||||
return new RegExp(String(condition.value)).test(
|
||||
String(account.name).toLowerCase(),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'onBudget':
|
||||
return account.offbudget === 0;
|
||||
case 'offBudget':
|
||||
return account.offbudget === 1;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAccountIdsFromConditions(
|
||||
conditions: RuleConditionEntity[],
|
||||
conditionsOp: 'and' | 'or',
|
||||
): Promise<string[] | undefined> {
|
||||
const accountConditions = conditions.filter(
|
||||
(condition): condition is AccountCondition => condition.field === 'account',
|
||||
);
|
||||
|
||||
if (accountConditions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accountData = await db.getAccounts();
|
||||
|
||||
const filteredAccounts = accountData.filter(account => {
|
||||
const matches = accountConditions.map(condition =>
|
||||
matchesAccountCondition(account, condition),
|
||||
);
|
||||
|
||||
return conditionsOp === 'or'
|
||||
? matches.some(Boolean)
|
||||
: matches.every(Boolean);
|
||||
});
|
||||
|
||||
return filteredAccounts.map(account => account.id);
|
||||
}
|
||||
|
||||
export function getAccountRestrictionMode(
|
||||
conditions: RuleConditionEntity[],
|
||||
conditionsOp: 'and' | 'or',
|
||||
) {
|
||||
if (conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountConditions = conditions.filter(
|
||||
condition => condition.field === 'account',
|
||||
);
|
||||
|
||||
if (accountConditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasNonAccountConditions = conditions.some(
|
||||
condition => condition.field !== 'account',
|
||||
);
|
||||
|
||||
return !hasNonAccountConditions || conditionsOp === 'and';
|
||||
}
|
||||
|
||||
export async function getAccounts(
|
||||
accountIds?: string[],
|
||||
): Promise<AccountWithComputedBalance[]> {
|
||||
const accounts = await db.getAccounts();
|
||||
const selectedAccounts =
|
||||
accountIds === undefined
|
||||
? accounts
|
||||
: accounts.filter(account => accountIds.includes(account.id));
|
||||
|
||||
if (selectedAccounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids = selectedAccounts.map(account => account.id);
|
||||
const { data: balanceRows } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({
|
||||
'account.id': { $oneof: ids },
|
||||
tombstone: false,
|
||||
})
|
||||
.groupBy('account')
|
||||
.select(['account', { balance_current: { $sum: '$amount' } }])
|
||||
.serialize(),
|
||||
);
|
||||
|
||||
const balanceByAccount = new Map<string, number>();
|
||||
for (const row of balanceRows as Array<{
|
||||
account: string;
|
||||
balance_current: number;
|
||||
}>) {
|
||||
balanceByAccount.set(row.account, row.balance_current ?? 0);
|
||||
}
|
||||
|
||||
return selectedAccounts.map(account => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
closed: account.closed,
|
||||
offbudget: account.offbudget,
|
||||
balance_current: balanceByAccount.get(account.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveForecastAccounts({
|
||||
accountIds,
|
||||
plainConditions,
|
||||
resolvedConditionsOp,
|
||||
canRestrictAccounts,
|
||||
}: {
|
||||
accountIds: string[] | undefined;
|
||||
plainConditions: RuleConditionEntity[];
|
||||
resolvedConditionsOp: 'and' | 'or';
|
||||
canRestrictAccounts: boolean;
|
||||
}) {
|
||||
let resolvedAccountIds = accountIds;
|
||||
|
||||
if (canRestrictAccounts && plainConditions.length > 0) {
|
||||
const conditionAccountIds = await resolveAccountIdsFromConditions(
|
||||
plainConditions,
|
||||
resolvedConditionsOp,
|
||||
);
|
||||
if (conditionAccountIds !== undefined) {
|
||||
resolvedAccountIds =
|
||||
resolvedAccountIds !== undefined
|
||||
? resolvedAccountIds.filter(id => conditionAccountIds.includes(id))
|
||||
: conditionAccountIds;
|
||||
}
|
||||
}
|
||||
|
||||
return getAccounts(resolvedAccountIds);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RuleConditionEntity } from '#types/models';
|
||||
|
||||
import { buildFilterInfo, matchesAQLFilter } from './forecast-filters';
|
||||
|
||||
describe('forecast filters', () => {
|
||||
it('does not pre-restrict accounts for mixed OR filters', () => {
|
||||
const { filterInfo } = buildFilterInfo(
|
||||
[
|
||||
{ op: 'is', field: 'account', value: 'acct-1' },
|
||||
{ op: 'is', field: 'payee', value: 'payee-1' },
|
||||
] satisfies RuleConditionEntity[],
|
||||
'or',
|
||||
);
|
||||
|
||||
expect(filterInfo.conditionsOpKey).toBe('$or');
|
||||
expect(filterInfo.canRestrictAccounts).toBe(false);
|
||||
});
|
||||
|
||||
it('allows account pre-restriction for account-only filters', () => {
|
||||
const { filterInfo } = buildFilterInfo(
|
||||
[
|
||||
{ op: 'is', field: 'account', value: 'acct-1' },
|
||||
] satisfies RuleConditionEntity[],
|
||||
'and',
|
||||
);
|
||||
|
||||
expect(filterInfo.conditionsOpKey).toBe('$and');
|
||||
expect(filterInfo.canRestrictAccounts).toBe(true);
|
||||
});
|
||||
|
||||
it('matches on-budget AQL filter (boolean) to enriched account.offbudget 0/1', () => {
|
||||
const { filterInfo } = buildFilterInfo(
|
||||
[
|
||||
{
|
||||
field: 'account',
|
||||
op: 'onBudget',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
'and',
|
||||
);
|
||||
|
||||
expect(filterInfo.filters.length).toBeGreaterThan(0);
|
||||
const aqlFilter = filterInfo.filters[0];
|
||||
const onBudgetAccount = {
|
||||
id: 'acct-checking',
|
||||
name: 'Checking',
|
||||
closed: 0,
|
||||
offbudget: 0,
|
||||
balance_current: 100,
|
||||
};
|
||||
|
||||
expect(
|
||||
matchesAQLFilter(
|
||||
{
|
||||
account: onBudgetAccount,
|
||||
} as Record<string, unknown>,
|
||||
aqlFilter,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const offBudgetAccount = { ...onBudgetAccount, offbudget: 1 };
|
||||
expect(
|
||||
matchesAQLFilter(
|
||||
{
|
||||
account: offBudgetAccount,
|
||||
} as Record<string, unknown>,
|
||||
aqlFilter,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
410
packages/loot-core/src/server/forecast/forecast-filters.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { aqlQuery } from '#server/aql';
|
||||
import { conditionsToAQL } from '#server/transactions/transaction-rules';
|
||||
import { q } from '#shared/query';
|
||||
import { ungroupTransactions } from '#shared/transactions';
|
||||
import type { RuleConditionEntity, TransactionEntity } from '#types/models';
|
||||
|
||||
import { getAccountRestrictionMode } from './forecast-accounts';
|
||||
import type { AccountWithComputedBalance } from './forecast-accounts';
|
||||
|
||||
type PayeeForFiltering = {
|
||||
id: string;
|
||||
name: string;
|
||||
transfer_acct: string | null;
|
||||
};
|
||||
|
||||
type CategoryGroupForFiltering = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CategoryForFiltering = {
|
||||
id: string;
|
||||
name: string;
|
||||
group: CategoryGroupForFiltering | null;
|
||||
};
|
||||
|
||||
export type ForecastFilterObject = {
|
||||
id: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
notes: string | null;
|
||||
cleared: boolean;
|
||||
reconciled: boolean;
|
||||
transfer_id: string | null;
|
||||
is_parent: boolean;
|
||||
imported_payee: string | null;
|
||||
account: AccountWithComputedBalance | null;
|
||||
payee: PayeeForFiltering | null;
|
||||
category: CategoryForFiltering | null;
|
||||
};
|
||||
|
||||
export type ForecastFilterInfo = {
|
||||
filters: Array<Record<string, unknown>>;
|
||||
conditionsOpKey: '$and' | '$or';
|
||||
canRestrictAccounts: boolean;
|
||||
};
|
||||
|
||||
export function buildFilterInfo(
|
||||
conditions?: RuleConditionEntity[],
|
||||
conditionsOp?: 'and' | 'or',
|
||||
) {
|
||||
const resolvedConditionsOp = conditionsOp ?? 'and';
|
||||
const plainConditions = (conditions ?? []).filter(cond => !cond.customName);
|
||||
const { filters } = conditionsToAQL(plainConditions);
|
||||
|
||||
return {
|
||||
filterInfo: {
|
||||
filters: [
|
||||
...filters,
|
||||
...plainConditions.flatMap(condition =>
|
||||
condition.queryFilter
|
||||
? [condition.queryFilter as Record<string, unknown>]
|
||||
: [],
|
||||
),
|
||||
],
|
||||
conditionsOpKey: resolvedConditionsOp === 'or' ? '$or' : '$and',
|
||||
canRestrictAccounts: getAccountRestrictionMode(
|
||||
plainConditions,
|
||||
resolvedConditionsOp,
|
||||
),
|
||||
} satisfies ForecastFilterInfo,
|
||||
plainConditions,
|
||||
resolvedConditionsOp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTransactions(
|
||||
accountIds: string[] | undefined,
|
||||
filterInfo: ForecastFilterInfo,
|
||||
) {
|
||||
let query = q('transactions')
|
||||
.filter({ tombstone: false })
|
||||
.select('*')
|
||||
.options({ splits: 'grouped' });
|
||||
|
||||
if (accountIds !== undefined) {
|
||||
if (accountIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
query = query.filter({ 'account.id': { $oneof: accountIds } });
|
||||
}
|
||||
|
||||
if (filterInfo.filters.length > 0) {
|
||||
query = query.filter({ [filterInfo.conditionsOpKey]: filterInfo.filters });
|
||||
}
|
||||
|
||||
const { data } = await aqlQuery(query);
|
||||
return ungroupTransactions(data);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getValueByPath(
|
||||
object: Record<string, unknown> | null | undefined,
|
||||
path: string,
|
||||
): unknown {
|
||||
return path.split('.').reduce<unknown>((current, key) => {
|
||||
if (current == null || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}, object);
|
||||
}
|
||||
|
||||
function normalizeComparisonValue(value: unknown) {
|
||||
if (
|
||||
value != null &&
|
||||
typeof value === 'object' &&
|
||||
'id' in (value as Record<string, unknown>)
|
||||
) {
|
||||
return (value as Record<string, unknown>).id;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** AQL from {@link conditionsToAQL} uses booleans for account.offbudget; enriched accounts use 0/1. */
|
||||
function equalComparableFilterValues(actual: unknown, expected: unknown) {
|
||||
const a = normalizeComparisonValue(actual);
|
||||
if (a === expected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof expected === 'boolean' && (a === 0 || a === 1)) {
|
||||
return (expected === false && a === 0) || (expected === true && a === 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyTransform(value: unknown, transform: unknown) {
|
||||
if (transform === '$lower' && typeof value === 'string') {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
if (transform === '$neg' && typeof value === 'number') {
|
||||
return -value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function likeToRegex(pattern: string) {
|
||||
const regex = '^' + escapeRegex(pattern).replace(/%/g, '.*') + '$';
|
||||
return new RegExp(regex, 'i');
|
||||
}
|
||||
|
||||
function evaluateClause(actualValue: unknown, clause: unknown): boolean {
|
||||
if (Array.isArray(clause)) {
|
||||
return clause.every(item => evaluateClause(actualValue, item));
|
||||
}
|
||||
|
||||
if (clause == null || typeof clause !== 'object') {
|
||||
return equalComparableFilterValues(actualValue, clause);
|
||||
}
|
||||
|
||||
const typedClause = clause as Record<string, unknown>;
|
||||
const transform = typedClause.$transform;
|
||||
const transformedActualValue = applyTransform(actualValue, transform);
|
||||
|
||||
return Object.entries(typedClause)
|
||||
.filter(([key]) => key !== '$transform')
|
||||
.every(([operator, expectedValue]) => {
|
||||
const normalizedActualValue = normalizeComparisonValue(
|
||||
transformedActualValue,
|
||||
);
|
||||
|
||||
switch (operator) {
|
||||
case '$eq':
|
||||
return equalComparableFilterValues(
|
||||
normalizedActualValue,
|
||||
expectedValue,
|
||||
);
|
||||
case '$ne':
|
||||
return !equalComparableFilterValues(
|
||||
normalizedActualValue,
|
||||
expectedValue,
|
||||
);
|
||||
case '$gt':
|
||||
return (
|
||||
normalizedActualValue != null &&
|
||||
(typeof normalizedActualValue === 'number' ||
|
||||
typeof normalizedActualValue === 'string') &&
|
||||
(typeof expectedValue === 'number' ||
|
||||
typeof expectedValue === 'string') &&
|
||||
normalizedActualValue > expectedValue
|
||||
);
|
||||
case '$gte':
|
||||
return (
|
||||
normalizedActualValue != null &&
|
||||
(typeof normalizedActualValue === 'number' ||
|
||||
typeof normalizedActualValue === 'string') &&
|
||||
(typeof expectedValue === 'number' ||
|
||||
typeof expectedValue === 'string') &&
|
||||
normalizedActualValue >= expectedValue
|
||||
);
|
||||
case '$lt':
|
||||
return (
|
||||
normalizedActualValue != null &&
|
||||
(typeof normalizedActualValue === 'number' ||
|
||||
typeof normalizedActualValue === 'string') &&
|
||||
(typeof expectedValue === 'number' ||
|
||||
typeof expectedValue === 'string') &&
|
||||
normalizedActualValue < expectedValue
|
||||
);
|
||||
case '$lte':
|
||||
return (
|
||||
normalizedActualValue != null &&
|
||||
(typeof normalizedActualValue === 'number' ||
|
||||
typeof normalizedActualValue === 'string') &&
|
||||
(typeof expectedValue === 'number' ||
|
||||
typeof expectedValue === 'string') &&
|
||||
normalizedActualValue <= expectedValue
|
||||
);
|
||||
case '$oneof':
|
||||
return (
|
||||
Array.isArray(expectedValue) &&
|
||||
expectedValue.some(expectedItem =>
|
||||
equalComparableFilterValues(normalizedActualValue, expectedItem),
|
||||
)
|
||||
);
|
||||
case '$like':
|
||||
return (
|
||||
typeof normalizedActualValue === 'string' &&
|
||||
typeof expectedValue === 'string' &&
|
||||
likeToRegex(expectedValue).test(normalizedActualValue)
|
||||
);
|
||||
case '$notlike':
|
||||
return (
|
||||
typeof normalizedActualValue === 'string' &&
|
||||
typeof expectedValue === 'string' &&
|
||||
!likeToRegex(expectedValue).test(normalizedActualValue)
|
||||
);
|
||||
case '$regexp':
|
||||
if (
|
||||
typeof normalizedActualValue !== 'string' ||
|
||||
typeof expectedValue !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new RegExp(expectedValue).test(normalizedActualValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesAQLFilter(
|
||||
object: Record<string, unknown>,
|
||||
filter: Record<string, unknown>,
|
||||
): boolean {
|
||||
if ('$and' in filter) {
|
||||
const clauses = filter.$and;
|
||||
return Array.isArray(clauses)
|
||||
? clauses.every(clause =>
|
||||
matchesAQLFilter(object, clause as Record<string, unknown>),
|
||||
)
|
||||
: false;
|
||||
}
|
||||
|
||||
if ('$or' in filter) {
|
||||
const clauses = filter.$or;
|
||||
return Array.isArray(clauses)
|
||||
? clauses.some(clause =>
|
||||
matchesAQLFilter(object, clause as Record<string, unknown>),
|
||||
)
|
||||
: false;
|
||||
}
|
||||
|
||||
return Object.entries(filter).every(([field, value]) => {
|
||||
const fieldValue = getValueByPath(object, field);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(clause => evaluateClause(fieldValue, clause));
|
||||
}
|
||||
|
||||
if (value != null && typeof value === 'object') {
|
||||
return evaluateClause(fieldValue, value);
|
||||
}
|
||||
|
||||
return equalComparableFilterValues(fieldValue, value);
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesForecastFilters(
|
||||
filterObject: ForecastFilterObject,
|
||||
filterInfo: ForecastFilterInfo,
|
||||
) {
|
||||
if (filterInfo.filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const method = filterInfo.conditionsOpKey === '$or' ? 'some' : 'every';
|
||||
return filterInfo.filters[method](filter =>
|
||||
matchesAQLFilter(filterObject as Record<string, unknown>, filter),
|
||||
);
|
||||
}
|
||||
|
||||
export async function enrichForecastFilterObjects(
|
||||
transactions: TransactionEntity[],
|
||||
accountsById: Map<string, AccountWithComputedBalance>,
|
||||
) {
|
||||
const payeeIds = [
|
||||
...new Set(transactions.flatMap(tx => (tx.payee ? [tx.payee] : []))),
|
||||
];
|
||||
const categoryIds = [
|
||||
...new Set(transactions.flatMap(tx => (tx.category ? [tx.category] : []))),
|
||||
];
|
||||
|
||||
const [{ data: payees }, { data: categories }] = await Promise.all([
|
||||
payeeIds.length > 0
|
||||
? aqlQuery(
|
||||
q('payees')
|
||||
.filter({ id: { $oneof: payeeIds } })
|
||||
.select(['id', 'name', 'transfer_acct']),
|
||||
)
|
||||
: Promise.resolve({ data: [] }),
|
||||
categoryIds.length > 0
|
||||
? aqlQuery(
|
||||
q('categories')
|
||||
.filter({ id: { $oneof: categoryIds } })
|
||||
.select(['id', 'name', 'group']),
|
||||
)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
const categoryGroupIds = [
|
||||
...new Set(
|
||||
(categories as Array<{ group: string | null }>).flatMap(category =>
|
||||
category.group ? [category.group] : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const { data: categoryGroups } =
|
||||
categoryGroupIds.length > 0
|
||||
? await aqlQuery(
|
||||
q('category_groups')
|
||||
.filter({ id: { $oneof: categoryGroupIds } })
|
||||
.select(['id', 'name']),
|
||||
)
|
||||
: { data: [] };
|
||||
|
||||
const payeesById = new Map(
|
||||
(payees as PayeeForFiltering[]).map(payee => [payee.id, payee]),
|
||||
);
|
||||
const categoryGroupsById = new Map(
|
||||
(categoryGroups as CategoryGroupForFiltering[]).map(group => [
|
||||
group.id,
|
||||
group,
|
||||
]),
|
||||
);
|
||||
const categoriesById = new Map(
|
||||
(
|
||||
categories as Array<{ id: string; name: string; group: string | null }>
|
||||
).map(category => [
|
||||
category.id,
|
||||
{
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
group: category.group
|
||||
? (categoryGroupsById.get(category.group) ?? null)
|
||||
: null,
|
||||
} satisfies CategoryForFiltering,
|
||||
]),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
transactions.map(transaction => [
|
||||
transaction.id,
|
||||
{
|
||||
id: transaction.id,
|
||||
amount: transaction.amount,
|
||||
date: transaction.date,
|
||||
notes: transaction.notes ?? null,
|
||||
cleared: !!transaction.cleared,
|
||||
reconciled: !!transaction.reconciled,
|
||||
transfer_id: transaction.transfer_id ?? null,
|
||||
is_parent: !!transaction.is_parent,
|
||||
imported_payee: transaction.imported_payee ?? null,
|
||||
account: accountsById.get(transaction.account) ?? null,
|
||||
payee: transaction.payee
|
||||
? (payeesById.get(transaction.payee) ?? null)
|
||||
: null,
|
||||
category: transaction.category
|
||||
? (categoriesById.get(transaction.category) ?? null)
|
||||
: null,
|
||||
} satisfies ForecastFilterObject,
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TransactionEntity } from '#types/models';
|
||||
|
||||
import type { AccountWithComputedBalance } from './forecast-accounts';
|
||||
import type { ForecastFilterInfo } from './forecast-filters';
|
||||
import { projectForecastData } from './forecast-projection';
|
||||
import type { ForecastDateContext } from './forecast-projection';
|
||||
import type { ForecastScheduleOccurrence } from './forecast-schedules';
|
||||
|
||||
describe('forecast projection', () => {
|
||||
it('combines posted and scheduled deltas into running balances', () => {
|
||||
const accounts: AccountWithComputedBalance[] = [
|
||||
{
|
||||
id: 'acct-1',
|
||||
name: 'Checking',
|
||||
closed: 0,
|
||||
offbudget: 0,
|
||||
balance_current: 70,
|
||||
},
|
||||
];
|
||||
const transactions: TransactionEntity[] = [
|
||||
{
|
||||
id: 'starting-balance',
|
||||
account: 'acct-1',
|
||||
amount: 100,
|
||||
date: '2024-03-01',
|
||||
},
|
||||
{
|
||||
id: 'posted-spend',
|
||||
account: 'acct-1',
|
||||
amount: -40,
|
||||
date: '2024-03-03',
|
||||
},
|
||||
];
|
||||
const futureOccurrences: ForecastScheduleOccurrence[] = [
|
||||
{
|
||||
transaction: {
|
||||
id: 'occurrence-1',
|
||||
account: 'acct-1',
|
||||
amount: 10,
|
||||
date: '2024-03-02',
|
||||
},
|
||||
filterObject: {
|
||||
id: 'occurrence-1',
|
||||
amount: 10,
|
||||
date: '2024-03-02',
|
||||
notes: null,
|
||||
cleared: false,
|
||||
reconciled: false,
|
||||
transfer_id: null,
|
||||
is_parent: false,
|
||||
imported_payee: null,
|
||||
account: accounts[0],
|
||||
payee: null,
|
||||
category: null,
|
||||
},
|
||||
amount: 10,
|
||||
payee: 'Paycheck',
|
||||
scheduleId: 'sched-1',
|
||||
scheduleName: 'Paycheck',
|
||||
},
|
||||
];
|
||||
const filterInfo: ForecastFilterInfo = {
|
||||
filters: [],
|
||||
conditionsOpKey: '$and',
|
||||
canRestrictAccounts: false,
|
||||
};
|
||||
const dateContext: ForecastDateContext = {
|
||||
forecastStartDate: '2024-03-02',
|
||||
forecastEndDate: '2024-03-04',
|
||||
forecastDays: ['2024-03-02', '2024-03-03', '2024-03-04'],
|
||||
firstForecastDate: '2024-03-02',
|
||||
endDateObj: new Date('2024-03-04T00:00:00'),
|
||||
};
|
||||
|
||||
const result = projectForecastData({
|
||||
accounts,
|
||||
transactions,
|
||||
futureOccurrences,
|
||||
filterInfo,
|
||||
dateContext,
|
||||
});
|
||||
|
||||
expect(result.dataPoints.map(point => point.balance)).toEqual([
|
||||
110, 70, 70,
|
||||
]);
|
||||
expect(result.dataPoints[0].transactions).toMatchObject([
|
||||
{ amount: 10, scheduleId: 'sched-1' },
|
||||
]);
|
||||
expect(result.lowestBalance).toEqual({
|
||||
date: '2024-03-03',
|
||||
balance: 70,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
301
packages/loot-core/src/server/forecast/forecast-projection.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { addMonths, format } from 'date-fns';
|
||||
|
||||
import * as monthUtils from '#shared/months';
|
||||
import type { TransactionEntity } from '#types/models';
|
||||
import type { ForecastDataPoint, ForecastResult } from '#types/models/forecast';
|
||||
|
||||
import type { AccountWithComputedBalance } from './forecast-accounts';
|
||||
import { matchesForecastFilters } from './forecast-filters';
|
||||
import type { ForecastFilterInfo } from './forecast-filters';
|
||||
import type { ForecastScheduleOccurrence } from './forecast-schedules';
|
||||
|
||||
type ScheduleOccurrenceSummary = {
|
||||
amount: number;
|
||||
payee: string;
|
||||
scheduleId: string;
|
||||
scheduleName: string;
|
||||
};
|
||||
|
||||
type ScheduleOccurrencesByAccount = Record<
|
||||
string,
|
||||
Record<string, ScheduleOccurrenceSummary[]>
|
||||
>;
|
||||
|
||||
type PostedTransactionSummary = {
|
||||
startingBalance: number;
|
||||
txsByDay: Record<string, number>;
|
||||
};
|
||||
|
||||
export type ForecastDateContext = {
|
||||
forecastStartDate: string;
|
||||
forecastEndDate: string;
|
||||
forecastDays: string[];
|
||||
firstForecastDate: string;
|
||||
endDateObj: Date;
|
||||
};
|
||||
|
||||
type ProjectForecastDataParams = {
|
||||
accounts: AccountWithComputedBalance[];
|
||||
transactions: TransactionEntity[];
|
||||
futureOccurrences: ForecastScheduleOccurrence[];
|
||||
filterInfo: ForecastFilterInfo;
|
||||
dateContext: ForecastDateContext;
|
||||
};
|
||||
|
||||
function maxDate(...dates: string[]) {
|
||||
return dates.reduce((latest, current) =>
|
||||
current > latest ? current : latest,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildForecastDateContext(
|
||||
startDate: string | undefined,
|
||||
endDate: string | undefined,
|
||||
): ForecastDateContext {
|
||||
const today = new Date();
|
||||
const forecastStartDate = startDate || format(today, 'yyyy-MM-dd');
|
||||
const forecastEndDate = endDate || format(addMonths(today, 12), 'yyyy-MM-dd');
|
||||
const todayString = format(today, 'yyyy-MM-dd');
|
||||
|
||||
return {
|
||||
forecastStartDate,
|
||||
forecastEndDate,
|
||||
forecastDays: monthUtils.dayRangeInclusive(
|
||||
forecastStartDate,
|
||||
forecastEndDate,
|
||||
),
|
||||
firstForecastDate:
|
||||
forecastEndDate < todayString
|
||||
? forecastStartDate
|
||||
: maxDate(forecastStartDate, todayString),
|
||||
endDateObj: endDate ? monthUtils.parseDate(endDate) : addMonths(today, 12),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyForecastResult(
|
||||
forecastStartDate: string,
|
||||
forecastEndDate: string,
|
||||
): ForecastResult {
|
||||
return {
|
||||
dataPoints: [],
|
||||
lowestBalance: {
|
||||
date: forecastStartDate,
|
||||
balance: 0,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
},
|
||||
forecastStartDate,
|
||||
forecastEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
function addScheduleOccurrence(
|
||||
scheduleOccurrencesByAccount: ScheduleOccurrencesByAccount,
|
||||
accountId: string,
|
||||
date: string,
|
||||
occurrence: ScheduleOccurrenceSummary,
|
||||
) {
|
||||
if (!scheduleOccurrencesByAccount[accountId]) {
|
||||
scheduleOccurrencesByAccount[accountId] = {};
|
||||
}
|
||||
|
||||
if (!scheduleOccurrencesByAccount[accountId][date]) {
|
||||
scheduleOccurrencesByAccount[accountId][date] = [];
|
||||
}
|
||||
|
||||
scheduleOccurrencesByAccount[accountId][date].push(occurrence);
|
||||
}
|
||||
|
||||
export function indexScheduleOccurrences(
|
||||
futureOccurrences: ForecastScheduleOccurrence[],
|
||||
accountIdSet: Set<string>,
|
||||
filterInfo: ForecastFilterInfo,
|
||||
firstForecastDate: string,
|
||||
forecastEndDate: string,
|
||||
): ScheduleOccurrencesByAccount {
|
||||
const scheduleOccurrencesByAccount: ScheduleOccurrencesByAccount = {};
|
||||
|
||||
for (const occurrence of futureOccurrences) {
|
||||
if (
|
||||
occurrence.transaction.date < firstForecastDate ||
|
||||
occurrence.transaction.date > forecastEndDate ||
|
||||
!accountIdSet.has(occurrence.transaction.account) ||
|
||||
!matchesForecastFilters(occurrence.filterObject, filterInfo)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addScheduleOccurrence(
|
||||
scheduleOccurrencesByAccount,
|
||||
occurrence.transaction.account,
|
||||
occurrence.transaction.date,
|
||||
{
|
||||
amount: occurrence.amount,
|
||||
payee: occurrence.payee,
|
||||
scheduleId: occurrence.scheduleId,
|
||||
scheduleName: occurrence.scheduleName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return scheduleOccurrencesByAccount;
|
||||
}
|
||||
|
||||
function groupTransactionsByAccount(transactions: TransactionEntity[]) {
|
||||
return transactions.reduce<Map<string, TransactionEntity[]>>(
|
||||
(grouped, tx) => {
|
||||
const accountTransactions = grouped.get(tx.account);
|
||||
if (accountTransactions) {
|
||||
accountTransactions.push(tx);
|
||||
} else {
|
||||
grouped.set(tx.account, [tx]);
|
||||
}
|
||||
return grouped;
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizePostedTransactions(
|
||||
accountTransactions: TransactionEntity[],
|
||||
forecastStartDate: string,
|
||||
forecastEndDate: string,
|
||||
): PostedTransactionSummary {
|
||||
const summary: PostedTransactionSummary = {
|
||||
startingBalance: 0,
|
||||
txsByDay: {},
|
||||
};
|
||||
|
||||
for (const tx of accountTransactions) {
|
||||
if (tx.date < forecastStartDate) {
|
||||
summary.startingBalance += tx.amount;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tx.date > forecastEndDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.txsByDay[tx.date] = (summary.txsByDay[tx.date] || 0) + tx.amount;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildAccountForecastDataPoints(
|
||||
account: AccountWithComputedBalance,
|
||||
postedTransactions: PostedTransactionSummary,
|
||||
scheduleOccurrencesByDay: Record<string, ScheduleOccurrenceSummary[]>,
|
||||
forecastDays: string[],
|
||||
): ForecastDataPoint[] {
|
||||
let runningBalance = postedTransactions.startingBalance;
|
||||
|
||||
return forecastDays.map(day => {
|
||||
const txDelta = postedTransactions.txsByDay[day] || 0;
|
||||
const scheduleTxns = scheduleOccurrencesByDay[day] || [];
|
||||
const scheduleDelta = scheduleTxns.reduce((sum, tx) => sum + tx.amount, 0);
|
||||
runningBalance += txDelta + scheduleDelta;
|
||||
|
||||
return {
|
||||
date: day,
|
||||
balance: runningBalance,
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
transactions: scheduleTxns.map(scheduleTxn => ({
|
||||
amount: scheduleTxn.amount,
|
||||
payee: scheduleTxn.payee,
|
||||
scheduleId: scheduleTxn.scheduleId,
|
||||
scheduleName: scheduleTxn.scheduleName,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function calculateLowestBalance(
|
||||
dataPoints: ForecastDataPoint[],
|
||||
accounts: AccountWithComputedBalance[],
|
||||
forecastStartDate: string,
|
||||
) {
|
||||
const combinedByDate: Record<string, number> = {};
|
||||
for (const dataPoint of dataPoints) {
|
||||
combinedByDate[dataPoint.date] =
|
||||
(combinedByDate[dataPoint.date] || 0) + dataPoint.balance;
|
||||
}
|
||||
|
||||
let lowestBalance = {
|
||||
date: forecastStartDate,
|
||||
balance: Infinity,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
};
|
||||
|
||||
for (const [date, combinedBalance] of Object.entries(combinedByDate)) {
|
||||
if (combinedBalance < lowestBalance.balance) {
|
||||
lowestBalance = {
|
||||
date,
|
||||
balance: combinedBalance,
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (lowestBalance.balance === Infinity) {
|
||||
return {
|
||||
date: forecastStartDate,
|
||||
balance: accounts.reduce(
|
||||
(sum, account) => sum + account.balance_current,
|
||||
0,
|
||||
),
|
||||
accountId: '',
|
||||
accountName: '',
|
||||
};
|
||||
}
|
||||
|
||||
return lowestBalance;
|
||||
}
|
||||
|
||||
export function projectForecastData({
|
||||
accounts,
|
||||
transactions,
|
||||
futureOccurrences,
|
||||
filterInfo,
|
||||
dateContext,
|
||||
}: ProjectForecastDataParams): Pick<
|
||||
ForecastResult,
|
||||
'dataPoints' | 'lowestBalance'
|
||||
> {
|
||||
const accountIdsToQuery = accounts.map(account => account.id);
|
||||
const accountIdSet = new Set(accountIdsToQuery);
|
||||
const scheduleOccurrencesByAccount = indexScheduleOccurrences(
|
||||
futureOccurrences,
|
||||
accountIdSet,
|
||||
filterInfo,
|
||||
dateContext.firstForecastDate,
|
||||
dateContext.forecastEndDate,
|
||||
);
|
||||
const transactionsByAccount = groupTransactionsByAccount(transactions);
|
||||
const dataPoints = accounts.flatMap(account =>
|
||||
buildAccountForecastDataPoints(
|
||||
account,
|
||||
summarizePostedTransactions(
|
||||
transactionsByAccount.get(account.id) ?? [],
|
||||
dateContext.forecastStartDate,
|
||||
dateContext.forecastEndDate,
|
||||
),
|
||||
scheduleOccurrencesByAccount[account.id] ?? {},
|
||||
dateContext.forecastDays,
|
||||
),
|
||||
);
|
||||
|
||||
dataPoints.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return {
|
||||
dataPoints,
|
||||
lowestBalance: calculateLowestBalance(
|
||||
dataPoints,
|
||||
accounts,
|
||||
dateContext.forecastStartDate,
|
||||
),
|
||||
};
|
||||
}
|
||||
312
packages/loot-core/src/server/forecast/forecast-schedules.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { aqlQuery } from '#server/aql';
|
||||
import * as db from '#server/db';
|
||||
import { runRules } from '#server/transactions/transaction-rules';
|
||||
import * as monthUtils from '#shared/months';
|
||||
import { q } from '#shared/query';
|
||||
import {
|
||||
extractScheduleConds,
|
||||
getNextDate,
|
||||
getScheduledAmount,
|
||||
} from '#shared/schedules';
|
||||
import type { RuleConditionEntity, TransactionEntity } from '#types/models';
|
||||
import type { RecurConfig } from '#types/models/schedule';
|
||||
|
||||
import type {
|
||||
AccountWithComputedBalance,
|
||||
DbAccountForRules,
|
||||
} from './forecast-accounts';
|
||||
import { enrichForecastFilterObjects } from './forecast-filters';
|
||||
import type { ForecastFilterObject } from './forecast-filters';
|
||||
|
||||
/** Synthetic account for schedules with no account; combined forecast only when explicitly included. */
|
||||
export const FORECAST_UNASSIGNED_ACCOUNT_ID = '__unassigned_schedule__';
|
||||
|
||||
type ScheduleDataBase = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
next_date: string;
|
||||
rule?: string | null;
|
||||
_payee: string | null;
|
||||
_account: string;
|
||||
_amount: number;
|
||||
};
|
||||
|
||||
export type ScheduleData =
|
||||
| (ScheduleDataBase & { _date: string })
|
||||
| (ScheduleDataBase & { _date: RecurConfig });
|
||||
|
||||
type RawScheduleData = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
next_date: string;
|
||||
rule?: string | null;
|
||||
_payee?: string | null;
|
||||
_account?: string | null;
|
||||
_amount?: number | { num1: number; num2: number } | null;
|
||||
_date?: string | RecurConfig | null;
|
||||
_conditions?: RuleConditionEntity[];
|
||||
};
|
||||
|
||||
type TransferPayee = {
|
||||
id: string;
|
||||
name: string;
|
||||
transfer_acct: string | null;
|
||||
};
|
||||
|
||||
export type ForecastScheduleOccurrence = {
|
||||
transaction: TransactionEntity;
|
||||
filterObject: ForecastFilterObject;
|
||||
amount: number;
|
||||
payee: string;
|
||||
scheduleId: string;
|
||||
scheduleName: string;
|
||||
};
|
||||
|
||||
async function getSchedules() {
|
||||
const filter: Record<string, unknown> = {
|
||||
tombstone: false,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
const result = await aqlQuery(q('schedules').filter(filter).select('*'));
|
||||
return result.data as RawScheduleData[];
|
||||
}
|
||||
|
||||
export function normalizeSchedule(
|
||||
schedule: RawScheduleData,
|
||||
): ScheduleData | null {
|
||||
const conditions = extractScheduleConds(schedule._conditions || []);
|
||||
const accountId =
|
||||
schedule._account ??
|
||||
(conditions.account?.value as string | undefined) ??
|
||||
FORECAST_UNASSIGNED_ACCOUNT_ID;
|
||||
const amountValue = schedule._amount ?? conditions.amount?.value;
|
||||
const dateValue = schedule._date ?? conditions.date?.value;
|
||||
|
||||
if (amountValue == null || dateValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
next_date: schedule.next_date,
|
||||
rule: schedule.rule,
|
||||
_payee: schedule._payee ?? conditions.payee?.value ?? null,
|
||||
_account: accountId,
|
||||
_amount: getScheduledAmount(amountValue),
|
||||
_date: dateValue,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNormalizedSchedules() {
|
||||
const rawSchedules = await getSchedules();
|
||||
|
||||
return rawSchedules.flatMap(schedule => {
|
||||
const normalizedSchedule = normalizeSchedule(schedule);
|
||||
return normalizedSchedule ? [normalizedSchedule] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function getTransferPayeesByAccountIds(accountIds: string[]) {
|
||||
if (accountIds.length === 0) {
|
||||
return new Map<string, TransferPayee>();
|
||||
}
|
||||
|
||||
const { data: payees } = await aqlQuery(
|
||||
q('payees')
|
||||
.filter({ transfer_acct: { $oneof: accountIds } })
|
||||
.select(['id', 'name', 'transfer_acct']),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
(payees as TransferPayee[])
|
||||
.filter(
|
||||
(payee): payee is TransferPayee & { transfer_acct: string } =>
|
||||
payee.transfer_acct != null,
|
||||
)
|
||||
.map(payee => [payee.transfer_acct, payee]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getFutureOccurrenceDates(
|
||||
schedule: ScheduleData,
|
||||
endDate: Date,
|
||||
) {
|
||||
if (typeof schedule._date === 'string') {
|
||||
const singleDate = monthUtils.parseDate(schedule._date);
|
||||
return singleDate <= endDate ? [format(singleDate, 'yyyy-MM-dd')] : [];
|
||||
}
|
||||
|
||||
const dateCondition = { op: 'is', field: 'date', value: schedule._date };
|
||||
const maxIterations = 10_000;
|
||||
const dates = [schedule.next_date];
|
||||
const seenDates = new Set(dates);
|
||||
let day = monthUtils.parseDate(schedule.next_date);
|
||||
let iterations = 0;
|
||||
|
||||
while (day <= endDate && iterations < maxIterations) {
|
||||
iterations++;
|
||||
const nextDate = getNextDate(dateCondition, day);
|
||||
const parsedNextDate =
|
||||
nextDate != null ? monthUtils.parseDate(nextDate) : null;
|
||||
|
||||
if (!nextDate || !parsedNextDate || parsedNextDate > endDate) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (parsedNextDate <= day) {
|
||||
if (seenDates.has(nextDate)) {
|
||||
day = monthUtils.parseDate(monthUtils.addDays(day, 1));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
dates.push(nextDate);
|
||||
seenDates.add(nextDate);
|
||||
day = monthUtils.parseDate(monthUtils.addDays(nextDate, 1));
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
export async function buildFutureScheduleOccurrences(
|
||||
schedules: ScheduleData[],
|
||||
endDateObj: Date,
|
||||
accountsById: Map<string, AccountWithComputedBalance>,
|
||||
ruleAccountsById: Map<string, DbAccountForRules>,
|
||||
) {
|
||||
const transferPayeesByAccountId = await getTransferPayeesByAccountIds([
|
||||
...ruleAccountsById.keys(),
|
||||
]);
|
||||
const payeesById = new Map<string, Awaited<ReturnType<typeof db.getPayee>>>();
|
||||
const simulatedTransactions: TransactionEntity[] = [];
|
||||
const occurrences: Array<{
|
||||
accountId: string;
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
scheduleId: string;
|
||||
scheduleName: string;
|
||||
}> = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const scheduleName = schedule.name ?? 'Unknown';
|
||||
|
||||
for (const date of getFutureOccurrenceDates(schedule, endDateObj)) {
|
||||
const baseTransaction: TransactionEntity = {
|
||||
id: `forecast-${schedule.id}-${date}`,
|
||||
account: schedule._account,
|
||||
amount: schedule._amount,
|
||||
payee: schedule._payee,
|
||||
date,
|
||||
schedule: schedule.id,
|
||||
cleared: false,
|
||||
};
|
||||
const sourceTransaction = await runRules(
|
||||
baseTransaction,
|
||||
ruleAccountsById,
|
||||
);
|
||||
simulatedTransactions.push(sourceTransaction);
|
||||
occurrences.push({
|
||||
accountId: sourceTransaction.account,
|
||||
transactionId: sourceTransaction.id,
|
||||
amount: sourceTransaction.amount,
|
||||
scheduleId: schedule.id,
|
||||
scheduleName,
|
||||
});
|
||||
|
||||
if (sourceTransaction.account === FORECAST_UNASSIGNED_ACCOUNT_ID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let transferPayee = null;
|
||||
if (sourceTransaction.payee != null) {
|
||||
if (payeesById.has(sourceTransaction.payee)) {
|
||||
transferPayee = payeesById.get(sourceTransaction.payee) ?? null;
|
||||
} else {
|
||||
transferPayee = await db.getPayee(sourceTransaction.payee);
|
||||
payeesById.set(sourceTransaction.payee, transferPayee);
|
||||
}
|
||||
}
|
||||
const transferAccountId = transferPayee?.transfer_acct;
|
||||
|
||||
if (
|
||||
!transferAccountId ||
|
||||
transferAccountId === sourceTransaction.account
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reverseTransferPayee = transferPayeesByAccountId.get(
|
||||
sourceTransaction.account,
|
||||
);
|
||||
const transferTransaction = await runRules(
|
||||
{
|
||||
id: `${sourceTransaction.id}-transfer`,
|
||||
account: transferAccountId,
|
||||
amount: -sourceTransaction.amount,
|
||||
payee: reverseTransferPayee?.id ?? null,
|
||||
date: sourceTransaction.date,
|
||||
transfer_id: sourceTransaction.id,
|
||||
notes: sourceTransaction.notes ?? null,
|
||||
schedule: sourceTransaction.schedule,
|
||||
cleared: false,
|
||||
},
|
||||
ruleAccountsById,
|
||||
);
|
||||
|
||||
const sourceAccount = accountsById.get(sourceTransaction.account);
|
||||
const transferAccount = accountsById.get(transferTransaction.account);
|
||||
const shouldClearCategory =
|
||||
sourceAccount != null &&
|
||||
transferAccount != null &&
|
||||
sourceAccount.offbudget === transferAccount.offbudget;
|
||||
|
||||
if (shouldClearCategory) {
|
||||
sourceTransaction.category = undefined;
|
||||
transferTransaction.category = undefined;
|
||||
}
|
||||
|
||||
sourceTransaction.transfer_id = transferTransaction.id;
|
||||
simulatedTransactions.push(transferTransaction);
|
||||
occurrences.push({
|
||||
accountId: transferTransaction.account,
|
||||
transactionId: transferTransaction.id,
|
||||
amount: transferTransaction.amount,
|
||||
scheduleId: schedule.id,
|
||||
scheduleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filterObjectsByTransactionId = await enrichForecastFilterObjects(
|
||||
simulatedTransactions,
|
||||
accountsById,
|
||||
);
|
||||
const transactionsById = new Map(
|
||||
simulatedTransactions.map(transaction => [transaction.id, transaction]),
|
||||
);
|
||||
|
||||
return occurrences.map(occurrence => {
|
||||
const filterObject = filterObjectsByTransactionId.get(
|
||||
occurrence.transactionId,
|
||||
);
|
||||
const transaction = transactionsById.get(occurrence.transactionId);
|
||||
|
||||
if (!filterObject || !transaction) {
|
||||
throw new Error('Missing simulated forecast transaction data');
|
||||
}
|
||||
|
||||
return {
|
||||
transaction,
|
||||
filterObject,
|
||||
amount: occurrence.amount,
|
||||
payee: filterObject.payee?.name ?? occurrence.scheduleName,
|
||||
scheduleId: occurrence.scheduleId,
|
||||
scheduleName: occurrence.scheduleName,
|
||||
} satisfies ForecastScheduleOccurrence;
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import * as db from './db';
|
||||
import * as encryption from './encryption';
|
||||
import { app as encryptionApp } from './encryption/app';
|
||||
import { app as filtersApp } from './filters/app';
|
||||
import { app as forecastApp } from './forecast/app';
|
||||
import { app } from './main-app';
|
||||
import { mutator, runHandler } from './mutators';
|
||||
import { app as notesApp } from './notes/app';
|
||||
@@ -134,6 +135,7 @@ app.combine(
|
||||
preferencesApp,
|
||||
toolsApp,
|
||||
filtersApp,
|
||||
forecastApp,
|
||||
reportsApp,
|
||||
rulesApp,
|
||||
adminApp,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BudgetFileHandlers } from '#server/budgetfiles/app';
|
||||
import type { DashboardHandlers } from '#server/dashboard/app';
|
||||
import type { EncryptionHandlers } from '#server/encryption/app';
|
||||
import type { FiltersHandlers } from '#server/filters/app';
|
||||
import type { ForecastHandlers } from '#server/forecast/app';
|
||||
import type { NotesHandlers } from '#server/notes/app';
|
||||
import type { PayeesHandlers } from '#server/payees/app';
|
||||
import type { PreferencesHandlers } from '#server/preferences/app';
|
||||
@@ -26,6 +27,7 @@ export type Handlers = {} & ServerHandlers &
|
||||
BudgetHandlers &
|
||||
DashboardHandlers &
|
||||
FiltersHandlers &
|
||||
ForecastHandlers &
|
||||
NotesHandlers &
|
||||
PreferencesHandlers &
|
||||
ReportsHandlers &
|
||||
|
||||
@@ -129,7 +129,8 @@ type SpecializedWidget =
|
||||
| CalendarWidget
|
||||
| FormulaWidget
|
||||
| SankeyWidget
|
||||
| AgeOfMoneyWidget;
|
||||
| AgeOfMoneyWidget
|
||||
| BalanceForecastWidget;
|
||||
export type DashboardWidgetEntity = SpecializedWidget | CustomReportWidget;
|
||||
export type NewDashboardWidgetEntity = Omit<
|
||||
DashboardWidgetEntity,
|
||||
@@ -227,3 +228,17 @@ export type SankeyWidget = AbstractWidget<
|
||||
layerTo?: string;
|
||||
} | null
|
||||
>;
|
||||
|
||||
export type BalanceForecastWidget = AbstractWidget<
|
||||
'balance-forecast-card',
|
||||
{
|
||||
name?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
accounts?: string[];
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
timeFrame?: TimeFrame;
|
||||
granularity?: 'Daily' | 'Monthly';
|
||||
} | null
|
||||
>;
|
||||
|
||||
36
packages/loot-core/src/types/models/forecast.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type ForecastDataPoint = {
|
||||
date: string;
|
||||
balance: number;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
transactions: ForecastTransaction[];
|
||||
};
|
||||
|
||||
export type ForecastTransaction = {
|
||||
amount: number;
|
||||
payee: string | null;
|
||||
scheduleId: string;
|
||||
scheduleName: string;
|
||||
};
|
||||
|
||||
export type BalanceForecastConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
forecastMonths: number;
|
||||
selectedAccounts: string[];
|
||||
showCombined: boolean;
|
||||
showIndividual: boolean;
|
||||
tombstone?: boolean;
|
||||
};
|
||||
|
||||
export type ForecastResult = {
|
||||
dataPoints: ForecastDataPoint[];
|
||||
lowestBalance: {
|
||||
date: string;
|
||||
balance: number;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
};
|
||||
forecastStartDate: string;
|
||||
forecastEndDate: string;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export type * from './category';
|
||||
export type * from './category-group';
|
||||
export type * from './dashboard';
|
||||
export type * from './enablebanking';
|
||||
export type * from './forecast';
|
||||
export type * from './gocardless';
|
||||
export type * from './import-transaction';
|
||||
export type * from './nearby-payee';
|
||||
|
||||
@@ -5,6 +5,8 @@ export type FeatureFlag =
|
||||
| 'formulaMode'
|
||||
| 'currency'
|
||||
| 'ageOfMoneyReport'
|
||||
| 'balanceForecastReport'
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport'
|
||||
| 'payeeLocations'
|
||||
| 'enableBanking'
|
||||
|
||||
6
upcoming-release-notes/7310.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [samaluk]
|
||||
---
|
||||
|
||||
New Balance Forecast Report
|
||||