Source code for ucamstaffoncosts.costs

"""
Costs calculation
=================

The :py:mod:`~ucamstaffoncosts.costs` module provides support for calculating the total cost to
employ a staff member. The functionality of the module is exposed through a single function,
:py:func:`~ucamstaffoncosts.costs.cost`, which takes a tax year, pension scheme and base salary and
returns an :py:class:`~ucamstaffoncosts.Cost` object representing the on-costs for that employee:

.. testsetup::

    from ucamstaffoncosts.costs import *
    import datetime

>>> calculate_cost(
...     base_salary=25000, scheme=Scheme.USS, year=2018
... ) # doctest: +NORMALIZE_WHITESPACE, +ELLIPSIS
Cost(salary=25000, exchange=0, employer_pension=4500,
     employer_nic=2287, apprenticeship_levy=125, total=31912, tax_year=...)

The :py:attr:`~ucamstaffoncosts.Cost.total` attribute from the return value
can be used to forecast total expenditure for an employee in a given tax year.

If *year* is omitted, then the latest tax year which has any calculators
implemented is used. This behaviour can also be signalled by using the special
value :py:const:`~ucamstaffoncosts.LATEST`:

>>> latest_cost = calculate_cost(base_salary=25000, scheme=Scheme.USS, year=LATEST)
>>> latest_cost == calculate_cost(base_salary=25000, scheme=Scheme.USS)
True

We can get a detailed breakdown of costs for each tax year using the :py:func:`.costs_by_tax_year`
function. Our example employee will be a grade 2 whose next employment anniversary is on the 1st
June 2016. The employee on on the USS salary exchange pension scheme and has a first date of
non-employment of 1st October 2020:

>>> from ucamstaffoncosts import Grade
>>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES
>>> initial_grade = Grade.GRADE_2
>>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade)
>>> next_anniversary_date = datetime.date(2016, 6, 1)
>>> scheme = Scheme.USS_EXCHANGE
>>> until_date = datetime.date(2020, 10, 1)

We can calculate the total cost of employment by passing these values to :

>>> costs = list(costs_by_tax_year(
...     2016, initial_grade, initial_point, scheme,
...     next_anniversary_date=next_anniversary_date,
...     until_date=until_date, scale_table=EXAMPLE_SALARY_SCALES))

We can print out details of these costs to fully explain the cost calculation:

>>> from ucamstaffoncosts.util import pprinttable
>>> for year, cost, salaries in costs:
...     print('=' * 60)
...     print('TAX YEAR: {}/{}'.format(year, year+1))
...     print('\\nSalaries\\n--------\\n')
...     pprinttable(salaries)
...     print('\\nCosts\\n-----')
...     if cost.tax_year != year:
...         print('(approximated using tax tables for {})'.format(cost.tax_year))
...     print('\\n')
...     pprinttable([cost])
...     print('\\n') # doctest: +NORMALIZE_WHITESPACE
============================================================
TAX YEAR: 2016/2017
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                      | grade         | point | base_salary | mapping_table_date
-----------+-----------------------------+---------------+-------+-------------+-------------------
2016-04-06 | start of tax year           | Grade.GRADE_2 | P3    | 14539       | 2015-08-01
2016-06-01 | anniversary: point P3 to P4 | Grade.GRADE_2 | P4    | 14818       | 2015-08-01
2016-08-01 | new salary table            | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
2017-04-06 | end of tax year             | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
14934  | -1195    | 3883             | 733          | 68                  | 18423 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2017/2018
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                      | grade         | point | base_salary | mapping_table_date
-----------+-----------------------------+---------------+-------+-------------+-------------------
2017-04-06 | start of tax year           | Grade.GRADE_2 | P4    | 15052       | 2016-08-01
2017-06-01 | anniversary: point P4 to P5 | Grade.GRADE_2 | P5    | 15356       | 2016-08-01
2017-08-01 | new salary table            | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
2018-04-06 | end of tax year             | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
15557  | -1245    | 4045             | 813          | 71                  | 19241 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2018/2019
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2018-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 15721       | 2017-08-01
2018-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
2019-04-06 | end of tax year                | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
<BLANKLINE>
Costs
-----
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
15934  | -1275    | 4143             | 860          | 73                  | 19735 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2019/2020
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2019-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 16035       | 2018-08-01
2019-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
2020-04-06 | end of tax year                | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
16253  | -1300    | 4226             | 901          | 74                  | 20154 | 2018
<BLANKLINE>
<BLANKLINE>
============================================================
TAX YEAR: 2020/2021
<BLANKLINE>
Salaries
--------
<BLANKLINE>
date       | reason                         | grade         | point | base_salary | mapping_table_date
-----------+--------------------------------+---------------+-------+-------------+-------------------
2020-04-06 | start of tax year              | Grade.GRADE_2 | P5    | 16356       | 2019-08-01
2020-08-01 | new salary table (approximate) | Grade.GRADE_2 | P5    | 16683       | 2020-08-01
2020-10-01 | end of employment              | Grade.GRADE_2 | P5    | 16683       | 2020-08-01
<BLANKLINE>
Costs
-----
(approximated using tax tables for 2018)
<BLANKLINE>
<BLANKLINE>
salary | exchange | employer_pension | employer_nic | apprenticeship_levy | total | tax_year
-------+----------+------------------+--------------+---------------------+-------+---------
8031   | -642     | 2088             | 0            | 36                  | 9513  | 2018
<BLANKLINE>
<BLANKLINE>


"""  # noqa: E501
import collections
import datetime
import enum
import fractions
import itertools
import math

from . import tax
from . import pension
from .salary import progression


[docs]@enum.unique class Scheme(enum.Enum): """ Possible pension schemes an employee can be a member of. """ #: No pension scheme. NONE = enum.auto() #: CPS hybrid CPS_HYBRID = enum.auto() #: CPS hybrid with salary exchange CPS_HYBRID_EXCHANGE = enum.auto() #: USS USS = enum.auto() #: USS with salary exchange USS_EXCHANGE = enum.auto() #: NHS NHS = enum.auto() #: MRC MRC = enum.auto() #: CPS pre-2013 CPS_PRE_2013 = enum.auto() #: CPS pre-2013 with salary exchange CPS_PRE_2013_EXCHANGE = enum.auto()
_Cost = collections.namedtuple( 'Cost', 'salary exchange employer_pension employer_nic apprenticeship_levy total tax_year' )
[docs]class Cost(_Cost): """An individual on-costs calculation for a base salary. .. note:: These values are all rounded to the nearest pound and so total may not be the sum of all the other fields. .. py:attribute:: salary Base salary for the employee. .. py:attribute:: exchange Amount of base salary exchanged as part of a salary exchange pension. By convention, this value is negative if non-zero. .. py:attribute:: employer_pension Employer pension contribution including any salary exchange amount. .. py:attribute:: employer_nic Employer National Insurance contribution .. py:attribute:: apprenticeship_levy Share of Apprenticeship Levy from this employee .. py:attribute:: total Total on-cost of employing this employee. See note above about situations where this value may not be the sum of the others. .. py:attribute:: tax_year Which year's table was used to calculate these costs. """
#: Special value to pass to :py:func:`~.cost` to represent the latest tax year which has an #: implementation. LATEST = 'LATEST'
[docs]def calculate_cost(base_salary, scheme, year=LATEST): """ Return a :py:class:`Cost` instance given a tax year, pension scheme and base salary. :param int year: tax year :param Scheme scheme: pension scheme :param int base_salary: base salary of employee :raises NotImplementedError: if there is not an implementation for the specified tax year and pension scheme. """ year = _LATEST_TAX_YEAR if year is LATEST else year try: calculator = _ON_COST_CALCULATORS[year][scheme] except KeyError: raise NotImplementedError() return calculator(base_salary)
[docs]def costs_by_tax_year(from_year, initial_grade, initial_point, scheme, occupancy=1, start_date=None, next_anniversary_date=None, tax_year_start_month=4, tax_year_start_day=6, until_date=None, **kwargs): """ Calculate total employment costs for each tax year for an employee who initially is at a particular grade and point. Keyword parameters are passed to :py:func:`ucamstaffoncosts.salary.progression.salary_progression`. :param from_year: tax year to start from :param initial_grade: grade of employee at start of tax year :param initial_point: salary spine point of employee at start of tax year :param scheme: pension scheme of employee :param start_date: date of employment start. If None, it is assumed the employee has been employed since before the start of the tax year :param until_date: if None, employee is no longer employed on and after this date >>> from ucamstaffoncosts import Grade >>> from ucamstaffoncosts.salary.scales import EXAMPLE_SALARY_SCALES >>> initial_grade = Grade.GRADE_2 >>> initial_point = EXAMPLE_SALARY_SCALES.starting_point_for_grade(initial_grade) >>> next_anniversary_date = datetime.date(2016, 6, 1) >>> until_date = datetime.date(2018, 5, 1) >>> costs = list(costs_by_tax_year( ... 2016, initial_grade, initial_point, Scheme.USS_EXCHANGE, ... next_anniversary_date=next_anniversary_date, ... until_date=until_date, scale_table=EXAMPLE_SALARY_SCALES)) Each row returns the tax year, cost and salary record: >>> costs[0][0] 2016 >>> costs[0][1] # doctest: +NORMALIZE_WHITESPACE Cost(salary=14934, exchange=-1195, employer_pension=3883, employer_nic=733, apprenticeship_levy=68, total=18423, tax_year=2018) >>> len(costs[0][2]) 4 >>> costs[0][2][0] # doctest: +NORMALIZE_WHITESPACE SalaryRecord(date=datetime.date(2016, 4, 6), reason='start of tax year', grade=<Grade.GRADE_2: 3>, point='P3', base_salary=14539, mapping_table_date=datetime.date(2015, 8, 1)) If *until_date* is before the start of *from_year* tax year, no results are returned >>> list(costs_by_tax_year( ... 2016, initial_grade, initial_point, Scheme.USS_EXCHANGE, ... next_anniversary_date=next_anniversary_date, ... until_date=datetime.date(2016, 3, 1), scale_table=EXAMPLE_SALARY_SCALES)) [] """ occupancy_fraction = fractions.Fraction(occupancy) for year in itertools.count(from_year): from_date = datetime.date(year, tax_year_start_month, tax_year_start_day) to_date = datetime.date(year+1, tax_year_start_month, tax_year_start_day) if start_date is not None and start_date >= from_date and start_date < to_date: # Employee start date is within this tax year initial_date = start_date initial_reason = 'employee start' else: initial_date = from_date initial_reason = 'start of tax year' if until_date is not None and initial_date >= until_date: # we're done return # Total number of days in year tax_year_days = (to_date - from_date).days if until_date is not None and until_date <= to_date: to_date = until_date ends_on_tax_year = False else: ends_on_tax_year = True # If we have an anniversary date which precedes this tax year, advance it by years until it # is within this tax year anniversary_date = next_anniversary_date if anniversary_date is not None: while anniversary_date < from_date: anniversary_date = datetime.date( anniversary_date.year+1, anniversary_date.month, anniversary_date.day) salaries = list(progression.salary_progression( initial_date, initial_grade, initial_point, initial_reason=initial_reason, until_date=to_date, next_anniversary_date=anniversary_date, **kwargs )) # Update initial grade and point for start of next year end_salary = salaries[-1] initial_grade, initial_point = end_salary.grade, end_salary.point # Add an "end of tax year" salary record which is a copy of the last salary change record salaries.append(progression.SalaryRecord( date=to_date, reason='end of tax year' if ends_on_tax_year else 'end of employment', grade=end_salary.grade, point=end_salary.point, base_salary=end_salary.base_salary, mapping_table_date=end_salary.mapping_table_date )) # Sum up per-day salaries total_salary = fractions.Fraction(0, 1) for start, end in zip(salaries, salaries[1:]): # how many days in this range? days = (end.date - start.date).days total_salary += fractions.Fraction(days * start.base_salary, tax_year_days) # Compute total salary earned this year taking into account occupancy total_salary = round(total_salary * occupancy_fraction) # Attempt to use this tax year for on-cost calculation, falling back to LATEST try: calculated_cost = calculate_cost(total_salary, scheme, year) except NotImplementedError: calculated_cost = calculate_cost(total_salary, scheme, LATEST) yield year, calculated_cost, salaries
def _cost_calculator(tax_year, employer_nic_cb, employer_pension_cb=lambda _: 0, exchange_cb=lambda _: 0, apprenticeship_levy_cb=tax.standard_apprenticeship_levy): """ Return a callable which will calculate an Cost entry from a base salary. Arguments which are callables each take a single argument which is a :py:class:`fractions.Fraction` instance representing the base salary of the employee. They should return a :py:class:`fractions.Fraction` instance. :param employer_pension_cb: callable which gives employer pension contribution from base salary. :param employer_nic_cb: callable which gives employer National Insurance contribution from base salary. :param exchange_cb: callable which gives amount of salary sacrificed in a salary exchange scheme from base salary. :param apprenticeship_levy_cb: callable which calculates the Apprenticeship Levy from base salary. """ def _calculate(base_salary): # Ensure base salary is a rational base_salary = fractions.Fraction(base_salary) # We use the convention that the salary exchange value is negative to match the exchange # column in HR tables. exchange = -exchange_cb(base_salary) # The employer pension contribution is the contribution based on base salary along with # the employee contribution sacrificed from their salary. employer_pension = employer_pension_cb(base_salary) - exchange # the taxable salary is the base less the amount sacrificed. HR would appear to round the # sacrifice first taxable_salary = base_salary + _excel_round(exchange) # The employer's NIC is calculated on the taxable salary. employer_nic = employer_nic_cb(taxable_salary) # The Apprenticeship Levy is calculated on the taxable salary. apprenticeship_levy = apprenticeship_levy_cb(taxable_salary) # The total is calculated using the rounded values. total = ( _excel_round(base_salary) + _excel_round(exchange) + _excel_round(employer_pension) + _excel_round(employer_nic) + _excel_round(apprenticeship_levy) ) # Round all of the values. Note the odd rounding for exchange. This matters since the # tables HR generate seem to include -_excel_round(-exchange) even though the total column # is calculated using _excel_round(exchange). Since Excel always rounds halves up, this # means that _excel_round(exchange) does not, in general, equal -_excel_round(-exchange) as # you might expect. Caveat programmer! return Cost( salary=_excel_round(base_salary), exchange=-_excel_round(-exchange), employer_pension=_excel_round(employer_pension), employer_nic=_excel_round(employer_nic), apprenticeship_levy=_excel_round(apprenticeship_levy), total=_excel_round(total), tax_year=tax_year, ) return _calculate def _excel_round(n): """ A version of round() which applies the Excel rule that halves rounds *up* rather than the conventional wisdom that they round to the nearest even. (The jury is out about whether Excel really rounds away from zero or up but rounding up matches the tables produced by HR.) """ # Ensure input is a rational n = fractions.Fraction(n) if n.denominator == 2: # always round up halves return math.ceil(n) return round(n) #: On cost calculators keyed initially by year and then by scheme identifier. _ON_COST_CALCULATORS = { 2018: { # An employee with no scheme in tax year 2018/19 Scheme.NONE: _cost_calculator( tax_year=2018, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee with USS in tax year 2018/19 Scheme.USS: _cost_calculator( tax_year=2018, employer_pension_cb=pension.uss_employer_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee with USS and salary exchange in tax year 2018/19 Scheme.USS_EXCHANGE: _cost_calculator( tax_year=2018, employer_pension_cb=pension.uss_employer_contribution, exchange_cb=pension.uss_employee_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee with CPS in tax year 2018/19 Scheme.CPS_HYBRID: _cost_calculator( tax_year=2018, employer_pension_cb=pension.cps_hybrid_employer_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee with CPS and salary exchange in tax year 2018/19 Scheme.CPS_HYBRID_EXCHANGE: _cost_calculator( tax_year=2018, employer_pension_cb=pension.cps_hybrid_employer_contribution, exchange_cb=pension.cps_hybrid_employee_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee on the NHS scheme in tax year 2018/19 Scheme.NHS: _cost_calculator( tax_year=2018, employer_pension_cb=pension.nhs_employer_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee on the MRC scheme in tax year 2018/19 Scheme.MRC: _cost_calculator( tax_year=2018, employer_pension_cb=pension.mrc_employer_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee with CPS (pre-2013) in tax year 2018/19 Scheme.CPS_PRE_2013: _cost_calculator( tax_year=2018, employer_pension_cb=pension.cps_pre_2013_employer_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), # An employee with CPS (pre-2013) and salary exchange in tax year 2018/19 Scheme.CPS_PRE_2013_EXCHANGE: _cost_calculator( tax_year=2018, employer_pension_cb=pension.cps_pre_2013_employer_contribution, exchange_cb=pension.cps_pre_2013_employee_contribution, employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], ), }, } _LATEST_TAX_YEAR = max(_ON_COST_CALCULATORS.keys())