diff --git a/packages/loot-core/src/server/budget/categoryTemplate.ts b/packages/loot-core/src/server/budget/categoryTemplate.ts new file mode 100644 index 00000000000..9a70e655784 --- /dev/null +++ b/packages/loot-core/src/server/budget/categoryTemplate.ts @@ -0,0 +1,540 @@ +// @ts-strict-ignore + +import * as monthUtils from '../../shared/months'; +import { amountToInteger } from '../../shared/util'; +import * as db from '../db'; + +import { getSheetValue } from './actions'; +import { goalsSchedule } from './goalsSchedule'; +import { getActiveSchedules } from './statements'; +import { Template } from './types/templates'; + +export class CategoryTemplate { + /*---------------------------------------------------------------------------- + * Using This Class: + * 1. instantiate via `await categoryTemplate.init(templates, categoryID, month)`; + * 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 + * 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. + * 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 + */ + + //----------------------------------------------------------------------------- + // Class interface + + // 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)), + `leftover-${categoryID}`, + ); + // run all checks + await CategoryTemplate.checkByAndScheduleAndSpend(templates, month); + await CategoryTemplate.checkPercentage(templates); + // call the private constructor + return new CategoryTemplate(templates, categoryID, month, fromLastMonth); + } + + getPriorities(): number[] { + return this.priorities; + } + getRemainderWeight(): number { + return this.remainderWeight; + } + + // what is the full requested amount this month + async runAll(available: number) { + let toBudget: number = 0; + for (let i = 0; i < this.priorities.length; i++) { + const p = this.priorities[i]; + toBudget += await this.runTemplatesForPriority(p, available, available); + } + //TODO does this need to run limits? maybe pass in option to ignore previous balance? + return toBudget; + } + + // run all templates in a given priority level + // return: amount budgeted in this priority level + async runTemplatesForPriority( + priority: number, + budgetAvail: number, + availStart: number, + ): Promise { + if (!this.priorities.includes(priority)) return 0; + + const t = this.templates.filter(t => t.priority === priority); + let available = budgetAvail || 0; + let toBudget = 0; + let first = true; // needed for by templates + let remainder = 0; + let scheduleFlag = false; + // switch on template type and calculate the amount for the line + for (let i = 0; i < t.length; i++) { + switch (t[i].type) { + case 'simple': { + toBudget += this.runSimple(t[i], this.limitAmount); + break; + } + case 'copy': { + toBudget += await this.runCopy(t[i]); + break; + } + case 'week': { + toBudget += this.runWeek(t[i]); + break; + } + case 'spend': { + toBudget += await this.runSpend(t[i]); + break; + } + case 'percentage': { + toBudget += await this.runPercentage(t[i], availStart); + break; + } + case 'by': { + //TODO add the logic to run all of these at once or whatever is needed + const ret = this.runBy(t[i], first, remainder); + toBudget += ret.ret; + remainder = ret.remainder; + first = false; + break; + } + case 'schedule': { + const budgeted = await getSheetValue( + monthUtils.sheetForMonth(this.month), + `leftover-${this.categoryID}`, + ); + const ret = await goalsSchedule( + scheduleFlag, + t, + this.month, + budgeted, + remainder, + this.fromLastMonth, + toBudget, + [], + this.categoryID, + ); + toBudget = ret.to_budget; + remainder = ret.remainder; + scheduleFlag = ret.scheduleFlag; + break; + } + case 'average': { + toBudget += await this.runAverage(t[i]); + break; + } + } + + // don't overbudget when using a priority + if (priority > 0 && toBudget > available) { + toBudget = available; + } + available = available - toBudget; + + if (priority > 0 && available <= 0) { + break; + } + } + + this.toBudgetAmount += toBudget; + return toBudget; + } + + applyLimit(): number { + if (this.limitCheck === false) { + return 0; + } + if (this.limitHold && this.fromLastMonth >= this.limitAmount) { + const orig = this.toBudgetAmount; + this.toBudgetAmount = 0; + return orig; + } + if (this.toBudgetAmount + this.fromLastMonth > this.limitAmount) { + const orig = this.toBudgetAmount; + this.toBudgetAmount = this.limitAmount - this.fromLastMonth; + 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; + const toBudget = Math.round(this.remainderWeight * perWeight); + //check possible overbudget from rounding, 1cent leftover + if (toBudget > budgetAvail) { + this.toBudgetAmount += budgetAvail; + } else if (budgetAvail - toBudget === 1) { + this.toBudgetAmount += toBudget + 1; + } else { + this.toBudgetAmount += toBudget; + } + return toBudget; + } + + getValues(): { budgeted; goal; longGoal } { + this.runGoal(); + return { + budgeted: this.toBudgetAmount, + goal: this.goalAmount, + longGoal: this.isLongGoal, + }; + } + + //----------------------------------------------------------------------------- + // Implementation + readonly categoryID: string; //readonly so we can double check the category this is using + private month: string; + private templates = []; + private remainder = []; + private goals = []; + private priorities: number[] = []; + private remainderWeight: number = 0; + private toBudgetAmount: number = 0; // amount that will be budgeted by the templates + 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 limitAmount = 0; + private limitCheck = false; + private limitHold = false; + + private constructor( + templates: Template[], + categoryID: string, + month: string, + fromLastMonth: number, + ) { + this.categoryID = categoryID; + this.month = month; + this.fromLastMonth = fromLastMonth; + // sort the template lines into regular template, goals, and remainder templates + if (templates) { + templates.forEach(t => { + if (t.directive === 'template' && t.type !== 'remainder') { + this.templates.push(t); + } + }); + templates.forEach(t => { + if (t.directive === 'template' && t.type === 'remainder') { + this.remainder.push(t); + } + }); + templates.forEach(t => { + if (t.directive === 'goal') this.goals.push(t); + }); + } + // check limits here since it needs to save states inside the object + this.checkLimit(); + this.checkSpend(); + this.checkGoal(); + + //find priorities + const p = []; + this.templates.forEach(t => { + if (t.priority != null) { + p.push(t.priority); + } + }); + //sort and reduce to unique items + this.priorities = p + .sort(function (a, b) { + return a - b; + }) + .filter((item, idx, curr) => curr.indexOf(item) === idx); + + //find remainder weight + let weight = 0; + this.remainder.forEach(r => { + weight += r.weight; + }); + this.remainderWeight = weight; + } + + private runGoal() { + if (this.goals.length > 0) { + this.isLongGoal = true; + this.goalAmount = amountToInteger(this.goals[0].amount); + return; + } + this.goalAmount = this.toBudgetAmount; + } + + //----------------------------------------------------------------------------- + // Template Validation + static async checkByAndScheduleAndSpend(templates, month) { + //check schedule names + const scheduleNames = (await getActiveSchedules()).map(({ name }) => + name.trim(), + ); + templates + .filter(t => t.type === 'schedule') + .forEach(t => { + if (!scheduleNames.includes(t.name.trim())) { + throw new Error(`Schedule ${t.name.trim()} does not exist`); + } + }); + //find lowest priority + const lowestPriority = Math.min( + ...templates + .filter(t => t.type === 'schedule' || t.type === 'by') + .map(t => t.priority), + ); + //warn if priority needs fixed + templates + .filter(t => t.type === 'schedule' || t.type === 'by') + .forEach(t => { + if (t.priority !== lowestPriority) { + throw new Error( + `Schedule and By templates must be the same priority level. Fix by setting all Schedule and By templates to priority level ${lowestPriority}`, + ); + //t.priority = lowestPriority; + } + }); + // check if the target date is past and not repeating + templates + .filter(t => t.type === 'by' || t.type === 'spend') + .forEach(t => { + const range = monthUtils.differenceInCalendarMonths( + `${t.month}`, + month, + ); + if (range < 0 && !(t.repeat || t.annual)) { + throw new Error( + `Target month has passed, remove or update the target month`, + ); + } + }); + } + + static async checkPercentage(templates) { + const pt = templates.filter(t => t.type === 'percentage'); + if (pt.length === 0) return; + const reqCategories = pt.map(t => t.category.toLowerCase()); + + const availCategories = await db.getCategories(); + const availNames = availCategories + .filter(c => c.is_income) + .map(c => c.name.toLocaleLowerCase()); + + reqCategories.forEach(n => { + if (n === 'available funds' || n === 'all income') { + //skip the name check since these are special + } else if (!availNames.includes(n)) { + throw new Error( + `Category \x22${n}\x22 is not found in available income categories`, + ); + } + }); + } + + private checkLimit() { + for (const t of this.templates) { + 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); + } + } 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; + } + } + } + + private checkSpend() { + const st = this.templates.filter(t => t.type === 'spend'); + if (st.length > 1) { + throw new Error('Only one spend template is allowed per category'); + } + } + + private checkGoal() { + if (this.goals.length > 1) { + throw new Error(`Only one #goal is allowed per category`); + } + } + + //----------------------------------------------------------------------------- + // Processor Functions + + private runSimple(template, limit): number { + if (template.monthly != null) { + return amountToInteger(template.monthly); + } else { + return limit; + } + } + + private async runCopy(template): Promise { + const sheetName = monthUtils.sheetForMonth( + monthUtils.subMonths(this.month, template.lookBack), + ); + return await getSheetValue(sheetName, `budget-${this.categoryID}`); + } + + private runWeek(template): number { + let toBudget = 0; + const amount = amountToInteger(template.amount); + const weeks = template.weeks != null ? Math.round(template.weeks) : 1; + let w = template.starting; + const nextMonth = monthUtils.addMonths(this.month, 1); + + while (w < nextMonth) { + if (w >= this.month) { + toBudget += amount; + } + w = monthUtils.addWeeks(w, weeks); + } + return toBudget; + } + + private async runSpend(template): Promise { + const fromMonth = `${template.from}`; + const toMonth = `${template.month}`; + let alreadyBudgeted = this.fromLastMonth; + let firstMonth = true; + + for ( + let m = fromMonth; + monthUtils.differenceInCalendarMonths(this.month, m) > 0; + m = monthUtils.addMonths(m, 1) + ) { + const sheetName = monthUtils.sheetForMonth(m); + if (firstMonth) { + //TODO figure out if I already found these values and can pass them in + const spent = await getSheetValue( + sheetName, + `sum-amount-${this.categoryID}`, + ); + const balance = await getSheetValue( + sheetName, + `leftover-${this.categoryID}`, + ); + alreadyBudgeted = balance - spent; + firstMonth = false; + } else { + alreadyBudgeted += await getSheetValue( + sheetName, + `budget-${this.categoryID}`, + ); + } + } + + const numMonths = monthUtils.differenceInCalendarMonths( + toMonth, + this.month, + ); + const target = amountToInteger(template.amount); + if (numMonths < 0) { + return 0; + } else { + return Math.round((target - alreadyBudgeted) / (numMonths + 1)); + } + } + + private async runPercentage(template, availableFunds): Promise { + const percent = template.percent; + const cat = template.category.toLowerCase(); + const prev = template.previous; + let sheetName; + let monthlyIncome = 1; + + //choose the sheet to find income for + if (prev) { + sheetName = monthUtils.sheetForMonth(monthUtils.subMonths(this.month, 1)); + } else { + sheetName = monthUtils.sheetForMonth(this.month); + } + if (cat === 'all income') { + monthlyIncome = await getSheetValue(sheetName, `total-income`); + } else if (cat === 'available funds') { + monthlyIncome = availableFunds; + } else { + const incomeCat = (await db.getCategories()).find( + c => c.is_income && c.name.toLowerCase() === cat, + ); + monthlyIncome = await getSheetValue( + sheetName, + `sum-amount-${incomeCat.id}`, + ); + } + + return Math.max(0, Math.round(monthlyIncome * (percent / 100))); + } + + private async runAverage(template): Promise { + let sum = 0; + for (let i = 1; i <= template.numMonths; i++) { + const sheetName = monthUtils.sheetForMonth( + monthUtils.subMonths(this.month, i), + ); + sum += await getSheetValue(sheetName, `sum-amount-${this.categoryID}`); + } + return -Math.round(sum / template.numMonths); + } + + private runBy( + template, + first: boolean, + remainder: number, + ): { ret: number; remainder: number } { + let target = 0; + let targetMonth = `${template.month}`; + let numMonths = monthUtils.differenceInCalendarMonths( + targetMonth, + this.month, + ); + const repeat = template.annual + ? (template.repeat || 1) * 12 + : template.repeat; + while (numMonths < 0 && repeat) { + targetMonth = monthUtils.addMonths(targetMonth, repeat); + numMonths = monthUtils.differenceInCalendarMonths( + targetMonth, + this.month, + ); + } + if (first) remainder = this.fromLastMonth; + remainder = amountToInteger(template.amount) - remainder; + if (remainder >= 0) { + target = remainder; + remainder = 0; + } else { + target = 0; + remainder = Math.abs(remainder); + } + const ret = numMonths >= 0 ? Math.round(target / (numMonths + 1)) : 0; + return { ret, remainder }; + } + + //private async runSchedule(template_lines) {} +} diff --git a/packages/loot-core/src/server/budget/goal-template.pegjs b/packages/loot-core/src/server/budget/goal-template.pegjs index e8805c7c5b1..fcf3201652f 100644 --- a/packages/loot-core/src/server/budget/goal-template.pegjs +++ b/packages/loot-core/src/server/budget/goal-template.pegjs @@ -13,33 +13,37 @@ expr ...(repeat ? repeat[3] : {}), from, priority: template.priority, directive: template.directive - } } + }} / template: template _ monthly: amount limit: limit? - { return { type: 'simple', monthly, limit, priority: template.priority, directive: template.directive } } + { return { type: 'simple', monthly, limit, priority: template.priority, directive: template.directive }} / template: template _ limit: limit - { return { type: 'simple', limit , priority: template.priority, directive: template.directive } } + { return { type: 'simple', monthly: null, limit, priority: template.priority, directive: template.directive }} / template: template _ schedule _ full:full? name: name - { return { type: 'schedule', name, priority: template.priority, directive: template.directive, full } } + { return { type: 'schedule', name, priority: template.priority, directive: template.directive, full }} / template: template _ remainder: remainder limit: limit? - { return { type: 'remainder', priority: null, directive: template.directive, weight: remainder, limit } } + { return { type: 'remainder', priority: null, directive: template.directive, weight: remainder, limit }} / template: template _ 'average'i _ amount: positive _ 'months'i? - { return { type: 'average', amount: +amount, priority: template.priority, directive: template.directive }} + { return { type: 'average', numMonths: +amount, priority: template.priority, directive: template.directive }} / template: template _ 'copy from'i _ lookBack: positive _ 'months ago'i limit:limit? { return { type: 'copy', priority: template.priority, directive: template.directive, lookBack: +lookBack, limit }} - / goal: goal amount: amount { return {type: 'simple', amount: amount, priority: null, directive: 'goal' }} + / goal: goal amount: amount { return {type: 'simple', amount: amount, priority: null, directive: goal }} repeat 'repeat interval' - = 'month'i { return { annual: false } } - / months: positive _ 'months'i { return { annual: false, repeat: +months } } - / 'year'i { return { annual: true } } - / years: positive _ 'years'i { return { annual: true, repeat: +years } } + = 'month'i { return { annual: false }} + / months: positive _ 'months'i { return { annual: false, repeat: +months }} + / 'year'i { return { annual: true }} + / years: positive _ 'years'i { return { annual: true, repeat: +years }} -limit = _? upTo _ amount: amount _ 'hold'i { return {amount: amount, hold: true } } - / _? upTo _ amount: amount { return {amount: amount, hold: false } } +limit = _? upTo _ amount: amount _ 'per week starting'i _ start:date _? hold:hold? + { return {amount: amount, hold: hold, period: 'weekly', start: start }} + / _? upTo _ amount: amount _ 'per day'i _? hold: hold? + { return {amount: amount, hold: hold, period: 'daily', start:null }} + / _? upTo _ amount: amount _? hold: hold? + { return {amount: amount, hold: hold, period: 'monthly', start:null }} -percentOf = percent:percent _ of _ 'previous'i _ { return { percent: percent, prev: true} } - / percent:percent _ of _ { return { percent: percent, prev: false} } +percentOf = percent:percent _ of _ 'previous'i _ { return { percent: percent, prev: true}} + / percent:percent _ of _ { return { percent: percent, prev: false}} weekCount = week { return null } @@ -54,12 +58,13 @@ of = 'of'i repeatEvery = 'repeat'i _ 'every'i starting = 'starting'i upTo = 'up'i _ 'to'i +hold = 'hold'i {return true} schedule = 'schedule'i full = 'full'i _ {return true} priority = '-'i number: number {return number} remainder = 'remainder'i _? weight: positive? { return +weight || 1 } template = '#template' priority: priority? {return {priority: +priority, directive: 'template'}} -goal = '#goal' +goal = '#goal'i { return 'goal'} _ 'space' = ' '+ d 'digit' = [0-9] diff --git a/packages/loot-core/src/server/budget/goals/goalsAverage.test.ts b/packages/loot-core/src/server/budget/goals/goalsAverage.test.ts deleted file mode 100644 index 07285d34c16..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsAverage.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as actions from '../actions'; - -import { goalsAverage } from './goalsAverage'; - -jest.mock('../actions'); - -describe('goalsAverage', () => { - const mockGetSheetValue = actions.getSheetValue as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should calculate the amount to budget based on the average of only one previous month of spending', async () => { - // Given - const template = { amount: 1 }; - const month = '2024-07'; - const category = { id: 1 }; - const errors: string[] = []; - const to_budget = 0; - - mockGetSheetValue.mockResolvedValueOnce(200); - - // When - const result = await goalsAverage( - template, - month, - category, - errors, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(-200); - expect(result.errors).toHaveLength(0); - }); - - it('should calculate the amount to budget based on the average of multiple previous months of spending', async () => { - // Given - const template = { amount: 4 }; - const month = '2024-08'; - const category = { id: 1 }; - const errors: string[] = []; - const to_budget = 0; - - mockGetSheetValue - .mockResolvedValueOnce(200) - .mockResolvedValueOnce(300) - .mockResolvedValueOnce(100) - .mockResolvedValueOnce(400); - - // When - const result = await goalsAverage( - template, - month, - category, - errors, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(-250); - expect(result.errors).toHaveLength(0); - }); - - it('should return error when template amount passed in is <= 0', async () => { - // Given - const template = { amount: 0 }; - const month = '2024-08'; - const category = { id: 1 }; - const errors: string[] = []; - const to_budget = 1000; - - // When - const result = await goalsAverage( - template, - month, - category, - errors, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(1000); - expect(result.errors).toStrictEqual([ - 'Number of months to average is not valid', - ]); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsAverage.ts b/packages/loot-core/src/server/budget/goals/goalsAverage.ts deleted file mode 100644 index 2dd7472ad06..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsAverage.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-strict-ignore - -import * as monthUtils from '../../../shared/months'; -import { getSheetValue } from '../actions'; - -export async function goalsAverage( - template, - month, - category, - errors, - to_budget, -) { - let increment = 0; - if (template.amount) { - let sum = 0; - for (let i = 1; i <= template.amount; i++) { - // add up other months - const sheetName = monthUtils.sheetForMonth( - monthUtils.subMonths(month, i), - ); - sum += await getSheetValue(sheetName, `sum-amount-${category.id}`); - } - increment = sum / template.amount; - } else { - errors.push('Number of months to average is not valid'); - return { to_budget, errors }; - } - to_budget += -Math.round(increment); - return { to_budget, errors }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsBy.test.ts b/packages/loot-core/src/server/budget/goals/goalsBy.test.ts deleted file mode 100644 index cafec5ae716..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsBy.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as actions from '../actions'; - -import { goalsBy } from './goalsBy'; - -jest.mock('../actions'); - -describe('goalsBy', () => { - const mockIsReflectBudget = actions.isReflectBudget as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return correct budget amount with target in the future and no current balance', async () => { - // Given - const template = { amount: 100, month: '2024-12' }; - const current_month = '2024-08'; - const last_month_balance = 0; - const to_budget = 0; - const errors: string[] = []; - const template_lines = [template]; - const l = 0; - const remainder = 0; - mockIsReflectBudget.mockReturnValue(false); - - // When - const result = await goalsBy( - template_lines, - current_month, - template, - l, - remainder, - last_month_balance, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(2000); - expect(result.errors).toHaveLength(0); - expect(result.remainder).toBe(0); - }); - - it('should return correct budget amount with target in the future and existing balance towards goal', async () => { - // Given - const template = { amount: 100, month: '2024-12' }; - const current_month = '2024-08'; - const last_month_balance = 5000; - const to_budget = 0; - const errors: string[] = []; - const template_lines = [template]; - const l = 0; - const remainder = 0; - mockIsReflectBudget.mockReturnValue(false); - - // When - const result = await goalsBy( - template_lines, - current_month, - template, - l, - remainder, - last_month_balance, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(1000); - expect(result.errors).toHaveLength(0); - expect(result.remainder).toBe(0); - }); - - it('should return correct budget amount when target balance met early', async () => { - // Given - const template = { amount: 100, month: '2024-12' }; - const current_month = '2024-08'; - const last_month_balance = 10000; - const to_budget = 0; - const errors: string[] = []; - const template_lines = [template]; - const l = 0; - const remainder = 0; - mockIsReflectBudget.mockReturnValue(false); - - // When - const result = await goalsBy( - template_lines, - current_month, - template, - l, - remainder, - last_month_balance, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(0); - expect(result.errors).toHaveLength(0); - expect(result.remainder).toBe(0); - }); - - it('should return error when budget is a reflect budget', async () => { - // Given - const template = { amount: -100, month: '2024-08', repeat: 1 }; - const current_month = '2024-08'; - const last_month_balance = 0; - const to_budget = 0; - const errors: string[] = []; - const template_lines = [template]; - const l = 0; - const remainder = 0; - mockIsReflectBudget.mockReturnValue(true); - - // When - const result = await goalsBy( - template_lines, - current_month, - template, - l, - remainder, - last_month_balance, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(0); - expect(result.errors).toStrictEqual([ - 'by templates are not supported in Report budgets', - ]); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsBy.ts b/packages/loot-core/src/server/budget/goals/goalsBy.ts deleted file mode 100644 index a02abe20bb9..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsBy.ts +++ /dev/null @@ -1,49 +0,0 @@ -// @ts-strict-ignore -import * as monthUtils from '../../../shared/months'; -import { amountToInteger } from '../../../shared/util'; -import { isReflectBudget } from '../actions'; - -export async function goalsBy( - template_lines, - current_month, - template, - l, - remainder, - last_month_balance, - to_budget, - errors, -) { - // by has 'amount' and 'month' params - if (!isReflectBudget()) { - let target = 0; - let target_month = `${template_lines[l].month}-01`; - let num_months = monthUtils.differenceInCalendarMonths( - target_month, - current_month, - ); - const repeat = - template.type === 'by' ? template.repeat : (template.repeat || 1) * 12; - while (num_months < 0 && repeat) { - target_month = monthUtils.addMonths(target_month, repeat); - num_months = monthUtils.differenceInCalendarMonths( - template_lines[l].month, - current_month, - ); - } - if (l === 0) remainder = last_month_balance; - remainder = amountToInteger(template_lines[l].amount) - remainder; - if (remainder >= 0) { - target = remainder; - remainder = 0; - } else { - target = 0; - remainder = Math.abs(remainder); - } - const increment = - num_months >= 0 ? Math.round(target / (num_months + 1)) : 0; - to_budget += increment; - } else { - errors.push(`by templates are not supported in Report budgets`); - } - return { to_budget, errors, remainder }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsCopy.ts b/packages/loot-core/src/server/budget/goals/goalsCopy.ts deleted file mode 100644 index 7932cd7447d..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsCopy.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-strict-ignore -import * as monthUtils from '../../../shared/months'; -import { amountToInteger } from '../../../shared/util'; -import { getSheetValue } from '../actions'; - -export async function goalsCopy( - template, - month, - category, - limitCheck, - errors, - limit, - hold, - to_budget, -) { - // simple has 'monthly' and/or 'limit' params - if (template.limit != null) { - if (limitCheck) { - errors.push(`More than one “up to” limit found.`); - return { to_budget, errors, limit, limitCheck, hold }; - } else { - limitCheck = true; - limit = amountToInteger(template.limit.amount); - hold = template.limit.hold; - } - } - let increment = 0; - if (template.lookBack) { - const sheetName = monthUtils.sheetForMonth( - monthUtils.subMonths(month, template.lookBack), - ); - increment = await getSheetValue(sheetName, `budget-${category.id}`); - } else { - errors.push('Missing number of months to look back'); - } - to_budget += increment; - return { to_budget, errors, limit, limitCheck, hold }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsPercentage.test.ts b/packages/loot-core/src/server/budget/goals/goalsPercentage.test.ts deleted file mode 100644 index 4dca6ac3706..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsPercentage.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as db from '../../db'; -import * as actions from '../actions'; - -import { goalsPercentage } from './goalsPercentage'; - -jest.mock('../actions'); -jest.mock('../../db'); - -describe('goalsPercentage', () => { - const mockGetSheetValue = actions.getSheetValue as jest.Mock; - const mockGetCategories = db.getCategories as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should calculate the budget based on a percentage of all income for the current month', async () => { - // Given - const template = { percent: 10, category: 'all income' }; - const month = '2024-08'; - const available_start = 0; - const sheetName = '2024-08'; - const to_budget = 0; - const errors: string[] = []; - - mockGetSheetValue.mockResolvedValueOnce(1000); - - // When - const result = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(100); - expect(result.errors).toHaveLength(0); - }); - - it('should calculate the budget based on a percentage of all income for the previous month', async () => { - // Given - const template = { percent: 10, category: 'all income', previous: true }; - const month = '2024-08'; - const available_start = 0; - const sheetName = '2024-08'; - const to_budget = 0; - const errors: string[] = []; - - mockGetSheetValue.mockResolvedValueOnce(1000); - - // When - const result = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(100); - expect(result.errors).toHaveLength(0); - }); - - it('should calculate the budget based on a percentage of available funds', async () => { - // Given - const template = { percent: 10, category: 'available funds' }; - const month = '2024-08'; - const available_start = 1000; - const sheetName = '2024-08'; - const to_budget = 0; - const errors: string[] = []; - - // When - const result = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(100); - expect(result.errors).toHaveLength(0); - }); - - it('should calculate the budget based on a percentage of a specific income category for the current month', async () => { - // Given - const template = { percent: 10, category: 'Salary' }; - const month = '2024-08'; - const available_start = 0; - const sheetName = '2024-08'; - const to_budget = 0; - const errors: string[] = []; - - mockGetCategories.mockResolvedValueOnce([ - { id: 1, name: 'Salary', is_income: true }, - ]); - mockGetSheetValue.mockResolvedValueOnce(1000); - - // When - const result = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(100); - expect(result.errors).toHaveLength(0); - }); - - it('should calculate the budget based on a percentage of a specific income category for the previous month', async () => { - // Given - const template = { percent: 10, category: 'Salary', previous: true }; - const month = '2024-08'; - const available_start = 0; - const sheetName = '2024-08'; - const to_budget = 0; - const errors: string[] = []; - - mockGetCategories.mockResolvedValueOnce([ - { id: 1, name: 'Salary', is_income: true }, - ]); - mockGetSheetValue.mockResolvedValueOnce(1000); - - // When - const result = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(100); - expect(result.errors).toHaveLength(0); - }); - - it('should return an error if the specified income category does not exist', async () => { - // Given - const template = { percent: 10, category: 'NonExistentCategory' }; - const month = '2024-08'; - const available_start = 0; - const sheetName = '2024-08'; - const to_budget = 0; - const errors: string[] = []; - - mockGetCategories.mockResolvedValueOnce([ - { id: 1, name: 'Salary', is_income: true }, - ]); - - // When - const result = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(0); - expect(result.errors).toStrictEqual([ - 'Could not find category “NonExistentCategory”', - ]); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsPercentage.ts b/packages/loot-core/src/server/budget/goals/goalsPercentage.ts deleted file mode 100644 index 175d5efe560..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsPercentage.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-strict-ignore -import * as monthUtils from '../../../shared/months'; -import * as db from '../../db'; -import { getSheetValue } from '../actions'; - -export async function goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, -) { - const percent = template.percent; - let monthlyIncome = 0; - - if (template.category.toLowerCase() === 'all income') { - if (template.previous) { - const sheetName_lastmonth = monthUtils.sheetForMonth( - monthUtils.addMonths(month, -1), - ); - monthlyIncome = await getSheetValue(sheetName_lastmonth, 'total-income'); - } else { - monthlyIncome = await getSheetValue(sheetName, `total-income`); - } - } else if (template.category.toLowerCase() === 'available funds') { - monthlyIncome = available_start; - } else { - const income_category = (await db.getCategories()).find( - c => - c.is_income && c.name.toLowerCase() === template.category.toLowerCase(), - ); - if (!income_category) { - errors.push(`Could not find category “${template.category}”`); - return { to_budget, errors }; - } - if (template.previous) { - const sheetName_lastmonth = monthUtils.sheetForMonth( - monthUtils.addMonths(month, -1), - ); - monthlyIncome = await getSheetValue( - sheetName_lastmonth, - `sum-amount-${income_category.id}`, - ); - } else { - monthlyIncome = await getSheetValue( - sheetName, - `sum-amount-${income_category.id}`, - ); - } - } - - const increment = Math.max(0, Math.round(monthlyIncome * (percent / 100))); - to_budget += increment; - return { to_budget, errors }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsRemainder.test.ts b/packages/loot-core/src/server/budget/goals/goalsRemainder.test.ts deleted file mode 100644 index b68b9897d96..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsRemainder.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { goalsRemainder } from './goalsRemainder'; - -describe('goalsRemainder', () => { - it('should calculate the budget correctly when remainder_scale is greater than 0', async () => { - // Given - const template = { weight: 100 }; - const budgetAvailable = 1000; - const remainder_scale = 0.5; - const to_budget = 0; - - // When - const result = await goalsRemainder( - template, - budgetAvailable, - remainder_scale, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(50); - }); - - it('should calculate the budget correctly when remainder_scale is 0', async () => { - // Given - const template = { weight: 100 }; - const budgetAvailable = 1000; - const remainder_scale = 0; - const to_budget = 0; - - // When - const result = await goalsRemainder( - template, - budgetAvailable, - remainder_scale, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(100); - }); - - it('should calculate the budget correctly when when the calculated budget exceeds the budget available', async () => { - // Given - const template = { weight: 1000 }; - const budgetAvailable = 500; - const remainder_scale = 1; - const to_budget = 0; - - // When - const result = await goalsRemainder( - template, - budgetAvailable, - remainder_scale, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(500); - }); - - it('should calculate the budget correctly when there is 1 minor unit leftover from rounding', async () => { - // Given - const template = { weight: 499 }; - const budgetAvailable = 500; - const remainder_scale = 1; - const to_budget = 0; - - // When - const result = await goalsRemainder( - template, - budgetAvailable, - remainder_scale, - to_budget, - ); - - // Then - expect(result.to_budget).toBe(500); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsRemainder.ts b/packages/loot-core/src/server/budget/goals/goalsRemainder.ts deleted file mode 100644 index 3b7e1d3be8e..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsRemainder.ts +++ /dev/null @@ -1,47 +0,0 @@ -// @ts-strict-ignore -export async function goalsRemainder( - template, - budgetAvailable, - remainder_scale, - to_budget, -) { - if (remainder_scale >= 0) { - to_budget += - remainder_scale === 0 - ? Math.round(template.weight) - : Math.round(remainder_scale * template.weight); - // can over budget with the rounding, so checking that - if (to_budget >= budgetAvailable) { - to_budget = budgetAvailable; - // check if there is 1 cent leftover from rounding - } else if (budgetAvailable - to_budget === 1) { - to_budget = to_budget + 1; - } - } - return { to_budget }; -} - -export function findRemainder(priority_list, categories, category_templates) { - // find all remainder templates, place them at highest priority - let remainder_found; - let remainder_weight_total = 0; - const remainder_priority = priority_list[priority_list.length - 1] + 1; - for (let c = 0; c < categories.length; c++) { - const category = categories[c]; - const templates = category_templates[category.id]; - if (templates) { - for (let i = 0; i < templates.length; i++) { - if (templates[i].type === 'remainder') { - templates[i].priority = remainder_priority; - remainder_weight_total += templates[i].weight; - remainder_found = true; - } - } - } - } - return { - remainder_found, - remainder_priority, - remainder_weight_total, - }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsSimple.test.ts b/packages/loot-core/src/server/budget/goals/goalsSimple.test.ts deleted file mode 100644 index fd7f2ac2ed1..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsSimple.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { goalsSimple } from './goalsSimple'; - -describe('goalsSimple', () => { - it('should return correct budget amount when limit set and no balance left from previous months', async () => { - // Given - const template = { limit: { amount: 100, hold: false } }; - const limitCheck = false; - const errors: string[] = []; - const limit = 0; - const hold = false; - const to_budget = 0; - const last_month_balance = 0; - - // When - const result = await goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, - ); - - // Then - expect(result.to_budget).toBe(10000); - expect(result.errors).toHaveLength(0); - expect(result.limitCheck).toBe(true); - expect(result.limit).toBe(10000); - expect(result.hold).toBe(false); - }); - - it('should return correct budget amount when limit set and balance from previous month left over', async () => { - // Given - const template = { limit: { amount: 100, hold: false } }; - const limitCheck = false; - const errors: string[] = []; - const limit = 0; - const hold = false; - const to_budget = 0; - const last_month_balance = 2000; - - // When - const result = await goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, - ); - - // Then - expect(result.to_budget).toBe(8000); - expect(result.errors).toHaveLength(0); - expect(result.limitCheck).toBe(true); - expect(result.limit).toBe(10000); - }); - - it('should return correct budget amount when assigned from previous month is greater than the limit set', async () => { - // Given - const template = { limit: { amount: 100, hold: false } }; - const limitCheck = false; - const errors: string[] = []; - const limit = 0; - const hold = false; - const to_budget = 0; - const last_month_balance = 20000; - - // When - const result = await goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, - ); - - // Then - expect(result.to_budget).toBe(-10000); - expect(result.errors).toHaveLength(0); - expect(result.limitCheck).toBe(true); - expect(result.limit).toBe(10000); - expect(result.hold).toBe(false); - }); - - it('should return correct budget amount when both limit and monthly limit set', async () => { - // Given - const template = { monthly: 50, limit: { amount: 100, hold: false } }; - const limitCheck = false; - const errors: string[] = []; - const limit = 0; - const hold = false; - const to_budget = 0; - const last_month_balance = 0; - - // When - const result = await goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, - ); - - // Then - expect(result.to_budget).toBe(5000); - expect(result.errors).toHaveLength(0); - expect(result.limitCheck).toBe(true); - expect(result.limit).toBe(10000); - expect(result.hold).toBe(false); - }); - - it('should fail when multiple limit checks exist', async () => { - // Given - const template = { limit: { amount: 100, hold: true } }; - const limitCheck = true; - const errors: string[] = []; - const limit = 0; - const hold = true; - const to_budget = 0; - const last_month_balance = 200; - - // When - const result = await goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, - ); - - // Then - expect(result.to_budget).toBe(0); - expect(result.errors).toStrictEqual(['More than one “up to” limit found.']); - expect(result.limitCheck).toBe(true); - expect(result.limit).toBe(0); - expect(result.hold).toBe(true); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsSimple.ts b/packages/loot-core/src/server/budget/goals/goalsSimple.ts deleted file mode 100644 index eec6e962497..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsSimple.ts +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-strict-ignore -import { amountToInteger } from '../../../shared/util'; - -export async function goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, -) { - // simple has 'monthly' and/or 'limit' params - if (template.limit != null) { - if (limitCheck) { - errors.push(`More than one “up to” limit found.`); - return { to_budget, errors, limit, limitCheck, hold }; - } else { - limitCheck = true; - limit = amountToInteger(template.limit.amount); - hold = template.limit.hold; - } - } - let increment = 0; - if (template.monthly != null) { - const monthly = amountToInteger(template.monthly); - increment = monthly; - } else { - increment = limit - last_month_balance; - } - to_budget += increment; - return { to_budget, errors, limit, limitCheck, hold }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsSpend.test.ts b/packages/loot-core/src/server/budget/goals/goalsSpend.test.ts deleted file mode 100644 index 0b69e800e94..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsSpend.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { getSheetValue } from '../actions'; - -import { goalsSpend } from './goalsSpend'; - -jest.mock('../actions'); - -describe('goalsSpend', () => { - const mockGetSheetValue = getSheetValue as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return correct budget amount for range when no spending has happened', async () => { - // Given - const template = { amount: 60, from: '2024-01', month: '2024-12' }; - const last_month_balance = 0; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - const category = { id: 'uuid' }; - - mockGetSheetValue - .mockResolvedValueOnce(0) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500); - - // When - const result = await goalsSpend( - template, - last_month_balance, - current_month, - to_budget, - errors, - category, - ); - - // Then - expect(result.to_budget).toBe(500); - expect(result.errors).toHaveLength(0); - }); - - it('should return correct budget amount for range when spending has happened', async () => { - // Given - const template = { amount: 60, from: '2024-01', month: '2024-12' }; - const last_month_balance = 0; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - const category = { id: 'uuid' }; - - mockGetSheetValue - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500) - .mockResolvedValueOnce(500); - - // When - const result = await goalsSpend( - template, - last_month_balance, - current_month, - to_budget, - errors, - category, - ); - - // Then - expect(result.to_budget).toBe(600); - expect(result.errors).toHaveLength(0); - }); - - it('should return error when range is in the past', async () => { - // Given - const template = { amount: 60, from: '2024-01', month: '2024-05' }; - const last_month_balance = 0; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - const category = { id: 'uuid' }; - - // When - const result = await goalsSpend( - template, - last_month_balance, - current_month, - to_budget, - errors, - category, - ); - - // Then - expect(result.to_budget).toBe(0); - expect(result.errors).toStrictEqual(['2024-05 is in the past.']); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsSpend.ts b/packages/loot-core/src/server/budget/goals/goalsSpend.ts deleted file mode 100644 index ca03c0072c2..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsSpend.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-strict-ignore -import * as monthUtils from '../../../shared/months'; -import { amountToInteger } from '../../../shared/util'; -import { getSheetValue } from '../actions'; - -export async function goalsSpend( - template, - last_month_balance, - current_month, - to_budget, - errors, - category, -) { - // spend has 'amount' and 'from' and 'month' params - const from_month = `${template.from}-01`; - const to_month = `${template.month}-01`; - let already_budgeted = last_month_balance; - let first_month = true; - for ( - let m = from_month; - monthUtils.differenceInCalendarMonths(current_month, m) > 0; - m = monthUtils.addMonths(m, 1) - ) { - const sheetName = monthUtils.sheetForMonth(monthUtils.format(m, 'yyyy-MM')); - - if (first_month) { - const spent = await getSheetValue(sheetName, `sum-amount-${category.id}`); - const balance = await getSheetValue(sheetName, `leftover-${category.id}`); - already_budgeted = balance - spent; - first_month = false; - } else { - const budgeted = await getSheetValue(sheetName, `budget-${category.id}`); - already_budgeted += budgeted; - } - } - const num_months = monthUtils.differenceInCalendarMonths( - to_month, - monthUtils._parse(current_month), - ); - const target = amountToInteger(template.amount); - - let increment = 0; - if (num_months < 0) { - errors.push(`${template.month} is in the past.`); - return { to_budget, errors }; - } else if (num_months === 0) { - increment = target - already_budgeted; - } else { - increment = Math.round((target - already_budgeted) / (num_months + 1)); - } - to_budget = increment; - return { to_budget, errors }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsWeek.test.ts b/packages/loot-core/src/server/budget/goals/goalsWeek.test.ts deleted file mode 100644 index c1351ca8cb7..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsWeek.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { goalsWeek } from './goalsWeek'; - -describe('goalsWeek', () => { - it('should return the correct budget amount for a weekly repeat', async () => { - // Given - const template = { amount: 100, starting: '2024-08-01', weeks: 1 }; - const limit = 0; - const limitCheck = false; - const hold = false; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - - // When - const result = await goalsWeek( - template, - limit, - limitCheck, - hold, - current_month, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(50000); - expect(result.errors).toHaveLength(0); - expect(result.limit).toBe(0); - expect(result.limitCheck).toBe(false); - expect(result.hold).toBe(false); - }); - - it('should return the correct budget amount for a bi-weekly repeat', async () => { - // Given - const template = { amount: '100', starting: '2024-08-01', weeks: 2 }; - const limit = 0; - const limitCheck = false; - const hold = false; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - - // When - const result = await goalsWeek( - template, - limit, - limitCheck, - hold, - current_month, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(30000); - expect(result.errors).toHaveLength(0); - expect(result.limit).toBe(0); - expect(result.limitCheck).toBe(false); - expect(result.hold).toBe(false); - }); - - it('should return the correct budget when limit set', async () => { - // Given - const template = { amount: 100, starting: '2024-08-01', weeks: 1 }; - const limit = 20000; - const limitCheck = false; - const hold = false; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - - // When - const result = await goalsWeek( - template, - limit, - limitCheck, - hold, - current_month, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(50000); - expect(result.errors).toHaveLength(0); - expect(result.limit).toBe(20000); - expect(result.limitCheck).toBe(false); - expect(result.hold).toBe(false); - }); - - it('should return error when multiple limit checks exist', async () => { - // Given - const template = { - amount: '100', - starting: '2024-08-01', - weeks: 1, - limit: { amount: 100, hold: true }, - }; - const limit = 1000; - const limitCheck = true; - const hold = false; - const current_month = '2024-08-01'; - const to_budget = 0; - const errors: string[] = []; - - // When - const result = await goalsWeek( - template, - limit, - limitCheck, - hold, - current_month, - to_budget, - errors, - ); - - // Then - expect(result.to_budget).toBe(0); - expect(result.errors).toStrictEqual(['More than one “up to” limit found.']); - expect(result.limit).toBe(1000); - expect(result.limitCheck).toBe(true); - expect(result.hold).toBe(false); - }); -}); diff --git a/packages/loot-core/src/server/budget/goals/goalsWeek.ts b/packages/loot-core/src/server/budget/goals/goalsWeek.ts deleted file mode 100644 index 6f05c1ce188..00000000000 --- a/packages/loot-core/src/server/budget/goals/goalsWeek.ts +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-strict-ignore -import * as monthUtils from '../../../shared/months'; -import { amountToInteger } from '../../../shared/util'; - -export async function goalsWeek( - template, - limit, - limitCheck, - hold, - current_month, - to_budget, - errors, -) { - // week has 'amount', 'starting', 'weeks' and optional 'limit' params - const amount = amountToInteger(template.amount); - const weeks = template.weeks != null ? Math.round(template.weeks) : 1; - if (template.limit != null) { - if (limit > 0) { - errors.push(`More than one “up to” limit found.`); - return { to_budget, errors, limit, limitCheck, hold }; - } else { - limitCheck = true; - limit = amountToInteger(template.limit.amount); - hold = template.limit.hold; - } - } - let w = template.starting; - const next_month = monthUtils.addMonths(current_month, 1); - - while (w < next_month) { - if (w >= current_month) { - to_budget += amount; - } - w = monthUtils.addWeeks(w, weeks); - } - return { to_budget, errors, limit, limitCheck, hold }; -} diff --git a/packages/loot-core/src/server/budget/goals/goalsSchedule.test.ts b/packages/loot-core/src/server/budget/goalsSchedule.test.ts similarity index 92% rename from packages/loot-core/src/server/budget/goals/goalsSchedule.test.ts rename to packages/loot-core/src/server/budget/goalsSchedule.test.ts index 29eefba3895..6fa59205e8a 100644 --- a/packages/loot-core/src/server/budget/goals/goalsSchedule.test.ts +++ b/packages/loot-core/src/server/budget/goalsSchedule.test.ts @@ -1,13 +1,13 @@ -import * as db from '../../db'; -import { getRuleForSchedule } from '../../schedules/app'; -import { isReflectBudget } from '../actions'; +import * as db from '../db'; +import { getRuleForSchedule } from '../schedules/app'; +import { isReflectBudget } from './actions'; import { goalsSchedule } from './goalsSchedule'; -jest.mock('../../db'); -jest.mock('../actions'); -jest.mock('../../schedules/app', () => { - const actualModule = jest.requireActual('../../schedules/app'); +jest.mock('../db'); +jest.mock('./actions'); +jest.mock('../schedules/app', () => { + const actualModule = jest.requireActual('../schedules/app'); return { ...actualModule, getRuleForSchedule: jest.fn(), diff --git a/packages/loot-core/src/server/budget/goals/goalsSchedule.ts b/packages/loot-core/src/server/budget/goalsSchedule.ts similarity index 95% rename from packages/loot-core/src/server/budget/goals/goalsSchedule.ts rename to packages/loot-core/src/server/budget/goalsSchedule.ts index 6abeec39b94..87a84485c7e 100644 --- a/packages/loot-core/src/server/budget/goals/goalsSchedule.ts +++ b/packages/loot-core/src/server/budget/goalsSchedule.ts @@ -1,13 +1,14 @@ // @ts-strict-ignore -import * as monthUtils from '../../../shared/months'; -import { extractScheduleConds } from '../../../shared/schedules'; -import * as db from '../../db'; +import * as monthUtils from '../../shared/months'; +import { extractScheduleConds } from '../../shared/schedules'; +import * as db from '../db'; import { getRuleForSchedule, getNextDate, getDateWithSkippedWeekend, -} from '../../schedules/app'; -import { isReflectBudget } from '../actions'; +} from '../schedules/app'; + +import { isReflectBudget } from './actions'; async function createScheduleList(template, current_month, category) { const t = []; @@ -15,8 +16,8 @@ async function createScheduleList(template, current_month, category) { 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], + 'SELECT * FROM schedules WHERE TRIM(name) = ? AND tombstone = 0', + [template[ll].name.trim()], ); const rule = await getRuleForSchedule(sid); const conditions = rule.serialize().conditions; diff --git a/packages/loot-core/src/server/budget/goaltemplates.ts b/packages/loot-core/src/server/budget/goaltemplates.ts index 6e36660ec3e..a91516c2157 100644 --- a/packages/loot-core/src/server/budget/goaltemplates.ts +++ b/packages/loot-core/src/server/budget/goaltemplates.ts @@ -1,39 +1,24 @@ // @ts-strict-ignore import { Notification } from '../../client/state-types/notifications'; import * as monthUtils from '../../shared/months'; -import { amountToInteger, integerToAmount } from '../../shared/util'; import * as db from '../db'; import { batchMessages } from '../sync'; -import { getSheetValue, isReflectBudget, setBudget, setGoal } from './actions'; -import { goalsAverage } from './goals/goalsAverage'; -import { goalsBy } from './goals/goalsBy'; -import { goalsCopy } from './goals/goalsCopy'; -import { goalsPercentage } from './goals/goalsPercentage'; -import { findRemainder, goalsRemainder } from './goals/goalsRemainder'; -import { goalsSchedule } from './goals/goalsSchedule'; -import { goalsSimple } from './goals/goalsSimple'; -import { goalsSpend } from './goals/goalsSpend'; -import { goalsWeek } from './goals/goalsWeek'; +import { isReflectBudget, getSheetValue, setGoal, setBudget } from './actions'; +import { CategoryTemplate } from './categoryTemplate'; import { checkTemplates, storeTemplates } from './template-notes'; -const TEMPLATE_PREFIX = '#template'; - -export async function applyTemplate({ month }) { +export async function applyTemplate({ month }): Promise { await storeTemplates(); - const category_templates = await getTemplates(null, 'template'); - const category_goals = await getTemplates(null, 'goal'); - const ret = await processTemplate(month, false, category_templates); - await processGoals(category_goals, month); + const categoryTemplates = await getTemplates(null); + const ret = await processTemplate(month, false, categoryTemplates, null); return ret; } -export async function overwriteTemplate({ month }) { +export async function overwriteTemplate({ month }): Promise { await storeTemplates(); - const category_templates = await getTemplates(null, 'template'); - const category_goals = await getTemplates(null, 'goal'); - const ret = await processTemplate(month, true, category_templates); - await processGoals(category_goals, month); + const categoryTemplates = await getTemplates(null); + const ret = await processTemplate(month, true, categoryTemplates, null); return ret; } @@ -42,15 +27,8 @@ export async function applyMultipleCategoryTemplates({ month, categoryIds }) { const query = `SELECT * FROM v_categories WHERE id IN (${placeholders})`; const categories = await db.all(query, categoryIds); await storeTemplates(); - const category_templates = await getTemplates(categories, 'template'); - const category_goals = await getTemplates(categories, 'goal'); - const ret = await processTemplate( - month, - true, - category_templates, - categories, - ); - await processGoals(category_goals, month); + const categoryTemplates = await getTemplates(categories); + const ret = await processTemplate(month, true, categoryTemplates, categories); return ret; } @@ -59,15 +37,8 @@ export async function applySingleCategoryTemplate({ month, category }) { category, ]); await storeTemplates(); - const category_templates = await getTemplates(categories[0], 'template'); - const category_goals = await getTemplates(categories[0], 'goal'); - const ret = await processTemplate( - month, - true, - category_templates, - categories, - ); - await processGoals(category_goals, month, categories[0]); + const categoryTemplates = await getTemplates(categories[0]); + const ret = await processTemplate(month, true, categoryTemplates, categories); return ret; } @@ -86,71 +57,15 @@ async function getCategories() { ); } -function checkScheduleTemplates(template) { - let lowPriority = template[0].priority; - let errorNotice = false; - for (let l = 1; l < template.length; l++) { - if (template[l].priority !== lowPriority) { - lowPriority = Math.min(lowPriority, template[l].priority); - errorNotice = true; - } - } - return { lowPriority, errorNotice }; -} - -async function setGoalBudget({ month, templateBudget }) { - await batchMessages(async () => { - templateBudget.forEach(element => { - setBudget({ - category: element.category, - month, - amount: element.amount, - }); - }); - }); -} - -async function setCategoryTargets({ month, idealTemplate }) { - await batchMessages(async () => { - idealTemplate.forEach(element => { - setGoal({ - category: element.category, - goal: element.amount, - month, - long_goal: 0, - }); - }); - }); -} - -async function resetCategoryTargets(month, category) { - let categories = []; - if (category === null) { - categories = await getCategories(); - } else { - categories = category; - } - await batchMessages(async () => { - for (let i = 0; i < categories.length; i++) { - setGoal({ - category: categories[i].id, - goal: null, - month, - long_goal: null, - }); - } - }); -} - -async function getTemplates(category, directive: string) { +async function getTemplates(category) { //retrieves template definitions from the database - const goal_def = await db.all( + const goalDef = await db.all( 'SELECT * FROM categories WHERE goal_def IS NOT NULL', ); const templates = []; - for (let ll = 0; ll < goal_def.length; ll++) { - templates[goal_def[ll].id] = JSON.parse(goal_def[ll].goal_def); + for (let ll = 0; ll < goalDef.length; ll++) { + templates[goalDef[ll].id] = JSON.parse(goalDef[ll].goal_def); } if (Array.isArray(category)) { const multipleCategoryTemplates = []; @@ -158,22 +73,13 @@ async function getTemplates(category, directive: string) { const categoryId = category[dd].id; if (templates[categoryId] !== undefined) { multipleCategoryTemplates[categoryId] = templates[categoryId]; - multipleCategoryTemplates[categoryId] = multipleCategoryTemplates[ - categoryId - ].filter(t => t.directive === directive); } } return multipleCategoryTemplates; } else if (category) { - const singleCategoryTemplate = []; - if (templates[category.id] !== undefined) { - singleCategoryTemplate[category.id] = templates[category.id].filter( - t => t.directive === directive, - ); - return singleCategoryTemplate; - } - singleCategoryTemplate[category.id] = undefined; - return singleCategoryTemplate; + const ret = []; + ret[category.id] = templates[category.id]; + return ret; } else { const categories = await getCategories(); const ret = []; @@ -181,520 +87,168 @@ async function getTemplates(category, directive: string) { const id = categories[cc].id; if (templates[id]) { ret[id] = templates[id]; - ret[id] = ret[id].filter(t => t.directive === directive); } } return ret; } } +async function setBudgets(month, templateBudget) { + await batchMessages(async () => { + templateBudget.forEach(element => { + setBudget({ + category: element.category, + month, + amount: element.budgeted, + }); + }); + }); +} + +async function setGoals(month, idealTemplate) { + await batchMessages(async () => { + idealTemplate.forEach(element => { + setGoal({ + month, + category: element.category, + goal: element.goal, + long_goal: element.longGoal, + }); + }); + }); +} + async function processTemplate( month, - force, - category_templates, - category?, + force: boolean, + categoryTemplates, + categoriesIn?, ): Promise { - let num_applied = 0; - let errors = []; - const idealTemplate = []; - const setToZero = []; - let priority_list = []; - + // setup categories let categories = []; - const categories_remove = []; - if (category) { - categories = category; + if (!categoriesIn) { + const isReflect = isReflectBudget(); + const categoriesLong = await getCategories(); + categoriesLong.forEach(c => { + if (!isReflect && !c.is_income) { + categories.push(c); + } + }); } else { - categories = await getCategories(); + categories = categoriesIn; } - //clears templated categories - for (let c = 0; c < categories.length; c++) { - const category = categories[c]; - const budgeted = await getSheetValue( - monthUtils.sheetForMonth(month), - `budget-${category.id}`, - ); - const template = category_templates[category.id]; - if (template) { - for (let l = 0; l < template.length; l++) { - //add each priority we need to a list. Will sort later - if (template[l].priority == null) { - continue; - } - priority_list.push(template[l].priority); - } - } - if (budgeted) { - if (!force) { - // save index of category to remove - categories_remove.push(c); - } else { - // add all categories with a template to the list to unset budget - if (template?.length > 0) { - setToZero.push({ - category: category.id, - }); - } + + // setup categories to process + const catObjects: CategoryTemplate[] = []; + let availBudget = await getSheetValue( + monthUtils.sheetForMonth(month), + `to-budget`, + ); + let priorities = []; + let remainderWeight = 0; + const errors = []; + const budgetList = []; + const goalList = []; + for (let i = 0; i < categories.length; i++) { + const id = categories[i].id; + const sheetName = monthUtils.sheetForMonth(month); + const templates = categoryTemplates[id]; + const budgeted = await getSheetValue(sheetName, `budget-${id}`); + const existingGoal = await getSheetValue(sheetName, `goal-${id}`); + + // only run categories that are unbudgeted or if we are forcing it + if ((budgeted === 0 || force) && templates) { + // add to available budget + // gather needed priorities + // gather remainder weights + try { + const obj = await CategoryTemplate.init(templates, id, month); + availBudget += budgeted; + const p = obj.getPriorities(); + p.forEach(pr => priorities.push(pr)); + remainderWeight += obj.getRemainderWeight(); + catObjects.push(obj); + } catch (e) { + //console.log(`${categories[i].name}: ${e}`); + errors.push(`${categories[i].name}: ${e.message}`); } + + // do a reset of the goals that are orphaned + } else if (existingGoal !== null && !templates) { + goalList.push({ + category: id, + goal: null, + longGoal: null, + }); + //await setGoal({ month, category: id, goal: null, long_goal: null }); } } - // remove the categories we are skipping - // Go backwards through the list so the indexes don't change - // on the categories we need - for (let i = categories_remove.length - 1; i >= 0; i--) { - categories.splice(categories_remove[i], 1); + //break early if nothing to do, or there are errors + if (catObjects.length === 0 && errors.length === 0) { + return { + type: 'message', + message: 'Everything is up to date', + }; + } + if (errors.length > 0) { + return { + sticky: true, + message: `There were errors interpreting some templates:`, + pre: errors.join(`\n\n`), + }; } - // zero out budget and goal from categories that need it - await setGoalBudget({ - month, - templateBudget: setToZero, - }); - await resetCategoryTargets(month, categories); - - // sort and filter down to just the requested priorities - priority_list = priority_list - .sort(function (a, b) { + //compress to needed, sorted priorities + priorities = priorities + .sort((a, b) => { return a - b; }) - .filter((item, index, curr) => curr.indexOf(item) === index); - - const { remainder_found, remainder_priority, remainder_weight_total } = - findRemainder(priority_list, categories, category_templates); - if (remainder_found) priority_list.push(remainder_priority); - - const sheetName = monthUtils.sheetForMonth(month); - const available_start = await getSheetValue(sheetName, `to-budget`); - let budgetAvailable = isReflectBudget() - ? await getSheetValue(sheetName, `total-saved`) - : await getSheetValue(sheetName, `to-budget`); - for (let ii = 0; ii < priority_list.length; ii++) { - const priority = priority_list[ii]; - const templateBudget = []; - - // setup scaling for remainder - let remainder_scale = 1; - if (priority === remainder_priority && remainder_found) { - const available_now = await getSheetValue(sheetName, `to-budget`); - remainder_scale = available_now / remainder_weight_total; - } - - for (let c = 0; c < categories.length; c++) { - const category = categories[c]; - let template_lines = category_templates[category.id]; - if (template_lines) { - //check that all schedule and by lines have the same priority level - let skipSchedule = false; - let isScheduleOrBy = false; - let priorityCheck = 0; - if ( - template_lines.filter( - t => - (t.type === 'schedule' || t.type === 'by') && - t.priority === priority, - ).length > 0 - ) { - template_lines = template_lines.filter( - t => - (t.priority === priority && - (t.type !== 'schedule' || t.type !== 'by')) || - t.type === 'schedule' || - t.type === 'by', - ); - const { lowPriority, errorNotice } = - await checkScheduleTemplates(template_lines); - priorityCheck = lowPriority; - skipSchedule = priorityCheck !== priority ? true : false; - isScheduleOrBy = true; - if (!skipSchedule && errorNotice) { - errors.push( - category.name + - ': Schedules and By templates should all have the same priority. Using priority ' + - priorityCheck, - ); - } - } - if (!skipSchedule) { - if (!isScheduleOrBy) { - template_lines = template_lines.filter( - t => t.priority === priority, - ); - } - if (template_lines.length > 0) { - errors = errors.concat( - template_lines - .filter(t => t.type === 'error') - .map(({ line, error }) => - [ - category.name + ': ' + error.message, - line, - ' '.repeat( - TEMPLATE_PREFIX.length + error.location.start.offset, - ) + '^', - ].join('\n'), - ), - ); - const prev_budgeted = await getSheetValue( - sheetName, - `budget-${category.id}`, - ); - const { amount: originalToBudget, errors: applyErrors } = - await applyCategoryTemplate( - category, - template_lines, - month, - remainder_scale, - available_start, - budgetAvailable, - prev_budgeted, - ); - - let to_budget = originalToBudget; - if (to_budget != null) { - num_applied++; - //only store goals from non remainder templates - if (priority !== remainder_priority) { - if ( - idealTemplate.filter(c => c.category === category.id).length > - 0 - ) { - idealTemplate.filter( - c => c.category === category.id, - )[0].amount += to_budget; - } else { - idealTemplate.push({ - category: category.id, - amount: to_budget, - }); - } - } - if (to_budget <= budgetAvailable || !priority) { - templateBudget.push({ - category: category.id, - amount: to_budget + prev_budgeted, - }); - } else if (to_budget > budgetAvailable && budgetAvailable >= 0) { - to_budget = budgetAvailable; - errors.push(`Insufficient funds.`); - templateBudget.push({ - category: category.id, - amount: to_budget + prev_budgeted, - }); - } - budgetAvailable -= to_budget; - } - if (applyErrors != null) { - errors = errors.concat( - applyErrors.map(error => `${category.name}: ${error}`), - ); - } - } - } - } - } - await setGoalBudget({ month, templateBudget }); - } - await setCategoryTargets({ month, idealTemplate }); - - if (num_applied === 0) { - if (errors.length) { - return { - type: 'error', - sticky: true, - message: `There were errors interpreting some templates:`, - pre: errors.join('\n\n'), - }; - } else { - return { type: 'message', message: 'All categories were up to date.' }; - } - } else { - const applied = `Successfully applied ${num_applied} templates.`; - if (errors.length) { - return { - sticky: true, - message: `${applied} There were errors interpreting some templates:`, - pre: errors.join('\n\n'), - }; - } else { - return { - type: 'message', - message: applied, - }; + .filter((item, idx, curr) => curr.indexOf(item) === idx); + + // run each priority level + for (let pi = 0; pi < priorities.length; pi++) { + const availStart = availBudget; + const p = priorities[pi]; + for (let i = 0; i < catObjects.length; i++) { + if (availBudget <= 0 && p > 0) break; + const ret = await catObjects[i].runTemplatesForPriority( + p, + availBudget, + availStart, + ); + availBudget -= ret; } - } -} - -async function processGoals(goals, month, category?) { - let categories = []; - if (category) { - categories[0] = category; - } else { - categories = await getCategories(); - } - for (let c = 0; c < categories.length; c++) { - const cat_id = categories[c].id; - const goal_lines = goals[cat_id]; - if (goal_lines?.length > 0) { - await setGoal({ - month, - category: cat_id, - goal: amountToInteger(goal_lines[0].amount), - long_goal: 1, - }); + if (availBudget <= 0) { + break; } } -} - -async function applyCategoryTemplate( - category, - template_lines, - month, - remainder_scale, - available_start, - budgetAvailable, - prev_budgeted, -) { - const current_month = `${month}-01`; - let errors = []; - let all_schedule_names = await db.all( - 'SELECT name from schedules WHERE name NOT NULL AND tombstone = 0', - ); - 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) { - case 'by': - case 'spend': - let target_month = `${template.month}-01`; - let num_months = monthUtils.differenceInCalendarMonths( - target_month, - current_month, - ); - const repeat = template.annual - ? (template.repeat || 1) * 12 - : template.repeat; - - let spend_from; - if (template.type === 'spend') { - spend_from = `${template.from}-01`; - } - while (num_months < 0 && repeat) { - target_month = monthUtils.addMonths(target_month, repeat); - if (spend_from) { - spend_from = monthUtils.addMonths(spend_from, repeat); - } - num_months = monthUtils.differenceInCalendarMonths( - target_month, - current_month, - ); - } - if (num_months < 0) { - errors.push(`${template.month} is in the past.`); - return false; - } - template.month = monthUtils.format(target_month, 'yyyy-MM'); - if (spend_from) { - template.from = monthUtils.format(spend_from, 'yyyy-MM'); - } - break; - case 'schedule': - if (!all_schedule_names.includes(template.name)) { - errors.push(`Schedule ${template.name} does not exist`); - return null; - } - break; - default: - } - return true; + // run limits + catObjects.forEach(o => { + availBudget += o.applyLimit(); }); - - if (template_lines.length > 1) { - template_lines = template_lines.sort((a, b) => { - if (a.type === 'by' && !a.annual) { - return monthUtils.differenceInCalendarMonths( - `${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); - } + // run remainder + if (availBudget > 0 && remainderWeight) { + const perWeight = availBudget / remainderWeight; + catObjects.forEach(o => { + availBudget -= o.runRemainder(availBudget, perWeight); }); } + // finish + catObjects.forEach(o => { + const ret = o.getValues(); + budgetList.push({ category: o.categoryID, budgeted: ret.budgeted }); + goalList.push({ + category: o.categoryID, + goal: ret.goal, + longGoal: ret.longGoal ? 1 : null, + }); + }); + await setBudgets(month, budgetList); + await setGoals(month, goalList); - const sheetName = monthUtils.sheetForMonth(month); - const spent = await getSheetValue(sheetName, `sum-amount-${category.id}`); - const balance = await getSheetValue(sheetName, `leftover-${category.id}`); - const last_month_balance = balance - spent - prev_budgeted; - let to_budget = 0; - let limit = 0; - let hold = false; - let limitCheck = false; - let remainder = 0; - - for (let l = 0; l < template_lines.length; l++) { - const template = template_lines[l]; - switch (template.type) { - case 'simple': { - const goalsReturn = await goalsSimple( - template, - limitCheck, - errors, - limit, - hold, - to_budget, - last_month_balance, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - limit = goalsReturn.limit; - limitCheck = goalsReturn.limitCheck; - hold = goalsReturn.hold; - break; - } - case 'copy': { - const goalsReturn = await goalsCopy( - template, - month, - category, - limitCheck, - errors, - limit, - hold, - to_budget, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - limit = goalsReturn.limit; - limitCheck = goalsReturn.limitCheck; - hold = goalsReturn.hold; - break; - } - case 'by': { - const goalsReturn = await goalsBy( - template_lines, - current_month, - template, - l, - remainder, - last_month_balance, - to_budget, - errors, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - remainder = goalsReturn.remainder; - break; - } - case 'week': { - const goalsReturn = await goalsWeek( - template, - limit, - limitCheck, - hold, - current_month, - to_budget, - errors, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - limit = goalsReturn.limit; - limitCheck = goalsReturn.limitCheck; - hold = goalsReturn.hold; - break; - } - case 'spend': { - const goalsReturn = await goalsSpend( - template, - last_month_balance, - current_month, - to_budget, - errors, - category, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - break; - } - case 'percentage': { - const goalsReturn = await goalsPercentage( - template, - month, - available_start, - sheetName, - to_budget, - errors, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - break; - } - case 'schedule': { - const goalsReturn = await goalsSchedule( - scheduleFlag, - template_lines, - current_month, - balance, - remainder, - last_month_balance, - to_budget, - errors, - category, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - remainder = goalsReturn.remainder; - scheduleFlag = goalsReturn.scheduleFlag; - break; - } - case 'remainder': { - const goalsReturn = await goalsRemainder( - template, - budgetAvailable, - remainder_scale, - to_budget, - ); - to_budget = goalsReturn.to_budget; - break; - } - case 'average': { - const goalsReturn = await goalsAverage( - template, - current_month, - category, - errors, - to_budget, - ); - to_budget = goalsReturn.to_budget; - errors = goalsReturn.errors; - break; - } - case 'error': - return { errors }; - default: - } - } - - if (limitCheck) { - if (hold && balance > limit) { - to_budget = 0; - } else if (to_budget + balance > limit) { - to_budget = limit - balance; - } - } - // setup notifications - let str = category.name + ': ' + integerToAmount(last_month_balance); - str += - ' + ' + - integerToAmount(to_budget) + - ' = ' + - integerToAmount(last_month_balance + to_budget); - str += ' ' + template_lines.map(x => x.line).join('\n'); - console.log(str); - return { amount: to_budget, errors }; + return { + type: 'message', + message: `Successfully applied templates to ${catObjects.length} categories`, + }; } diff --git a/packages/loot-core/src/server/budget/types/templates.d.ts b/packages/loot-core/src/server/budget/types/templates.d.ts index 8b6efda780d..8b5eb6e323d 100644 --- a/packages/loot-core/src/server/budget/types/templates.d.ts +++ b/packages/loot-core/src/server/budget/types/templates.d.ts @@ -55,7 +55,7 @@ interface RemainderTemplate extends BaseTemplate { interface AverageTemplate extends BaseTemplate { type: 'average'; - amount: number; + numMonths: number; } interface GoalTemplate extends BaseTemplate { @@ -63,6 +63,11 @@ interface GoalTemplate extends BaseTemplate { amount: number; } +interface CopyTemplate extends BaseTemplate { + type: 'copy'; + lookBack: number; +} + interface ErrorTemplate extends BaseTemplate { type: 'error'; line: string; @@ -79,4 +84,5 @@ export type Template = | RemainderTemplate | AverageTemplate | GoalTemplate + | CopyTemplate | ErrorTemplate; diff --git a/upcoming-release-notes/3754.md b/upcoming-release-notes/3754.md new file mode 100644 index 00000000000..a2ecd8f273b --- /dev/null +++ b/upcoming-release-notes/3754.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [youngcw] +--- + +Reorganize goal template code