Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add percentage adjustments to schedule templates (#4098) #4257

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
32 changes: 26 additions & 6 deletions packages/loot-core/src/server/budget/goal-template.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ expr
{ return { type: 'simple', monthly, limit, priority: template.priority, directive: template.directive }}
/ template: template _ limit: limit
{ 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 }}
/ 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 }}
/ 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?
Expand All @@ -28,6 +28,13 @@ expr
{ 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 }}

modifiers = _ '[' modifier:modifier ']' { return modifier }

modifier
= op:('increase'i / 'decrease'i) _ value:percent {
const multiplier = op.toLowerCase() === 'increase' ? 1 : -1;
return { adjustment: multiplier * +value }
}

repeat 'repeat interval'
= 'month'i { return { annual: false }}
Expand Down Expand Up @@ -59,24 +66,37 @@ repeatEvery = 'repeat'i _ 'every'i
starting = 'starting'i
upTo = 'up'i _ 'to'i
hold = 'hold'i {return true}
schedule = 'schedule'i
schedule = 'schedule'i { return text() }
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'i { return 'goal'}

_ 'space' = ' '+
_ "whitespace" = [ \t]* { return text() }
__ "mandatory whitespace" = [ \t]+ { return text() }

d 'digit' = [0-9]
number 'number' = $(d+)
positive = $([1-9][0-9]*)
amount 'amount' = currencySymbol? _? amount: $('-'?d+ ('.' (d d?)?)?) { return +amount }
percent 'percentage' = percent: $(d+ ('.' (d+)?)?) _? '%' { return +percent }
percent 'percentage' = percent: $(d+ ('.' (d+)?)?) _? '%' { return percent }
year 'year' = $(d d d d)
month 'month' = $(year '-' d d)
day 'day' = $(d d)
date = $(month '-' day)
currencySymbol 'currency symbol' = symbol: . & { return /\p{Sc}/u.test(symbol) }

name 'Name' = $([^\r\n\t]+)
// Match schedule name including spaces up until we see a [, looking ahead to make sure it's followed by increase/decrease
rawScheduleName = $(
(
[^ \t\r\n\[] // First character can't be space or [
(
[^\r\n\[] // Subsequent characters can include spaces but not [
/
(![^\r\n\[]* '['('increase'i/'decrease'i)) [ ] // Or spaces if not followed by [increase/decrease
)*
)
) { return text() }

name 'Name' = $([^\r\n\t]+) { return text() }
7 changes: 6 additions & 1 deletion packages/loot-core/src/server/budget/goalsSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ async function createScheduleList(
const conditions = rule.serialize().conditions;
const { date: dateConditions, amount: amountCondition } =
extractScheduleConds(conditions);
const scheduleAmount =
let scheduleAmount =
amountCondition.op === 'isbetween'
? Math.round(amountCondition.value.num1 + amountCondition.value.num2) /
2
: amountCondition.value;
// Apply adjustment percentage if specified
if (template[ll].adjustment) {
const adjustmentFactor = 1 + template[ll].adjustment / 100;
scheduleAmount = Math.round(scheduleAmount * adjustmentFactor);
}
const { amount: postRuleAmount, subtransactions } = rule.execActions({
amount: scheduleAmount,
category: category.id,
Expand Down
32 changes: 32 additions & 0 deletions packages/loot-core/src/server/budget/template-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,38 @@ describe('checkTemplates', () => {
pre: 'Category 1: Schedule “Non-existent Schedule” does not exist',
},
},
{
description: 'Returns errors for invalid increase schedule adjustments',
mockTemplateNotes: [
{
id: 'cat1',
name: 'Category 1',
note: '#template schedule Mock Schedule 1 [increase 1001%]',
},
],
mockSchedules: mockSchedules(),
expected: {
sticky: true,
message: 'There were errors interpreting some templates:',
pre: 'Category 1: #template schedule Mock Schedule 1 [increase 1001%]\nError: Invalid adjustment percentage (1001%). Must be between -100% and 1000%',
},
},
{
description: 'Returns errors for invalid decrease schedule adjustments',
mockTemplateNotes: [
{
id: 'cat1',
name: 'Category 1',
note: '#template schedule Mock Schedule 1 [decrease 101%]',
},
],
mockSchedules: mockSchedules(),
expected: {
sticky: true,
message: 'There were errors interpreting some templates:',
pre: 'Category 1: #template schedule Mock Schedule 1 [decrease 101%]\nError: Invalid adjustment percentage (-101%). Must be between -100% and 1000%',
},
},
];

it.each(testCases)(
Expand Down
22 changes: 21 additions & 1 deletion packages/loot-core/src/server/budget/template-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ export async function checkTemplates(): Promise<Notification> {
categoryWithTemplates.forEach(({ name, templates }) => {
templates.forEach(template => {
if (template.type === 'error') {
errors.push(`${name}: ${template.line}`);
// Only show detailed error for adjustment-related errors
if (template.error && template.error.includes('adjustment')) {
errors.push(`${name}: ${template.line}\nError: ${template.error}`);
} else {
errors.push(`${name}: ${template.line}`);
}
} else if (
template.type === 'schedule' &&
!scheduleNames.includes(template.name)
Expand Down Expand Up @@ -91,6 +96,21 @@ async function getCategoriesWithTemplates(): Promise<CategoryWithTemplates[]> {
try {
const parsedTemplate: Template = parse(trimmedLine);

// Validate schedule adjustments
if (
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%`,
);
}
}

parsedTemplates.push(parsedTemplate);
} catch (e: unknown) {
parsedTemplates.push({
Expand Down
1 change: 1 addition & 0 deletions packages/loot-core/src/server/budget/types/templates.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ interface ScheduleTemplate extends BaseTemplate {
type: 'schedule';
name: string;
full?: boolean;
adjustment?: number;
}

interface RemainderTemplate extends BaseTemplate {
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/4257.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MattFaz]
---

Add percentage adjustments to schedule templates