diff --git a/packages/loot-core/src/server/budget/goals/goalsSchedule.ts b/packages/loot-core/src/server/budget/goals/goalsSchedule.ts index b311d5bcf9e..f71a0f7f379 100644 --- a/packages/loot-core/src/server/budget/goals/goalsSchedule.ts +++ b/packages/loot-core/src/server/budget/goals/goalsSchedule.ts @@ -8,82 +8,71 @@ import { } from '../../schedules/app'; import { isReflectBudget } from '../actions'; -export async function goalsSchedule( - scheduleFlag, - template_lines, - current_month, - balance, - remainder, - last_month_balance, - to_budget, - errors, - category, -) { - if (!scheduleFlag) { - scheduleFlag = true; - const 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; +async function createScheduleList(template, current_month, category) { + const t = []; + const errors = []; - for (let ll = 0; ll < template.length; ll++) { - const { id: sid, completed: complete } = await db.first( - 'SELECT * FROM schedules WHERE name = ?', - [template[ll].name], - ); - const rule = await getRuleForSchedule(sid); - const conditions = rule.serialize().conditions; - const { date: dateConditions, amount: amountCondition } = - extractScheduleConds(conditions); - const sign = category.is_income ? 1 : -1; - const target = - amountCondition.op === 'isbetween' - ? (sign * - Math.round( - amountCondition.value.num1 + amountCondition.value.num2, - )) / - 2 - : sign * amountCondition.value; - const next_date_string = getNextDate( - dateConditions, - monthUtils._parse(current_month), - ); - const target_interval = dateConditions.value.interval - ? dateConditions.value.interval - : 1; - const target_frequency = dateConditions.value.frequency; - const isRepeating = - Object(dateConditions.value) === dateConditions.value && - 'frequency' in dateConditions.value; - const num_months = monthUtils.differenceInCalendarMonths( - next_date_string, - current_month, - ); - const startDate = dateConditions.value.start ?? dateConditions.value; - const started = startDate <= monthUtils.addMonths(current_month, 1); + for (let ll = 0; ll < template.length; ll++) { + const { id: sid, completed: complete } = await db.first( + 'SELECT * FROM schedules WHERE name = ? AND tombstone = 0', + [template[ll].name], + ); + const rule = await getRuleForSchedule(sid); + const conditions = rule.serialize().conditions; + const { date: dateConditions, amount: amountCondition } = + extractScheduleConds(conditions); + const sign = category.is_income ? 1 : -1; + const target = + amountCondition.op === 'isbetween' + ? (sign * + Math.round( + amountCondition.value.num1 + amountCondition.value.num2, + )) / + 2 + : sign * amountCondition.value; + const next_date_string = getNextDate( + dateConditions, + monthUtils._parse(current_month), + ); + const target_interval = dateConditions.value.interval + ? dateConditions.value.interval + : 1; + const target_frequency = dateConditions.value.frequency; + const isRepeating = + Object(dateConditions.value) === dateConditions.value && + 'frequency' in dateConditions.value; + const num_months = monthUtils.differenceInCalendarMonths( + next_date_string, + current_month, + ); + if (num_months < 0) { + //non-repeating schedules could be negative + errors.push(`Schedule ${template[ll].name} is in the Past.`); + } else { t.push({ - template: template[ll], target, next_date_string, target_interval, target_frequency, num_months, completed: complete, - started, + //started, + full: template[ll].full === null ? false : template[ll].full, + repeat: isRepeating, + name: template[ll].name, }); - if (!complete && started) { + if (!complete) { if (isRepeating) { let monthlyTarget = 0; const nextMonth = monthUtils.addMonths( current_month, - t[ll].num_months + 1, + t[t.length - 1].num_months + 1, ); let nextBaseDate = getNextDate( dateConditions, monthUtils._parse(current_month), true, ); - let nextDate = dateConditions.value.skipWeekend ? monthUtils.dayFromDate( getDateWithSkippedWeekend( @@ -92,7 +81,6 @@ export async function goalsSchedule( ), ) : nextBaseDate; - while (nextDate < nextMonth) { monthlyTarget += -target; const currentDate = nextBaseDate; @@ -119,92 +107,129 @@ export async function goalsSchedule( break; } } - t[ll].target = -monthlyTarget; - totalScheduledGoal += target; + t[t.length - 1].target = -monthlyTarget; } } else { errors.push( - `Schedule ${t[ll].template.name} is not active during the month in question.`, + `Schedule ${t[ll].name} is not active during the month in question.`, ); } } + } + return { t: t.filter(c => c.completed === 0), errors }; +} - t = t.filter(t => t.completed === 0 && t.started); - t = t.sort((a, b) => b.target - a.target); +async function getPayMonthOfTotal(t) { + //return the contribution amounts of full or every month type schedules + let total = 0; + const schedules = t.filter(c => c.num_months === 0); + for (let ll = 0; ll < schedules.length; ll++) { + total += schedules[ll].target; + } + return total; +} - let increment = 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' - ) { - increment += t[ll].target; - } else if (t[ll].template.full && t[ll].num_months > 0) { - increment += 0; - } else { - increment += 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' - ) { - increment += tg; - } else if (t[ll].template.full && t[ll].num_months > 0) { - increment += 0; - } else { - increment += tg / (t[ll].num_months + 1); - } - } - } +async function getSinkingContributionTotal(t, remainder, last_month_balance) { + //return the contribution amount if there is a balance carried in the category + let total = 0; + for (let ll = 0; ll < t.length; ll++) { + remainder = + ll === 0 ? t[ll].target - last_month_balance : t[ll].target - remainder; + let tg = 0; + if (remainder >= 0) { + tg = remainder; + remainder = 0; + } else { + tg = 0; + remainder = Math.abs(remainder); + } + total += tg / (t[ll].num_months + 1); + } + return total; +} + +async function getSinkingBaseContributionTotal(t) { + //return only the base contribution of each schedule + let total = 0; + for (let ll = 0; ll < t.length; ll++) { + total += t[ll].target / t[ll].target_interval; + } + return total; +} + +async function getSinkingTotal(t) { + //sum the total of all upcoming schedules + let total = 0; + for (let ll = 0; ll < t.length; ll++) { + total += t[ll].target; + } + return total; +} + +export async function goalsSchedule( + scheduleFlag, + template_lines, + current_month, + balance, + remainder, + last_month_balance, + to_budget, + errors, + category, +) { + if (!scheduleFlag) { + scheduleFlag = true; + const template = template_lines.filter(t => t.type === 'schedule'); + //in the case of multiple templates per category, schedules may have wrong priority level + + const t = await createScheduleList(template, current_month, category); + errors = errors.concat(t.errors); + + const t_payMonthOf = t.t.filter( + c => + c.full || + (c.target_frequency === 'monthly' && + c.target_interval === 1 && + c.num_months === 0) || + (c.target_frequency === 'weekly' && + c.target_interval >= 0 && + c.num_months === 0) || + c.target_frequency === 'daily' || + isReflectBudget(), + ); + + const t_sinking = t.t + .filter( + c => + (!c.full && + c.target_frequency === 'monthly' && + c.target_interval > 1) || + (!c.full && + c.target_frequency === 'monthly' && + c.num_months > 0 && + c.target_interval === 1) || + (!c.full && c.target_frequency === 'yearly') || + (!c.full && c.target_frequency === undefined), + ) + .sort((a, b) => a.next_date_string.localeCompare(b.next_date_string)); + + const totalPayMonthOf = await getPayMonthOfTotal(t_payMonthOf); + + const totalSinking = await getSinkingTotal(t_sinking); + const totalSinkingBaseContribution = await getSinkingBaseContributionTotal( + t_sinking, + ); + + if (balance >= totalSinking + totalPayMonthOf) { + to_budget += Math.round(totalPayMonthOf + totalSinkingBaseContribution); + } else { + const totalSinkingContribution = await getSinkingContributionTotal( + t_sinking, + remainder, + last_month_balance, + ); + to_budget += Math.round(totalPayMonthOf + totalSinkingContribution); } - increment = Math.round(increment); - to_budget += increment; } return { to_budget, errors, remainder, scheduleFlag }; } diff --git a/upcoming-release-notes/2102.md b/upcoming-release-notes/2102.md new file mode 100644 index 00000000000..a632e1dd315 --- /dev/null +++ b/upcoming-release-notes/2102.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [shall0pass] +--- + +Goals: Refactor schedules file into functions and improve the readability of the code. \ No newline at end of file