diff --git a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx index c6a2d3d720701ed54c9c8b6ced8c014fb77e8db7..cb33597e0ffa58b365d69f2ff3c9030c64682913 100644 --- a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx @@ -402,6 +402,10 @@ export function BudgetSummary({ name: 'overwrite-goal-template', text: 'Overwrite with budget template', }, + isGoalTemplatesEnabled && { + name: 'cleanup-goal-template', + text: 'End of month cleanup', + }, ]} /> </Tooltip> diff --git a/packages/loot-core/src/client/actions/queries.ts b/packages/loot-core/src/client/actions/queries.ts index d324a627f65e7d2478fe0dba84a5ba8b8ae97ea7..70ab734faec2df65a8ee218bb44dd189da7ce8c1 100644 --- a/packages/loot-core/src/client/actions/queries.ts +++ b/packages/loot-core/src/client/actions/queries.ts @@ -37,6 +37,13 @@ export function applyBudgetAction(month, type, args) { ), ); break; + case 'cleanup-goal-template': + dispatch( + addNotification( + await send('budget/cleanup-goal-template', { month }), + ), + ); + break; case 'hold': await send('budget/hold-for-next-month', { month, diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index bf626da569c9ccb55c8a63d75d74f42f05fe407f..a501281eed0b2f8bd196fa360a85184a17300f2e 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -3,6 +3,7 @@ import { mutator } from '../mutators'; import { undoable } from '../undo'; import * as actions from './actions'; +import * as cleanupActions from './cleanup-template'; import * as goalActions from './goaltemplates'; let app = createApp(); @@ -22,6 +23,10 @@ app.method( 'budget/overwrite-goal-template', mutator(undoable(goalActions.overwriteTemplate)), ); +app.method( + 'budget/cleanup-goal-template', + mutator(undoable(cleanupActions.cleanupTemplate)), +); app.method( 'budget/hold-for-next-month', mutator(undoable(actions.holdForNextMonth)), diff --git a/packages/loot-core/src/server/budget/cleanup-template.pegjs b/packages/loot-core/src/server/budget/cleanup-template.pegjs new file mode 100644 index 0000000000000000000000000000000000000000..ba958eaab2c369ccda52f0e42c6ba7fa9220c7de --- /dev/null +++ b/packages/loot-core/src/server/budget/cleanup-template.pegjs @@ -0,0 +1,15 @@ +// https://peggyjs.org + +expr + = source + { return { type: 'source' } } + / sink _? weight: weight? + { return { type: 'sink', weight: +weight || 1 } } + +source = 'source' +sink = 'sink' + +_ 'space' = ' '+ +d 'digit' = [0-9] + +weight 'weight' = weight: $(d+) { return +weight } \ No newline at end of file diff --git a/packages/loot-core/src/server/budget/cleanup-template.ts b/packages/loot-core/src/server/budget/cleanup-template.ts new file mode 100644 index 0000000000000000000000000000000000000000..356546b20788fe7b5584ce39de7add91c0272213 --- /dev/null +++ b/packages/loot-core/src/server/budget/cleanup-template.ts @@ -0,0 +1,160 @@ +import * as monthUtils from '../../shared/months'; +import * as db from '../db'; + +import { setBudget, getSheetValue } from './actions'; +import { parse } from './cleanup-template.pegjs'; + +export function cleanupTemplate({ month }) { + return processCleanup(month); +} + +async function processCleanup(month) { + let num_sources = 0; + let num_sinks = 0; + let total_weight = 0; + let errors = []; + let sinkCategory = []; + + let category_templates = await getCategoryTemplates(); + let categories = await db.all( + 'SELECT * FROM v_categories WHERE tombstone = 0', + ); + let sheetName = monthUtils.sheetForMonth(month); + for (let c = 0; c < categories.length; c++) { + let category = categories[c]; + let template = category_templates[category.id]; + if (template) { + if (template.filter(t => t.type === 'source').length > 0) { + let balance = await getSheetValue(sheetName, `leftover-${category.id}`); + let budgeted = await getSheetValue(sheetName, `budget-${category.id}`); + await setBudget({ + category: category.id, + month, + amount: budgeted - balance, + }); + num_sources += 1; + } + if (template.filter(t => t.type === 'sink').length > 0) { + sinkCategory.push({ cat: category, temp: template }); + num_sinks += 1; + total_weight += template[0].weight; + } + } + } + + //funds all underfunded categories first unless the overspending rollover is checked + let db_month = parseInt(month.replace('-', '')); + for (let c = 0; c < categories.length; c++) { + let category = categories[c]; + let budgetAvailable = await getSheetValue(sheetName, `to-budget`); + let balance = await getSheetValue(sheetName, `leftover-${category.id}`); + let budgeted = await getSheetValue(sheetName, `budget-${category.id}`); + let to_budget = budgeted + Math.abs(balance); + let categoryId = category.id; + let carryover = await db.first( + `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, + [db_month, categoryId], + ); + + if ( + balance < 0 && + Math.abs(balance) <= budgetAvailable && + !category.is_income && + carryover.carryover === 0 + ) { + await setBudget({ + category: category.id, + month, + amount: to_budget, + }); + } + } + + let budgetAvailable = await getSheetValue(sheetName, `to-budget`); + + if (budgetAvailable <= 0) { + errors.push('No funds are available to reallocate.'); + } + + for (let c = 0; c < sinkCategory.length; c++) { + let budgeted = await getSheetValue( + sheetName, + `budget-${sinkCategory[c].cat.id}`, + ); + let categoryId = sinkCategory[c].cat.id; + let to_budget = + budgeted + + Math.round( + (sinkCategory[c].temp[0].weight / total_weight) * budgetAvailable, + ); + if (c === sinkCategory.length - 1) { + let currentBudgetAvailable = await getSheetValue(sheetName, `to-budget`); + if (to_budget > currentBudgetAvailable) { + to_budget = budgeted + currentBudgetAvailable; + } + } + await setBudget({ + category: categoryId, + month, + amount: to_budget, + }); + } + + if (num_sources === 0) { + if (errors.length) { + return { + type: 'error', + sticky: true, + message: `There were errors interpreting some templates:`, + pre: errors.join('\n\n'), + }; + } else { + return { type: 'message', message: 'All categories were up to date.' }; + } + } else { + let applied = `Successfully returned funds from ${num_sources} ${ + num_sources === 1 ? 'source' : 'sources' + } and funded ${num_sinks} sinking ${num_sinks === 1 ? 'fund' : 'funds'}.`; + if (errors.length) { + return { + sticky: true, + message: `${applied} There were errors interpreting some templates:`, + pre: errors.join('\n\n'), + }; + } else { + return { + type: 'message', + message: applied, + }; + } + } +} + +const TEMPLATE_PREFIX = '#cleanup '; +async function getCategoryTemplates() { + let templates = {}; + + let notes = await db.all( + `SELECT * FROM notes WHERE lower(note) like '%${TEMPLATE_PREFIX}%'`, + ); + + for (let n = 0; n < notes.length; n++) { + let lines = notes[n].note.split('\n'); + let template_lines = []; + for (let l = 0; l < lines.length; l++) { + let line = lines[l].trim(); + if (!line.toLowerCase().startsWith(TEMPLATE_PREFIX)) continue; + let expression = line.slice(TEMPLATE_PREFIX.length); + try { + let parsed = parse(expression); + template_lines.push(parsed); + } catch (e) { + template_lines.push({ type: 'error', line, error: e }); + } + } + if (template_lines.length) { + templates[notes[n].id] = template_lines; + } + } + return templates; +} diff --git a/packages/loot-core/src/server/budget/types/handlers.d.ts b/packages/loot-core/src/server/budget/types/handlers.d.ts index c41294e4f60f7b75f30bdd401ef4b890ddb6d6dc..eb251efbd897e65c10916535eb5535c8d2247dd6 100644 --- a/packages/loot-core/src/server/budget/types/handlers.d.ts +++ b/packages/loot-core/src/server/budget/types/handlers.d.ts @@ -11,6 +11,8 @@ export interface BudgetHandlers { 'budget/overwrite-goal-template': (...args: unknown[]) => Promise<unknown>; + 'budget/cleanup-goal-template': (...args: unknown[]) => Promise<unknown>; + 'budget/hold-for-next-month': (...args: unknown[]) => Promise<unknown>; 'budget/reset-hold': (...args: unknown[]) => Promise<unknown>; diff --git a/upcoming-release-notes/1016.md b/upcoming-release-notes/1016.md new file mode 100644 index 0000000000000000000000000000000000000000..01b0ee1134794d0944f895f5345e8496e966cf9e --- /dev/null +++ b/upcoming-release-notes/1016.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [shall0pass] +--- + +Add menu item and keywords for end-of-month budget reassignments