From c581a8016cb25ba2042ac79030ed6b83cc1abd77 Mon Sep 17 00:00:00 2001 From: shall0pass <20625555+shall0pass@users.noreply.github.com> Date: Sat, 5 Aug 2023 14:26:27 -0500 Subject: [PATCH] Goals: Schedule multi month forecasting (#1452) References discord discussion starting here: https://discord.com/channels/937901803608096828/940290142579605514/1133523705063030824 Currently the schedule keyword won't fill any future budget cells if the category balance already satisfies the schedule. This PR is an attempt to improve the behavior by allowing budget fills regardless of the category balance. This is a drastic rewrite of the schedule keyword. Though I've tried not to have any regressions, it is possible because of how different the logic is. I've tested compounding using a simple template, so a small change in the 'by' keyword was also made. --- .../src/server/budget/goaltemplates.ts | 244 ++++++++++++------ upcoming-release-notes/1452.md | 6 + 2 files changed, 171 insertions(+), 79 deletions(-) create mode 100644 upcoming-release-notes/1452.md diff --git a/packages/loot-core/src/server/budget/goaltemplates.ts b/packages/loot-core/src/server/budget/goaltemplates.ts index 06a5ef21d..eede6a8c0 100644 --- a/packages/loot-core/src/server/budget/goaltemplates.ts +++ b/packages/loot-core/src/server/budget/goaltemplates.ts @@ -1,9 +1,6 @@ import { Notification } from '../../client/state-types/notifications'; import * as monthUtils from '../../shared/months'; -import { - extractScheduleConds, - getScheduledAmount, -} from '../../shared/schedules'; +import { extractScheduleConds } from '../../shared/schedules'; import { amountToInteger, integerToAmount } from '../../shared/util'; import * as db from '../db'; import { getRuleForSchedule, getNextDate } from '../schedules/app'; @@ -156,9 +153,19 @@ async function processTemplate(month, force, category_templates) { let isScheduleOrBy = false; let priorityCheck = 0; if ( - template.filter(t => t.type === 'schedule' || t.type === 'by') - .length > 0 + template.filter( + t => + (t.type === 'schedule' || t.type === 'by') && + t.priority === priority, + ).length > 0 ) { + template = template.filter( + t => + (t.priority === priority && + (t.type !== 'schedule' || t.type !== 'by')) || + t.type === 'schedule' || + t.type === 'by', + ); let { lowPriority, errorNotice } = await checkScheduleTemplates( template, ); @@ -322,6 +329,8 @@ async function applyCategoryTemplate( ); all_schedule_names = all_schedule_names.map(v => v.name); + let scheduleFlag = false; //only run schedules portion once + // remove lines for past dates, calculate repeating dates template_lines = template_lines.filter(template => { switch (template.type) { @@ -377,6 +386,8 @@ async function applyCategoryTemplate( `${a.month}-01`, `${b.month}-01`, ); + } else if (a.type === 'schedule' || b.type === 'schedule') { + return a.priority - b.priority; } else { return a.type.localeCompare(b.type); } @@ -411,7 +422,7 @@ async function applyCategoryTemplate( } else { increment = limit; } - if (increment < budgetAvailable || !priority) { + if (to_budget + increment < budgetAvailable || !priority) { to_budget += increment; } else { if (budgetAvailable > 0) to_budget += budgetAvailable; @@ -608,82 +619,157 @@ async function applyCategoryTemplate( break; } case 'schedule': { - let { id: schedule_id } = await db.first( - 'SELECT id FROM schedules WHERE name = ?', - [template.name], - ); - let rule = await getRuleForSchedule(schedule_id); - let conditions = rule.serialize().conditions; - let { date: dateCond, amount: amountCond } = - extractScheduleConds(conditions); - let next_date_string = getNextDate( - dateCond, - monthUtils._parse(current_month), - ); - - let isRepeating = - Object(dateCond.value) === dateCond.value && - 'frequency' in dateCond.value; - - let num_months = monthUtils.differenceInCalendarMonths( - next_date_string, - current_month, - ); - - if (isRepeating) { - let monthlyTarget = 0; - let next_month = monthUtils.addMonths(current_month, num_months + 1); - let next_date = getNextDate( - dateCond, - monthUtils._parse(current_month), - ); - while (next_date < next_month) { - monthlyTarget += amountCond.value; - next_date = monthUtils.addDays(next_date, 1); - next_date = getNextDate(dateCond, monthUtils._parse(next_date)); - } - amountCond.value = monthlyTarget; - } - - if (template.full === true || isReflectBudget()) { - if (num_months === 0) { - to_budget = -getScheduledAmount(amountCond.value); - } - if (isReflectBudget() && !template.full) { - errors.push( - `Report budgets require the full option for Schedules.`, + if (!scheduleFlag) { + scheduleFlag = true; + let template = template_lines.filter(t => t.type === 'schedule'); + //in the case of multiple templates per category, schedules may have wrong priority level + let t = []; + let totalScheduledGoal = 0; + + for (let ll = 0; ll < template.length; ll++) { + let { id: sid, completed: complete } = await db.first( + 'SELECT * FROM schedules WHERE name = ?', + [template[ll].name], + ); + console.log(complete); + let rule = await getRuleForSchedule(sid); + let conditions = rule.serialize().conditions; + let { date: dateConditions, amount: amountCondition } = + extractScheduleConds(conditions); + let target = -amountCondition.value; + let next_date_string = getNextDate( + dateConditions, + monthUtils._parse(current_month), ); + let target_interval = dateConditions.value.interval; + let target_frequency = dateConditions.value.frequency; + let isRepeating = + Object(dateConditions.value) === dateConditions.value && + 'frequency' in dateConditions.value; + let num_months = monthUtils.differenceInCalendarMonths( + next_date_string, + current_month, + ); + t.push({ + template: template[ll], + target: target, + next_date_string: next_date_string, + target_interval: target_interval, + target_frequency: target_frequency, + num_months: num_months, + completed: complete, + }); + if (!complete) { + if (isRepeating) { + let monthlyTarget = 0; + let next_month = monthUtils.addMonths( + current_month, + t[ll].num_months + 1, + ); + let next_date = getNextDate( + dateConditions, + monthUtils._parse(current_month), + ); + while (next_date < next_month) { + monthlyTarget += amountCondition.value; + next_date = monthUtils.addDays(next_date, 1); + next_date = getNextDate( + dateConditions, + monthUtils._parse(next_date), + ); + } + t[ll].target = -monthlyTarget; + totalScheduledGoal += target; + } + } else { + errors.push( + `Schedule ${t[ll].template.name} is a completed schedule.`, + ); + } } - break; - } - if (l === 0) remainder = last_month_balance; - remainder = -getScheduledAmount(amountCond.value) - remainder; - let target = 0; - if (remainder >= 0) { - target = remainder; - remainder = 0; - } else { - target = 0; - remainder = Math.abs(remainder); - } - let diff = num_months >= 0 ? Math.round(target / (num_months + 1)) : 0; - if (num_months < 0) { - errors.push( - `Non-repeating schedule ${template.name} was due on ${next_date_string}, which is in the past.`, - ); - return { errors }; - } else if (num_months >= 0) { - if ( - (diff >= 0 && - num_months >= 0 && - to_budget + diff < budgetAvailable) || - !priority - ) { + t = t.filter(t => t.completed === 0); + t = t.sort((a, b) => b.target - a.target); + + let diff = 0; + if (balance >= totalScheduledGoal) { + for (let ll = 0; ll < t.length; ll++) { + if (t[ll].num_months < 0) { + errors.push( + `Non-repeating schedule ${t[ll].template.name} was due on ${t[ll].next_date_string}, which is in the past.`, + ); + break; + } + if ( + (t[ll].template.full && t[ll].num_months === 0) || + t[ll].target_frequency === 'weekly' || + t[ll].target_frequency === 'daily' + ) { + diff += t[ll].target; + } else if (t[ll].template.full && t[ll].num_months > 0) { + diff += 0; + } else { + diff += t[ll].target / t[ll].target_interval; + } + } + } else if (balance < totalScheduledGoal) { + for (let ll = 0; ll < t.length; ll++) { + if (isReflectBudget()) { + if (!t[ll].template.full) { + errors.push( + `Report budgets require the full option for Schedules.`, + ); + break; + } + if (t[ll].template.full && t[ll].num_months === 0) { + to_budget += t[ll].target; + } + } + if (!isReflectBudget()) { + if (t[ll].num_months < 0) { + errors.push( + `Non-repeating schedule ${t[ll].template.name} was due on ${t[ll].next_date_string}, which is in the past.`, + ); + break; + } + if (t[ll].template.full && t[ll].num_months > 0) { + remainder = 0; + } else if (ll === 0 && !t[ll].template.full) { + remainder = t[ll].target - last_month_balance; + } else { + remainder = t[ll].target - remainder; + } + let tg = 0; + if (remainder >= 0) { + tg = remainder; + remainder = 0; + } else { + tg = 0; + remainder = Math.abs(remainder); + } + if ( + t[ll].template.full || + t[ll].num_months === 0 || + t[ll].target_frequency === 'weekly' || + t[ll].target_frequency === 'daily' + ) { + diff += tg; + } else if (t[ll].template.full && t[ll].num_months > 0) { + diff += 0; + } else { + diff += tg / (t[ll].num_months + 1); + } + } + } + } + diff = Math.round(diff); + if ((diff > 0 && to_budget + diff <= budgetAvailable) || !priority) { to_budget += diff; - if (l === template_lines.length - 1) to_budget -= spent; - } else { - if (budgetAvailable > 0) to_budget = budgetAvailable; + } else if ( + to_budget + diff > budgetAvailable && + budgetAvailable >= 0 + ) { + to_budget = budgetAvailable; errors.push(`Insufficient funds.`); } } diff --git a/upcoming-release-notes/1452.md b/upcoming-release-notes/1452.md new file mode 100644 index 000000000..824eef019 --- /dev/null +++ b/upcoming-release-notes/1452.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [shall0pass] +--- + +Goals: Schedules allow filling for future months \ No newline at end of file -- GitLab