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
+
+[![CircleCI](https://circleci.com/gh/uisautomation/pidash-ucamstaffoncosts.svg?style=svg)](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