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

@@ -394,43 +394,57 @@ Below is an example of using the "Full" flag assuming a once-per-year schedule f
| `#template schedule full Simplefin` | $ 0 | Budget in all months except May |
| `#template schedule full Simplefin` | $ 15 | Budget in May |
#### Percentage Increase / Decrease
#### Adjustments
Yearly expenses (e.g. insurance, property rates, etc.) increase year on year. Often the amount is unknown until close to the due date. This creates a budget crunch - if your $ 1,000 insurance jumps 20% ($ 1,200), you need to make up that extra $ 200 in just a month or two.
This feature adds percentage adjustments to templates, letting you gradually save the expected increase throughout the year. By proactively budgeting a percentage change for these yearly increases, you avoid last-minute scrambling when renewal notices arrive with higher amounts.
This feature adds adjustments to the template (either percentage or fixed), letting you gradually save the expected increase throughout the year. By proactively budgeting a percentage/fixed change for these yearly increases, you avoid last-minute scrambling when renewal notices arrive with higher amounts.
| Syntax | Description |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `#template schedule {SCHEDULE NAME} [{increase/decrease} {number}%]` | Fund the upcoming scheduled transaction over time, increasing or decreasing the amount by the given percentage |
| Syntax | Description |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `#template schedule {SCHEDULE NAME} [{increase/decrease} {number\|number%}]` | Fund the upcoming scheduled transaction over time, increasing or decreasing the amount by the given value |
As an example, assume the amount Scheduled for 'Insurance' the prior year was $ 1000 and $ 83.33 was budgeted monthly; the below will apply.
As an example, assume the amount scheduled for 'Insurance' the prior year was $ 1000 and $ 83.33 was budgeted monthly; the below will apply.
| Category | Template line | Budgeted Amount |
| --------- | --------------------------------------------- | :-------------: |
| Insurance | `#template schedule Insurance [increase 20%]` | $ 100 |
| Category | Template line | Monthly Budget | Annual Budget |
| --------- | --------------------------------------------- | :------------: | :-----------: |
| Insurance | `#template schedule Insurance [increase 20%]` | $ 100 | $ 1200 |
| Insurance | `#template schedule Insurance [increase 500]` | $ 125 | $ 1500 |
When "Insurance" comes due at the end of the year, $1200 will be available.
When "Insurance" comes due at the end of the year, $1200 will be available for the first example, or $1500 for the second example.
#### Available Variations
Below is a table of the variations of the Schedule template.
| Syntax | Description | Example Application |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `#template schedule {SCHEDULE NAME}` | Fund upcoming scheduled transactions over time | Monthly schedules, or larger non-monthly scheduled transactions |
| `#template schedule full {SCHEDULE NAME}` | Fund upcoming scheduled transaction only on needed month | Small schedules that are non-monthly |
| `#template schedule {SCHEDULE NAME} [{increase/decrease} {number}%]` | Fund upcoming scheduled transaction over time, increasing or decreasing the amount by the given percentage | Yearly renewals where the amount changes |
| Syntax | Description | Example Application |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `#template schedule {SCHEDULE NAME}` | Fund upcoming scheduled transactions over time | Monthly schedules, or larger non-monthly scheduled transactions |
| `#template schedule full {SCHEDULE NAME}` | Fund upcoming scheduled transaction only on needed month | Small schedules that are non-monthly |
| `#template schedule {SCHEDULE NAME} [{increase/decrease} {number\|number%}]` | Fund upcoming scheduled transaction over time, increasing or decreasing the amount by the given value | Yearly renewals where the amount changes |
### Average Type
The Average template allows you to budget the average amount spend over a number of months.
This is the same function provided by the menu in the budget table but it can be used in a single category automatically where the menu option must be applied to the whole budget or a single category.
The table below shows how to use the Average template.
This template allows you to budget based on the average amount spent over a number of months, adding flexibility beyond the menu built-ins (3 months, 6 months).
| Syntax | Description | Example Application |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `#template average 6 months` | Budget the average amount spent over the last 6 months. Can set the number to any number > 0. Matches the existing option on the budget page but with flexible month ranges | Try to budget only what you need to spend based on the last 6 months of spending data |
You can also adjust the budgeted amount from the average by a percentage or by a fixed whole number. This functionality may be useful when you want to budget an average, but bump it up or down a bit to account for inflation or to slowly wean off a category you'd like to spend less on. (See also [adjustments](#adjustments))
| Syntax | Description |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `#template average {number} months` | Budget the average amount spent over the last `{number}` months. Can set the number to any number > 0. Matches the existing option on the budget page but with flexible month ranges |
| `#template average {number} months [{increase/decrease} {number\|number%}]` | Budget the average amount spent over a period, with an adjustment |
#### Examples
As an example, assume the spend for the category was [\$40, \$50, \$60] for the past 3 months; here are some example usages.
| Template line | Budgeted Amount |
| ------------------------------------------- | :-------------: |
| `#template average 3 months` | \$ 50 |
| `#template average 3 months [increase 20%]` | \$ 60 |
| `#template average 3 months [decrease 10%]` | \$ 45 |
| `#template average 3 months [increase 11]` | \$ 61 |
| `#template average 3 months [decrease 1]` | \$ 49 |
### Copy Type

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 = {

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [totallynotjon]
---
Enhance Average Goal Template to allow adjusting the budgeted amount from the average by a percent or fixed amount