[Goals] Round template amounts if hide decimals is set (#5288)

* round amounts

* cleanup, handle not pref set

* move comment

* fix test

* bunny, add a test
This commit is contained in:
youngcw
2025-07-08 06:53:45 -07:00
committed by GitHub
parent 3a09d91399
commit 37d91a90f7
3 changed files with 104 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { vi } from 'vitest';
import { amountToInteger } from '../../shared/util';
import { type CategoryEntity } from '../../types/models';
import { type Template } from '../../types/models/templates';
import * as aql from '../aql';
import * as db from '../db';
import * as actions from './actions';
@@ -18,6 +19,10 @@ vi.mock('../db', () => ({
getCategories: vi.fn(),
}));
vi.mock('../aql', () => ({
aqlQuery: vi.fn(),
}));
// Test helper class to access constructor and methods
class TestCategoryTemplateContext extends CategoryTemplateContext {
public constructor(
@@ -801,6 +806,10 @@ describe('CategoryTemplateContext', () => {
// Mock the sheet values needed for init
vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance
vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover
vi.mocked(aql.aqlQuery).mockResolvedValueOnce({
data: [{ value: 'false' }],
dependencies: [],
});
// Initialize the template
const instance = await CategoryTemplateContext.init(
@@ -864,6 +873,10 @@ describe('CategoryTemplateContext', () => {
// Mock the sheet values needed for init
vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance
vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover
vi.mocked(aql.aqlQuery).mockResolvedValueOnce({
data: [{ value: 'false' }],
dependencies: [],
});
// Initialize the template
const instance = await CategoryTemplateContext.init(
@@ -916,6 +929,10 @@ describe('CategoryTemplateContext', () => {
// Mock the sheet values needed for init
vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance
vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover
vi.mocked(aql.aqlQuery).mockResolvedValueOnce({
data: [{ value: 'false' }],
dependencies: [],
});
// Initialize the template
const instance = await CategoryTemplateContext.init(
@@ -974,6 +991,10 @@ describe('CategoryTemplateContext', () => {
// Mock the sheet values needed for init
vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance
vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover
vi.mocked(aql.aqlQuery).mockResolvedValueOnce({
data: [{ value: 'false' }],
dependencies: [],
});
// Initialize the template
const instance = await CategoryTemplateContext.init(
@@ -1015,6 +1036,10 @@ describe('CategoryTemplateContext', () => {
// Mock the sheet values needed for init
vi.mocked(actions.getSheetValue).mockResolvedValueOnce(10000); // lastMonthBalance
vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover
vi.mocked(aql.aqlQuery).mockResolvedValueOnce({
data: [{ value: 'false' }],
dependencies: [],
});
// Initialize the template
const instance = await CategoryTemplateContext.init(
@@ -1033,5 +1058,56 @@ describe('CategoryTemplateContext', () => {
expect(values.goal).toBe(100000); // Should be the goal amount
expect(values.longGoal).toBe(true); // Should have a long goal
});
it('should handle hide fraction', async () => {
const category: CategoryEntity = {
id: 'test',
name: 'Test Category',
group: 'test-group',
is_income: false,
};
const templates: Template[] = [
{
type: 'simple',
monthly: 100.89,
directive: 'template',
priority: 1,
},
{
type: 'goal',
amount: 1000,
directive: 'goal',
},
];
// Mock the sheet values needed for init
vi.mocked(actions.getSheetValue).mockResolvedValueOnce(0); // lastMonthBalance
vi.mocked(actions.getSheetBoolean).mockResolvedValueOnce(false); // carryover
vi.mocked(aql.aqlQuery).mockResolvedValueOnce({
data: [{ value: 'true' }],
dependencies: [],
});
// Initialize the template
const instance = await CategoryTemplateContext.init(
templates,
category,
'2024-01',
0,
);
// Run the templates with more than enough funds
const result = await instance.runTemplatesForPriority(1, 100000, 100000);
// Get the final values
const values = instance.getValues();
// Verify the results
expect(result).toBe(10100); // Should get full amount rounded up
expect(values.budgeted).toBe(10100); // Should match the result
expect(values.goal).toBe(100000); // Should be the goal amount
expect(values.longGoal).toBe(true); // Should have a long goal
expect(instance.isGoalOnly()).toBe(false); // Should not be goal only
});
});
});

View File

@@ -1,7 +1,8 @@
// @ts-strict-ignore
import { q } from 'loot-core/shared/query';
import * as monthUtils from '../../shared/months';
import { amountToInteger } from '../../shared/util';
import { amountToInteger, integerToAmount } from '../../shared/util';
import { CategoryEntity } from '../../types/models';
import {
AverageTemplate,
@@ -15,6 +16,7 @@ import {
Template,
WeekTemplate,
} from '../../types/models/templates';
import { aqlQuery } from '../aql';
import * as db from '../db';
import { getSheetValue, getSheetBoolean } from './actions';
@@ -71,6 +73,11 @@ export class CategoryTemplateContext {
// run all checks
await CategoryTemplateContext.checkByAndScheduleAndSpend(templates, month);
await CategoryTemplateContext.checkPercentage(templates);
const hideDecimal = await aqlQuery(
q('preferences').filter({ id: 'hideFraction' }).select('*'),
);
// call the private constructor
return new CategoryTemplateContext(
templates,
@@ -78,6 +85,9 @@ export class CategoryTemplateContext {
month,
fromLastMonth,
budgeted,
hideDecimal.data.length > 0
? hideDecimal.data[0].value === 'true'
: false,
);
}
@@ -210,6 +220,10 @@ export class CategoryTemplateContext {
available = available + orig - toBudget;
}
}
//round all budget values if needed
if (this.hideDecimal) toBudget = this.removeFraction(toBudget);
// don't overbudget when using a priority
if (priority > 0 && available < 0) {
this.fullAmount += toBudget;
@@ -258,6 +272,7 @@ export class CategoryTemplateContext {
private remainder: RemainderTemplate[] = [];
private goals: GoalTemplate[] = [];
private priorities: number[] = [];
readonly hideDecimal: boolean = false;
private remainderWeight: number = 0;
private toBudgetAmount: number = 0; // amount that will be budgeted by the templates
private fullAmount: number = null; // the full requested amount, start null for remainder only cats
@@ -277,11 +292,13 @@ export class CategoryTemplateContext {
month: string,
fromLastMonth: number,
budgeted: number,
hideDecimal: boolean = false,
) {
this.category = category;
this.month = month;
this.fromLastMonth = fromLastMonth;
this.previouslyBudgeted = budgeted;
this.hideDecimal = hideDecimal;
// sort the template lines into regular template, goals, and remainder templates
if (templates) {
templates.forEach(t => {
@@ -474,6 +491,10 @@ export class CategoryTemplateContext {
}
}
private removeFraction(amount: number): number {
return amountToInteger(Math.round(integerToAmount(amount)));
}
//-----------------------------------------------------------------------------
// Processor Functions

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [youngcw]
---
Round all template amounts when hide decimals is set