[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:
Sebastián Maluk
2026-06-08 07:40:51 -04:00
committed by GitHub
parent 9350b2ddc1
commit edc0cd9fbc
6 changed files with 412 additions and 17 deletions

View File

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

View File

@@ -115,6 +115,7 @@ export async function generateForecast({
dateContext.endDateObj,
accountsById,
ruleAccountsById,
transactions,
);
const { dataPoints, lowestBalance } = projectForecastData({
accounts,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [samaluk]
---
Fix Balance Forecast double-counting scheduled transactions that were already posted.