From 618337ec09c3678149845de6278978ee3ed9cb12 Mon Sep 17 00:00:00 2001 From: Rich Wareham <rjw57@cam.ac.uk> Date: Thu, 17 May 2018 14:32:48 +0100 Subject: [PATCH] initial implementation --- .circleci/config.yml | 82 +++++++ .coveragerc | 9 + .editorconfig | 12 + .flake8 | 3 + .gitignore | 112 +++++++++ LICENSE | 21 ++ README.md | 37 +++ doc/conf.py | 165 +++++++++++++ doc/index.rst | 69 ++++++ doc/requirements.txt | 3 + setup.py | 6 + tox.ini | 45 ++++ ucamstaffoncosts/__init__.py | 232 ++++++++++++++++++ ucamstaffoncosts/pension.py | 41 ++++ ucamstaffoncosts/rates.py | 31 +++ ucamstaffoncosts/tax.py | 61 +++++ ucamstaffoncosts/tests/__init__.py | 0 .../tests/test_data/private/.gitignore | 2 + .../tests/test_data/private/README.md | 27 ++ .../tests/test_data/private/normalise.py | 30 +++ .../test_data/private/on-costs-hashes.txt | 6 + .../tests/test_data/public/README.md | 13 + .../tests/test_data/public/extractspine.py | 40 +++ .../single_salary_spine_august_2017.csv | 123 ++++++++++ .../public/spine_points_august_2017.csv | 91 +++++++ .../tests/test_ucamstaffoncosts.py | 185 ++++++++++++++ 26 files changed, 1446 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 doc/conf.py create mode 100644 doc/index.rst create mode 100644 doc/requirements.txt create mode 100644 setup.py create mode 100644 tox.ini create mode 100644 ucamstaffoncosts/__init__.py create mode 100644 ucamstaffoncosts/pension.py create mode 100644 ucamstaffoncosts/rates.py create mode 100644 ucamstaffoncosts/tax.py create mode 100644 ucamstaffoncosts/tests/__init__.py create mode 100644 ucamstaffoncosts/tests/test_data/private/.gitignore create mode 100644 ucamstaffoncosts/tests/test_data/private/README.md create mode 100755 ucamstaffoncosts/tests/test_data/private/normalise.py create mode 100644 ucamstaffoncosts/tests/test_data/private/on-costs-hashes.txt create mode 100644 ucamstaffoncosts/tests/test_data/public/README.md create mode 100755 ucamstaffoncosts/tests/test_data/public/extractspine.py create mode 100644 ucamstaffoncosts/tests/test_data/public/single_salary_spine_august_2017.csv create mode 100644 ucamstaffoncosts/tests/test_data/public/spine_points_august_2017.csv create mode 100644 ucamstaffoncosts/tests/test_ucamstaffoncosts.py diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..83ba4ee --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,82 @@ +version: 2 + +jobs: + test: + docker: + - image: circleci/python:3.6 + steps: + - checkout + + - run: + name: Install and activate venv + command: | + python3 -m venv venv + . venv/bin/activate + pip install tox codecov + + - run: + name: Run tests + command: | + . venv/bin/activate + tox + + - run: + name: Submit code coverage information + command: | + . venv/bin/activate + codecov + + - store_artifacts: + path: build/htmlcov + destination: htmlcov + + - store_artifacts: + path: build/doc + destination: doc + + - store_test_results: + path: build/test-results + + - persist_to_workspace: + root: build + paths: + - doc + + deploy_docs: + docker: + - image: circleci/python:3.6 + steps: + - attach_workspace: + at: /tmp/workspace + + - checkout + + - run: + name: Install and activate venv + command: | + python3 -m venv venv + . venv/bin/activate + pip install ghp-import + + - run: + name: Import GH pages + command: | + . venv/bin/activate + ghp-import /tmp/workspace/doc + git config --local user.email "automation@uis.cam.ac.uk" + git config --local user.name "Deployment robot" + git push -f https://${GITHUB_TOKEN}@github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME} gh-pages:gh-pages + +workflows: + version: 2 + + test_and_deploy: + jobs: + - test + - deploy_docs: + requires: + - test + filters: + branches: + only: + - master diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..a457dc5 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[run] +omit= + setup.py + manage.py + */test/* + */tests/* + smswebapp/settings/* + smswebapp/wsgi.py + .tox/* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7ac1eec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root=true + +[*.py] +max_line_length=99 + +[*.ini] +indent_size=4 +indent_style=space + +[*.{yml,yaml}] +indent_size=2 +indent_style=space diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6008451 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length=99 +exclude = venv,.tox,assets/migrations diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a65df97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,112 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +.static_storage/ +.media/ +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +*.sqlite3 + +# Local configuration +setupenv.sh +smswebapp/settings/local.py + +# PyCharm +.idea diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7f46ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 University of Cambridge Information Services + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..be1b088 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,37 @@ +# University of Cambridge Salary On-cost Calculation + +[](https://circleci.com/gh/uisautomation/pidash-ucamstaffoncosts) + +This repository contains a Python module which calculates total on-costs +associated with employing staff members in the University of Cambridge. The +total on-costs value calculated by this module reflects the expenditure which +will result from employing a staff member on a grant. + +The aim is to replicate the +[information](https://www.hr.admin.cam.ac.uk/Salaries/242) available on the +University HR's website using only the publicly available rates. + +[Documentation](https://uisautomation.github.io/pidash-ucamstaffoncosts/) is +available on this repository's GitHub pages. + +## Example + +The functionality of the module is exposed through a single function, +``on_cost()``, which takes a tax year, pension scheme and gross salary and +returns an ``OnCost`` object representing the on-costs for that employee: + +```python +>>> import ucamstaffoncosts +>>> ucamstaffoncosts.on_cost(gross_salary=25000, +... scheme=ucamstaffoncosts.Scheme.USS, year=2018) +OnCost(salary=25000, exchange=0, employer_pension=4500, + employer_nic=2287, apprenticeship_levy=125, total=31912) +``` + +## Configuring CircleCI + +The CircleCI workflow includes automatically building and pushing documentation +to GitHub pages whenever there is a commit to the master branch. In order to +enable this, a personal access token for a robot user must be generated and +added to the CircleCI configuration as the ``GITHUB_TOKEN`` environment +variable. diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..87d29ae --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'University of Cambridge Staff On-costs' +copyright = '2018, UIS Automation Team' +author = 'UIS Automation Team' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'UniversityofCambridgeStaffOn-costsdoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'UniversityofCambridgeStaffOn-costs.tex', + 'University of Cambridge Staff On-costs Documentation', + 'UIS Automation Team', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'universityofcambridgestaffon-costs', + 'University of Cambridge Staff On-costs Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'UniversityofCambridgeStaffOn-costs', + 'University of Cambridge Staff On-costs Documentation', + author, 'UniversityofCambridgeStaffOn-costs', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Extension configuration ------------------------------------------------- diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..50c33ff --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,69 @@ +University of Cambridge Staff On-costs Calculator +================================================= + +.. toctree:: + :maxdepth: 2 + :hidden: + +The ``ucamstaffoncosts`` module calculates total on-costs associated with +employing staff members in the University of Cambridge. The total on-costs value +calculated by this module reflects the expenditure which will result from +employing a staff member on a grant. + +The aim is to replicate the `information +<https://www.hr.admin.cam.ac.uk/Salaries/242>`_ available on the University HR's +website using only the publicly available rates. + +Installation +------------ + +Installation is best done via pip: + +.. code:: console + + $ pip install git+https://github.com/uisautomation/pidash-ucamstaffoncosts + +Example +------- + +The functionality of the module is exposed through a single function, +:py:func:`~ucamstaffoncosts.on_cost`, which takes a tax year, pension scheme and +gross salary and returns an :py:class:`~ucamstaffoncosts.OnCost` object +representing the on-costs for that employee: + +.. doctest:: + :options: +NORMALIZE_WHITESPACE + + >>> import ucamstaffoncosts + >>> ucamstaffoncosts.on_cost(gross_salary=25000, + ... scheme=ucamstaffoncosts.Scheme.USS, year=2018) + OnCost(salary=25000, exchange=0, employer_pension=4500, + employer_nic=2287, apprenticeship_levy=125, total=31912) + +The :py:attr:`~ucamstaffoncosts.OnCost.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`: + +.. doctest:: + :options: +NORMALIZE_WHITESPACE + + >>> import ucamstaffoncosts + >>> ucamstaffoncosts.on_cost(gross_salary=25000, + ... scheme=ucamstaffoncosts.Scheme.USS, + ... year=ucamstaffoncosts.LATEST) + OnCost(salary=25000, exchange=0, employer_pension=4500, + employer_nic=2287, apprenticeship_levy=125, total=31912) + >>> ucamstaffoncosts.on_cost(gross_salary=25000, + ... scheme=ucamstaffoncosts.Scheme.USS) + OnCost(salary=25000, exchange=0, employer_pension=4500, + employer_nic=2287, apprenticeship_levy=125, total=31912) + + +Reference +--------- + +.. automodule:: ucamstaffoncosts + :members: diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..5e2c4ce --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,3 @@ +# Additional requirements for building documentation +sphinx +sphinx_rtd_theme diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4ccb234 --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +from setuptools import setup, find_packages + +setup( + name='salaries', + packages=find_packages(), +) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1670b4d --- /dev/null +++ b/tox.ini @@ -0,0 +1,45 @@ +[tox] +envlist=flake8,doc,py36 + +# The "_vars" section is ignored by tox but we place some useful shared +# variables in it to avoid needless repetition. +[_vars] +# Where to write build artefacts. We default to the "build" directory in the +# tox.ini file's directory. Override with the TOXINI_ARTEFACT_DIR environment +# variable. +build_root={env:TOXINI_ARTEFACT_DIR:{toxinidir}/build} + +[testenv] +deps= + nose + coverage +passenv= +# Allow people to override the coverage report location should they so wish. + COVERAGE_FILE +whitelist_externals=mkdir +commands= + mkdir -p "{[_vars]build_root}/test-results/nosetests" + coverage run -m nose --with-xunit --xunit-file={[_vars]build_root}/test-results/nosetests/results.xml {posargs} + coverage html --directory {[_vars]build_root}/htmlcov/ + coverage report + +# Build documentation +[testenv:doc] +basepython=python3.6 +deps= + -rdoc/requirements.txt +commands= + sphinx-build -a -v -b doctest doc/ {[_vars]build_root}/doctest/ + sphinx-build -a -v -b html doc/ {[_vars]build_root}/doc/ + +# Check for PEP8 violations +[testenv:flake8] +basepython=python3.6 +deps= +# We specify a specific version of flake8 to avoid introducing "false" +# regressions when new checks are introduced. The version of flake8 used may +# be overridden via the TOXINI_FLAKE8_VERSION environment variable. + flake8=={env:TOXINI_FLAKE8_VERSION:3.5.0} +commands= + flake8 --version + flake8 . diff --git a/ucamstaffoncosts/__init__.py b/ucamstaffoncosts/__init__.py new file mode 100644 index 0000000..b54ff33 --- /dev/null +++ b/ucamstaffoncosts/__init__.py @@ -0,0 +1,232 @@ +import collections +import enum +import fractions +import math + +from . import tax +from . import pension + +_OnCost = collections.namedtuple( + 'OnCost', + 'salary exchange employer_pension employer_nic apprenticeship_levy total' +) + + +class OnCost(_OnCost): + """An individual on-costs calculation for a gross 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 + + Gross salary for the employee. + + .. py:attribute:: exchange + + Amount of gross 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. + + """ + + +class Scheme(enum.Enum): + """ + Possible pension schemes an employee can be a member of. + + """ + + #: No pension scheme. + NONE = 'none' + + #: CPS hybrid + CPS_HYBRID = 'cps_hybrid' + + #: CPS hybrid with salary exchange + CPS_HYBRID_EXCHANGE = 'cps_hybrid_exchange' + + #: USS + USS = 'uss' + + #: USS with salary exchange + USS_EXCHANGE = 'uss_exchange' + + #: NHS + NHS = 'nhs' + + +#: Special value to pass to :py:func:`~.on_cost` to represent the latest tax year which has an +#: implementation. +LATEST = 'LATEST' + + +def on_cost(gross_salary, scheme, year=LATEST): + """ + Return a :py:class:`OnCost` instance given a tax year, pension scheme and gross salary. + + :param int year: tax year + :param Scheme scheme: pension scheme + :param int gross_salary: gross 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(gross_salary) + + +def _on_cost_calculator(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 OnCost entry from a gross salary. Arguments which are + callables each take a single argument which is a :py:class:`fractions.Fraction` instance + representing the gross salary of the employee. They should return a + :py:class:`fractions.Fraction` instance. + + :param employer_pension_cb: callable which gives employer pension contribution from gross + salary. + :param employer_nic_cb: callable which gives employer National Insurance contribution from + gross salary. + :param exchange_cb: callable which gives amount of salary sacrificed in a salary exchange + scheme from gross salary. + :param apprenticeship_levy_cb: callable which calculates the Apprenticeship Levy from gross + salary. + + """ + def on_cost(gross_salary): + # Ensure gross salary is a rational + gross_salary = fractions.Fraction(gross_salary) + + # We use the convention that the salary exchange value is negative to match the exchange + # column in HR tables. + exchange = -exchange_cb(gross_salary) + + # The employer pension contribution is the contribution based on gross salary along with + # the employee contribution sacrificed from their salary. + employer_pension = employer_pension_cb(gross_salary) - exchange + + # the taxable salary is the gross less the amount sacrificed. HR would appear to round the + # sacrifice first + taxable_salary = gross_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(gross_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 OnCost( + salary=_excel_round(gross_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), + ) + + return on_cost + + +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: _on_cost_calculator( + employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], + ), + + # An employee with USS in tax year 2018/19 + Scheme.USS: _on_cost_calculator( + 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: _on_cost_calculator( + 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: _on_cost_calculator( + 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: _on_cost_calculator( + 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: _on_cost_calculator( + employer_pension_cb=pension.nhs_employer_contribution, + employer_nic_cb=tax.TABLE_A_EMPLOYER_NIC[2018], + ), + }, +} + +_LATEST_TAX_YEAR = max(_ON_COST_CALCULATORS.keys()) diff --git a/ucamstaffoncosts/pension.py b/ucamstaffoncosts/pension.py new file mode 100644 index 0000000..98c6e3e --- /dev/null +++ b/ucamstaffoncosts/pension.py @@ -0,0 +1,41 @@ +from . import rates + + +def uss_employer_contribution(gross_salary): + """ + Return the USS employer contribution from gross salary. + + """ + return gross_salary * rates.USS_EMPLOYER_RATE + + +def uss_employee_contribution(gross_salary): + """ + Return the USS employee contribution from gross salary. + + """ + return gross_salary * rates.USS_EMPLOYEE_RATE + + +def cps_hybrid_employer_contribution(gross_salary): + """ + Return the CPS_HYBRID employer contribution from gross salary. + + """ + return gross_salary * rates.CPS_HYBRID_EMPLOYER_RATE + + +def cps_hybrid_employee_contribution(gross_salary): + """ + Return the CPS_HYBRID employee contribution from gross salary. + + """ + return gross_salary * rates.CPS_HYBRID_EMPLOYEE_RATE + + +def nhs_employer_contribution(gross_salary): + """ + Return the NHS employer contribution. + + """ + return gross_salary * rates.NHS_EMPLOYER_RATE diff --git a/ucamstaffoncosts/rates.py b/ucamstaffoncosts/rates.py new file mode 100644 index 0000000..cbbb8a2 --- /dev/null +++ b/ucamstaffoncosts/rates.py @@ -0,0 +1,31 @@ +import fractions + +#: Rate of apprenticeship levy. This is not expected to change every tax year so it is added as an +#: un-dated constant. Taken from: +#: https://www.gov.uk/guidance/pay-apprenticeship-levy#how-much-you-need-to-pay +APPRENTICESHIP_LEVY_RATE = fractions.Fraction(5, 1000) # 0.5% + +#: Rate of USS employer contribution. This is not expected to change every tax year so it is added +#: as an un-dated constant. Taken from USS website at +#: https://www.uss.co.uk/~/media/document-libraries/uss/member/member-guides/post-april-2016/your-guide-to-universities-superannuation-scheme.pdf # noqa: E501 +USS_EMPLOYER_RATE = fractions.Fraction(18, 100) # 18% + +#: Rate of USS employee contribution. This is not expected to change every tax year so it is added +#: as an un-dated constant. Taken from USS website at +#: https://www.uss.co.uk/~/media/document-libraries/uss/member/member-guides/post-april-2016/your-guide-to-universities-superannuation-scheme.pdf # noqa: E501 +USS_EMPLOYEE_RATE = fractions.Fraction(8, 100) # 8% + +#: Rate of CPS employee contribution. This is not expected to change every tax year so it is added +#: as an un-dated constant. Taken from CPS website at +#: https://www.pensions.admin.cam.ac.uk/files/8cps_hybrid_contributions_march_2017.pdf +CPS_HYBRID_EMPLOYEE_RATE = fractions.Fraction(3, 100) # 3% + +#: Rate of CPS employer contribution. This is not expected to change every tax year so it is added +#: as an un-dated constant. +#: FIXME: where does this come from? +CPS_HYBRID_EMPLOYER_RATE = fractions.Fraction(237, 1000) # 23.7% + +#: Rate of NHS employer contribution. This is not expected to change every tax year so it is added +#: as an un-dated constant. Taken from NHS website at: +#: http://www.nhsemployers.org/your-workforce/pay-and-reward/pensions/pension-contribution-tax-relief # noqa: E501 +NHS_EMPLOYER_RATE = fractions.Fraction(1438, 10000) # 14.38% diff --git a/ucamstaffoncosts/tax.py b/ucamstaffoncosts/tax.py new file mode 100644 index 0000000..c1bbb75 --- /dev/null +++ b/ucamstaffoncosts/tax.py @@ -0,0 +1,61 @@ +import fractions +import math + +from . import rates + + +def _make_nic_calculator(boundaries): + """ + Return a NIC calculator which implements the usual method of calculation where NICs are + calculated on different salary bands with differing rates. + + Returns a function which takes a gross salary as input and returns the employer NIC. + + """ + def calculate(gross_salary): + # Make sure gross salary is rational + gross_salary = fractions.Fraction(gross_salary) + + # Keep track of bottom of current boundary + boundary_bottom = fractions.Fraction(0) + + # Keep track of total NIC + contribution = fractions.Fraction(0) + + for boundary_top, rate in boundaries: + # Note: boundary_top being None signals "infinity" + + if boundary_top is not None and gross_salary >= boundary_top: + # Salary entirely encompasses this entire range + contribution += (boundary_top-boundary_bottom) * rate + elif gross_salary > boundary_bottom and (boundary_top is None + or gross_salary < boundary_top): + # Salary is in top-most range + contribution += (gross_salary-boundary_bottom) * rate + + boundary_bottom = boundary_top + + return contribution + + return calculate + + +#: Table A employer NICs keyed by tax year. +TABLE_A_EMPLOYER_NIC = { + 2018: _make_nic_calculator(( + (6032, 0), + + (8424, 0), + (46350, fractions.Fraction(138, 1000)), + (None, fractions.Fraction(138, 1000)), + )), +} + + +def standard_apprenticeship_levy(gross_salary): + """ + Return the standard Apprenticeship Levy assuming no special circumstances. Note that HR round + this figure *down* in on-costs tables. + + """ + return math.floor(gross_salary * rates.APPRENTICESHIP_LEVY_RATE) diff --git a/ucamstaffoncosts/tests/__init__.py b/ucamstaffoncosts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ucamstaffoncosts/tests/test_data/private/.gitignore b/ucamstaffoncosts/tests/test_data/private/.gitignore new file mode 100644 index 0000000..a59cd8e --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/private/.gitignore @@ -0,0 +1,2 @@ +*.tsv +*.csv diff --git a/ucamstaffoncosts/tests/test_data/private/README.md b/ucamstaffoncosts/tests/test_data/private/README.md new file mode 100644 index 0000000..1e6e8f4 --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/private/README.md @@ -0,0 +1,27 @@ +# Hashed private data + +This directory contains SHA256 checksums of private data from the HR website. + +## On-costs + +Hashes for the on-cost tables are generated in the following way: + +* The [HR on-costs tables](https://www.hr.admin.cam.ac.uk/Salaries/242) are + copied and pasted as plain text. On Firefox, this results in tab-separated + files. + +* These TSV files are normalised via [normalise.py](normalise.py) into CSV + files using the following command: + +```bash +$ for i in *.tsv; do ./normalise.py <$i >$(basename $i .tsv).csv; done +``` + +* The files are checksummed via ``sha256sum``: + +```bash +$ sha256sum *.csv >on-costs-hashes.txt +``` + +The CSV and TSV files have been added to .gitignore to ensure that they are not +inadvertently checked in. diff --git a/ucamstaffoncosts/tests/test_data/private/normalise.py b/ucamstaffoncosts/tests/test_data/private/normalise.py new file mode 100755 index 0000000..2527854 --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/private/normalise.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +Normalise data read from standard input as a TSB into a CSV file on standard +output. + +The following normalisation is used: + + 1. Blank lines are skipped + 2. Whitespace characters surrounding cell entries is stripped + 3. Commas are stripped + 4. Leading zeros are stripped + +""" + +import csv +import sys + + +def normalise(cell): + return cell.strip().replace(',', '').lstrip('0') + + +def main(): + reader = csv.reader(sys.stdin, dialect='excel-tab') + writer = csv.writer(sys.stdout) + writer.writerows([normalise(cell) for cell in row] for row in reader if len(row) > 0) + + +if __name__ == '__main__': + main() diff --git a/ucamstaffoncosts/tests/test_data/private/on-costs-hashes.txt b/ucamstaffoncosts/tests/test_data/private/on-costs-hashes.txt new file mode 100644 index 0000000..e8becb8 --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/private/on-costs-hashes.txt @@ -0,0 +1,6 @@ +423e7140e96331f94b5e812b8ee305094730f071c237693186ad1ca870463576 cps_hybrid_2018.csv +2630aef54878a33d2388331f02e2f0123c81af78f26b5203d090130e982cece3 cps_hybrid_exchange_2018.csv +cfced74c09a1c2e2cc30f725a6e4f44652219927c1a9694f5b6faa29584d5412 nhs_2018.csv +4a8d8a27839c59d3daefb042c1249be3aac4f8c98507ceba113ab0cdf1feccc6 no_scheme_2018.csv +225e04f1af8a73079695a15aa59528a22762315a430063bb5ca8c90bb052b6d2 uss_2018.csv +1b8e7bb5fa1a1e3c186d6b028d4c32b159ec4c43cf2a61dc42b2531c15a60f4e uss_exchange_2018.csv diff --git a/ucamstaffoncosts/tests/test_data/public/README.md b/ucamstaffoncosts/tests/test_data/public/README.md new file mode 100644 index 0000000..c651e2e --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/public/README.md @@ -0,0 +1,13 @@ +# Public test data + +This data is publicly available data taken from the [University of Cambridge HR +Website](https://www.hr.admin.cam.ac.uk/pay-benefits/salary-scales). + +## Salary spine + +The [salary spine data](single_salary_spine_august_2017.csv) is a CSV export of +the [Excel +spreadsheet](https://www.hr.admin.cam.ac.uk/files/single_salary_spine_as_at_1_august_2017_.xlsx) +published by HR. + +It came into effect on the 1st August 2017. diff --git a/ucamstaffoncosts/tests/test_data/public/extractspine.py b/ucamstaffoncosts/tests/test_data/public/extractspine.py new file mode 100755 index 0000000..819ff63 --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/public/extractspine.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Extract salary spine from spine Excel sheet. +""" +import csv +import re +import sys + + +def main(): + reader = csv.reader(sys.stdin) + writer = csv.writer(sys.stdout) + + writer.writerow(['Scale point', 'Salary (£)']) + + for row in reader: + # Extract values + try: + spine_point_1, spine_point_2 = row[2], row[-3] + base_salary = row[-1] + except IndexError: + continue + + # We detect salary rows becausse the spine point is repeated... + if spine_point_1 != spine_point_2: + continue + + # ...and it is numeric + if not re.match('^[0-9]+$', spine_point_1): + continue + + # Strip characters from salary + base_salary = base_salary.replace(',', '') + base_salary = base_salary.replace('£', '') + + writer.writerow([spine_point_1, base_salary]) + + +if __name__ == '__main__': + main() diff --git a/ucamstaffoncosts/tests/test_data/public/single_salary_spine_august_2017.csv b/ucamstaffoncosts/tests/test_data/public/single_salary_spine_august_2017.csv new file mode 100644 index 0000000..3c8c7a0 --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/public/single_salary_spine_august_2017.csv @@ -0,0 +1,123 @@ +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,University of Cambridge: Single Salary Spine as at 1st August 2017,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,, +,Point on scale,,Grades,,,,,,,,,,,,,,,,Point on scale,Single spine salary, +,,,T,1,2,3,4,5,6,7,8,9,10,11,12,,,,,, +,,,,1,1,1,1,1,1,1,1,1,1,1,Band 1,Band 2,Band 3,Band 4,,From 1 August 2016,From 1 August 2017 +,,100,,,,,,,,,,,,,,,,*,100,"£177,005","£180,014" +,,99,,,,,,,,,,,,,,,,*,99,"£171,855","£174,776" +,,98,,,,,,,,,,,,,,,,*,98,"£166,852","£169,689" +,,97,,,,,,,,,,,,,,,,*,97,"£161,998","£164,752" +,,96,,,,,,,,,,,,,,,,*,96,"£157,282","£159,956" +,,95,,,,,,,,,,,,,,,,*,95,"£152,708","£155,304" +,,94,,,,,,,,,,,,,,,,*,94,"£148,263","£150,784" +,,93,,,,,,,,,,,,,,,,*,93,"£143,950","£146,397" +,,92,,,,,,,,,,,,,,,,*,92,"£139,763","£142,139" +,,91,,,,,,,,,,,,,,,,*,91,"£135,697","£138,004" +,,90,,,,,,,,,,,,,,,,*,90,"£131,749","£133,989" +,,89,,,,,,,,,,,,,,,12 Band 3,*,89,"£127,917","£130,091" +,,88,,,,,,,,,,,,,,,*,*,88,"£124,196","£126,308" +,,87,,,,,,,,,,,,,,,*,*,87,"£120,584","£122,634" +,,86,,,,,,,,,,,,,,,*,,86,"£117,074","£119,064" +,,85,,,,,,,,,,,,,,,*,,85,"£113,671","£115,603" +,Cambridge,84,,,,,,,,,,,,,,,*,,84,"£110,364","£112,240" +,extension to,83,,,,,,,,,,,,,,12 Band 2,*,,83,"£107,155","£108,977" +,national spine,82,,,,,,,,,,,,,,*,*,,82,"£104,038","£105,807" +,,81,,,,,,,,,,,,,,*,*,,81,"£101,012","£102,729" +,,80,,,,,,,,,,,,,,*,,,80,"£98,074","£99,741" +,,79,,,,,,,,,,,,,,*,,,79,"£95,223","£96,842" +,,78,,,,,,,,,,,,,,*,,,78,"£92,453","£94,025" +,,77,,,,,,,,,,,,,12 Band 1,*,,,77,"£89,767","£91,293" +,,76,,,,,,,,,,,,,*,*,,,76,"£87,156","£88,638" +,,75,,,,,,,,,,,,,*,*,,,75,"£84,622","£86,060" +,,74,,,,,,,,,,,,,*,,,,74,"£82,161","£83,558" +,,73,,,,,,,,,,,,,*,,,,73,"£79,773","£81,129" +,,72,,,, ,,,,,,,,,*,,,,72,"£77,456","£78,772" +,,71,,,,,,,,,,,,,*,,,,71,"£75,202","£76,481" +,,70,,,,,,,,,,,,,*,,,,70,"£73,015","£74,257" +,,69,,,,,,,,,,,,,*,,,,69,"£70,893","£72,099" +,,68,,,,,,,,,,,,Grade 11,*,,,,68,"£68,834","£70,004" +,,67,,,,,,,,,,,,6*,,,,,67,"£66,835","£67,971" +,,66,,,,,,,,,,,,5*,,,,,66,"£64,894","£65,997" +,,65,,,,,,,,,,,Grade 10,4*,,,,,65,"£63,009","£64,080" +,,64,,,,,,,,,,,6*,3*,,,,,64,"£61,178","£62,218" +,,63,,,,,,,,,,,5*,2*,,,,,63,"£59,400","£60,410" +,,62,,,,,,,,,,Grade 9,4*,1*,,,,,62,"£57,674","£58,655" +,,61,,,,,,,,,,13*,3,,,,,,61,"£55,998","£56,950" +,,60,,,,,,,,,,12*,2,,,,,,60,"£54,372","£55,297" +,,59,,,,,,,,,Grade 8,11*,1,,,,,,59,"£52,793","£53,691" +,,58,,,,,,,,,14*,10*,,,,,,,58,"£51,260","£52,132" +,,57,,,,,,,,,13*,9,,,,,,,57,"£49,772","£50,618" +,,56,,,,,,,,,12*,8,,,,,,,56,"£48,327","£49,149" +,,55,,,,,,,,,11,7,,,,,,,55,"£46,924","£47,722" +,,54,,,,,,,,,10,6,,,,,,,54,"£45,562","£46,336" +,,53,,,,,,,,Grade 7,9,5,,,,,,,53,"£44,240","£44,992" +,,52,,,,,,,,14*,8,4,,,,,,,52,"£42,955","£43,685" +,,51,,,,,,,,13*,7,3,,,,,,,51,"£41,709","£42,418" +,,50,,,,,,,,12*,6,2,,,,,,,50,"£40,523","£41,212" +,,49,,,,,,,Grade 6,11*,5,1,,,,,,,49,"£39,324","£39,992" +,,48,,,,,,,12*,10,4,,,,,,,,48,"£38,183","£38,833" +,,47,,,,,,,11*,9,3,,,,,,,,47,"£37,075","£37,706" +,,46,,,,,,,10*,8,2,,,,,,,,46,"£36,001","£36,613" +,,45,,,,,,Grade 5,9*,7,1,,,,,,,,45,"£34,956","£35,550" +,,44,,,,,,11*,8*,6,,,,,,,,,44,"£33,943","£34,520" +,,43,,,,,,10*,7,5,,,,,,,,,43,"£32,958","£33,518" +,,42,,,,,,9*,6,4,,,,,,,,,42,"£32,004","£32,548" +,National,41,,,,,,8*,5,3,,,,,,,,,41,"£31,076","£31,604" +,Spine,40,,,,,,7*,4,2,,,,,,,,,40,"£30,175","£30,688" +,(Framework,39,,,,,Grade 4,6,3,1,,,,,,,,,39,"£29,301","£29,799" +,Agreement),38,,,,,10*,5,2,,,,,,,,,,38,"£28,452","£28,936" +,,37,,,,,9*,4,1,,,,,,,,,,37,"£27,629","£28,098" +,,36,,,,,8*,3,,,,,,,,,,,36,"£26,829","£27,285" +,,35,,,,,7*,2,,,,,,,,,,,35,"£26,052","£26,495" +,,34,,,,Grade 3,6,1,,,,,,,,,,,34,"£25,298","£25,728" +,,33,,,,10*,5,,,,,,,,,,,,33,"£24,565","£24,983" +,,32,,,,9*,4,,,,,,,,,,,,32,"£23,879","£24,285" +,,31,,,,8*,3,,,,,,,,,,,,31,"£23,164","£23,557" +,,30,,,,7*,2,,,,,,,,,,,,30,"£22,494","£22,876" +,,29,,,Grade 2,6,1,,,,,,,,,,,,29,"£21,843","£22,214" +,,28,,,10*,5,,,,,,,,,,,,,28,"£21,220","£21,585" +,,27,,,9*,4,,,,,,,,,,,,,27,"£20,624","£20,989" +,,26,,,8*,3,,,,,,,,,,,,,26,"£20,046","£20,411" +,,25,,,7*,2,,,,,,,,,,,,,25,"£19,485","£19,850" +,,24,,,6,1,,,,,,,,,,,,,24,"£18,940","£19,305" +,,23,,,5,,,,,,,,,,,,,,23,"£18,412","£18,777" +,,22,,Grade 1,4,,,,,,,,,,,,,,22,"£17,898","£18,263" +,,21,,8*,3,,,,,,,,,,,,,,21,"£17,399","£17,764" +,,20,,7*,2,,,,,,,,,,,,,,20,"£16,961","£17,326" +,,19,,6*,1,,,,,,,,,,,,,,19,"£16,618","£16,983" +,,18,,5,,,,,,,,,,,,,,,18,"£16,289","£16,654" +,,17,,4,,,,,,,,,,,,,,,17,"£15,976","£16,341" +,,16,,3,,,,,,,,,,,,,,,16,"£15,670","£16,035" +,,15,,2,,,,,,,,,,,,,,,15,"£15,356","£15,721" +,,14,T Grade,1,,,,,,,,,,,,,,,14,"£15,052","£15,417" +,Trainees ,13,T13,,,,,,,,,,,,,,,,13,"£14,767","£15,126" +,(Steps 1 - 10 no ,12,T12,,,,,,,,,,,,,,,,12,"£14,327","£14,675" +,longer in use),11,T11,,,,,,,,,,,,,,,,11,"£13,965","£14,304" +,,,,,,,,,,,,,,,,,,,,, +,Note 1:,An asterisk (*) denotes a contribution point and progress through these is awarded on merit. ,,,,,,,,,,,,,,,,,,, +,Note 2:,Grade T is for staff who are studying for an approved qualification or undergoing 'in-service' training.,,,,,,,,,,,,,,,,,,, +,Note 3:,"On 1 January 2010 the first contribution points of grades 2, 3, and 4 became service points.",,,,,,,,,,,,,,,,,,, +,Note 4:,University Lecturers (ULs) and University Senior Lecturers (USLs) will be appointed to grades 9 and 10 respectively.,,,,,,,,,,,,,,,,,,, +,,ULs may progress through service points 1–9 of grade 9.,,,,,,,,,,,,,,,,,,, +,,USLs may progress through service points 1–3 and contribution points 4-5 of grade 10.,,,,,,,,,,,,,,,,,,, +,,Readers will only be appointed to point 2 in grade 11 (point 63).,,,,,,,,,,,,,,,,,,, +,,Senior Research Associates will be appointed to grade 9.,,,,,,,,,,,,,,,,,,, +,,Research Associates will be appointed to grade 7 spine point 40 from 6 April 2017 and to spine point 41 from 1 October 2017.,,,,,,,,,,,,,,,,,,, +,,Research Assistants will be appointed to grade 5.,,,,,,,,,,,,,,,,,,, +,,The contribution points in grades 9 and 11 do not apply to ULs and Readers. They apply to academic-related staff.,,,,,,,,,,,,,,,,,,, +,,The professorial minimum will be point 68 in band 1 of grade 12.,,,,,,,,,,,,,,,,,,, +,Note 5:,For academic staff (other than Professors and USLs) contribution will be recognised through the promotions procedure as now and not by use of contribution points.,,,,,,,,,,,,,,,,,,, +,,USLs will also have access to the Senior Academic Promotions procedure under which they may also be awarded contribution points 4-5 in grade 10.,,,,,,,,,,,,,,,,,,, +,Note 6:,Academic-related professorial-equivalent staff will be appointed on the contribution bands of grade 12 according to the HERA points boundaries for each level.,,,,,,,,,,,,,,,,,,, +,Note 7:,Specific arrangements will apply to progression in service-related points on some grades in compliance with the Memorandum of Understanding.,,,,,,,,,,,,,,,,,,, +,Note 8:,"Incremental progression through the service related points occurs on the incremental date which will normally be on the anniversary of appointment or 1 April, 1 July or 1 October respectively ",,,,,,,,,,,,,,,,,,, +,,"for staff engaged on terms and conditions for Manual, Clerical/Secretarial and Technical Division appointments.",,,,,,,,,,,,,,,,,,, +,Note 9:,"Direct employees of the University appointed to grade 1 will not be paid below spine point 18, with effect from 1 November 2017.",,,,,,,,,,,,,,,,,,, +,Note 10: ,"Points 32 and 50 were aligned to the National Single Pay Spine for Higher Education Academic and Support Staff , as negotiated by the Universities and Colleges Employers Association on behalf of ",,,,,,,,,,,,,,,,,,, +,,"UK higher education employers, with effect from 1 January 2014.",,,,,,,,,,,,,,,,,,, +,Note 11: ,"On 1 January 2015 the first contribution points of grades 1, 5, and 6 became service points.",,,,,,,,,,,,,,,,,,, +,Note 12:,Spine point 13 was removed from the National Spine and the University's grade 1 with effect from 1 August 2016,,,,,,,,,,,,,,,,,,, diff --git a/ucamstaffoncosts/tests/test_data/public/spine_points_august_2017.csv b/ucamstaffoncosts/tests/test_data/public/spine_points_august_2017.csv new file mode 100644 index 0000000..42f02df --- /dev/null +++ b/ucamstaffoncosts/tests/test_data/public/spine_points_august_2017.csv @@ -0,0 +1,91 @@ +Scale point,Salary (£) +100,180014 +99,174776 +98,169689 +97,164752 +96,159956 +95,155304 +94,150784 +93,146397 +92,142139 +91,138004 +90,133989 +89,130091 +88,126308 +87,122634 +86,119064 +85,115603 +84,112240 +83,108977 +82,105807 +81,102729 +80,99741 +79,96842 +78,94025 +77,91293 +76,88638 +75,86060 +74,83558 +73,81129 +72,78772 +71,76481 +70,74257 +69,72099 +68,70004 +67,67971 +66,65997 +65,64080 +64,62218 +63,60410 +62,58655 +61,56950 +60,55297 +59,53691 +58,52132 +57,50618 +56,49149 +55,47722 +54,46336 +53,44992 +52,43685 +51,42418 +50,41212 +49,39992 +48,38833 +47,37706 +46,36613 +45,35550 +44,34520 +43,33518 +42,32548 +41,31604 +40,30688 +39,29799 +38,28936 +37,28098 +36,27285 +35,26495 +34,25728 +33,24983 +32,24285 +31,23557 +30,22876 +29,22214 +28,21585 +27,20989 +26,20411 +25,19850 +24,19305 +23,18777 +22,18263 +21,17764 +20,17326 +19,16983 +18,16654 +17,16341 +16,16035 +15,15721 +14,15417 +13,15126 +12,14675 +11,14304 diff --git a/ucamstaffoncosts/tests/test_ucamstaffoncosts.py b/ucamstaffoncosts/tests/test_ucamstaffoncosts.py new file mode 100644 index 0000000..ebd327e --- /dev/null +++ b/ucamstaffoncosts/tests/test_ucamstaffoncosts.py @@ -0,0 +1,185 @@ +import csv +import hashlib +import io +import os +import unittest.mock as mock + +import nose.tools + +import ucamstaffoncosts + + +TEST_DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'test_data') +PUBLIC_DATA_DIR = os.path.join(TEST_DATA_DIR, 'public') +PRIVATE_DATA_DIR = os.path.join(TEST_DATA_DIR, 'private') + + +# Load the SHA256 hashes of the correct tables from the private data directory. A dictionary keyed +# by original file name giving the hash of the correct file. +PRIVATE_DATA_HASHES = dict( + line.split()[::-1] for line in open(os.path.join(PRIVATE_DATA_DIR, 'on-costs-hashes.txt')) +) + + +def test_not_implemented_scheme(): + """Check NotImplementedError is raised if the scheme is unknown.""" + + with nose.tools.assert_raises(NotImplementedError): + # Bad year + ucamstaffoncosts.on_cost(100, -1000, ucamstaffoncosts.Scheme.USS) + + with nose.tools.assert_raises(NotImplementedError): + # Bad year + ucamstaffoncosts.on_cost(100, 2018, 'this-is-not-a-pension-scheme') + + +def test_no_scheme_2018(): + """Check on-costs if employee has no pension scheme.""" + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.NONE, 2018), + 'no_scheme_2018.csv', with_exchange_column=True) + + +def test_uss_2018(): + """Check on-costs if employee has a USS scheme.""" + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.USS, 2018), + 'uss_2018.csv') + + +def test_uss_2018_exchange(): + """Check on-costs if employee has a USS scheme with salary exchange.""" + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.USS_EXCHANGE, 2018), + 'uss_exchange_2018.csv', with_exchange_column=True) + + +def test_cps_hybrid_2018(): + """Check on-costs if employee has a CPS hybrid scheme.""" + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.CPS_HYBRID, 2018), + 'cps_hybrid_2018.csv', with_exchange_column=True) + + +def test_cps_hybrid_2018_exchange(): + """Check on-costs if employee has a CPS hybrid scheme with salary exchange.""" + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.CPS_HYBRID_EXCHANGE, 2018), + 'cps_hybrid_exchange_2018.csv', with_exchange_column=True) + + +def test_nhs_2018(): + """Check on-costs if employee has a NHS scheme.""" + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.NHS, 2018), + 'nhs_2018.csv', with_exchange_column=True) + + +def test_default_scheme(): + """Check on-costs for default scheme and year""" + with mock.patch('ucamstaffoncosts._LATEST_TAX_YEAR', 2018): + assert_generator_matches_table( + lambda s: ucamstaffoncosts.on_cost(s, ucamstaffoncosts.Scheme.NHS), + 'nhs_2018.csv', with_exchange_column=True) + + +def assert_generator_matches_table(on_cost_generator, table_filename, with_exchange_column=False): + """ + Take a generator callable which returns an OnCost from a base salary and check that its output + matches a HR table. *table_filename* should be the HR table filename and *with_exchange_column* + indicates if that table has an Exchange column. + + """ + headings, spine_rows = read_spine_points() + + if with_exchange_column: + headings.append('Exchange (£)') + + headings.extend([ + 'Pension (£)', 'NI (£)', 'Apprenticeship Levy (£)', 'Total (£)' + ]) + + out = io.StringIO() + writer = csv.writer(out) + writer.writerow(headings) + + for point, base_salary in spine_rows: + on_cost = on_cost_generator(int(base_salary)) + + row = [point, base_salary] + + if with_exchange_column: + row.append(blank_if_zero(on_cost.exchange)) + + row.extend([ + blank_if_zero(on_cost.employer_pension), + blank_if_zero(on_cost.employer_nic), + blank_if_zero(on_cost.apprenticeship_levy), + blank_if_zero(on_cost.total), + ]) + + writer.writerow(row) + + assert_tables_match(out.getvalue().encode('utf8'), table_filename) + + +def assert_tables_match(table_contents, filename): + """ + Assert that a generate table matches the one specified by filename. *table* must be a bytes + object. + + """ + expected_hash, expected_table_contents = read_table(filename) + + # If we have the expected table, compare it directly. + if expected_table_contents is not None: + # Compare line-wise + for line, expected_line in zip(table_contents.decode('utf8').splitlines(), + expected_table_contents.decode('utf8').splitlines()): + nose.tools.assert_equal(line, expected_line) + + # Compare byte-wise + nose.tools.assert_equal(table_contents, expected_table_contents) + + # Compare the hashes + table_hash = hashlib.sha256(table_contents).hexdigest() + nose.tools.assert_equal(table_hash, expected_hash) + + +def read_spine_points(): + """ + Return table header and a sequence of rows from the spine points table. + + """ + with open(os.path.join(PUBLIC_DATA_DIR, 'spine_points_august_2017.csv')) as f: + reader = csv.reader(f) + headings = next(reader) + return headings, list(reader) + + +def read_table(filename): + """ + Return the hash of the filename and its contents if present in the TEST_DATA_DIR directory. If + the file is not present, the contents are returned as None. + + The file itself may not be present as the contents are private. + + """ + hash_value = PRIVATE_DATA_HASHES[filename] + abs_path = os.path.join(PRIVATE_DATA_DIR, filename) + + # Return hash file contents if the file can be read, otherwise return hash and None. + try: + with open(abs_path, 'rb') as f: + return hash_value, f.read() + except IOError: + return hash_value, None + + +def blank_if_zero(value): + """ + Helper function to return the string representation of a value or the blank string if the + value is zero. + + """ + return str(value) if value != 0 else '' -- GitLab