From d6408599400c389a0f7dc7241bb5d7cca3f0cc63 Mon Sep 17 00:00:00 2001 From: shall0pass <20625555+shall0pass@users.noreply.github.com> Date: Tue, 30 May 2023 14:24:03 -0500 Subject: [PATCH] End of month cleanup script (#1016) ~This is really just a proof of concept. I have no delusions that this might get included. I'm sure others might have a much cleaner implementation.~ I'm now delusional. Resolves https://github.com/actualbudget/actual/issues/508 Taking @youngcw 's advice, I changed the keyword to #cleanup for the end of month script to keep it separated. This screen video shows two categories that are sources of funds. At the end of the month, any excess in these funds can be redistributed to your highest priorities. Three categories are set as sinks, or recipients, of excess funds. #cleanup source -> Move 'extra' funds to To Budget #cleanup sink -> Fund category with To Budget funds, default weight = 1 #cleanup sink 2 -> Fund category with To Budget funds, weight = 2 Steps of the script: 1. Return funds from any category marked 'source' 2. Fund overspent categories fully if negative carryover is not allowed. 3. Fund each 'sink' category by the desired weight. I run through the script twice. Once to show that if there is a debt category that has a rolling negative balance, it will skip funding that category first and once to show how if a rolling negative balance isn't allowed, it will fund it before applying the weighted remainder. The example shown uses weights of 60, 20, and 20; therefore, the Debt category will receive 60% of the To Budget funds while General and Bills receive 20% each. The weights could have been changed to 6, 2, and 2 or 3 for the Debt category with no additional value for General and Bills to achieve the same result.  --- .../budget/rollover/BudgetSummary.tsx | 4 + .../loot-core/src/client/actions/queries.ts | 7 + packages/loot-core/src/server/budget/app.ts | 5 + .../src/server/budget/cleanup-template.pegjs | 15 ++ .../src/server/budget/cleanup-template.ts | 160 ++++++++++++++++++ .../src/server/budget/types/handlers.d.ts | 2 + upcoming-release-notes/1016.md | 6 + 7 files changed, 199 insertions(+) create mode 100644 packages/loot-core/src/server/budget/cleanup-template.pegjs create mode 100644 packages/loot-core/src/server/budget/cleanup-template.ts create mode 100644 upcoming-release-notes/1016.md diff --git a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.tsx index c6a2d3d72..cb33597e0 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 d324a627f..70ab734fa 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 bf626da56..a501281ee 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 000000000..ba958eaab --- /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 000000000..356546b20 --- /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 c41294e4f..eb251efbd 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 000000000..01b0ee113 --- /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 -- GitLab