paye 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paye-0.1.0/.gitignore +207 -0
- paye-0.1.0/LICENSE +21 -0
- paye-0.1.0/PKG-INFO +16 -0
- paye-0.1.0/README.md +2 -0
- paye-0.1.0/pyproject.toml +23 -0
- paye-0.1.0/requirements.txt +4 -0
- paye-0.1.0/src/__init__.py +0 -0
- paye-0.1.0/src/paye.py +621 -0
paye-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
paye-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Paul Worrall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
paye-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paye
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A partial implementation of UK income tax Pay-As-You-Earn calculations
|
|
5
|
+
Project-URL: Homepage, https://github.com/Silver-Saucepan/paye
|
|
6
|
+
Project-URL: Issues, https://github.com/Silver-Saucepan/paye/issues
|
|
7
|
+
Author-email: Paul Worrall <p.r.worrall@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# paye
|
|
16
|
+
Partial implementation of UK Income Tax Pay-As-You-Earn algorithms
|
paye-0.1.0/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "paye"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="Paul Worrall", email="p.r.worrall@gmail.com" },
|
|
6
|
+
]
|
|
7
|
+
description = "A partial implementation of UK income tax Pay-As-You-Earn calculations"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICEN[CS]E*"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/Silver-Saucepan/paye"
|
|
19
|
+
Issues = "https://github.com/Silver-Saucepan/paye/issues"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling >= 1.26"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
File without changes
|
paye-0.1.0/src/paye.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A partial implementation of PAYE income tax rules for England.
|
|
3
|
+
|
|
4
|
+
In the UK, employees and pensioners usually pay their income tax in
|
|
5
|
+
monthly or weekly installments deducted automatically from their pay
|
|
6
|
+
throughout the tax year under a scheme called 'PAYE' (Pay As You Earn).
|
|
7
|
+
|
|
8
|
+
This module provides a partial implementation of:
|
|
9
|
+
|
|
10
|
+
'HMRC Specification for PAYE Tax Table Routines'
|
|
11
|
+
Version 23.0, January 2025
|
|
12
|
+
|
|
13
|
+
Exported classes:
|
|
14
|
+
TaxCode: Analyses an HMRC tax code
|
|
15
|
+
Payslip: All the data normally shown on a payslip
|
|
16
|
+
|
|
17
|
+
Exported functions:
|
|
18
|
+
tax_due: calculate tax due for monthly income
|
|
19
|
+
constants_from_csv: Obtain HMRC yearly constants from a CSV file
|
|
20
|
+
constants_from_google_sheets: Obtain HMRC yearly constants from Google Sheets
|
|
21
|
+
|
|
22
|
+
Currently not implemented:
|
|
23
|
+
* Weekly pay
|
|
24
|
+
* Scottish tax codes
|
|
25
|
+
* Welsh tax codes
|
|
26
|
+
"""
|
|
27
|
+
import datetime
|
|
28
|
+
import re
|
|
29
|
+
import csv
|
|
30
|
+
from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
import os.path
|
|
33
|
+
|
|
34
|
+
from google.auth.transport.requests import Request
|
|
35
|
+
from google.oauth2.credentials import Credentials
|
|
36
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
37
|
+
from googleapiclient.discovery import build
|
|
38
|
+
from googleapiclient.errors import HttpError
|
|
39
|
+
|
|
40
|
+
# If modifying these scopes, delete the file token.json.
|
|
41
|
+
SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
|
|
42
|
+
|
|
43
|
+
# noinspection SpellCheckingInspection
|
|
44
|
+
PROGNOSTICATOR_ID = "180xcr-5WQ_6W4pwSpbxTkwydLfdQUN5nUqut6PufO0E"
|
|
45
|
+
HMRC_DATA = "HMRC & ONS Parameters!A4:P"
|
|
46
|
+
CONSTANTS = {}
|
|
47
|
+
|
|
48
|
+
TAX_CODE_REGEX = r'^([SC])?(BR|NT|0T|D|K)?(\d*)([LMNTPY])?[\s/]*(.*)'
|
|
49
|
+
# Meaning of the groups:
|
|
50
|
+
# Group 1: Indicates if Scottish or Welsh rules apply
|
|
51
|
+
# Group 2: The Prefix
|
|
52
|
+
# BR = Basic Rate on whole amount
|
|
53
|
+
# NT = No Tax
|
|
54
|
+
# 0T = Personal allowance has been used up or employer doesn't have all the details
|
|
55
|
+
# D = All income taxed at higher or additional rate (which is determined by group 3)
|
|
56
|
+
# K = Negative numeric part
|
|
57
|
+
# Group 3: The Numeric Part
|
|
58
|
+
# Group 4: The Suffix
|
|
59
|
+
# L = Standard tax free personal allowance applies
|
|
60
|
+
# M = 10% of partner's personal allowance (marriage allowance tax break)
|
|
61
|
+
# N = Some personal allowance passed to partner (marriage allowance tax break)
|
|
62
|
+
# T = "Other calculations" included
|
|
63
|
+
# P = Disused?
|
|
64
|
+
# Y = Disused?
|
|
65
|
+
# Group 5: The basis (cumulative vs week 1/month 1) identified by the following codes
|
|
66
|
+
|
|
67
|
+
MONTH_1_BASIS_CODES = ('1', '(M1)', 'X')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TaxCode:
|
|
71
|
+
"""
|
|
72
|
+
Attributes and methods for holding and interrogating HMRC tax codes.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
nation: 'S', 'C' if Scottish or Welsh, or none if English or NI
|
|
76
|
+
prefix: 'BR', 'NT', '0T', 'D', 'K' or none
|
|
77
|
+
numeric_part: The numbers used to calculate tax-free amount
|
|
78
|
+
suffix: Indicates various conditions like Personal Allowance
|
|
79
|
+
basis: Cumulative or month 1
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
nation: str | None
|
|
83
|
+
prefix: str | None
|
|
84
|
+
numeric_part: str | None
|
|
85
|
+
suffix: str | None
|
|
86
|
+
basis: str | None
|
|
87
|
+
|
|
88
|
+
def __init__(self, code: str) -> None:
|
|
89
|
+
"""Parse a Tax Code into its component parts and check for unsupported features."""
|
|
90
|
+
self.code = code.strip()
|
|
91
|
+
if self.code:
|
|
92
|
+
p = re.compile(TAX_CODE_REGEX)
|
|
93
|
+
r = p.match(self.code)
|
|
94
|
+
if r:
|
|
95
|
+
(
|
|
96
|
+
self.nation,
|
|
97
|
+
self.prefix,
|
|
98
|
+
self.numeric_part,
|
|
99
|
+
self.suffix,
|
|
100
|
+
self.basis,
|
|
101
|
+
) = r.groups()
|
|
102
|
+
if self.nation == 'C':
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"Welsh tax codes not currently supported")
|
|
105
|
+
if self.nation == 'S':
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"Scottish tax codes not currently supported")
|
|
108
|
+
|
|
109
|
+
def __str__(self):
|
|
110
|
+
return str(
|
|
111
|
+
(
|
|
112
|
+
self.nation,
|
|
113
|
+
self.prefix,
|
|
114
|
+
self.numeric_part,
|
|
115
|
+
self.suffix,
|
|
116
|
+
self.basis,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def is_br(self) -> bool:
|
|
121
|
+
"""Return True if it's a basic rate code."""
|
|
122
|
+
return self.prefix == 'BR'
|
|
123
|
+
|
|
124
|
+
def is_nt(self) -> bool:
|
|
125
|
+
"""Return True if it's a 'No Tax' code."""
|
|
126
|
+
return self.prefix == 'NT'
|
|
127
|
+
|
|
128
|
+
def is_na(self) -> bool:
|
|
129
|
+
"""Return True if the code is '#N/A'."""
|
|
130
|
+
return self.code == '#N/A'
|
|
131
|
+
|
|
132
|
+
def d_index(self) -> int | None:
|
|
133
|
+
"""
|
|
134
|
+
If the code prefix is 'D' indicating all income is to be taxed
|
|
135
|
+
at the Higher or Additional tax rate, return the following
|
|
136
|
+
character as an integer which says which rate to use.
|
|
137
|
+
Otherwise, return None
|
|
138
|
+
"""
|
|
139
|
+
if self.prefix == 'D':
|
|
140
|
+
if self.numeric_part:
|
|
141
|
+
return int(self.numeric_part)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def is_month1(self) -> bool:
|
|
145
|
+
"""Return True if the code indicates a 'Month 1' code."""
|
|
146
|
+
# HMRC and individual payroll systems use different suffixes to
|
|
147
|
+
# indicate month 1
|
|
148
|
+
return self.basis in MONTH_1_BASIS_CODES
|
|
149
|
+
|
|
150
|
+
def is_cumulative(self) -> bool:
|
|
151
|
+
"""Return True if the code does not indicate a 'Month 1' code."""
|
|
152
|
+
return not self.is_month1()
|
|
153
|
+
|
|
154
|
+
def free_pay_m1(self) -> Decimal:
|
|
155
|
+
"""
|
|
156
|
+
Calculate the Free Pay or Additional Pay for Month 1.
|
|
157
|
+
|
|
158
|
+
Implementation of algorithm specified in section 4.3.1 of
|
|
159
|
+
"HMRC Specification for PAYE Tax Table Routines".
|
|
160
|
+
"""
|
|
161
|
+
if self.numeric_part:
|
|
162
|
+
numeric = Decimal(self.numeric_part)
|
|
163
|
+
else:
|
|
164
|
+
numeric = Decimal('0.00')
|
|
165
|
+
|
|
166
|
+
if self.is_br():
|
|
167
|
+
# 5.2 Whole of cumulative pay is to be taxed at basic rate
|
|
168
|
+
free_pay = Decimal('0.00')
|
|
169
|
+
elif self.d_index():
|
|
170
|
+
# Whole of cumulative pay is to be taxed at Higher or Additional rate
|
|
171
|
+
free_pay = Decimal('0.00')
|
|
172
|
+
elif numeric == 0:
|
|
173
|
+
free_pay = Decimal('0.00')
|
|
174
|
+
else:
|
|
175
|
+
# Using the "Note for programmers" method
|
|
176
|
+
# at the end of 4.3.1
|
|
177
|
+
q, r = divmod(numeric - 1, Decimal('500'))
|
|
178
|
+
r += 1
|
|
179
|
+
free_pay_r = ((r * 10 + 9) / 12).quantize(
|
|
180
|
+
Decimal('0.01'), rounding=ROUND_CEILING)
|
|
181
|
+
free_pay_q = q * Decimal('416.67')
|
|
182
|
+
free_pay = free_pay_q + free_pay_r
|
|
183
|
+
if self.prefix == 'K':
|
|
184
|
+
free_pay *= -1
|
|
185
|
+
|
|
186
|
+
return free_pay
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class Payslip:
|
|
191
|
+
"""Payslip Model
|
|
192
|
+
|
|
193
|
+
Attributes:
|
|
194
|
+
payer_name (str): Name of organisation making payment
|
|
195
|
+
year (int): The calendar year in which the tax year starts
|
|
196
|
+
period (int): The tax period (1 to 12)
|
|
197
|
+
code (TaxCode): The tax code provided by HMRC
|
|
198
|
+
pay_to_date (Decimal): The pay received this tax year (including this period)
|
|
199
|
+
tax_to_date (Decimal): The tax paid this tax year (including this period)
|
|
200
|
+
pbik (Decimal): payrolled benefits in kind
|
|
201
|
+
|
|
202
|
+
Properties:
|
|
203
|
+
basic_pay (Decimal): The basic pay
|
|
204
|
+
income_tax (Decimal): The income tax deducted this period
|
|
205
|
+
pay_adjustment (Decimal): Adjustment to basic_pay
|
|
206
|
+
other_deductions (Decimal): Other deductions
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
payer_name: str
|
|
211
|
+
year: int
|
|
212
|
+
period: int
|
|
213
|
+
pay_date: datetime.date
|
|
214
|
+
code: TaxCode
|
|
215
|
+
pay_to_date: Decimal
|
|
216
|
+
tax_to_date: Decimal
|
|
217
|
+
pbik: Decimal = Decimal('0.00')
|
|
218
|
+
total_gross: Decimal = field(init=False, default=Decimal('0.00'))
|
|
219
|
+
total_deductions: Decimal = field(init=False, default=Decimal('0.00'))
|
|
220
|
+
net_pay: Decimal = field(init=False)
|
|
221
|
+
_basic_pay: Decimal = field(init=False)
|
|
222
|
+
_pay_adjustments: list[Decimal] = field(init=False, default_factory=list)
|
|
223
|
+
_income_tax: Decimal = field(init=False)
|
|
224
|
+
_other_deductions: list[Decimal] = field(init=False, default_factory=list)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def basic_pay(self) -> Decimal:
|
|
228
|
+
return self._basic_pay
|
|
229
|
+
|
|
230
|
+
@basic_pay.setter
|
|
231
|
+
def basic_pay(self, value: Decimal) -> None:
|
|
232
|
+
self._basic_pay = value
|
|
233
|
+
self.total_gross = self.basic_pay + sum(self.pay_adjustments)
|
|
234
|
+
self.net_pay = self.total_gross - self.total_deductions
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def pay_adjustments(self) -> list[Decimal]:
|
|
238
|
+
return self._pay_adjustments
|
|
239
|
+
|
|
240
|
+
@pay_adjustments.setter
|
|
241
|
+
def pay_adjustments(self, value: list[Decimal]) -> None:
|
|
242
|
+
self._pay_adjustments = value
|
|
243
|
+
self.total_gross = self.basic_pay + sum(self.pay_adjustments)
|
|
244
|
+
self.net_pay = self.total_gross - self.total_deductions
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def income_tax(self) -> Decimal:
|
|
248
|
+
return self._income_tax
|
|
249
|
+
|
|
250
|
+
@income_tax.setter
|
|
251
|
+
def income_tax(self, value: Decimal) -> None:
|
|
252
|
+
self._income_tax = value
|
|
253
|
+
self.total_deductions = self.income_tax + sum(self.other_deductions)
|
|
254
|
+
self.net_pay = self.total_gross - self.total_deductions
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def other_deductions(self) -> list[Decimal]:
|
|
258
|
+
return self._other_deductions
|
|
259
|
+
|
|
260
|
+
@other_deductions.setter
|
|
261
|
+
def other_deductions(self, value: list[Decimal]):
|
|
262
|
+
self._other_deductions = value
|
|
263
|
+
self.total_deductions = self.income_tax + sum(self.other_deductions)
|
|
264
|
+
self.net_pay = self.total_gross - self.total_deductions
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def uk_tax_period_start_date(tax_year: int, tax_period: int) -> datetime.date:
|
|
268
|
+
"""Return the start date of the tax_period in tax_year
|
|
269
|
+
tax_periods in the range 1 to 12
|
|
270
|
+
"""
|
|
271
|
+
q, r = divmod(tax_period+3, 12)
|
|
272
|
+
d = datetime.date(year=tax_year + q, month=r, day=6)
|
|
273
|
+
return d
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def str_to_decimal(amount: str) -> Decimal:
|
|
277
|
+
"""
|
|
278
|
+
Remove characters from 'amount' that are not allowed in the Decimal
|
|
279
|
+
constructor and return the result as a Decimal. Note: does not fully
|
|
280
|
+
implement the spec at:
|
|
281
|
+
https://docs.python.org/3/library/decimal.html#decimal.Decimal
|
|
282
|
+
"""
|
|
283
|
+
if amount == '#N/A':
|
|
284
|
+
return Decimal('NaN')
|
|
285
|
+
return Decimal(re.sub(r'[^+\-.0-9]', '', amount))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# noinspection PyPep8Naming
|
|
289
|
+
def __taxable_pay_to_date(
|
|
290
|
+
period: int,
|
|
291
|
+
code: TaxCode,
|
|
292
|
+
cumulative_pay_to_date: Decimal,
|
|
293
|
+
) -> Decimal:
|
|
294
|
+
# pylint: disable=invalid-name
|
|
295
|
+
"""Stage 2: Calculation of Taxable Pay to date (section 4.3)."""
|
|
296
|
+
if code.is_nt():
|
|
297
|
+
U_n = cumulative_pay_to_date
|
|
298
|
+
elif code.d_index() is not None:
|
|
299
|
+
U_n = cumulative_pay_to_date
|
|
300
|
+
else:
|
|
301
|
+
# Free pay or Additional pay for Month n
|
|
302
|
+
na_1 = code.free_pay_m1() * period
|
|
303
|
+
|
|
304
|
+
# 4.3.2 Calculation of Taxable Pay to date, U_n
|
|
305
|
+
U_n = cumulative_pay_to_date - na_1
|
|
306
|
+
|
|
307
|
+
return U_n
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# noinspection PyPep8Naming
|
|
311
|
+
def __tax_due_to_date(
|
|
312
|
+
year: int,
|
|
313
|
+
period: int,
|
|
314
|
+
code: TaxCode,
|
|
315
|
+
taxable_pay_to_date: Decimal,
|
|
316
|
+
) -> Decimal:
|
|
317
|
+
"""Stage 3: Calculation of tax due to date (section 4.4)."""
|
|
318
|
+
# pylint: disable=invalid-name
|
|
319
|
+
# 4.4.4 round down to nearest pound
|
|
320
|
+
# Added Decimal('0.00') to restore two decimal points
|
|
321
|
+
T_n = taxable_pay_to_date.quantize(
|
|
322
|
+
Decimal('0'), rounding=ROUND_FLOOR) + Decimal('0.00')
|
|
323
|
+
|
|
324
|
+
if code.is_nt():
|
|
325
|
+
L_n = Decimal('0.00')
|
|
326
|
+
elif taxable_pay_to_date <= 0:
|
|
327
|
+
# 4.3.3 No tax liability if Free Pay exceeds Taxable Pay
|
|
328
|
+
L_n = Decimal('0.00') # L_n, or Tax due to date is zero
|
|
329
|
+
elif code.is_br():
|
|
330
|
+
# Section 5.4, whole of rounded pay to date taxed at rate G
|
|
331
|
+
rate_pointer = CONSTANTS[year]['G']
|
|
332
|
+
L_n = T_n * CONSTANTS[year]['R'][rate_pointer]
|
|
333
|
+
elif code.d_index() is not None:
|
|
334
|
+
# Section 6: Whole of taxable pay taxed at Higher
|
|
335
|
+
# or Additional rate
|
|
336
|
+
rate_pointer = CONSTANTS[year]['G'] + 1 + code.d_index()
|
|
337
|
+
L_n = T_n * CONSTANTS[year]['R'][rate_pointer]
|
|
338
|
+
else:
|
|
339
|
+
# Threshold values, Definition 9
|
|
340
|
+
c = [C * period / 12 for C in CONSTANTS[year]['C']]
|
|
341
|
+
|
|
342
|
+
# Rounded threshold taxes, Definition 10
|
|
343
|
+
v = [item.quantize(Decimal('1'), rounding=ROUND_CEILING) for
|
|
344
|
+
item in c]
|
|
345
|
+
|
|
346
|
+
# Threshold taxes, Definition 11
|
|
347
|
+
k = [K * period / 12 for K in CONSTANTS[year]['K']]
|
|
348
|
+
|
|
349
|
+
if taxable_pay_to_date <= v[1]:
|
|
350
|
+
# Tax Formula 1
|
|
351
|
+
L_n = k[0] + (T_n - c[0]) * CONSTANTS[year]['R'][1]
|
|
352
|
+
elif taxable_pay_to_date <= v[2]:
|
|
353
|
+
# Tax Formula 2
|
|
354
|
+
L_n = k[1] + (T_n - c[1]) * CONSTANTS[year]['R'][2]
|
|
355
|
+
elif taxable_pay_to_date <= v[3]:
|
|
356
|
+
# Tax Formula 3
|
|
357
|
+
L_n = k[2] + (T_n - c[2]) * CONSTANTS[year]['R'][3]
|
|
358
|
+
else:
|
|
359
|
+
# Tax Formula x + 1
|
|
360
|
+
L_n = k[3] + (T_n - c[3]) * CONSTANTS[year]['R'][4]
|
|
361
|
+
|
|
362
|
+
L_n = L_n.quantize(Decimal('0.00'), rounding=ROUND_FLOOR)
|
|
363
|
+
|
|
364
|
+
return L_n
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# noinspection PyPep8Naming
|
|
368
|
+
def __tax_due_cumulative(
|
|
369
|
+
year: int,
|
|
370
|
+
period: int,
|
|
371
|
+
code: TaxCode,
|
|
372
|
+
p_n: Decimal,
|
|
373
|
+
P_n: Decimal,
|
|
374
|
+
L_n_1: Decimal,
|
|
375
|
+
pbik: Decimal,
|
|
376
|
+
) -> Decimal:
|
|
377
|
+
"""
|
|
378
|
+
Calculate the income tax due for cumulative suffix codes and
|
|
379
|
+
cumulative prefix k, - employees paid monthly.
|
|
380
|
+
"""
|
|
381
|
+
# pylint: disable=invalid-name
|
|
382
|
+
|
|
383
|
+
# 4.2 Stage 1 Calculation of Cumulative Pay to date is delegated
|
|
384
|
+
# to the calling function which provides P_n
|
|
385
|
+
|
|
386
|
+
# 4.3 Stage 2 Calculation of Taxable Pay to date U_n
|
|
387
|
+
U_n = __taxable_pay_to_date(period=period,
|
|
388
|
+
code=code,
|
|
389
|
+
cumulative_pay_to_date=P_n)
|
|
390
|
+
|
|
391
|
+
# 4.4 Stage 3 Calculation of tax due to date L_n
|
|
392
|
+
L_n = __tax_due_to_date(period=period,
|
|
393
|
+
code=code,
|
|
394
|
+
taxable_pay_to_date=U_n,
|
|
395
|
+
year=year)
|
|
396
|
+
|
|
397
|
+
# 4.5 Stage 4 Calculation of Tax Deduction or Refund
|
|
398
|
+
maxrate = CONSTANTS[year]['M'] * (p_n - pbik)
|
|
399
|
+
l_n = min(
|
|
400
|
+
L_n - L_n_1,
|
|
401
|
+
maxrate,
|
|
402
|
+
).quantize(Decimal('0.00'), rounding=ROUND_FLOOR)
|
|
403
|
+
|
|
404
|
+
return l_n
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# noinspection PyPep8Naming
|
|
408
|
+
def __tax_due_month1(
|
|
409
|
+
year: int,
|
|
410
|
+
code: TaxCode,
|
|
411
|
+
p_n: Decimal,
|
|
412
|
+
pbik: Decimal,
|
|
413
|
+
) -> Decimal:
|
|
414
|
+
"""
|
|
415
|
+
Calculate the tax due on a 'Month 1' basis.
|
|
416
|
+
|
|
417
|
+
Each payment is treated IN ISOLATION, as if it were the first
|
|
418
|
+
payment of the Income Tax year to be taxed on a normal suffix or
|
|
419
|
+
prefix K code.
|
|
420
|
+
"""
|
|
421
|
+
# pylint: disable=invalid-name
|
|
422
|
+
# Stage 1: Taxable pay for the month, section 8.2
|
|
423
|
+
U_n = p_n - code.free_pay_m1()
|
|
424
|
+
|
|
425
|
+
# Stage 2: Tax due, section 8.3
|
|
426
|
+
L_n = __tax_due_to_date(year=year,
|
|
427
|
+
period=1,
|
|
428
|
+
code=code,
|
|
429
|
+
taxable_pay_to_date=U_n)
|
|
430
|
+
l_n = L_n - 0
|
|
431
|
+
maxrate = CONSTANTS[year]['M'] * (p_n - pbik)
|
|
432
|
+
l_n = min(
|
|
433
|
+
l_n,
|
|
434
|
+
maxrate,
|
|
435
|
+
).quantize(Decimal('0.00'), rounding=ROUND_FLOOR)
|
|
436
|
+
return l_n
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def tax_due(
|
|
440
|
+
payslip: Payslip,
|
|
441
|
+
tax_to_date: Decimal
|
|
442
|
+
) -> Decimal | None:
|
|
443
|
+
"""Calculate the income tax due for employees paid monthly
|
|
444
|
+
:param payslip: populated with year, period, code, gross, gross to date and
|
|
445
|
+
benefits in kind if any
|
|
446
|
+
:param tax_to_date: Income tax paid up to, but not including, this payslip
|
|
447
|
+
:returns: Income tax due for the tax period
|
|
448
|
+
:raises: ValueError if HMRC constants aren't available
|
|
449
|
+
"""
|
|
450
|
+
# pylint: disable=invalid-name
|
|
451
|
+
global CONSTANTS
|
|
452
|
+
if not CONSTANTS:
|
|
453
|
+
CONSTANTS = constants_from_google_sheets()
|
|
454
|
+
|
|
455
|
+
if payslip.total_gross.is_nan():
|
|
456
|
+
return Decimal('NaN')
|
|
457
|
+
if payslip.year not in CONSTANTS:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"HMRC constants for year {payslip.year} is missing")
|
|
460
|
+
if payslip.code.is_cumulative():
|
|
461
|
+
return __tax_due_cumulative(
|
|
462
|
+
year=payslip.year,
|
|
463
|
+
period=payslip.period,
|
|
464
|
+
code=payslip.code,
|
|
465
|
+
p_n=payslip.total_gross,
|
|
466
|
+
P_n=payslip.pay_to_date,
|
|
467
|
+
L_n_1=tax_to_date,
|
|
468
|
+
pbik=payslip.pbik,
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
return __tax_due_month1(
|
|
472
|
+
year=payslip.year,
|
|
473
|
+
code=payslip.code,
|
|
474
|
+
p_n=payslip.total_gross,
|
|
475
|
+
pbik=payslip.pbik,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def constants_from_csv(
|
|
480
|
+
file_name: str = 'Income Prognosticator - HMRC & ONS Parameters.csv'
|
|
481
|
+
) -> dict[int, dict]:
|
|
482
|
+
"""
|
|
483
|
+
Obtains the HMRC constants by parsing a CSV file
|
|
484
|
+
|
|
485
|
+
As obtained by downloading from my Income Prognosticator Google
|
|
486
|
+
spreadsheet into the active directory.
|
|
487
|
+
"""
|
|
488
|
+
consts: dict[int, dict] = {}
|
|
489
|
+
fieldnames = (
|
|
490
|
+
'Tax year', 'B_1', 'B_2', 'B_3', 'C_1', 'C_2', 'C_3',
|
|
491
|
+
'K_1', 'K_2', 'K_3', 'R_1', 'R_2', 'R_3', 'R_4', 'G',
|
|
492
|
+
'M'
|
|
493
|
+
)
|
|
494
|
+
with open(file_name,
|
|
495
|
+
'r', encoding='utf-8') as csvfile:
|
|
496
|
+
reader = csv.DictReader(csvfile, fieldnames=fieldnames)
|
|
497
|
+
for _ in range(3):
|
|
498
|
+
# Skip the header rows
|
|
499
|
+
next(reader)
|
|
500
|
+
for row in reader:
|
|
501
|
+
if '#N/A' in row.values():
|
|
502
|
+
# Only process years when all the data are present
|
|
503
|
+
continue
|
|
504
|
+
tax_year = int(row['Tax year'][-4:])
|
|
505
|
+
|
|
506
|
+
consts[tax_year] = {
|
|
507
|
+
'B': (
|
|
508
|
+
Decimal('0'),
|
|
509
|
+
str_to_decimal(row['B_1']),
|
|
510
|
+
str_to_decimal(row['B_2']),
|
|
511
|
+
str_to_decimal(row['B_3']),
|
|
512
|
+
),
|
|
513
|
+
'C': (
|
|
514
|
+
Decimal('0'),
|
|
515
|
+
str_to_decimal(row['C_1']),
|
|
516
|
+
str_to_decimal(row['C_2']),
|
|
517
|
+
str_to_decimal(row['C_3']),
|
|
518
|
+
),
|
|
519
|
+
'K': (
|
|
520
|
+
Decimal('0'),
|
|
521
|
+
str_to_decimal(row['K_1']),
|
|
522
|
+
str_to_decimal(row['K_2']),
|
|
523
|
+
str_to_decimal(row['K_3']),
|
|
524
|
+
),
|
|
525
|
+
'R': (
|
|
526
|
+
Decimal('0'),
|
|
527
|
+
str_to_decimal(row['R_1']) / 100,
|
|
528
|
+
str_to_decimal(row['R_2']) / 100,
|
|
529
|
+
str_to_decimal(row['R_3']) / 100,
|
|
530
|
+
str_to_decimal(row['R_4']) / 100,
|
|
531
|
+
),
|
|
532
|
+
'G': int(row['G']),
|
|
533
|
+
'M': str_to_decimal(row['M']) / 100,
|
|
534
|
+
}
|
|
535
|
+
return consts
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def constants_from_google_sheets():
|
|
539
|
+
"""
|
|
540
|
+
Obtains the HMRC constants directly from Google Sheets
|
|
541
|
+
|
|
542
|
+
From my Income Prognosticator spreadsheet using the Sheets API.
|
|
543
|
+
"""
|
|
544
|
+
consts: dict[int, dict] = {}
|
|
545
|
+
|
|
546
|
+
creds = None
|
|
547
|
+
# The file token.json stores the user's access and refresh tokens,
|
|
548
|
+
# and is created automatically when the authorization flow completes
|
|
549
|
+
# for the first time.
|
|
550
|
+
if os.path.exists("token.json"):
|
|
551
|
+
creds = Credentials.from_authorized_user_file(
|
|
552
|
+
"token.json", SCOPES)
|
|
553
|
+
# If there are no (valid) credentials available,
|
|
554
|
+
# let the user log in.
|
|
555
|
+
if not creds or not creds.valid:
|
|
556
|
+
if creds and creds.expired and creds.refresh_token:
|
|
557
|
+
creds.refresh(Request())
|
|
558
|
+
else:
|
|
559
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
560
|
+
"credentials.json", SCOPES
|
|
561
|
+
)
|
|
562
|
+
creds = flow.run_local_server(port=0)
|
|
563
|
+
# Save the credentials for the next run
|
|
564
|
+
with open("token.json", "w") as token:
|
|
565
|
+
token.write(creds.to_json())
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
service = build("sheets", "v4", credentials=creds)
|
|
569
|
+
|
|
570
|
+
# Call the Sheets API
|
|
571
|
+
sheet = service.spreadsheets()
|
|
572
|
+
result = (
|
|
573
|
+
sheet.values()
|
|
574
|
+
.get(spreadsheetId=PROGNOSTICATOR_ID, range=HMRC_DATA)
|
|
575
|
+
.execute()
|
|
576
|
+
)
|
|
577
|
+
values = result.get("values", [])
|
|
578
|
+
|
|
579
|
+
if not values:
|
|
580
|
+
raise ValueError("No data found.")
|
|
581
|
+
|
|
582
|
+
for row in values:
|
|
583
|
+
if '#N/A' in row:
|
|
584
|
+
# Only process years when all the data are present
|
|
585
|
+
continue
|
|
586
|
+
tax_year = int(row[0][-4:])
|
|
587
|
+
|
|
588
|
+
consts[tax_year] = {
|
|
589
|
+
'B': (
|
|
590
|
+
Decimal('0'),
|
|
591
|
+
str_to_decimal(row[1]),
|
|
592
|
+
str_to_decimal(row[2]),
|
|
593
|
+
str_to_decimal(row[3]),
|
|
594
|
+
),
|
|
595
|
+
'C': (
|
|
596
|
+
Decimal('0'),
|
|
597
|
+
str_to_decimal(row[4]),
|
|
598
|
+
str_to_decimal(row[5]),
|
|
599
|
+
str_to_decimal(row[6]),
|
|
600
|
+
),
|
|
601
|
+
'K': (
|
|
602
|
+
Decimal('0'),
|
|
603
|
+
str_to_decimal(row[7]),
|
|
604
|
+
str_to_decimal(row[8]),
|
|
605
|
+
str_to_decimal(row[9]),
|
|
606
|
+
),
|
|
607
|
+
'R': (
|
|
608
|
+
Decimal('0'),
|
|
609
|
+
str_to_decimal(row[10]) / 100,
|
|
610
|
+
str_to_decimal(row[11]) / 100,
|
|
611
|
+
str_to_decimal(row[12]) / 100,
|
|
612
|
+
str_to_decimal(row[13]) / 100,
|
|
613
|
+
),
|
|
614
|
+
'G': int(row[14]),
|
|
615
|
+
'M': str_to_decimal(row[15]) / 100,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
except HttpError as err:
|
|
619
|
+
print(err)
|
|
620
|
+
|
|
621
|
+
return consts
|