mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
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 <calebyoung94@gmail.com>
This commit is contained in:
committed by
GitHub
parent
c6656a2815
commit
26dbb219aa
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -107,4 +107,4 @@ rawScheduleName = $(
|
||||
)
|
||||
) { return text().trim() }
|
||||
|
||||
name 'Name' = $([^\r\n\t]+) { return text() }
|
||||
name 'Name' = $([^\r\n\t]+) { return text() }
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const result = `${prefix} copy from ${template.lookBack} months ago`;
|
||||
return result;
|
||||
}
|
||||
case 'limit': {
|
||||
if (!refill) {
|
||||
// #template 0 up to <limit>
|
||||
return `${prefix} 0 ${limitToString(template)}`;
|
||||
}
|
||||
// #template up to <limit>
|
||||
const mergedPrefix = prefixFromPriority(refill.priority);
|
||||
return `${mergedPrefix} ${limitToString(template)}`;
|
||||
}
|
||||
// No 'refill' support since a refill requires a limit
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
upcoming-release-notes/6690.md
Normal file
6
upcoming-release-notes/6690.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Implement missing logic for refill template type
|
||||
Reference in New Issue
Block a user