diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index e0bfbf05c14..ebd1b0bf76f 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -1,4 +1,5 @@ // @ts-strict-ignore + import * as monthUtils from '../../shared/months'; import { integerToCurrency, safeNumber } from '../../shared/util'; import * as db from '../db'; @@ -13,6 +14,14 @@ export async function getSheetValue( return safeNumber(typeof node.value === 'number' ? node.value : 0); } +export async function getSheetBoolean( + sheetName: string, + cell: string, +): Promise { + const node = await sheet.getCell(sheetName, cell); + return typeof node.value === 'boolean' ? node.value : false; +} + // We want to only allow the positive movement of money back and // forth. buffered should never be allowed to go into the negative, // and you shouldn't be allowed to pull non-existent money from diff --git a/packages/loot-core/src/server/budget/categoryTemplate.ts b/packages/loot-core/src/server/budget/categoryTemplate.ts index a930fc601ff..0f1e3787330 100644 --- a/packages/loot-core/src/server/budget/categoryTemplate.ts +++ b/packages/loot-core/src/server/budget/categoryTemplate.ts @@ -4,7 +4,7 @@ import * as monthUtils from '../../shared/months'; import { amountToInteger } from '../../shared/util'; import * as db from '../db'; -import { getSheetValue } from './actions'; +import { getSheetValue, getSheetBoolean } from './actions'; import { goalsSchedule } from './goalsSchedule'; import { getActiveSchedules } from './statements'; import { Template } from './types/templates'; @@ -16,11 +16,10 @@ export class CategoryTemplate { * templates: all templates for this category (including templates and goals) * categoryID: the ID of the category that this Class will be for * month: the month string of the month for templates being applied - * 2. gather needed data for external use. ex: remainder weights, priorities + * 2. gather needed data for external use. ex: remainder weights, priorities, limitExcess * 3. run each priority level that is needed via runTemplatesForPriority - * 4. run applyLimits to apply any existing limit to the category - * 5. run the remainder templates via runRemainder() - * 6. finish processing by running getValues() and saving values for batch processing. + * 4. run the remainder templates via runRemainder() + * 5. finish processing by running getValues() and saving values for batch processing. * Alternate: * If the situation calls for it you can run all templates in a catagory in one go using the * method runAll which will run all templates and goals for reference, and can optionally be saved @@ -32,10 +31,23 @@ export class CategoryTemplate { // set up the class and check all templates static async init(templates: Template[], categoryID: string, month) { // get all the needed setup values - const fromLastMonth = await getSheetValue( - monthUtils.sheetForMonth(monthUtils.subMonths(month, 1)), + const lastMonthSheet = monthUtils.sheetForMonth( + monthUtils.subMonths(month, 1), + ); + const lastMonthBalance = await getSheetValue( + lastMonthSheet, `leftover-${categoryID}`, ); + const carryover = await getSheetBoolean( + lastMonthSheet, + `carryover-${categoryID}`, + ); + let fromLastMonth; + if (lastMonthBalance < 0 && !carryover) { + fromLastMonth = 0; + } else { + fromLastMonth = lastMonthBalance; + } // run all checks await CategoryTemplate.checkByAndScheduleAndSpend(templates, month); await CategoryTemplate.checkPercentage(templates); @@ -49,6 +61,9 @@ export class CategoryTemplate { getRemainderWeight(): number { return this.remainderWeight; } + getLimitExcess(): number { + return this.limitExcess; + } // what is the full requested amount this month async runAll(available: number) { @@ -69,6 +84,7 @@ export class CategoryTemplate { availStart: number, ): Promise { if (!this.priorities.includes(priority)) return 0; + if (this.limitMet) return 0; const t = this.templates.filter(t => t.priority === priority); let available = budgetAvail || 0; @@ -137,6 +153,18 @@ export class CategoryTemplate { available = available - toBudget; } + //check limit + if (this.limitCheck) { + if ( + toBudget + this.toBudgetAmount + this.fromLastMonth >= + this.limitAmount + ) { + const orig = toBudget; + toBudget = this.limitAmount - this.toBudgetAmount - this.fromLastMonth; + this.limitMet = true; + available = available + orig - toBudget; + } + } // don't overbudget when using a priority if (priority > 0 && available < 0) { this.fullAmount += toBudget; @@ -149,25 +177,6 @@ export class CategoryTemplate { return toBudget; } - applyLimit(): number { - if (this.limitCheck === false) { - return 0; - } - if (this.limitHold && this.fromLastMonth >= this.limitAmount) { - const orig = this.toBudgetAmount; - this.fullAmount = 0; - this.toBudgetAmount = 0; - return orig; - } - if (this.toBudgetAmount + this.fromLastMonth > this.limitAmount) { - const orig = this.toBudgetAmount; - this.toBudgetAmount = this.limitAmount - this.fromLastMonth; - this.fullAmount = this.toBudgetAmount; - return orig - this.toBudgetAmount; - } - return 0; - } - // run all of the 'remainder' type templates runRemainder(budgetAvail: number, perWeight: number) { if (this.remainder.length === 0) return 0; @@ -206,6 +215,8 @@ export class CategoryTemplate { private isLongGoal: boolean = null; //defaulting the goals to null so templates can be unset private goalAmount: number = null; private fromLastMonth = 0; // leftover from last month + private limitMet = false; + private limitExcess: number = 0; private limitAmount = 0; private limitCheck = false; private limitHold = false; @@ -344,31 +355,43 @@ export class CategoryTemplate { if (!t.limit) continue; if (this.limitCheck) { throw new Error('Only one `up to` allowed per category'); - } else if (t.limit) { - if (t.limit.period === 'daily') { - const numDays = monthUtils.differenceInCalendarDays( - monthUtils.addMonths(this.month, 1), - this.month, - ); - this.limitAmount += amountToInteger(t.limit.amount) * numDays; - } else if (t.limit.period === 'weekly') { - const nextMonth = monthUtils.nextMonth(this.month); - let week = t.limit.start; - const baseLimit = amountToInteger(t.limit.amount); - while (week < nextMonth) { - if (week >= this.month) { - this.limitAmount += baseLimit; - } - week = monthUtils.addWeeks(week, 1); + } + if (t.limit.period === 'daily') { + const numDays = monthUtils.differenceInCalendarDays( + monthUtils.addMonths(this.month, 1), + this.month, + ); + this.limitAmount += amountToInteger(t.limit.amount) * numDays; + } else if (t.limit.period === 'weekly') { + const nextMonth = monthUtils.nextMonth(this.month); + let week = t.limit.start; + const baseLimit = amountToInteger(t.limit.amount); + while (week < nextMonth) { + if (week >= this.month) { + this.limitAmount += baseLimit; } - } else if (t.limit.period === 'monthly') { - this.limitAmount = amountToInteger(t.limit.amount); + week = monthUtils.addWeeks(week, 1); + } + } else if (t.limit.period === 'monthly') { + this.limitAmount = amountToInteger(t.limit.amount); + } else { + throw new Error('Invalid limit period. Check template syntax'); + } + //amount is good save the rest + this.limitCheck = true; + this.limitHold = t.limit.hold ? true : false; + // check if the limit is already met and save the excess + if (this.fromLastMonth >= this.limitAmount) { + this.limitMet = true; + if (this.limitHold) { + this.limitExcess = 0; + this.toBudgetAmount = 0; + this.fullAmount = 0; } else { - throw new Error('Invalid limit period. Check template syntax'); + this.limitExcess = this.fromLastMonth - this.limitAmount; + this.toBudgetAmount = -this.limitExcess; + this.fullAmount = -this.limitExcess; } - //amount is good save the rest - this.limitCheck = true; - this.limitHold = t.limit.hold ? true : false; } } } diff --git a/packages/loot-core/src/server/budget/goaltemplates.ts b/packages/loot-core/src/server/budget/goaltemplates.ts index f0509ccbb2a..f10cd35fa80 100644 --- a/packages/loot-core/src/server/budget/goaltemplates.ts +++ b/packages/loot-core/src/server/budget/goaltemplates.ts @@ -164,6 +164,7 @@ async function processTemplate( try { const obj = await CategoryTemplate.init(templates, id, month); availBudget += budgeted; + availBudget += obj.getLimitExcess(); const p = obj.getPriorities(); p.forEach(pr => priorities.push(pr)); remainderWeight += obj.getRemainderWeight(); @@ -219,10 +220,6 @@ async function processTemplate( availBudget -= ret; } } - // run limits - catObjects.forEach(o => { - availBudget += o.applyLimit(); - }); // run remainder if (availBudget > 0 && remainderWeight) { const perWeight = availBudget / remainderWeight; diff --git a/upcoming-release-notes/3829.md b/upcoming-release-notes/3829.md new file mode 100644 index 00000000000..316a9ffb0db --- /dev/null +++ b/upcoming-release-notes/3829.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [youngcw] +--- + +Fix template limits