From edc0cd9fbcf2f71d2db6aeda649d095be11b93a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Mon, 8 Jun 2026 07:40:51 -0400 Subject: [PATCH] [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. --- .../loot-core/src/server/forecast/app.test.ts | 133 ++++++++++++++ packages/loot-core/src/server/forecast/app.ts | 1 + .../src/server/forecast/forecast-schedules.ts | 30 +++- .../loot-core/src/shared/schedules.test.ts | 165 +++++++++++++++++- packages/loot-core/src/shared/schedules.ts | 94 ++++++++-- upcoming-release-notes/8029.md | 6 + 6 files changed, 412 insertions(+), 17 deletions(-) create mode 100644 upcoming-release-notes/8029.md diff --git a/packages/loot-core/src/server/forecast/app.test.ts b/packages/loot-core/src/server/forecast/app.test.ts index 3f71e36159..242ddad1d3 100644 --- a/packages/loot-core/src/server/forecast/app.test.ts +++ b/packages/loot-core/src/server/forecast/app.test.ts @@ -20,6 +20,106 @@ const createSchedule = createScheduleBase as (args: { conditions: RuleConditionEntity[]; }) => Promise; +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' }); diff --git a/packages/loot-core/src/server/forecast/app.ts b/packages/loot-core/src/server/forecast/app.ts index fcdc82bbcd..6c5d2dfe67 100644 --- a/packages/loot-core/src/server/forecast/app.ts +++ b/packages/loot-core/src/server/forecast/app.ts @@ -115,6 +115,7 @@ export async function generateForecast({ dateContext.endDateObj, accountsById, ruleAccountsById, + transactions, ); const { dataPoints, lowestBalance } = projectForecastData({ accounts, diff --git a/packages/loot-core/src/server/forecast/forecast-schedules.ts b/packages/loot-core/src/server/forecast/forecast-schedules.ts index 3e63546456..0ecd7da23c 100644 --- a/packages/loot-core/src/server/forecast/forecast-schedules.ts +++ b/packages/loot-core/src/server/forecast/forecast-schedules.ts @@ -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, ruleAccountsById: Map, + 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, diff --git a/packages/loot-core/src/shared/schedules.test.ts b/packages/loot-core/src/shared/schedules.test.ts index 3795e921fe..ca1a0bf992 100644 --- a/packages/loot-core/src/shared/schedules.test.ts +++ b/packages/loot-core/src/shared/schedules.test.ts @@ -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[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 = { diff --git a/packages/loot-core/src/shared/schedules.ts b/packages/loot-core/src/shared/schedules.ts index 39743f8815..d2d98689df 100644 --- a/packages/loot-core/src/shared/schedules.ts +++ b/packages/loot-core/src/shared/schedules.ts @@ -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 { + const byScheduleId = new Map(); + + 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, + ), }, }, }; diff --git a/upcoming-release-notes/8029.md b/upcoming-release-notes/8029.md new file mode 100644 index 0000000000..aa08043ec2 --- /dev/null +++ b/upcoming-release-notes/8029.md @@ -0,0 +1,6 @@ +--- +category: Bugfixes +authors: [samaluk] +--- + +Fix Balance Forecast double-counting scheduled transactions that were already posted.