tool/forecast/forecast.js

/**
 * This component provides a tax forecast (as in, what's coming up)
 * from the provided start month/year for the specified time
 * e.g. see 36 months worth of tax starting from 4/19
 * you need to "inject" income (Salary or Benefits), in the order
 * you want the tax to be worked out
 * (usually Salary, Other Incomes, Vehicle and Fuel benefit)
 * The injectors are provided as an array of structs, and a corresponding
 * injector class needs to exist (they need to extend injector.cfc)
 * e.g. injectorSalary is for calculating tax on salary
 *
 * To use create an instance of this forecast class and run forecast.calculateTaxes()
 * @class
 */

import { dateDifference } from '../../date/util';
import { createTaxYears, describeTaxYear } from './taxyears';
import { InjectorTaxCharges } from './injectortaxcharges';
import { InjectorSalary } from './injectorsalary';
import { InjectorCompanyVehicle } from './injectorcompanyvehicle';
import { InjectorTaxes } from './injectortaxes';

class Forecast {
    /**
     * @param {struct} skv_config To include start_month, start_year, period_months, arr_injectors, region_code
     * @param {array} arr_tax_charges Tax bands and rates across the tax years (income tax and nic)
     * @param {array} arr_tax_company_vehicle Tax bands and rates across the tax years (vehicle ben, fuel ben)
     */
    constructor(
        skv_config,
        arr_tax_charges,
        arr_tax_company_vehicle = [],
    ) {
        const required_keys = ['start_month', 'start_year', 'period_months', 'arr_injectors', 'region_code'];
        const cnt_keys = required_keys.length;

        for (let i = 0; i < cnt_keys; i += 1) {
            const str_key = required_keys[i];

            // throw error if the key isn't present in skv_config
            if (!Object.prototype.hasOwnProperty.call(skv_config, str_key)) {
                throw new Error(`Missing key required for tax forecast: ${str_key}`);
            }
        }

        // start_month The month the period starts from
        this.start_month = skv_config.start_month;

        // start_year_short The year the period starts from in xx format. Will be worked out later
        this.start_year_short = 0;

        // start_year_short The year the period starts from in xxxx format. Will be worked out later
        this.start_year_full = 0;

        // period_months The period of time over which we must calculate
        this.period_months = skv_config.period_months;

        // arr_injectors Array of TaxYears that this forecast period spans
        this.arr_injectors = skv_config.arr_injectors;

        // where each tax year will eventually be included
        this.arr_tax_years = [];

        // work out start_year_short and start_year_long i.e xx and 20xx respectively
        if (skv_config.start_year < 2000) {
            this.start_year_short = skv_config.start_year;
            this.start_year_full = 2000 + skv_config.start_year;
        } else {
            this.start_year_full = skv_config.start_year;
            this.start_year_short = skv_config.start_year - 2000;
        }

        // build the years that the forecast spans
        this.arr_tax_years = Forecast.buildYears(
            this.start_month,
            this.start_year_full,
            this.period_months,
        );

        // apply the tax charges injector to get the rates and bands for that tax year
        const obj_injector_tax_charges = new InjectorTaxCharges(
            skv_config,
            this.arr_tax_years,
            arr_tax_charges,
            arr_tax_company_vehicle,
        );

        // update tax years with tax charges added
        this.arr_tax_years = obj_injector_tax_charges.taxYears;

        // bring the various injectors together
        this.arr_tax_years = Forecast.applyInjectors(this.arr_injectors, this.arr_tax_years);

        // ensure we have skv_config available for calculateTaxes()
        this.skv_config = skv_config;

        // expose functions
        this.buildYears = Forecast.buildYears;
    }

    /**
     * Calculate all the taxes based on injectors setup previously
     * @returns {array} calculated tax years
     */
    calculateTaxes() {
        // Call the taxes on renumeration injector, pretty manditory for everything so not dependent on the config
        const obj_injector_taxes = new InjectorTaxes(
            this.skv_config,
            this.arr_tax_years,
        );

        return obj_injector_taxes.calculate(this.arr_tax_years);
    }

    /**
     * Bring the various injectors together
     * @param {array} arr_injectors
     * @param {array} arr_tax_years
     * @returns {array} Calculated array of tax years
     */
    static applyInjectors(arr_injectors, arr_tax_years) {
        const cnt_injectors = arr_injectors.length;
        let arr_return = [];

        // walk over the arr_injectors array calling the injectors
        for (let i = 0; i < cnt_injectors; i += 1) {
            const curr_injector = arr_injectors[i];

            switch (curr_injector.type) {
            case 'salary': {
                const obj_injector_salary = new InjectorSalary(curr_injector.args, arr_tax_years);
                arr_return = obj_injector_salary.taxYears;
                break;
            }
            case 'company_vehicle': {
                const obj_injector_company_vehicle = new InjectorCompanyVehicle(curr_injector.args, arr_tax_years);
                arr_return = obj_injector_company_vehicle.taxYears;
                break;
            }
            default: {
                throw new Error(`Injector type of ${curr_injector.type} is not recognised`);
            }
            }
        }

        return arr_return;
    }

    /**
     * Create the array of years that span the forecast
     * @param {number} start_month Month number e.g 5 for May
     * @param {number} start_year_full Year number e.g 2021
     * @param {number} period_months Number of months the forecast will cover
     * @returns {array} of years
     */
    static buildYears(
        start_month,
        start_year_full,
        period_months,
    ) {
        // to be returned once years constructed
        const arr_tax_years = [];

        // work out the difference from the start/end of the tax year
        const obj_start_date = new Date(start_year_full, start_month);
        let obj_tmp_date_end = new Date(start_year_full, 4);
        let months_diff = dateDifference('months', obj_start_date, obj_tmp_date_end);

        // Calculate the number of taxyears that the period spans
        // Ceiling this will round up the answer, so if it takes
        // 1.2 years, we need to get 2 tax years
        let cnt_years = Math.ceil(period_months / 12);

        // If the period does not start right at the beginning of a tax year
        // (april) then we'll need to check if the period specified goes into
        // another tax year (period > diff to end of year)
        // if it does, we need to add an extra tax year.
        // this is because we'll only be using part of the first year,
        // we'll need part of an extra year at the end
        // e.g 1/4 of year 1, 2 full years, 3/4 of year 4 adds up to 4 years
        if (
            start_month !== 4
            && period_months > months_diff
        ) {
            cnt_years += 1;
        }

        // if the start date is after april, we need to use the
        // tax year, which is start_year + 1
        let start_tax_year = start_year_full.toString();
        // work out the short tax year version
        start_tax_year = Number(start_tax_year.substring(2, 4));

        if (start_month >= 4) {
            start_tax_year += 1;
        }

        // make a basic tax year array to suit out start date and period
        // the start date needs to be XX format, e.g. 19 not 2019
        // will return with just date information
        const arr_basic_years = createTaxYears(
            start_tax_year,
            cnt_years,
        );

        // for each tax year, make a new struct with start/end dates,
        // actual months occupied (might not be the full 12 months)
        // and create basic income array (we always need in come in the forecast)
        for (let i = 0; i < cnt_years; i += 1) {
            const skv_year = arr_basic_years[i];

            // get the month/year date of the current year in the loop
            skv_year.date_end = `04/20${skv_year.tax_year}`;
            obj_tmp_date_end = new Date(`20${skv_year.tax_year}`, 4);

            // work out how far the end of the current year in the loop
            // is from the start date of the forecast
            months_diff = dateDifference('months', obj_start_date, obj_tmp_date_end);

            if (months_diff < 12) {
                // if less than a year has passed,
                // from the start of the forecase to the end of the current
                // tax year then we just store that date difference
                skv_year.months_occupied = Math.min(months_diff, period_months);
            } else if (i === cnt_years - 1) {
                // if we're in the final year, then we need the remaining
                // months, which is the difference between the total months
                // and the sum of those already occupied
                let cnt_months_occupied = 0;
                arr_basic_years.forEach((year) => {
                    if (Object.prototype.hasOwnProperty.call(year, 'months_occupied')) {
                        cnt_months_occupied += year.months_occupied;
                    }
                });
                skv_year.months_occupied = period_months - cnt_months_occupied;
            } else {
                // anything else will be a full year
                skv_year.months_occupied = 12;
            }

            // build a description for the year
            skv_year.description = describeTaxYear(
                skv_year.months_occupied,
                skv_year.year_slash,
            );

            arr_tax_years.push(skv_year);
        }

        return arr_tax_years;
    }

    get taxYears() {
        return this.arr_tax_years;
    }
}

export {
    Forecast,
};