Skip to content
Snippets Groups Projects
goaltemplates.ts 20.28 KiB
// @ts-strict-ignore
import { Notification } from '../../client/state-types/notifications';
import * as monthUtils from '../../shared/months';
import { integerToAmount } from '../../shared/util';
import * as db from '../db';
import { batchMessages } from '../sync';

import { setBudget, getSheetValue, isReflectBudget, setGoal } from './actions';
import { parse } from './goal-template.pegjs';
import { goalsAverage } from './goals/goalsAverage';
import { goalsBy } from './goals/goalsBy';
import { goalsPercentage } from './goals/goalsPercentage';
import { findRemainder, goalsRemainder } from './goals/goalsRemainder';
import { goalsSchedule } from './goals/goalsSchedule';
import { goalsSimple } from './goals/goalsSimple';
import { goalsSpend } from './goals/goalsSpend';
import { goalsWeek } from './goals/goalsWeek';

export async function applyTemplate({ month }) {
  await storeTemplates();
  const category_templates = await getTemplates(null);
  await resetCategoryTargets({ month, category: null });
  return processTemplate(month, false, category_templates);
}

export async function overwriteTemplate({ month }) {
  await storeTemplates();
  const category_templates = await getTemplates(null);
  await resetCategoryTargets({ month, category: null });
  return processTemplate(month, true, category_templates);
}

export async function applySingleCategoryTemplate({ month, category }) {
  const categories = await db.all(`SELECT * FROM v_categories WHERE id = ?`, [
    category,
  ]);
  await storeTemplates();
  const category_templates = await getTemplates(categories[0]);
  await resetCategoryTargets({ month, category: categories });
  return processTemplate(month, true, category_templates);
}

export function runCheckTemplates() {
  return checkTemplates();
}

async function getCategories() {
  return await db.all(
    `
    SELECT categories.* FROM categories 
    INNER JOIN category_groups on categories.cat_group = category_groups.id 
    WHERE categories.tombstone = 0 AND categories.hidden = 0 
    AND category_groups.hidden = 0
    `,
  );
}

function checkScheduleTemplates(template) {
  let lowPriority = template[0].priority;
  let errorNotice = false;
  for (let l = 1; l < template.length; l++) {
    if (template[l].priority !== lowPriority) {
      lowPriority = Math.min(lowPriority, template[l].priority);
      errorNotice = true;
    }
  }
  return { lowPriority, errorNotice };
}

async function setGoalBudget({ month, templateBudget }) {
  await batchMessages(async () => {
    templateBudget.forEach(element => {
      setBudget({
        category: element.category,
        month,
        amount: element.amount,
      });
    });
  });
}

async function setCategoryTargets({ month, idealTemplate }) {
  await batchMessages(async () => {
    idealTemplate.forEach(element => {
      setGoal({
        category: element.category,
        goal: element.amount,
        month,
      });
    });
  });
}

async function resetCategoryTargets({ month, category }) {
  let categories;
  if (category === null) {
    categories = await getCategories();
  } else {
    categories = category;
  }
  await batchMessages(async () => {
    categories.forEach(element => {
      setGoal({
        category: element.id,
        goal: null,
        month,
      });
    });
  });
}

async function storeTemplates() {
  //stores the template definitions to the database
  const templates = await getCategoryTemplates(null);
  const categories = await getCategories();

  for (let c = 0; c < categories.length; c++) {
    const template = templates[categories[c].id];
    if (template) {
      await db.update('categories', {
        id: categories[c].id,
        goal_def: JSON.stringify(template),
      });
    } else {
      await db.update('categories', {
        id: categories[c].id,
        goal_def: null,
      });
    }
  }
}

async function getTemplates(category) {
  //retrieves template definitions from the database
  const goal_def = await db.all(
    'SELECT * FROM categories WHERE goal_def IS NOT NULL',
  );

  const templates = [];
  for (let ll = 0; ll < goal_def.length; ll++) {
    templates[goal_def[ll].id] = JSON.parse(goal_def[ll].goal_def);
  }
  if (category) {
    const singleCategoryTemplate = {};
    if (templates[category.id] !== undefined) {
      singleCategoryTemplate[category.id] = templates[category.id];
    }
    return singleCategoryTemplate;
  } else {
    return templates;
  }
}

async function processTemplate(
  month,
  force,
  category_templates,
): Promise<Notification> {
  let num_applied = 0;
  let errors = [];
  const idealTemplate = [];
  const setToZero = [];
  let priority_list = [];

  const categories = await getCategories();
  const categories_remove = [];

  //clears templated categories
  for (let c = 0; c < categories.length; c++) {
    const category = categories[c];
    const budgeted = await getSheetValue(
      monthUtils.sheetForMonth(month),
      `budget-${category.id}`,
    );
    const template = category_templates[category.id];
    if (template) {
      for (let l = 0; l < template.length; l++) {
        //add each priority we need to a list.  Will sort later
        if (template[l].priority == null) {
          continue;
        }
        priority_list.push(template[l].priority);
      }
    }
    if (budgeted) {
      if (!force) {
        // save index of category to remove
        categories_remove.push(c);
      } else {
        // if we are overwritting add this category to list to zero
        setToZero.push({
          category: category.id,
          amount: 0,
          isIncome: category.is_income,
          isTemplate: template ? true : false,
        });
      }
    }
  }

  // remove the categories we are skipping
  // Go backwards through the list so the indexes don't change
  // on the categories we need
  for (let i = categories_remove.length - 1; i >= 0; i--) {
    categories.splice(categories_remove[i], 1);
  }

  // zero out the categories that need it
  await setGoalBudget({
    month,
    templateBudget: setToZero.filter(f => f.isTemplate === true),
  });

  // sort and filter down to just the requested priorities
  priority_list = priority_list
    .sort(function (a, b) {
      return a - b;
    })
    .filter((item, index, curr) => curr.indexOf(item) === index);

  const { remainder_found, remainder_priority, remainder_weight_total } =
    findRemainder(priority_list, categories, category_templates);
  if (remainder_found) priority_list.push(remainder_priority);

  const sheetName = monthUtils.sheetForMonth(month);
  const available_start = await getSheetValue(sheetName, `to-budget`);
  let budgetAvailable = isReflectBudget()
    ? await getSheetValue(sheetName, `total-saved`)
    : await getSheetValue(sheetName, `to-budget`);
  for (let ii = 0; ii < priority_list.length; ii++) {
    const priority = priority_list[ii];
    const templateBudget = [];

    // setup scaling for remainder
    let remainder_scale = 1;
    if (priority === remainder_priority && remainder_found) {
      const available_now = await getSheetValue(sheetName, `to-budget`);
      remainder_scale = available_now / remainder_weight_total;
    }

    for (let c = 0; c < categories.length; c++) {
      const category = categories[c];
      let template_lines = category_templates[category.id];
      if (template_lines) {
        //check that all schedule and by lines have the same priority level
        let skipSchedule = false;
        let isScheduleOrBy = false;
        let priorityCheck = 0;
        if (
          template_lines.filter(
            t =>
              (t.type === 'schedule' || t.type === 'by') &&
              t.priority === priority,
          ).length > 0
        ) {
          template_lines = template_lines.filter(
            t =>
              (t.priority === priority &&
                (t.type !== 'schedule' || t.type !== 'by')) ||
              t.type === 'schedule' ||
              t.type === 'by',
          );
          const { lowPriority, errorNotice } =
            await checkScheduleTemplates(template_lines);
          priorityCheck = lowPriority;
          skipSchedule = priorityCheck !== priority ? true : false;
          isScheduleOrBy = true;
          if (!skipSchedule && errorNotice) {
            errors.push(
              category.name +
                ': Schedules and By templates should all have the same priority.  Using priority ' +
                priorityCheck,
            );
          }
        }
        if (!skipSchedule) {
          if (!isScheduleOrBy) {
            template_lines = template_lines.filter(
              t => t.priority === priority,
            );
          }
          if (template_lines.length > 0) {
            errors = errors.concat(
              template_lines
                .filter(t => t.type === 'error')
                .map(({ line, error }) =>
                  [
                    category.name + ': ' + error.message,
                    line,
                    ' '.repeat(
                      TEMPLATE_PREFIX.length + error.location.start.offset,
                    ) + '^',
                  ].join('\n'),
                ),
            );
            const prev_budgeted = await getSheetValue(
              sheetName,
              `budget-${category.id}`,
            );
            const { amount: originalToBudget, errors: applyErrors } =
              await applyCategoryTemplate(
                category,
                template_lines,
                month,
                remainder_scale,
                available_start,
                budgetAvailable,
                prev_budgeted,
              );

            let to_budget = originalToBudget;
            if (to_budget != null) {
              num_applied++;
              //only store goals from non remainder templates
              if (priority !== remainder_priority) {
                if (
                  idealTemplate.filter(c => c.category === category.id).length >
                  0
                ) {
                  idealTemplate.filter(
                    c => c.category === category.id,
                  )[0].amount += to_budget;
                } else {
                  idealTemplate.push({
                    category: category.id,
                    amount: to_budget,
                  });
                }
              }
              if (to_budget <= budgetAvailable || !priority) {
                templateBudget.push({
                  category: category.id,
                  amount: to_budget + prev_budgeted,
                });
              } else if (to_budget > budgetAvailable && budgetAvailable >= 0) {
                to_budget = budgetAvailable;
                errors.push(`Insufficient funds.`);
                templateBudget.push({
                  category: category.id,
                  amount: to_budget + prev_budgeted,
                });
              }
              budgetAvailable -= to_budget;
            }
            if (applyErrors != null) {
              errors = errors.concat(
                applyErrors.map(error => `${category.name}: ${error}`),
              );
            }
          }
        }
      }
    }
    await setGoalBudget({ month, templateBudget });
  }
  await setCategoryTargets({ month, idealTemplate });
  if (num_applied === 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 {
    const applied = `Successfully applied ${num_applied} templates.`;
    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 = '#template';
async function getCategoryTemplates(category) {
  const templates = {};

  let notes = await db.all(
    `SELECT * FROM notes WHERE lower(note) like '%${TEMPLATE_PREFIX}%'`,
  );
  if (category) notes = notes.filter(n => n.id === category.id);

  for (let n = 0; n < notes.length; n++) {
    const lines = notes[n].note.split('\n');
    const template_lines = [];
    for (let l = 0; l < lines.length; l++) {
      const line = lines[l].trim();
      if (!line.toLowerCase().startsWith(TEMPLATE_PREFIX)) continue;
      const expression = line.slice(TEMPLATE_PREFIX.length);
      try {
        const 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;
}

async function applyCategoryTemplate(
  category,
  template_lines,
  month,
  remainder_scale,
  available_start,
  budgetAvailable,
  prev_budgeted,
) {
  const current_month = `${month}-01`;
  let errors = [];
  let all_schedule_names = await db.all(
    'SELECT name from schedules WHERE name NOT NULL AND tombstone = 0',
  );
  all_schedule_names = all_schedule_names.map(v => v.name);

  let scheduleFlag = false; //only run schedules portion once

  // remove lines for past dates, calculate repeating dates
  template_lines = template_lines.filter(template => {
    switch (template.type) {
      case 'by':
      case 'spend':
        let target_month = `${template.month}-01`;
        let num_months = monthUtils.differenceInCalendarMonths(
          target_month,
          current_month,
        );
        const repeat = template.annual
          ? (template.repeat || 1) * 12
          : template.repeat;

        let spend_from;
        if (template.type === 'spend') {
          spend_from = `${template.from}-01`;
        }
        while (num_months < 0 && repeat) {
          target_month = monthUtils.addMonths(target_month, repeat);
          if (spend_from) {
            spend_from = monthUtils.addMonths(spend_from, repeat);
          }
          num_months = monthUtils.differenceInCalendarMonths(
            target_month,
            current_month,
          );
        }
        if (num_months < 0) {
          errors.push(`${template.month} is in the past.`);
          return false;
        }
        template.month = monthUtils.format(target_month, 'yyyy-MM');
        if (spend_from) {
          template.from = monthUtils.format(spend_from, 'yyyy-MM');
        }
        break;
      case 'schedule':
        if (!all_schedule_names.includes(template.name)) {
          errors.push(`Schedule ${template.name} does not exist`);
          return null;
        }
        break;
      default:
    }
    return true;
  });

  if (template_lines.length > 1) {
    template_lines = template_lines.sort((a, b) => {
      if (a.type === 'by' && !a.annual) {
        return monthUtils.differenceInCalendarMonths(
          `${a.month}-01`,
          `${b.month}-01`,
        );
      } else if (a.type === 'schedule' || b.type === 'schedule') {
        return a.priority - b.priority;
      } else {
        return a.type.localeCompare(b.type);
      }
    });
  }

  const sheetName = monthUtils.sheetForMonth(month);
  const spent = await getSheetValue(sheetName, `sum-amount-${category.id}`);
  const balance = await getSheetValue(sheetName, `leftover-${category.id}`);
  const last_month_balance = balance - spent - prev_budgeted;
  let to_budget = 0;
  let limit = 0;
  let hold = false;
  let limitCheck = false;
  let remainder = 0;

  for (let l = 0; l < template_lines.length; l++) {
    const template = template_lines[l];
    switch (template.type) {
      case 'simple': {
        const goalsReturn = await goalsSimple(
          template,
          limitCheck,
          errors,
          limit,
          hold,
          to_budget,
          last_month_balance,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        limit = goalsReturn.limit;
        limitCheck = goalsReturn.limitCheck;
        hold = goalsReturn.hold;
        break;
      }
      case 'by': {
        const goalsReturn = await goalsBy(
          template_lines,
          current_month,
          template,
          l,
          remainder,
          last_month_balance,
          to_budget,
          errors,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        remainder = goalsReturn.remainder;
        break;
      }
      case 'week': {
        const goalsReturn = await goalsWeek(
          template,
          limit,
          limitCheck,
          hold,
          current_month,
          to_budget,
          errors,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        limit = goalsReturn.limit;
        limitCheck = goalsReturn.limitCheck;
        hold = goalsReturn.hold;
        break;
      }
      case 'spend': {
        const goalsReturn = await goalsSpend(
          template,
          last_month_balance,
          current_month,
          to_budget,
          errors,
          category,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        break;
      }
      case 'percentage': {
        const goalsReturn = await goalsPercentage(
          template,
          month,
          available_start,
          sheetName,
          to_budget,
          errors,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        break;
      }
      case 'schedule': {
        const goalsReturn = await goalsSchedule(
          scheduleFlag,
          template_lines,
          current_month,
          balance,
          remainder,
          last_month_balance,
          to_budget,
          errors,
          category,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        remainder = goalsReturn.remainder;
        scheduleFlag = goalsReturn.scheduleFlag;
        break;
      }
      case 'remainder': {
        const goalsReturn = await goalsRemainder(
          template,
          budgetAvailable,
          remainder_scale,
          to_budget,
        );
        to_budget = goalsReturn.to_budget;
        break;
      }
      case 'average': {
        const goalsReturn = await goalsAverage(
          template,
          current_month,
          category,
          errors,
          to_budget,
        );
        to_budget = goalsReturn.to_budget;
        errors = goalsReturn.errors;
        break;
      }
      case 'error':
        return { errors };
      default:
    }
  }
  if (limitCheck) {
    if (hold && balance > limit) {
      to_budget = 0;
    } else if (to_budget + balance > limit) {
      to_budget = limit - balance;
    }
  }
  // setup notifications
  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 { amount: to_budget, errors };
}

async function checkTemplates(): Promise<Notification> {
  const category_templates = await getCategoryTemplates(null);
  const errors = [];

  const categories = await db.all(
    'SELECT * FROM v_categories WHERE tombstone = 0',
  );
  let all_schedule_names = await db.all(
    'SELECT name from schedules WHERE name NOT NULL AND tombstone = 0',
  );
  all_schedule_names = all_schedule_names.map(v => v.name);

  // run through each line and see if its an error
  for (let c = 0; c < categories.length; c++) {
    const category = categories[c];
    const template = category_templates[category.id];

    if (template) {
      for (let l = 0; l < template.length; l++) {
        //check for basic error
        if (template[l].type === 'error') {
          errors.push(category.name + ': ' + template[l].line);
        }
        // check schedule name error
        if (template[l].type === 'schedule') {
          if (!all_schedule_names.includes(template[l].name)) {
            errors.push(
              category.name +
                ': Schedule “' +
                template[l].name +
                '” does not exist',
            );
          }
        }
      }
    }
  }
  if (errors.length) {
    return {
      sticky: true,
      message: `There were errors interpreting some templates:`,
      pre: errors.join('\n\n'),
    };
  } else {
    return {
      type: 'message',
      message: 'All templates passed! 🎉',
    };
  }
}