Skip to content

Commit

Permalink
[Goals] fix limits (actualbudget#3829)
Browse files Browse the repository at this point in the history
* fix limits

* cleanup

* fix cases of negative previous balance
  • Loading branch information
youngcw authored Nov 19, 2024
1 parent 23bb89b commit 5cf4398
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 52 deletions.
9 changes: 9 additions & 0 deletions packages/loot-core/src/server/budget/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @ts-strict-ignore

import * as monthUtils from '../../shared/months';
import { integerToCurrency, safeNumber } from '../../shared/util';
import * as db from '../db';
Expand All @@ -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<boolean> {
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
Expand Down
119 changes: 71 additions & 48 deletions packages/loot-core/src/server/budget/categoryTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -69,6 +84,7 @@ export class CategoryTemplate {
availStart: number,
): Promise<number> {
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
Expand Down
5 changes: 1 addition & 4 deletions packages/loot-core/src/server/budget/goaltemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/3829.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [youngcw]
---

Fix template limits

0 comments on commit 5cf4398

Please sign in to comment.