From 26dbb219aa3ab41234f20d0871b6648071f78609 Mon Sep 17 00:00:00 2001 From: Julian Dominguez-Schatz Date: Sat, 14 Feb 2026 16:52:48 -0500 Subject: [PATCH] Implement missing logic for limit template type (#6690) * core: support limit refill templates * notes: refill templates * core: apply refill limits during runs * core: prioritize refill limits * Patch * Update release note * Fix typecheck * rework. Tests and template notes still need reworked * fix parser syntax * Fix type issue * Fix after rebase, support merging limit+refill * PR feedback --------- Co-authored-by: youngcw --- .../src/components/budget/goals/reducer.ts | 2 + .../budget/category-template-context.test.ts | 99 +++++++++++++++++++ .../budget/category-template-context.ts | 15 ++- .../src/server/budget/goal-template.pegjs | 2 +- .../src/server/budget/template-notes.test.ts | 37 +++++++ .../src/server/budget/template-notes.ts | 25 ++++- .../loot-core/src/types/models/templates.ts | 5 + upcoming-release-notes/6690.md | 6 ++ 8 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 upcoming-release-notes/6690.md diff --git a/packages/desktop-client/src/components/budget/goals/reducer.ts b/packages/desktop-client/src/components/budget/goals/reducer.ts index 2f7ae6feed..ab6e5e1541 100644 --- a/packages/desktop-client/src/components/budget/goals/reducer.ts +++ b/packages/desktop-client/src/components/budget/goals/reducer.ts @@ -38,6 +38,8 @@ export const getInitialState = (template: Template | null): ReducerState => { throw new Error('Remainder is not yet supported'); case 'limit': throw new Error('Limit is not yet supported'); + case 'refill': + throw new Error('Refill is not yet supported'); case 'average': case 'copy': return { diff --git a/packages/loot-core/src/server/budget/category-template-context.test.ts b/packages/loot-core/src/server/budget/category-template-context.test.ts index 98c957597d..f5acd097dd 100644 --- a/packages/loot-core/src/server/budget/category-template-context.test.ts +++ b/packages/loot-core/src/server/budget/category-template-context.test.ts @@ -169,6 +169,105 @@ describe('CategoryTemplateContext', () => { }); }); + describe('runRefill', () => { + it('should refill up to the monthly limit', async () => { + const category: CategoryEntity = { + id: 'test', + name: 'Test Category', + group: 'test-group', + is_income: false, + }; + const limitTemplate: Template = { + type: 'limit', + amount: 150, + hold: false, + period: 'monthly', + directive: 'template', + priority: null, + }; + const refillTemplate: Template = { + type: 'refill', + directive: 'template', + priority: 1, + }; + + const instance = new TestCategoryTemplateContext( + [limitTemplate, refillTemplate], + category, + '2024-01', + 9000, + 0, + ); + + const result = await instance.runTemplatesForPriority(1, 10000, 10000); + expect(result).toBe(6000); // 150 - 90 + }); + + it('should handle weekly limit refill', async () => { + const category: CategoryEntity = { + id: 'test', + name: 'Test Category', + group: 'test-group', + is_income: false, + }; + const limitTemplate: Template = { + type: 'limit', + amount: 100, + hold: false, + period: 'weekly', + start: '2024-01-01', + directive: 'template', + priority: null, + }; + const refillTemplate: Template = { + type: 'refill', + directive: 'template', + priority: 1, + }; + + const instance = new TestCategoryTemplateContext( + [limitTemplate, refillTemplate], + category, + '2024-01', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority(1, 100000, 100000); + expect(result).toBe(50000); // 5 Mondays * 100 + }); + + it('should handle daily limit refill', async () => { + const category: CategoryEntity = { + id: 'test', + name: 'Test Category', + group: 'test-group', + is_income: false, + }; + const limitTemplate: Template = { + type: 'limit', + amount: 10, + hold: false, + period: 'daily', + directive: 'template', + priority: null, + }; + const refillTemplate: Template = { + type: 'refill', + directive: 'template', + priority: 1, + }; + const instance = new TestCategoryTemplateContext( + [limitTemplate, refillTemplate], + category, + '2024-01', + 0, + 0, + ); + const result = await instance.runTemplatesForPriority(1, 100000, 100000); + expect(result).toBe(31000); // 31 days * 10 + }); + }); + describe('runCopy', () => { let instance: TestCategoryTemplateContext; diff --git a/packages/loot-core/src/server/budget/category-template-context.ts b/packages/loot-core/src/server/budget/category-template-context.ts index 5be1e85aa2..7b500073d2 100644 --- a/packages/loot-core/src/server/budget/category-template-context.ts +++ b/packages/loot-core/src/server/budget/category-template-context.ts @@ -12,6 +12,7 @@ import type { GoalTemplate, PercentageTemplate, PeriodicTemplate, + RefillTemplate, RemainderTemplate, SimpleTemplate, SpendTemplate, @@ -157,6 +158,10 @@ export class CategoryTemplateContext { newBudget = CategoryTemplateContext.runSimple(template, this); break; } + case 'refill': { + newBudget = CategoryTemplateContext.runRefill(template, this); + break; + } case 'copy': { newBudget = await CategoryTemplateContext.runCopy(template, this); break; @@ -553,6 +558,13 @@ export class CategoryTemplateContext { } } + static runRefill( + template: RefillTemplate, + templateContext: CategoryTemplateContext, + ): number { + return templateContext.limitAmount - templateContext.fromLastMonth; + } + static async runCopy( template: CopyTemplate, templateContext: CategoryTemplateContext, @@ -577,7 +589,8 @@ export class CategoryTemplateContext { ); const period = template.period.period; const numPeriods = template.period.amount; - let date = template.starting; + let date = + template.starting ?? monthUtils.firstDayOfMonth(templateContext.month); let dateShiftFunction; switch (period) { diff --git a/packages/loot-core/src/server/budget/goal-template.pegjs b/packages/loot-core/src/server/budget/goal-template.pegjs index 4d94289fce..fd2f790bfa 100644 --- a/packages/loot-core/src/server/budget/goal-template.pegjs +++ b/packages/loot-core/src/server/budget/goal-template.pegjs @@ -107,4 +107,4 @@ rawScheduleName = $( ) ) { return text().trim() } -name 'Name' = $([^\r\n\t]+) { return text() } \ No newline at end of file +name 'Name' = $([^\r\n\t]+) { return text() } diff --git a/packages/loot-core/src/server/budget/template-notes.test.ts b/packages/loot-core/src/server/budget/template-notes.test.ts index 5a4fb9b197..6ddb10d0b6 100644 --- a/packages/loot-core/src/server/budget/template-notes.test.ts +++ b/packages/loot-core/src/server/budget/template-notes.test.ts @@ -354,3 +354,40 @@ describe('unparse/parse round-trip', () => { expect(parsed).toEqual(reparsed); }); }); + +describe('unparse limit templates', () => { + it('serializes refill limits to notes syntax', async () => { + const serialized = await unparse([ + { + type: 'limit', + amount: 150, + hold: false, + period: 'monthly', + directive: 'template', + priority: null, + }, + { + type: 'refill', + directive: 'template', + priority: 2, + }, + ]); + + expect(serialized).toBe('#template-2 up to 150'); + }); + + it('serializes non-refill limits with a zero base amount', async () => { + const serialized = await unparse([ + { + type: 'limit', + amount: 200, + hold: false, + period: 'monthly', + directive: 'template', + priority: null, + }, + ]); + + expect(serialized).toBe('#template 0 up to 200'); + }); +}); diff --git a/packages/loot-core/src/server/budget/template-notes.ts b/packages/loot-core/src/server/budget/template-notes.ts index 557b5cc82a..9cf88bf9e7 100644 --- a/packages/loot-core/src/server/budget/template-notes.ts +++ b/packages/loot-core/src/server/budget/template-notes.ts @@ -142,8 +142,17 @@ async function getCategoriesWithTemplates(): Promise< return templatesForCategory; } +function prefixFromPriority(priority: number | null): string { + return priority === null ? TEMPLATE_PREFIX : `${TEMPLATE_PREFIX}-${priority}`; +} + export async function unparse(templates: Template[]): Promise { - return templates + // Refill will be merged into the limit template if both exist + // Assumption: at most one limit and one refill template per category + const refill = templates.find(t => t.type === 'refill'); + const withoutRefill = templates.filter(t => t.type !== 'refill'); + + return withoutRefill .flatMap(template => { if (template.type === 'error') { return []; @@ -153,9 +162,7 @@ export async function unparse(templates: Template[]): Promise { return `${GOAL_PREFIX} ${template.amount}`; } - const prefix = template.priority - ? `${TEMPLATE_PREFIX}-${template.priority}` - : TEMPLATE_PREFIX; + const prefix = prefixFromPriority(template.priority); switch (template.type) { case 'simple': { @@ -245,6 +252,16 @@ export async function unparse(templates: Template[]): Promise { const result = `${prefix} copy from ${template.lookBack} months ago`; return result; } + case 'limit': { + if (!refill) { + // #template 0 up to + return `${prefix} 0 ${limitToString(template)}`; + } + // #template up to + const mergedPrefix = prefixFromPriority(refill.priority); + return `${mergedPrefix} ${limitToString(template)}`; + } + // No 'refill' support since a refill requires a limit default: return []; } diff --git a/packages/loot-core/src/types/models/templates.ts b/packages/loot-core/src/types/models/templates.ts index 2c1b8c9585..ab9ad680a4 100644 --- a/packages/loot-core/src/types/models/templates.ts +++ b/packages/loot-core/src/types/models/templates.ts @@ -92,6 +92,10 @@ export type RemainderTemplate = { priority: null; } & BaseTemplate; +export type RefillTemplate = { + type: 'refill'; +} & BaseTemplateWithPriority; + export type GoalTemplate = { type: 'goal'; amount: number; @@ -126,5 +130,6 @@ export type Template = | AverageTemplate | GoalTemplate | CopyTemplate + | RefillTemplate | LimitTemplate | ErrorTemplate; diff --git a/upcoming-release-notes/6690.md b/upcoming-release-notes/6690.md new file mode 100644 index 0000000000..474cbb736a --- /dev/null +++ b/upcoming-release-notes/6690.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [jfdoming] +--- + +Implement missing logic for refill template type