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 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,2 @@
1
+ # paye
2
+ Partial implementation of UK Income Tax Pay-As-You-Earn algorithms
@@ -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"
@@ -0,0 +1,4 @@
1
+
2
+ google-api-python-client
3
+ google-auth-httplib2
4
+ google-auth-oauthlib
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