Enhance Average Goal Template to allow adjusting the budgetted amount… (#6711)

* Enhance Average Goal Template to allow adjusting the budgetted amount from the average by a percent or fixed amount

* fix typos

* ensure check is for undefined, so zero doesn't cause edge cases

* typo in enhancement message

* scale by cents for fixed amount, fixup tests for this as well

* Add support for fixed values in Schedule template

* [autofix.ci] apply automated fixes

* Changed to 'fixed' from 'amount'. Added unparse logic for Average, also fixed Schedule unparse

* move to generic for syntax from specific number

* consider currency preferences when calculating fixed modifications

* lint

* [autofix.ci] apply automated fixes

* timeframe -> period

* percentage -> value

* pass currency for calculation

* lint

* import from proper local dir

* [autofix.ci] apply automated fixes

* script block around {number} to prevent mdx conflict

* match order of ops between schedule and average for percent adjustment

* diversify example

* Link to schedule>adjustments from average

* Removed example column in favor of extra context
Slight rewording to avoid 'average' overload

* rabbit nitpicks

* number

* period

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: youngcw <calebyoung94@gmail.com>
This commit is contained in:
J B
2026-01-21 12:31:46 -05:00
committed by GitHub
parent 8d1f0cf1de
commit b651238ad2
9 changed files with 284 additions and 41 deletions

View File

@@ -591,6 +591,137 @@ describe('CategoryTemplateContext', () => {
);
expect(result).toBe(67); // Average of -100, 200, -300
});
it('should handle positive percent adjustments', async () => {
const template: Template = {
type: 'average',
numMonths: 3,
directive: 'template',
priority: 1,
adjustment: 10,
adjustmentType: 'percent',
};
vi.mocked(actions.getSheetValue)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100);
const result = await CategoryTemplateContext.runAverage(
template,
instance,
);
expect(result).toBe(110);
});
it('should handle negative percent adjustments', async () => {
const template: Template = {
type: 'average',
numMonths: 3,
directive: 'template',
priority: 1,
adjustment: -10,
adjustmentType: 'percent',
};
vi.mocked(actions.getSheetValue)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100);
const result = await CategoryTemplateContext.runAverage(
template,
instance,
);
expect(result).toBe(90);
});
it('should handle zero percent adjustments', async () => {
const template: Template = {
type: 'average',
numMonths: 3,
directive: 'template',
priority: 1,
adjustment: 0,
adjustmentType: 'percent',
};
vi.mocked(actions.getSheetValue)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100);
const result = await CategoryTemplateContext.runAverage(
template,
instance,
);
expect(result).toBe(100);
});
it('should handle zero amount adjustments', async () => {
const template: Template = {
type: 'average',
numMonths: 3,
directive: 'template',
priority: 1,
adjustment: 0,
adjustmentType: 'fixed',
};
vi.mocked(actions.getSheetValue)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100)
.mockResolvedValueOnce(-100);
const result = await CategoryTemplateContext.runAverage(
template,
instance,
);
expect(result).toBe(100);
});
it('should handle positive amount adjustments', async () => {
const template: Template = {
type: 'average',
numMonths: 3,
directive: 'template',
priority: 1,
adjustment: 11,
adjustmentType: 'fixed',
};
vi.mocked(actions.getSheetValue)
.mockResolvedValueOnce(-10000)
.mockResolvedValueOnce(-10000)
.mockResolvedValueOnce(-10000);
const result = await CategoryTemplateContext.runAverage(
template,
instance,
);
expect(result).toBe(11100);
});
it('should handle negative amount adjustments', async () => {
const template: Template = {
type: 'average',
numMonths: 3,
directive: 'template',
priority: 1,
adjustment: -1,
adjustmentType: 'fixed',
};
vi.mocked(actions.getSheetValue)
.mockResolvedValueOnce(-10000)
.mockResolvedValueOnce(-10000)
.mockResolvedValueOnce(-10000);
const result = await CategoryTemplateContext.runAverage(
template,
instance,
);
expect(result).toBe(9900);
});
});
describe('runBy', () => {

View File

@@ -199,6 +199,7 @@ export class CategoryTemplateContext {
toBudget,
[],
this.category,
this.currency,
);
// Schedules assume that its to budget value is the whole thing so this
// needs to remove the previous funds so they aren't double counted
@@ -737,7 +738,31 @@ export class CategoryTemplateContext {
`sum-amount-${templateContext.category.id}`,
);
}
return -Math.round(sum / template.numMonths);
// negate as sheet value is cost ie negative
let average = -(sum / template.numMonths);
if (template.adjustment !== undefined && template.adjustmentType) {
switch (template.adjustmentType) {
case 'percent': {
const adjustmentFactor = 1 + template.adjustment / 100;
average = adjustmentFactor * average;
break;
}
case 'fixed': {
average += amountToInteger(
template.adjustment,
templateContext.currency.decimalPlaces,
);
break;
}
default:
//no valid adjustment was found
}
}
return Math.round(average);
}
static runBy(templateContext: CategoryTemplateContext): number {

View File

@@ -19,11 +19,11 @@ expr
/ template: template _ limit: limit
{ return { type: 'simple', monthly: null, limit, priority: template.priority, directive: template.directive }}
/ template: template _ schedule:schedule _ full:full? name:rawScheduleName modifiers:modifiers?
{ return { type: 'schedule', name: name.trim(), priority: template.priority, directive: template.directive, full, adjustment: modifiers?.adjustment }}
{ return { type: 'schedule', name: name.trim(), priority: template.priority, directive: template.directive, full, adjustment: modifiers?.adjustment, adjustmentType: modifiers?.adjustmentType }}
/ template: template _ remainder: remainder limit: limit?
{ return { type: 'remainder', priority: null, directive: template.directive, weight: remainder, limit }}
/ template: template _ 'average'i _ amount: positive _ 'months'i?
{ return { type: 'average', numMonths: +amount, priority: template.priority, directive: template.directive }}
/ template: template _ 'average'i _ amount: positive _ 'months'i? modifiers:modifiers?
{ return { type: 'average', numMonths: +amount, priority: template.priority, directive: template.directive, adjustment: modifiers?.adjustment, adjustmentType: modifiers?.adjustmentType }}
/ 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: 'goal', amount: amount, priority: null, directive: goal }}
@@ -31,11 +31,15 @@ expr
modifiers = _ '[' modifier:modifier ']' { return modifier }
modifier
= op:('increase'i / 'decrease'i) _ value:percent {
= op:('increase'i / 'decrease'i) _ value:percentOrNumber {
const multiplier = op.toLowerCase() === 'increase' ? 1 : -1;
return { adjustment: multiplier * +value }
return { adjustment: multiplier * +value.value, adjustmentType: value.type }
}
percentOrNumber
= value:$(d+ ('.' (d+)?)?) _? '%' { return { value: value, type: 'percent' } }
/ value:$(d+ ('.' (d+)?)?) { return { value: value, type: 'fixed' } }
repeat 'repeat interval'
= 'month'i { return { annual: false }}
/ months: positive _ 'months'i { return { annual: false, repeat: +months }}

View File

@@ -1,3 +1,5 @@
import { type Currency } from 'loot-core/shared/currencies';
import { type CategoryEntity } from '../../types/models';
import * as db from '../db';
import { Rule } from '../rules';
@@ -38,6 +40,14 @@ describe('runSchedule', () => {
const to_budget = 0;
const errors: string[] = [];
const category = { id: '1', name: 'Test Category' } as CategoryEntity;
const currency: Currency = {
code: '',
symbol: '',
name: '',
decimalPlaces: 2,
numberFormat: 'comma-dot',
symbolFirst: false,
};
vi.mocked(db.first).mockResolvedValue({ id: 1, completed: 0 });
vi.mocked(getRuleForSchedule).mockResolvedValue(
@@ -84,6 +94,7 @@ describe('runSchedule', () => {
to_budget,
errors,
category,
currency,
);
// Then
@@ -109,6 +120,14 @@ describe('runSchedule', () => {
const to_budget = 0;
const errors: string[] = [];
const category = { id: '1', name: 'Test Category' } as CategoryEntity;
const currency: Currency = {
code: '',
symbol: '',
name: '',
decimalPlaces: 2,
numberFormat: 'comma-dot',
symbolFirst: false,
};
vi.mocked(db.first).mockResolvedValue({ id: 1, completed: 0 });
vi.mocked(getRuleForSchedule).mockResolvedValue(
@@ -155,6 +174,7 @@ describe('runSchedule', () => {
to_budget,
errors,
category,
currency,
);
// Then

View File

@@ -1,4 +1,8 @@
// @ts-strict-ignore
import { type Currency } from 'loot-core/shared/currencies';
import { amountToInteger } from 'loot-core/shared/util';
import * as monthUtils from '../../shared/months';
import {
extractScheduleConds,
@@ -31,6 +35,7 @@ async function createScheduleList(
templates: ScheduleTemplate[],
current_month: string,
category: CategoryEntity,
currency: Currency,
) {
const t: Array<ScheduleTemplateTarget> = [];
const errors: string[] = [];
@@ -52,10 +57,27 @@ async function createScheduleList(
2
: amountCondition.value;
// Apply adjustment percentage if specified
if (template.adjustment) {
const adjustmentFactor = 1 + template.adjustment / 100;
scheduleAmount = Math.round(scheduleAmount * adjustmentFactor);
if (template.adjustment !== undefined && template.adjustmentType) {
switch (template.adjustmentType) {
case 'percent': {
const adjustmentFactor = 1 + template.adjustment / 100;
scheduleAmount = scheduleAmount * adjustmentFactor;
break;
}
case 'fixed': {
const sign = scheduleAmount < 0 ? -1 : 1;
scheduleAmount +=
sign * amountToInteger(template.adjustment, currency.decimalPlaces);
break;
}
default:
//no valid adjustment was found
}
}
scheduleAmount = Math.round(scheduleAmount);
const { amount: postRuleAmount, subtransactions } = rule.execActions({
amount: scheduleAmount,
category: category.id,
@@ -265,6 +287,7 @@ export async function runSchedule(
to_budget: number,
errors: string[],
category: CategoryEntity,
currency: Currency,
) {
const scheduleTemplates = template_lines.filter(t => t.type === 'schedule');
@@ -272,6 +295,7 @@ export async function runSchedule(
scheduleTemplates,
current_month,
category,
currency,
);
errors = errors.concat(t.errors);

View File

@@ -99,16 +99,21 @@ async function getCategoriesWithTemplates(): Promise<
// Validate schedule adjustments
if (
parsedTemplate.type === 'schedule' &&
(parsedTemplate.type === 'average' ||
parsedTemplate.type === 'schedule') &&
parsedTemplate.adjustment !== undefined
) {
if (
parsedTemplate.adjustment <= -100 ||
parsedTemplate.adjustment > 1000
) {
throw new Error(
`Invalid adjustment percentage (${parsedTemplate.adjustment}%). Must be between -100% and 1000%`,
);
if (parsedTemplate.adjustmentType === 'percent') {
if (
parsedTemplate.adjustment <= -100 ||
parsedTemplate.adjustment > 1000
) {
throw new Error(
`Invalid adjustment percentage (${parsedTemplate.adjustment}%). Must be between -100% and 1000%`,
);
}
} else if (parsedTemplate.adjustmentType === 'fixed') {
//placeholder for potential validation of amount/fixed adjustments
}
}
@@ -175,7 +180,8 @@ export async function unparse(templates: Template[]): Promise<string> {
const adj = template.adjustment;
const op = adj >= 0 ? 'increase' : 'decrease';
const val = Math.abs(adj);
result += ` [${op} ${val}%]`;
const type = template.adjustmentType === 'percent' ? '%' : '';
result += ` [${op} ${val}${type}]`;
}
return result;
}
@@ -221,8 +227,18 @@ export async function unparse(templates: Template[]): Promise<string> {
return result;
}
case 'average': {
// #template average <numMonths> months
return `${prefix} average ${template.numMonths} months`;
let result = `${prefix} average ${template.numMonths} months`;
if (template.adjustment !== undefined) {
const adj = template.adjustment;
const op = adj >= 0 ? 'increase' : 'decrease';
const val = Math.abs(adj);
const type = template.adjustmentType === 'percent' ? '%' : '';
result += ` [${op} ${val}${type}]`;
}
// #template average <numMonths> months [increase/decrease {number|number%}]
return result;
}
case 'copy': {
// #template copy from <lookBack> months ago [limit]

View File

@@ -64,11 +64,14 @@ export type ScheduleTemplate = {
name: string;
full?: boolean;
adjustment?: number;
adjustmentType?: 'percent' | 'fixed';
} & BaseTemplateWithPriority;
export type AverageTemplate = {
type: 'average';
numMonths: number;
adjustment?: number;
adjustmentType?: 'percent' | 'fixed';
} & BaseTemplateWithPriority;
export type CopyTemplate = {