diff --git a/packages/desktop-client/src/components/settings/Experimental.js b/packages/desktop-client/src/components/settings/Experimental.js index f3e96b5ad03ccf3467c1387f8b7b2c30563f413c..a96411f34da3f0b3fe021469813a70eb1ec9f8e5 100644 --- a/packages/desktop-client/src/components/settings/Experimental.js +++ b/packages/desktop-client/src/components/settings/Experimental.js @@ -14,6 +14,7 @@ export default function ExperimentalFeatures({ prefs, savePrefs }) { .map(([key, value]) => [key.replace('flags.', ''), value]) ); let disabled = prefs.budgetType === 'report' && flags.reportBudget; + return ( <Setting primaryAction={ @@ -45,7 +46,7 @@ export default function ExperimentalFeatures({ prefs, savePrefs }) { <label style={{ display: 'flex' }}> <Checkbox - id="report-budget-flag" + id="sync-account-flag" checked={flags.syncAccount} onChange={() => { savePrefs({ 'flags.syncAccount': !flags.syncAccount }); @@ -53,6 +54,18 @@ export default function ExperimentalFeatures({ prefs, savePrefs }) { />{' '} <View>Enable account syncing</View> </label> + <label style={{ display: 'flex' }}> + <Checkbox + id="goal-templates-flag" + checked={flags.goalTemplatesEnabled} + onChange={() => { + savePrefs({ + 'flags.goalTemplatesEnabled': !flags.goalTemplatesEnabled + }); + }} + />{' '} + <View>Enable Goal Templates</View> + </label> </View> ) : ( <Link diff --git a/packages/loot-core/src/client/actions/queries.js b/packages/loot-core/src/client/actions/queries.js index 8068224ceca625ed16fb1645af656114dfa42427..0a785d83289b2a619285d4cbff8a4929b990a7d5 100644 --- a/packages/loot-core/src/client/actions/queries.js +++ b/packages/loot-core/src/client/actions/queries.js @@ -25,6 +25,12 @@ export function applyBudgetAction(month, type, args) { case 'set-3-avg': await send('budget/set-3month-avg', { month }); break; + case 'apply-goal-template': + await send('budget/apply-goal-template', { month }); + break; + case 'overwrite-goal-template': + await send('budget/overwrite-goal-template', { month }); + break; case 'hold': await send('budget/hold-for-next-month', { month, diff --git a/packages/loot-core/src/server/budget/actions.js b/packages/loot-core/src/server/budget/actions.js index 47f98f1cc19aa931410408eed99e20ed387d58fc..ae6c7bff085670c90f4cdc3e22ddf7a039d2d33c 100644 --- a/packages/loot-core/src/server/budget/actions.js +++ b/packages/loot-core/src/server/budget/actions.js @@ -5,7 +5,7 @@ import * as prefs from '../prefs'; import * as sheet from '../sheet'; import { batchMessages } from '../sync'; -async function getSheetValue(sheetName, cell) { +export async function getSheetValue(sheetName, cell) { const node = await sheet.getCell(sheetName, cell); return safeNumber(typeof node.value === 'number' ? node.value : 0); } diff --git a/packages/loot-core/src/server/budget/app.js b/packages/loot-core/src/server/budget/app.js index 99766c8a5c333cd40cb89a04f2b3c8cc1a5e0f4a..ed8c35e0bfd4e998feb42ac236a437bf45ef0a05 100644 --- a/packages/loot-core/src/server/budget/app.js +++ b/packages/loot-core/src/server/budget/app.js @@ -3,6 +3,7 @@ import { mutator } from '../mutators'; import { undoable } from '../undo'; import * as actions from './actions'; +import * as goalActions from './goaltemplates'; let app = createApp(); @@ -13,6 +14,14 @@ app.method( ); app.method('budget/set-zero', mutator(undoable(actions.setZero))); app.method('budget/set-3month-avg', mutator(undoable(actions.set3MonthAvg))); +app.method( + 'budget/apply-goal-template', + mutator(undoable(goalActions.applyTemplate)) +); +app.method( + 'budget/overwrite-goal-template', + mutator(undoable(goalActions.overwriteTemplate)) +); app.method( 'budget/hold-for-next-month', mutator(undoable(actions.holdForNextMonth)) diff --git a/packages/loot-core/src/server/budget/goaltemplates.js b/packages/loot-core/src/server/budget/goaltemplates.js new file mode 100644 index 0000000000000000000000000000000000000000..d4ec2db12b7be2e70a9a49e3c643e52f98dbd8e2 --- /dev/null +++ b/packages/loot-core/src/server/budget/goaltemplates.js @@ -0,0 +1,443 @@ +import { + differenceInCalendarMonths, + addMonths, + addWeeks, + format +} from 'date-fns'; + +import * as monthUtils from '../../shared/months'; +import { amountToInteger, integerToAmount } from '../../shared/util'; +import * as db from '../db'; + +import { setBudget, getSheetValue } from './actions'; + +export async function applyTemplate({ month }) { + await processTemplate(month, false); +} + +export async function overwriteTemplate({ month }) { + await processTemplate(month, true); +} + +async function processTemplate(month, force) { + let category_templates = await getCategoryTemplates(); + + let categories = await db.all( + 'SELECT * FROM v_categories WHERE tombstone = 0' + ); + + let num_applied = 0; + for (let c = 0; c < categories.length; c++) { + let category = categories[c]; + + let budgeted = await getSheetValue( + monthUtils.sheetForMonth(month), + `budget-${category.id}` + ); + + if (budgeted === 0 || force) { + let template = category_templates[category.id]; + + if (template) { + let to_budget = await applyCategoryTemplate( + category, + template, + month, + force + ); + if (to_budget != null) { + num_applied++; + await setBudget({ category: category.id, month, amount: to_budget }); + } + } + } + } + if (num_applied === 0) { + console.log('All categories were up to date.'); + } else { + console.log(`${num_applied} categories updated.`); + } +} + +async function getCategoryTemplates() { + const matches = [ + { + type: 'simple', + re: /^#template \$?(\-?\d+(\.\d{2})?)$/im,//eslint-disable-line + params: ['monthly'] + }, + { + type: 'simple', + re: /^#template up to \$?(\d+(\.\d{2})?)$/im, + params: ['limit'] + }, + { + type: 'simple', + re: /^#template \$?(\d+(\.\d{2})?) up to \$?(\d+(\.\d{2})?)$/im, + params: ['monthly', null, 'limit'] + }, + { + type: 'by', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2})$/im,//eslint-disable-line + params: ['amount', null, 'month'] + }, + { + type: 'by', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) repeat every (\d+) months$/im,//eslint-disable-line + params: ['amount', null, 'month', 'repeat'] + }, + { + type: 'week', + re: /^#template \$?(\d+(\.\d{2})?) repeat every week starting (\d{4}\-\d{2}\-\d{2})$/im,//eslint-disable-line + params: ['amount', null, 'starting'] + }, + { + type: 'week', + re: /^#template \$?(\d+(\.\d{2})?) repeat every week starting (\d{4}\-\d{2}\-\d{2}) up to \$?(\d+(\.\d{2})?)$/im,//eslint-disable-line + params: ['amount', null, 'starting', 'limit'] + }, + { + type: 'weeks', + re: /^#template \$?(\d+(\.\d{2})?) repeat every (\d+) weeks starting (\d{4}\-\d{2}\-\d{2})$/im,//eslint-disable-line + params: ['amount', null, 'weeks', 'starting'] + }, + { + type: 'weeks', + re: /^#template \$?(\d+(\.\d{2})?) repeat every (\d+) weeks starting (\d{4}\-\d{2}\-\d{2}) up to \$?(\d+(\.\d{2})?)$/im,//eslint-disable-line + params: ['amount', null, 'weeks', 'starting', 'limit'] + }, + { + type: 'by_annual', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) repeat every year$/im,//eslint-disable-line + params: ['amount', null, 'month'] + }, + { + type: 'by_annual', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) repeat every (\d+) years$/im,//eslint-disable-line + params: ['amount', null, 'month', 'repeat'] + }, + { + type: 'spend', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) spend from (\d{4}\-\d{2})$/im,//eslint-disable-line + params: ['amount', null, 'month', 'from'] + }, + { + type: 'spend', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) spend from (\d{4}\-\d{2}) repeat every (\d+) months$/im,//eslint-disable-line + params: ['amount', null, 'month', 'from', 'repeat'] + }, + { + type: 'spend_annual', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) spend from (\d{4}\-\d{2}) repeat every year$/im,//eslint-disable-line + params: ['amount', null, 'month', 'from'] + }, + { + type: 'spend_annual', + re: /^#template \$?(\d+(\.\d{2})?) by (\d{4}\-\d{2}) spend from (\d{4}\-\d{2}) repeat every (\d+) years$/im,//eslint-disable-line + params: ['amount', null, 'month', 'from', 'repeat'] + }, + { + type: 'percentage', + re: /^#template (\d+(\.\d+)?)% of (.*)$/im, + params: ['percent', null, 'category'] + }, + { type: 'error', re: /^#template .*$/im, params: [] } + ]; + + let templates = {}; + + let notes = await db.all(`SELECT * FROM notes WHERE note like '%#template%'`); + + 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++) { + for (let m = 0; m < matches.length; m++) { + let arr = matches[m].re.exec(lines[l]); + if (arr) { + let matched = {}; + matched.line = arr[0]; + matched.type = matches[m].type; + for (let p = 0; p < matches[m].params.length; p++) { + let param_name = matches[m].params[p]; + if (param_name) { + matched[param_name] = arr[p + 1]; + } + } + template_lines.push(matched); + break; + } + } + } + if (template_lines.length) { + templates[notes[n].id] = template_lines; + } + } + return templates; +} + +async function applyCategoryTemplate(category, template_lines, month, force) { + let current_month = new Date(`${month}-01`); + + // remove lines for past dates, calculate repeating dates + let got_by = false; + template_lines = template_lines.filter(template => { + //debugger; + + switch (template.type) { + case 'by': + case 'by_annual': + case 'spend': + case 'spend_annual': + let target_month = new Date(`${template.month}-01`); + let num_months = differenceInCalendarMonths( + target_month, + current_month + ); + let repeat = template.type.includes('annual') + ? (template.repeat || 1) * 12 + : template.repeat; + + let spend_from; + if (template.type.includes('spend')) { + spend_from = new Date(`${template.from}-01`); + } + while (num_months < 0 && repeat) { + target_month = addMonths(target_month, repeat); + if (spend_from) { + spend_from = addMonths(spend_from, repeat); + } + num_months = differenceInCalendarMonths(target_month, current_month); + } + if (num_months < 0) { + console.log( + `${category.name}: ${`${template.month} is in the past:`} ${ + template.line + }` + ); + return null; + } + template.month = format(target_month, 'yyyy-MM'); + if (spend_from) { + template.from = format(spend_from, 'yyyy-MM'); + } + break; + default: + } + return template; + }); + + if (template_lines.length > 1) { + template_lines = template_lines + .sort((a, b) => { + if ( + a.type.slice(0, 2) === b.type.slice(0, 2) && + a.type.slice(0, 2) === 'by' + ) { + return differenceInCalendarMonths( + new Date(`${a.month}-01`), + new Date(`${b.month}-01`) + ); + } else { + return a.type.localeCompare(b.type); + } + }) + .filter(el => { + if (el.type.slice(0, 2) === 'by') { + if (!got_by) { + got_by = true; + return el; + } else { + return null; + } + } else { + return el; + } + }); + } + + let to_budget = 0; + let limit; + let sheetName = monthUtils.sheetForMonth(month); + let budgeted = await getSheetValue(sheetName, `budget-${category.id}`); + let spent = await getSheetValue(sheetName, `sum-amount-${category.id}`); + let balance = await getSheetValue(sheetName, `leftover-${category.id}`); + let last_month_balance = balance - spent - budgeted; + for (let l = 0; l < template_lines.length; l++) { + let template = template_lines[l]; + switch (template.type) { + case 'simple': { + // simple has 'monthly' and/or 'limit' params + if (template.limit != null) { + if (limit != null) { + console.log( + `${category.name}: ${`More than one 'up to' limit found.`} ${ + template.line + }` + ); + return null; + } else { + limit = amountToInteger(template.limit); + } + } + if (template.monthly) { + let monthly = amountToInteger(template.monthly); + to_budget += monthly; + } else { + to_budget += limit; + } + break; + } + case 'by': + case 'by_annual': { + // by has 'amount' and 'month' params + let target_month = new Date(`${template.month}-01`); + let target = amountToInteger(template.amount); + let num_months = differenceInCalendarMonths( + target_month, + current_month + ); + let repeat = + template.type === 'by' + ? template.repeat + : (template.repeat || 1) * 12; + while (num_months < 0 && repeat) { + target_month = addMonths(target_month, repeat); + num_months = differenceInCalendarMonths(target_month, current_month); + } + let diff = target - last_month_balance; + if (diff >= 0 && num_months > -1) { + to_budget += Math.round(diff / (num_months + 1)); + } + break; + } + case 'week': + case 'weeks': { + // weeks has 'amount', 'starting' and optional 'limit' params + // weeks has 'amount', 'starting', 'weeks' and optional 'limit' params + let amount = amountToInteger(template.amount); + let weeks = template.weeks != null ? Math.round(template.weeks) : 1; + if (template.limit != null) { + if (limit != null) { + console.log( + `${category.name}: ${`More than one 'up to' limit found.`} ${ + template.line + }` + ); + return null; + } else { + limit = amountToInteger(template.limit); + } + } + let w = new Date(template.starting); + + let next_month = addMonths(current_month, 1); + + while (w.getTime() < next_month.getTime()) { + if (w.getTime() >= current_month.getTime()) { + to_budget += amount; + } + w = addWeeks(w, weeks); + } + break; + } + case 'spend': + case 'spend_annual': { + // spend has 'amount' and 'from' and 'month' params + let from_month = new Date(`${template.from}-01`); + let to_month = new Date(`${template.month}-01`); + let already_budgeted = last_month_balance; + let first_month = true; + for ( + let m = from_month; + differenceInCalendarMonths(current_month, m) > 0; + m = addMonths(m, 1) + ) { + let sheetName = monthUtils.sheetForMonth(format(m, 'yyyy-MM')); + + if (first_month) { + let spent = await getSheetValue( + sheetName, + `sum-amount-${category.id}` + ); + let balance = await getSheetValue( + sheetName, + `leftover-${category.id}` + ); + already_budgeted = balance - spent; + first_month = false; + } else { + let budgeted = await getSheetValue( + sheetName, + `budget-${category.id}` + ); + already_budgeted += budgeted; + } + } + let num_months = differenceInCalendarMonths(to_month, current_month); + let target = amountToInteger(template.amount); + if (num_months < 0) { + console.log( + `${category.name}: ${`${template.to} is in the past:`} ${ + template.line + }` + ); + return null; + } else if (num_months === 0) { + to_budget = target - already_budgeted; + } else { + to_budget = Math.round( + (target - already_budgeted) / (num_months + 1) + ); + } + break; + } + case 'percentage': { + /* + let income_category = (await actual.getCategories()).filter(c => c.is_income == true && c.name == template.category); + let func = (getBudgetMonthTestFunc || getBudgetMonth); + let budget = await func(month); + for (var g = 0; g < budget.categoryGroups.length; g++) { + if (income_category.group_id == budget.categoryGroups[g].id) { + for (var c = 0; c < budget.categoryGroups[g].categories.length; c++) + if (income_category.id == budget.categoryGroups[g].categories[c].id) { + let month_category = budget.categoryGroups[g].categories[c]; + } + } + } + */ + break; + } + case 'error': + console.log(`${category.name}: ${`Failed to match:`} ${template.line}`); + return null; + default: + } + } + + if (limit != null) { + if (to_budget + last_month_balance > limit) { + to_budget = limit - last_month_balance; + } + } + + if ( + ((category.budgeted != null && category.budgeted !== 0) || + to_budget === 0) && + !force + ) { + return null; + } else if (category.budgeted === to_budget && force) { + return null; + } else { + let str = category.name + ': ' + integerToAmount(last_month_balance); + str += + ' + ' + + integerToAmount(to_budget) + + ' = ' + + integerToAmount(last_month_balance + to_budget); + str += ' ' + template_lines.map(x => x.line).join('\n'); + console.log(str); + return to_budget; + } +} diff --git a/packages/loot-design/src/components/budget/rollover/BudgetSummary.js b/packages/loot-design/src/components/budget/rollover/BudgetSummary.js index 74bb5a788d3cf260499471a19ab1667c15a2cbc3..39479aa7546958d3b5953e670848303cd3776fa5 100644 --- a/packages/loot-design/src/components/budget/rollover/BudgetSummary.js +++ b/packages/loot-design/src/components/budget/rollover/BudgetSummary.js @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { connect } from 'react-redux'; import Component from '@reactions/component'; import { css } from 'glamor'; @@ -6,6 +7,7 @@ import { css } from 'glamor'; import { rolloverBudget } from 'loot-core/src/client/queries'; import * as monthUtils from 'loot-core/src/shared/months'; +import * as actions from '../../../../../loot-core/src/client/actions'; import { colors, styles } from '../../../style'; import DotsHorizontalTriple from '../../../svg/v1/DotsHorizontalTriple'; import ArrowButtonDown1 from '../../../svg/v2/ArrowButtonDown1'; @@ -243,7 +245,7 @@ function ToBudget({ month, prevMonthName, collapsed, onBudgetAction }) { ); } -export default React.memo(function BudgetSummary({ month }) { +function BudgetSummary({ month, localPrefs }) { let { currentMonth, summaryCollapsed: collapsed, @@ -264,6 +266,8 @@ export default React.memo(function BudgetSummary({ month }) { let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1; + let goalTemplatesEnabled = localPrefs['flags.goalTemplatesEnabled']; + return ( <View style={{ @@ -373,6 +377,14 @@ export default React.memo(function BudgetSummary({ month }) { { name: 'set-3-avg', text: 'Set budgets to 3 month avg' + }, + goalTemplatesEnabled && { + name: 'apply-goal-template', + text: 'Apply budget template' + }, + goalTemplatesEnabled && { + name: 'overwrite-goal-template', + text: 'Overwrite with budget template' } ]} /> @@ -410,4 +422,9 @@ export default React.memo(function BudgetSummary({ month }) { </NamespaceContext.Provider> </View> ); -}); +} + +export default connect( + state => ({ localPrefs: state.prefs.local }), + actions +)(BudgetSummary);