mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-18 01:03:42 -05:00
[AI] Skip posted schedule occurrences in balance forecast (#8029)
* [AI] Extract schedule occurrence match start date helper Centralize the lower-bound rules for matching posted transactions to schedule dates and reuse them in getHasTransactionsQuery, including the correct 2-day lookback for recurring schedules stored with op is. * [AI] Add schedule occurrence posted matching helpers Add isScheduleOccurrencePosted with the match-start logic built in and indexPostedScheduleTransactions for efficient per-schedule lookups. * [AI] Skip posted schedule occurrences in balance forecast Skip synthetic schedule occurrences when a posted transaction already covers that date, using indexed per-schedule lookups in the forecast path. * [AI] Add release notes for balance forecast schedule dedup fix * [AI] Treat op is schedule dates as exact match start Restore master semantics: recurring schedules with op is use exact occurrence matching, not a 2-day lookback. Remove conflicting tests. * [AI] Add daily recurring forecast dedup regression test Prove daily schedules with op is do not double-count when posted on the due date, while subsequent occurrences are still forecast.
This commit is contained in:
@@ -20,6 +20,106 @@ const createSchedule = createScheduleBase as (args: {
|
||||
conditions: RuleConditionEntity[];
|
||||
}) => Promise<string>;
|
||||
|
||||
async function createForecastWithPostedMonthlySchedule({
|
||||
txId,
|
||||
txDate,
|
||||
}: {
|
||||
txId: string;
|
||||
txDate: string;
|
||||
}) {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
const salaryAmount = 500_000;
|
||||
|
||||
const scheduleId = await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'account', value: accountId },
|
||||
{ op: 'is', field: 'amount', value: salaryAmount },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-10',
|
||||
frequency: 'monthly',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
await db.insertTransaction({
|
||||
id: txId,
|
||||
account: accountId,
|
||||
amount: salaryAmount,
|
||||
date: txDate,
|
||||
schedule: scheduleId,
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [accountId],
|
||||
startDate: '2024-03-01',
|
||||
endDate: '2024-04-30',
|
||||
});
|
||||
|
||||
return {
|
||||
salaryAmount,
|
||||
balanceByDate: Object.fromEntries(
|
||||
result.dataPoints.map(({ date, balance }) => [date, balance]),
|
||||
),
|
||||
dataPointByDate: Object.fromEntries(
|
||||
result.dataPoints.map(dataPoint => [dataPoint.date, dataPoint]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function createForecastWithPostedDailySchedule({
|
||||
txId,
|
||||
txDate,
|
||||
}: {
|
||||
txId: string;
|
||||
txDate: string;
|
||||
}) {
|
||||
const accountId = await db.insertAccount({ id: 'acct', name: 'Checking' });
|
||||
const amount = -5_000;
|
||||
|
||||
const scheduleId = await createSchedule({
|
||||
conditions: [
|
||||
{ op: 'is', field: 'account', value: accountId },
|
||||
{ op: 'is', field: 'amount', value: amount },
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: {
|
||||
start: '2024-03-10',
|
||||
frequency: 'daily',
|
||||
},
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
});
|
||||
|
||||
await db.insertTransaction({
|
||||
id: txId,
|
||||
account: accountId,
|
||||
amount,
|
||||
date: txDate,
|
||||
schedule: scheduleId,
|
||||
});
|
||||
|
||||
const result = await generateForecast({
|
||||
accountIds: [accountId],
|
||||
startDate: '2024-03-10',
|
||||
endDate: '2024-03-12',
|
||||
});
|
||||
|
||||
return {
|
||||
amount,
|
||||
balanceByDate: Object.fromEntries(
|
||||
result.dataPoints.map(({ date, balance }) => [date, balance]),
|
||||
),
|
||||
dataPointByDate: Object.fromEntries(
|
||||
result.dataPoints.map(dataPoint => [dataPoint.date, dataPoint]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await emptyDatabase()();
|
||||
await loadMappings();
|
||||
@@ -385,6 +485,39 @@ describe('forecast app', () => {
|
||||
expect(balanceByDate['2024-03-31']).toBe(100);
|
||||
});
|
||||
|
||||
it('does not double-count schedule occurrences already posted on the due date', async () => {
|
||||
const { salaryAmount, balanceByDate, dataPointByDate } =
|
||||
await createForecastWithPostedMonthlySchedule({
|
||||
txId: 'posted-salary',
|
||||
txDate: '2024-03-10',
|
||||
});
|
||||
|
||||
expect(balanceByDate['2024-03-09']).toBe(0);
|
||||
expect(balanceByDate['2024-03-10']).toBe(salaryAmount);
|
||||
expect(balanceByDate['2024-04-09']).toBe(salaryAmount);
|
||||
expect(balanceByDate['2024-04-10']).toBe(salaryAmount * 2);
|
||||
expect(dataPointByDate['2024-03-10'].transactions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not double-count daily recurring schedule occurrences with op is posted on the due date', async () => {
|
||||
const { amount, balanceByDate, dataPointByDate } =
|
||||
await createForecastWithPostedDailySchedule({
|
||||
txId: 'posted-daily',
|
||||
txDate: '2024-03-10',
|
||||
});
|
||||
|
||||
expect(balanceByDate['2024-03-10']).toBe(amount);
|
||||
expect(dataPointByDate['2024-03-10'].transactions).toEqual([]);
|
||||
expect(dataPointByDate['2024-03-11']).toMatchObject({
|
||||
balance: amount * 2,
|
||||
transactions: [{ amount }],
|
||||
});
|
||||
expect(dataPointByDate['2024-03-12']).toMatchObject({
|
||||
balance: amount * 3,
|
||||
transactions: [{ amount }],
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function generateForecast({
|
||||
dateContext.endDateObj,
|
||||
accountsById,
|
||||
ruleAccountsById,
|
||||
transactions,
|
||||
);
|
||||
const { dataPoints, lowestBalance } = projectForecastData({
|
||||
accounts,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
extractScheduleConds,
|
||||
getNextDate,
|
||||
getScheduledAmount,
|
||||
indexPostedScheduleTransactions,
|
||||
isScheduleOccurrencePosted,
|
||||
} from '#shared/schedules';
|
||||
import type { RuleConditionEntity, TransactionEntity } from '#types/models';
|
||||
import type { RecurConfig } from '#types/models/schedule';
|
||||
@@ -28,6 +30,8 @@ type ScheduleDataBase = {
|
||||
name: string | null;
|
||||
next_date: string;
|
||||
rule?: string | null;
|
||||
posts_transaction: boolean;
|
||||
_conditions: RuleConditionEntity[];
|
||||
_payee: string | null;
|
||||
_account: string;
|
||||
_amount: number;
|
||||
@@ -42,6 +46,7 @@ type RawScheduleData = {
|
||||
name: string | null;
|
||||
next_date: string;
|
||||
rule?: string | null;
|
||||
posts_transaction?: boolean;
|
||||
_payee?: string | null;
|
||||
_account?: string | null;
|
||||
_amount?: number | { num1: number; num2: number } | null;
|
||||
@@ -94,6 +99,8 @@ export function normalizeSchedule(
|
||||
name: schedule.name,
|
||||
next_date: schedule.next_date,
|
||||
rule: schedule.rule,
|
||||
posts_transaction: schedule.posts_transaction ?? false,
|
||||
_conditions: schedule._conditions ?? [],
|
||||
_payee: schedule._payee ?? conditions.payee?.value ?? null,
|
||||
_account: accountId,
|
||||
_amount: getScheduledAmount(amountValue),
|
||||
@@ -157,12 +164,9 @@ export function getFutureOccurrenceDates(
|
||||
break;
|
||||
}
|
||||
|
||||
if (parsedNextDate <= day) {
|
||||
if (seenDates.has(nextDate)) {
|
||||
day = monthUtils.parseDate(monthUtils.addDays(day, 1));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
if (seenDates.has(nextDate)) {
|
||||
day = monthUtils.parseDate(monthUtils.addDays(day, 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
dates.push(nextDate);
|
||||
@@ -178,7 +182,10 @@ export async function buildFutureScheduleOccurrences(
|
||||
endDateObj: Date,
|
||||
accountsById: Map<string, AccountWithComputedBalance>,
|
||||
ruleAccountsById: Map<string, DbAccountForRules>,
|
||||
postedTransactions: TransactionEntity[],
|
||||
) {
|
||||
const postedByScheduleId =
|
||||
indexPostedScheduleTransactions(postedTransactions);
|
||||
const transferPayeesByAccountId = await getTransferPayeesByAccountIds([
|
||||
...ruleAccountsById.keys(),
|
||||
]);
|
||||
@@ -196,6 +203,17 @@ export async function buildFutureScheduleOccurrences(
|
||||
const scheduleName = schedule.name ?? 'Unknown';
|
||||
|
||||
for (const date of getFutureOccurrenceDates(schedule, endDateObj)) {
|
||||
if (
|
||||
isScheduleOccurrencePosted({
|
||||
schedule,
|
||||
scheduleId: schedule.id,
|
||||
occurrenceDate: date,
|
||||
postedTransactions: postedByScheduleId.get(schedule.id) ?? [],
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseTransaction: TransactionEntity = {
|
||||
id: `forecast-${schedule.id}-${date}`,
|
||||
account: schedule._account,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import MockDate from 'mockdate';
|
||||
|
||||
import type { ScheduleEntity } from '#types/models';
|
||||
import type { RuleConditionEntity, ScheduleEntity } from '#types/models';
|
||||
|
||||
import * as monthUtils from './months';
|
||||
import {
|
||||
computeSchedulePreviewTransactions,
|
||||
getNextDate,
|
||||
getScheduleOccurrenceMatchStartDate,
|
||||
getStatus,
|
||||
getUpcomingDays,
|
||||
indexPostedScheduleTransactions,
|
||||
isScheduleOccurrencePosted,
|
||||
} from './schedules';
|
||||
import type { ScheduleStatuses } from './schedules';
|
||||
|
||||
@@ -266,6 +269,166 @@ describe('schedules', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScheduleOccurrenceMatchStartDate', () => {
|
||||
const occurrenceDate = '2024-03-10';
|
||||
|
||||
it('uses exact date for one-time schedules', () => {
|
||||
expect(
|
||||
getScheduleOccurrenceMatchStartDate(
|
||||
{
|
||||
_conditions: [{ op: 'is', field: 'date', value: occurrenceDate }],
|
||||
},
|
||||
occurrenceDate,
|
||||
),
|
||||
).toBe(occurrenceDate);
|
||||
});
|
||||
|
||||
it('uses exact date for auto-posted recurring schedules', () => {
|
||||
expect(
|
||||
getScheduleOccurrenceMatchStartDate(
|
||||
{ posts_transaction: true },
|
||||
occurrenceDate,
|
||||
),
|
||||
).toBe(occurrenceDate);
|
||||
});
|
||||
|
||||
it('uses a 2-day lookback for manual recurring schedules', () => {
|
||||
expect(
|
||||
getScheduleOccurrenceMatchStartDate(
|
||||
{ posts_transaction: false },
|
||||
occurrenceDate,
|
||||
),
|
||||
).toBe('2024-03-08');
|
||||
});
|
||||
|
||||
it('uses exact date for recurring schedules with op is', () => {
|
||||
expect(
|
||||
getScheduleOccurrenceMatchStartDate(
|
||||
{
|
||||
posts_transaction: false,
|
||||
_conditions: [
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: { start: occurrenceDate, frequency: 'monthly' },
|
||||
},
|
||||
],
|
||||
},
|
||||
occurrenceDate,
|
||||
),
|
||||
).toBe(occurrenceDate);
|
||||
});
|
||||
|
||||
it('uses exact date for daily recurring schedules with op is', () => {
|
||||
expect(
|
||||
getScheduleOccurrenceMatchStartDate(
|
||||
{
|
||||
posts_transaction: false,
|
||||
_conditions: [
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: { start: occurrenceDate, frequency: 'daily' },
|
||||
},
|
||||
],
|
||||
},
|
||||
occurrenceDate,
|
||||
),
|
||||
).toBe(occurrenceDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexPostedScheduleTransactions', () => {
|
||||
it('groups schedule-linked transactions by schedule id', () => {
|
||||
const indexed = indexPostedScheduleTransactions([
|
||||
{ schedule: 'sched-1', date: '2024-03-09' },
|
||||
{ schedule: 'sched-2', date: '2024-03-10' },
|
||||
{ schedule: 'sched-1', date: '2024-04-10' },
|
||||
{ date: '2024-03-11' },
|
||||
]);
|
||||
|
||||
expect(indexed.get('sched-1')).toEqual([
|
||||
{ schedule: 'sched-1', date: '2024-03-09' },
|
||||
{ schedule: 'sched-1', date: '2024-04-10' },
|
||||
]);
|
||||
expect(indexed.get('sched-2')).toEqual([
|
||||
{ schedule: 'sched-2', date: '2024-03-10' },
|
||||
]);
|
||||
expect(indexed.has('missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isScheduleOccurrencePosted', () => {
|
||||
const scheduleId = 'sched-1';
|
||||
const occurrenceDate = '2024-03-10';
|
||||
const manualRecurringSchedule = { posts_transaction: false };
|
||||
const autoPostSchedule = { posts_transaction: true };
|
||||
const oneTimeSchedule = {
|
||||
_conditions: [
|
||||
{ op: 'is', field: 'date', value: occurrenceDate } as const,
|
||||
],
|
||||
};
|
||||
const manualRecurringWithIsOp = {
|
||||
posts_transaction: false,
|
||||
_conditions: [
|
||||
{
|
||||
op: 'is',
|
||||
field: 'date',
|
||||
value: { start: occurrenceDate, frequency: 'monthly' },
|
||||
},
|
||||
] satisfies RuleConditionEntity[],
|
||||
};
|
||||
|
||||
function expectPosted(
|
||||
schedule: Parameters<typeof getScheduleOccurrenceMatchStartDate>[0],
|
||||
txDate: string,
|
||||
expected: boolean,
|
||||
) {
|
||||
expect(
|
||||
isScheduleOccurrencePosted({
|
||||
schedule,
|
||||
scheduleId,
|
||||
occurrenceDate,
|
||||
postedTransactions: [{ schedule: scheduleId, date: txDate }],
|
||||
}),
|
||||
).toBe(expected);
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'same-day manual recurring',
|
||||
manualRecurringSchedule,
|
||||
occurrenceDate,
|
||||
true,
|
||||
],
|
||||
[
|
||||
'early pay day before due for recurring date cond',
|
||||
manualRecurringWithIsOp,
|
||||
'2024-03-09',
|
||||
false,
|
||||
],
|
||||
['early pay within 2 days', manualRecurringSchedule, '2024-03-09', true],
|
||||
[
|
||||
'early pay outside window',
|
||||
manualRecurringSchedule,
|
||||
'2024-03-07',
|
||||
false,
|
||||
],
|
||||
['auto-post day before due', autoPostSchedule, '2024-03-09', false],
|
||||
['auto-post on due date', autoPostSchedule, occurrenceDate, true],
|
||||
['one-time on due date', oneTimeSchedule, occurrenceDate, true],
|
||||
['one-time day before due', oneTimeSchedule, '2024-03-09', false],
|
||||
[
|
||||
'later month tx does not satisfy earlier occurrence',
|
||||
manualRecurringSchedule,
|
||||
'2024-04-10',
|
||||
false,
|
||||
],
|
||||
] as const)('%s', (_label, schedule, txDate, expected) => {
|
||||
expectPosted(schedule, txDate, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextDate', () => {
|
||||
it('returns last occurrence for a recurring schedule with an end date in the past', () => {
|
||||
const dateCond = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { IRuleOptions } from '@rschedule/core';
|
||||
import * as d from 'date-fns';
|
||||
|
||||
import { Condition } from '#server/rules';
|
||||
import type { ScheduleEntity } from '#types/models';
|
||||
import type { RuleConditionEntity, ScheduleEntity } from '#types/models';
|
||||
|
||||
import * as monthUtils from './months';
|
||||
import { q } from './query';
|
||||
@@ -34,29 +34,103 @@ export function getStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export type ScheduleOccurrenceMatchInput = {
|
||||
posts_transaction?: boolean;
|
||||
_conditions?: RuleConditionEntity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lower bound for matching a posted transaction to a schedule occurrence date.
|
||||
*
|
||||
* Used by getHasTransactionsQuery for `next_date` (lower bound only). Forecast
|
||||
* occurrence dedup also applies an upper bound of `occurrenceDate`; see
|
||||
* isScheduleOccurrencePosted.
|
||||
*/
|
||||
export function getScheduleOccurrenceMatchStartDate(
|
||||
schedule: ScheduleOccurrenceMatchInput,
|
||||
occurrenceDate: string,
|
||||
): string {
|
||||
const dateCond = schedule._conditions?.find(c => c.field === 'date');
|
||||
if (dateCond?.op === 'is') {
|
||||
return occurrenceDate;
|
||||
}
|
||||
if (schedule.posts_transaction) {
|
||||
return occurrenceDate;
|
||||
}
|
||||
return monthUtils.subDays(occurrenceDate, 2);
|
||||
}
|
||||
|
||||
export type PostedScheduleTransaction = {
|
||||
schedule?: string | null;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export function indexPostedScheduleTransactions(
|
||||
transactions: PostedScheduleTransaction[],
|
||||
): Map<string, PostedScheduleTransaction[]> {
|
||||
const byScheduleId = new Map<string, PostedScheduleTransaction[]>();
|
||||
|
||||
for (const transaction of transactions) {
|
||||
if (!transaction.schedule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = byScheduleId.get(transaction.schedule);
|
||||
if (existing) {
|
||||
existing.push(transaction);
|
||||
} else {
|
||||
byScheduleId.set(transaction.schedule, [transaction]);
|
||||
}
|
||||
}
|
||||
|
||||
return byScheduleId;
|
||||
}
|
||||
|
||||
export function isScheduleOccurrencePosted({
|
||||
schedule,
|
||||
scheduleId,
|
||||
occurrenceDate,
|
||||
postedTransactions,
|
||||
}: {
|
||||
schedule: ScheduleOccurrenceMatchInput;
|
||||
scheduleId: string;
|
||||
occurrenceDate: string;
|
||||
postedTransactions: PostedScheduleTransaction[];
|
||||
}): boolean {
|
||||
const matchStartDate = getScheduleOccurrenceMatchStartDate(
|
||||
schedule,
|
||||
occurrenceDate,
|
||||
);
|
||||
|
||||
return postedTransactions.some(
|
||||
tx =>
|
||||
tx.schedule === scheduleId &&
|
||||
tx.date >= matchStartDate &&
|
||||
tx.date <= occurrenceDate,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a query to check if each schedule already has a matching transaction.
|
||||
*
|
||||
* The date lower-bound varies:
|
||||
* - `dateCond.op === 'is'` (one-time): exact `next_date`, no lookback.
|
||||
* - `dateCond.op === 'is'` (one-time or recurring): exact `next_date`, no lookback.
|
||||
* - `posts_transaction` (auto-posted recurring): exact `next_date`, since
|
||||
* auto-posted dates are always precise. A lookback here would cause
|
||||
* yesterday's transaction to falsely match today's occurrence.
|
||||
* - Otherwise (manual recurring): 2-day lookback to catch early payments.
|
||||
* - Otherwise (manual recurring with `isapprox`, etc.): 2-day lookback to catch
|
||||
* early payments.
|
||||
*/
|
||||
export function getHasTransactionsQuery(schedules) {
|
||||
const filters = schedules.map(schedule => {
|
||||
const dateCond = schedule._conditions?.find(c => c.field === 'date');
|
||||
return {
|
||||
$and: {
|
||||
schedule: schedule.id,
|
||||
date: {
|
||||
$gte:
|
||||
dateCond && dateCond.op === 'is'
|
||||
? schedule.next_date
|
||||
: schedule.posts_transaction
|
||||
? schedule.next_date
|
||||
: monthUtils.subDays(schedule.next_date, 2),
|
||||
$gte: getScheduleOccurrenceMatchStartDate(
|
||||
schedule,
|
||||
schedule.next_date,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
6
upcoming-release-notes/8029.md
Normal file
6
upcoming-release-notes/8029.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [samaluk]
|
||||
---
|
||||
|
||||
Fix Balance Forecast double-counting scheduled transactions that were already posted.
|
||||
Reference in New Issue
Block a user