[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
This commit is contained in:
Sebastián Maluk
2026-05-14 22:07:32 -04:00
committed by GitHub
parent 2e0342574f
commit e8d95fdf6b
41 changed files with 4062 additions and 123 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
});
}

View File

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

View File

@@ -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',
],

View 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.
![Balance Forecast report showing projected balances over time](/img/experimental/balance-forecast-report/balance-forecast-report-overview.png)
## 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.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View 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);
});
});

View 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);

View 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);
}

View File

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

View 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,
]),
);
}

View File

@@ -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: '',
});
});
});

View 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,
),
};
}

View 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;
});
}

View File

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

View File

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

View File

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

View 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;
};

View File

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

View File

@@ -5,6 +5,8 @@ export type FeatureFlag =
| 'formulaMode'
| 'currency'
| 'ageOfMoneyReport'
| 'balanceForecastReport'
| 'customThemes'
| 'budgetAnalysisReport'
| 'payeeLocations'
| 'enableBanking'

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [samaluk]
---
New Balance Forecast Report