ngtaxkit 0.0.1__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.
- ngtaxkit-0.0.1/.gitignore +42 -0
- ngtaxkit-0.0.1/PKG-INFO +28 -0
- ngtaxkit-0.0.1/package.json +13 -0
- ngtaxkit-0.0.1/pyproject.toml +48 -0
- ngtaxkit-0.0.1/src/ngtaxkit/__init__.py +17 -0
- ngtaxkit-0.0.1/src/ngtaxkit/errors.py +158 -0
- ngtaxkit-0.0.1/src/ngtaxkit/marketplace.py +90 -0
- ngtaxkit-0.0.1/src/ngtaxkit/paye.py +231 -0
- ngtaxkit-0.0.1/src/ngtaxkit/payroll.py +149 -0
- ngtaxkit-0.0.1/src/ngtaxkit/pension.py +72 -0
- ngtaxkit-0.0.1/src/ngtaxkit/py.typed +1 -0
- ngtaxkit-0.0.1/src/ngtaxkit/rates.py +121 -0
- ngtaxkit-0.0.1/src/ngtaxkit/statutory.py +112 -0
- ngtaxkit-0.0.1/src/ngtaxkit/types.py +241 -0
- ngtaxkit-0.0.1/src/ngtaxkit/utils.py +111 -0
- ngtaxkit-0.0.1/src/ngtaxkit/vat.py +157 -0
- ngtaxkit-0.0.1/src/ngtaxkit/wht.py +115 -0
- ngtaxkit-0.0.1/tests/__init__.py +1 -0
- ngtaxkit-0.0.1/tests/test_parity.py +403 -0
- ngtaxkit-0.0.1/tests/test_properties.py +256 -0
- ngtaxkit-0.0.1/tests/test_smoke.py +333 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Build outputs
|
|
5
|
+
dist/
|
|
6
|
+
.turbo/
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.pyc
|
|
11
|
+
*.pyo
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
build/
|
|
16
|
+
*.whl
|
|
17
|
+
.hypothesis/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# Environment
|
|
31
|
+
.env
|
|
32
|
+
.env.local
|
|
33
|
+
.env.*.local
|
|
34
|
+
|
|
35
|
+
# Coverage
|
|
36
|
+
coverage/
|
|
37
|
+
.nyc_output/
|
|
38
|
+
htmlcov/
|
|
39
|
+
|
|
40
|
+
# Logs
|
|
41
|
+
*.log
|
|
42
|
+
npm-debug.log*
|
ngtaxkit-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ngtaxkit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Nigerian tax compliance SDK — VAT, PAYE, WHT, Pension, Payroll, Invoicing. NTA 2025 compliant.
|
|
5
|
+
Project-URL: Homepage, https://ngtaxkit.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/mr-tanta/ngtaxkit
|
|
7
|
+
Project-URL: Issues, https://github.com/mr-tanta/ngtaxkit/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/mr-tanta/ngtaxkit/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Abraham Tanta <sir.tanta@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: compliance,fintech,nigeria,nta-2025,paye,payroll,pension,tax,vat,wht
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: cloud
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == 'cloud'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: hypothesis>=6.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: pdf
|
|
28
|
+
Requires-Dist: fpdf2>=2.7; extra == 'pdf'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ngtaxkit/python",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Python port of ngtaxkit — managed via Turborepo",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "echo 'Python build handled by hatchling'",
|
|
8
|
+
"test": "pytest",
|
|
9
|
+
"lint": "ruff check src/",
|
|
10
|
+
"type-check": "mypy --strict src/",
|
|
11
|
+
"clean": "rm -rf dist build *.egg-info"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ngtaxkit"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Nigerian tax compliance SDK — VAT, PAYE, WHT, Pension, Payroll, Invoicing. NTA 2025 compliant."
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Abraham Tanta", email = "sir.tanta@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
keywords = ["nigeria", "tax", "vat", "paye", "wht", "payroll", "pension", "compliance", "nta-2025", "fintech"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Office/Business :: Financial",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
cloud = ["httpx>=0.27"]
|
|
29
|
+
pdf = ["fpdf2>=2.7"]
|
|
30
|
+
dev = ["pytest>=8.0", "hypothesis>=6.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://ngtaxkit.dev"
|
|
34
|
+
Repository = "https://github.com/mr-tanta/ngtaxkit"
|
|
35
|
+
Issues = "https://github.com/mr-tanta/ngtaxkit/issues"
|
|
36
|
+
Changelog = "https://github.com/mr-tanta/ngtaxkit/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
strict = true
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/ngtaxkit"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ngtaxkit — Nigerian tax compliance SDK (Python port)."""
|
|
2
|
+
|
|
3
|
+
from . import errors, marketplace, paye, payroll, pension, rates, statutory, types, utils, vat, wht
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"errors",
|
|
7
|
+
"marketplace",
|
|
8
|
+
"paye",
|
|
9
|
+
"payroll",
|
|
10
|
+
"pension",
|
|
11
|
+
"rates",
|
|
12
|
+
"statutory",
|
|
13
|
+
"types",
|
|
14
|
+
"utils",
|
|
15
|
+
"vat",
|
|
16
|
+
"wht",
|
|
17
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Error system for ngtaxkit — structured, typed errors with legal citations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ─── Error Codes ──────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
INVALID_AMOUNT = "NGTK_INVALID_AMOUNT"
|
|
12
|
+
INVALID_CATEGORY = "NGTK_INVALID_CATEGORY"
|
|
13
|
+
INVALID_SERVICE_TYPE = "NGTK_INVALID_SERVICE_TYPE"
|
|
14
|
+
INVALID_STATE = "NGTK_INVALID_STATE"
|
|
15
|
+
INVALID_PENSION_RATE = "NGTK_INVALID_PENSION_RATE"
|
|
16
|
+
INVALID_TIN = "NGTK_INVALID_TIN"
|
|
17
|
+
INVALID_DATE = "NGTK_INVALID_DATE"
|
|
18
|
+
RATE_NOT_FOUND = "NGTK_RATE_NOT_FOUND"
|
|
19
|
+
VALIDATION_ERROR = "NGTK_VALIDATION_ERROR"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ─── Base Error ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NgtaxkitError(Exception):
|
|
26
|
+
"""Base error class for all ngtaxkit errors."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code: str, message: str, legal_basis: str | None = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.legal_basis = legal_basis
|
|
32
|
+
|
|
33
|
+
def to_json(self) -> dict[str, Any]:
|
|
34
|
+
result: dict[str, Any] = {
|
|
35
|
+
"name": type(self).__name__,
|
|
36
|
+
"code": self.code,
|
|
37
|
+
"message": str(self),
|
|
38
|
+
}
|
|
39
|
+
if self.legal_basis is not None:
|
|
40
|
+
result["legal_basis"] = self.legal_basis
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return super().__str__()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─── Subclasses ───────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InvalidAmountError(NgtaxkitError, ValueError):
|
|
51
|
+
"""Thrown when a monetary amount is invalid (e.g. negative)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str, legal_basis: str | None = None) -> None:
|
|
54
|
+
NgtaxkitError.__init__(self, INVALID_AMOUNT, message, legal_basis)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InvalidCategoryError(NgtaxkitError, ValueError):
|
|
58
|
+
"""Thrown when an unrecognised VAT category is provided."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
message: str,
|
|
63
|
+
valid_categories: list[str],
|
|
64
|
+
legal_basis: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
NgtaxkitError.__init__(self, INVALID_CATEGORY, message, legal_basis)
|
|
67
|
+
self.valid_categories = valid_categories
|
|
68
|
+
|
|
69
|
+
def to_json(self) -> dict[str, Any]:
|
|
70
|
+
result = super().to_json()
|
|
71
|
+
result["valid_categories"] = self.valid_categories
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidServiceTypeError(NgtaxkitError, ValueError):
|
|
76
|
+
"""Thrown when an unrecognised WHT service type is provided."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
message: str,
|
|
81
|
+
valid_service_types: list[str],
|
|
82
|
+
legal_basis: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
NgtaxkitError.__init__(self, INVALID_SERVICE_TYPE, message, legal_basis)
|
|
85
|
+
self.valid_service_types = valid_service_types
|
|
86
|
+
|
|
87
|
+
def to_json(self) -> dict[str, Any]:
|
|
88
|
+
result = super().to_json()
|
|
89
|
+
result["valid_service_types"] = self.valid_service_types
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InvalidStateError(NgtaxkitError, ValueError):
|
|
94
|
+
"""Thrown when an invalid Nigerian state code is provided."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
message: str,
|
|
99
|
+
valid_states: list[str],
|
|
100
|
+
legal_basis: str | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
NgtaxkitError.__init__(self, INVALID_STATE, message, legal_basis)
|
|
103
|
+
self.valid_states = valid_states
|
|
104
|
+
|
|
105
|
+
def to_json(self) -> dict[str, Any]:
|
|
106
|
+
result = super().to_json()
|
|
107
|
+
result["valid_states"] = self.valid_states
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class InvalidPensionRateError(NgtaxkitError, ValueError):
|
|
112
|
+
"""Thrown when a pension contribution rate is below the legal minimum."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, message: str, legal_basis: str | None = None) -> None:
|
|
115
|
+
NgtaxkitError.__init__(self, INVALID_PENSION_RATE, message, legal_basis)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class InvalidTinError(NgtaxkitError, ValueError):
|
|
119
|
+
"""Thrown when a TIN is malformed or invalid."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, message: str, legal_basis: str | None = None) -> None:
|
|
122
|
+
NgtaxkitError.__init__(self, INVALID_TIN, message, legal_basis)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class InvalidDateError(NgtaxkitError, ValueError):
|
|
126
|
+
"""Thrown when a date string is invalid or out of range."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, message: str, legal_basis: str | None = None) -> None:
|
|
129
|
+
NgtaxkitError.__init__(self, INVALID_DATE, message, legal_basis)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class RateNotFoundError(NgtaxkitError, KeyError):
|
|
133
|
+
"""Thrown when a rate lookup fails (key not found in the registry)."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, message: str, legal_basis: str | None = None) -> None:
|
|
136
|
+
NgtaxkitError.__init__(self, RATE_NOT_FOUND, message, legal_basis)
|
|
137
|
+
|
|
138
|
+
def __str__(self) -> str:
|
|
139
|
+
# KeyError wraps the message in quotes; override to match NgtaxkitError
|
|
140
|
+
return Exception.__str__(self)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ValidationError(NgtaxkitError, ValueError):
|
|
144
|
+
"""Thrown when one or more field-level validation errors occur."""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
message: str,
|
|
149
|
+
errors: list[dict[str, str]],
|
|
150
|
+
legal_basis: str | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
NgtaxkitError.__init__(self, VALIDATION_ERROR, message, legal_basis)
|
|
153
|
+
self.errors = errors
|
|
154
|
+
|
|
155
|
+
def to_json(self) -> dict[str, Any]:
|
|
156
|
+
result = super().to_json()
|
|
157
|
+
result["errors"] = self.errors
|
|
158
|
+
return result
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Marketplace Module — Pure-function marketplace transaction calculator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import vat as vat_module
|
|
6
|
+
from . import wht as wht_module
|
|
7
|
+
from .types import (
|
|
8
|
+
CommissionBreakdown,
|
|
9
|
+
MarketplaceResult,
|
|
10
|
+
TransactionBreakdown,
|
|
11
|
+
VatLiability,
|
|
12
|
+
)
|
|
13
|
+
from .utils import bankers_round
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def calculate_transaction(
|
|
17
|
+
sale_amount: float,
|
|
18
|
+
platform_commission: float,
|
|
19
|
+
seller_vat_registered: bool,
|
|
20
|
+
buyer_type: str = "individual",
|
|
21
|
+
service_category: str = "standard",
|
|
22
|
+
seller_tin: str | None = None,
|
|
23
|
+
platform_is_vat_agent: bool = False,
|
|
24
|
+
payment_date: str | None = None,
|
|
25
|
+
) -> MarketplaceResult:
|
|
26
|
+
"""Calculate the full tax breakdown for a marketplace buyer-seller transaction.
|
|
27
|
+
|
|
28
|
+
1. VAT is calculated on the full sale_amount (not just commission).
|
|
29
|
+
2. total_from_buyer = sale_amount + vat_amount.
|
|
30
|
+
3. commission_amount = bankers_round(sale_amount × platform_commission).
|
|
31
|
+
4. If seller is NOT VAT-registered, WHT is deducted from the seller payout.
|
|
32
|
+
5. seller_payout = sale_amount − commission_amount − wht_amount.
|
|
33
|
+
6. Balance invariant: total_from_buyer === seller_payout + commission + VAT + WHT
|
|
34
|
+
"""
|
|
35
|
+
# 1. Calculate VAT on the full sale amount
|
|
36
|
+
vat_result = vat_module.calculate(amount=sale_amount, category=service_category)
|
|
37
|
+
vat_amount = vat_result["vat"]
|
|
38
|
+
|
|
39
|
+
# 2. Total charged to buyer
|
|
40
|
+
total_from_buyer = vat_result["gross"]
|
|
41
|
+
|
|
42
|
+
# 3. Platform commission on the sale amount (pre-VAT)
|
|
43
|
+
commission_amount = bankers_round(sale_amount * platform_commission)
|
|
44
|
+
|
|
45
|
+
# 4. WHT: only when seller is NOT VAT-registered
|
|
46
|
+
wht_result = None
|
|
47
|
+
wht_amount = 0.0
|
|
48
|
+
if not seller_vat_registered:
|
|
49
|
+
wht_result = wht_module.calculate(
|
|
50
|
+
amount=sale_amount,
|
|
51
|
+
service_type="professional",
|
|
52
|
+
payee_type="individual",
|
|
53
|
+
payment_date=payment_date,
|
|
54
|
+
)
|
|
55
|
+
wht_amount = wht_result["wht_amount"]
|
|
56
|
+
|
|
57
|
+
# 5. Seller payout — computed as residual to guarantee the balance invariant
|
|
58
|
+
seller_payout = bankers_round(total_from_buyer - commission_amount - vat_amount - wht_amount)
|
|
59
|
+
|
|
60
|
+
# 6. VAT liability assignment
|
|
61
|
+
vat_collected_by: str = (
|
|
62
|
+
"seller" if seller_vat_registered and not platform_is_vat_agent else "platform"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return MarketplaceResult(
|
|
66
|
+
sale_amount=sale_amount,
|
|
67
|
+
vat=vat_result,
|
|
68
|
+
total_from_buyer=total_from_buyer,
|
|
69
|
+
platform_commission=CommissionBreakdown(
|
|
70
|
+
rate=platform_commission,
|
|
71
|
+
amount=commission_amount,
|
|
72
|
+
vat_on_commission=0.0,
|
|
73
|
+
net_commission=commission_amount,
|
|
74
|
+
),
|
|
75
|
+
seller_payout=seller_payout,
|
|
76
|
+
wht=wht_result,
|
|
77
|
+
vat_liability=VatLiability(
|
|
78
|
+
collected_by=vat_collected_by, # type: ignore[arg-type]
|
|
79
|
+
amount=vat_amount,
|
|
80
|
+
remitted_by=vat_collected_by, # type: ignore[arg-type]
|
|
81
|
+
),
|
|
82
|
+
breakdown=TransactionBreakdown(
|
|
83
|
+
sale_amount=sale_amount,
|
|
84
|
+
vat_amount=vat_amount,
|
|
85
|
+
commission_amount=commission_amount,
|
|
86
|
+
wht_amount=wht_amount,
|
|
87
|
+
seller_payout=seller_payout,
|
|
88
|
+
total_from_buyer=total_from_buyer,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""PAYE Module — Pure-function PAYE calculation engine per NTA 2025."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import InvalidAmountError
|
|
6
|
+
from .rates import get
|
|
7
|
+
from .types import (
|
|
8
|
+
EmployerCosts,
|
|
9
|
+
MonthlyDeductions,
|
|
10
|
+
PayeResult,
|
|
11
|
+
PensionContributions,
|
|
12
|
+
ReliefBreakdown,
|
|
13
|
+
TaxBand,
|
|
14
|
+
)
|
|
15
|
+
from .utils import bankers_round
|
|
16
|
+
|
|
17
|
+
# ─── Internal Helpers ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_brackets() -> list[dict]:
|
|
21
|
+
"""Load PAYE brackets from the rates registry."""
|
|
22
|
+
bands = get("paye.bands")
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
"lower": b["lower"],
|
|
26
|
+
"upper": b["upper"],
|
|
27
|
+
"rate": b["rate"],
|
|
28
|
+
}
|
|
29
|
+
for b in bands # type: ignore[union-attr]
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _apply_bands(taxable_income: float, brackets: list[dict]) -> list[TaxBand]:
|
|
34
|
+
"""Apply graduated tax bands to taxable income and return per-band breakdown."""
|
|
35
|
+
result: list[TaxBand] = []
|
|
36
|
+
for band in brackets:
|
|
37
|
+
upper = band["upper"] if band["upper"] is not None else float("inf")
|
|
38
|
+
income_in_band = max(0.0, min(taxable_income, upper) - band["lower"])
|
|
39
|
+
tax_in_band = bankers_round(income_in_band * band["rate"])
|
|
40
|
+
result.append(
|
|
41
|
+
TaxBand(
|
|
42
|
+
lower=band["lower"],
|
|
43
|
+
upper=upper,
|
|
44
|
+
rate=band["rate"],
|
|
45
|
+
tax_in_band=tax_in_band,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── Public API ───────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def calculate(
|
|
55
|
+
gross_annual: float,
|
|
56
|
+
pension_contributing: bool = False,
|
|
57
|
+
nhf_contributing: bool = False,
|
|
58
|
+
rent_paid_annual: float = 0.0,
|
|
59
|
+
disability_status: bool = False,
|
|
60
|
+
tax_year: int | None = None,
|
|
61
|
+
) -> PayeResult:
|
|
62
|
+
"""Calculate PAYE for a given gross annual income."""
|
|
63
|
+
if gross_annual < 0:
|
|
64
|
+
raise InvalidAmountError(
|
|
65
|
+
f"Gross annual income must be non-negative, received {gross_annual}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
exemption_threshold: float = get("paye.exemptionThreshold") # type: ignore[assignment]
|
|
69
|
+
legal_basis: str = get("paye.legalBasis") # type: ignore[assignment]
|
|
70
|
+
brackets = _load_brackets()
|
|
71
|
+
|
|
72
|
+
# ── Exemption check ──
|
|
73
|
+
if gross_annual <= exemption_threshold:
|
|
74
|
+
gross_monthly = bankers_round(gross_annual / 12)
|
|
75
|
+
return PayeResult(
|
|
76
|
+
gross_annual=gross_annual,
|
|
77
|
+
gross_monthly=gross_monthly,
|
|
78
|
+
pension=PensionContributions(employee=0.0, employer=0.0),
|
|
79
|
+
nhf=0.0,
|
|
80
|
+
reliefs=ReliefBreakdown(
|
|
81
|
+
consolidated_relief=0.0,
|
|
82
|
+
rent_relief=0.0,
|
|
83
|
+
pension_relief=0.0,
|
|
84
|
+
nhf_relief=0.0,
|
|
85
|
+
total=0.0,
|
|
86
|
+
),
|
|
87
|
+
taxable_income=0.0,
|
|
88
|
+
tax_bands=_apply_bands(0.0, brackets),
|
|
89
|
+
annual_paye=0.0,
|
|
90
|
+
monthly_paye=0.0,
|
|
91
|
+
effective_rate=0.0,
|
|
92
|
+
exempt=True,
|
|
93
|
+
exemption_basis=get("paye.exemptionBasis"), # type: ignore[arg-type]
|
|
94
|
+
net_monthly=gross_monthly,
|
|
95
|
+
monthly_deductions=MonthlyDeductions(paye=0.0, pension=0.0, nhf=0.0, total=0.0),
|
|
96
|
+
employer_costs=EmployerCosts(pension=0.0, nsitf=0.0, itf=0.0, total=0.0),
|
|
97
|
+
legal_basis=legal_basis,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# ── Reliefs ──
|
|
101
|
+
reliefs = calculate_relief(
|
|
102
|
+
gross_annual=gross_annual,
|
|
103
|
+
pension_contributing=pension_contributing,
|
|
104
|
+
nhf_contributing=nhf_contributing,
|
|
105
|
+
rent_paid_annual=rent_paid_annual,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# ── Taxable income ──
|
|
109
|
+
taxable_income = bankers_round(max(0.0, gross_annual - reliefs["total"]))
|
|
110
|
+
|
|
111
|
+
# ── Apply graduated bands ──
|
|
112
|
+
tax_bands = _apply_bands(taxable_income, brackets)
|
|
113
|
+
annual_paye = bankers_round(sum(b["tax_in_band"] for b in tax_bands))
|
|
114
|
+
|
|
115
|
+
# ── Monthly values ──
|
|
116
|
+
gross_monthly = bankers_round(gross_annual / 12)
|
|
117
|
+
monthly_paye = bankers_round(annual_paye / 12)
|
|
118
|
+
|
|
119
|
+
# ── Effective rate (4dp) ──
|
|
120
|
+
effective_rate = round(annual_paye / gross_annual, 4) if gross_annual > 0 else 0.0
|
|
121
|
+
|
|
122
|
+
# ── Pension & NHF amounts ──
|
|
123
|
+
min_employee_rate: float = get("pension.minimumRates.employee") # type: ignore[assignment]
|
|
124
|
+
min_employer_rate: float = get("pension.minimumRates.employer") # type: ignore[assignment]
|
|
125
|
+
nhf_rate: float = get("statutory.nhf.rate") # type: ignore[assignment]
|
|
126
|
+
|
|
127
|
+
employee_pension = bankers_round(gross_annual * min_employee_rate) if pension_contributing else 0.0
|
|
128
|
+
employer_pension = bankers_round(gross_annual * min_employer_rate) if pension_contributing else 0.0
|
|
129
|
+
nhf_amount = bankers_round(gross_annual * nhf_rate) if nhf_contributing else 0.0
|
|
130
|
+
|
|
131
|
+
# ── Monthly deductions ──
|
|
132
|
+
monthly_employee_pension = bankers_round(employee_pension / 12)
|
|
133
|
+
monthly_nhf = bankers_round(nhf_amount / 12)
|
|
134
|
+
total_monthly_deductions = bankers_round(monthly_paye + monthly_employee_pension + monthly_nhf)
|
|
135
|
+
monthly_deductions = MonthlyDeductions(
|
|
136
|
+
paye=monthly_paye,
|
|
137
|
+
pension=monthly_employee_pension,
|
|
138
|
+
nhf=monthly_nhf,
|
|
139
|
+
total=total_monthly_deductions,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# ── Net monthly ──
|
|
143
|
+
net_monthly = bankers_round(gross_monthly - total_monthly_deductions)
|
|
144
|
+
|
|
145
|
+
# ── Employer costs ──
|
|
146
|
+
monthly_employer_pension = bankers_round(employer_pension / 12)
|
|
147
|
+
nsitf_rate: float = get("statutory.nsitf.rate") # type: ignore[assignment]
|
|
148
|
+
itf_rate: float = get("statutory.itf.rate") # type: ignore[assignment]
|
|
149
|
+
monthly_nsitf = bankers_round(gross_monthly * nsitf_rate)
|
|
150
|
+
monthly_itf = bankers_round(gross_monthly * itf_rate)
|
|
151
|
+
employer_costs = EmployerCosts(
|
|
152
|
+
pension=monthly_employer_pension,
|
|
153
|
+
nsitf=monthly_nsitf,
|
|
154
|
+
itf=monthly_itf,
|
|
155
|
+
total=bankers_round(monthly_employer_pension + monthly_nsitf + monthly_itf),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return PayeResult(
|
|
159
|
+
gross_annual=gross_annual,
|
|
160
|
+
gross_monthly=gross_monthly,
|
|
161
|
+
pension=PensionContributions(employee=employee_pension, employer=employer_pension),
|
|
162
|
+
nhf=nhf_amount,
|
|
163
|
+
reliefs=reliefs,
|
|
164
|
+
taxable_income=taxable_income,
|
|
165
|
+
tax_bands=tax_bands,
|
|
166
|
+
annual_paye=annual_paye,
|
|
167
|
+
monthly_paye=monthly_paye,
|
|
168
|
+
effective_rate=effective_rate,
|
|
169
|
+
exempt=False,
|
|
170
|
+
exemption_basis=None,
|
|
171
|
+
net_monthly=net_monthly,
|
|
172
|
+
monthly_deductions=monthly_deductions,
|
|
173
|
+
employer_costs=employer_costs,
|
|
174
|
+
legal_basis=legal_basis,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def is_exempt(gross_annual: float, tax_year: int | None = None) -> bool:
|
|
179
|
+
"""Check if a gross annual income is exempt from PAYE."""
|
|
180
|
+
threshold: float = get("paye.exemptionThreshold") # type: ignore[assignment]
|
|
181
|
+
return gross_annual <= threshold
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_brackets(tax_year: int | None = None) -> list[dict]:
|
|
185
|
+
"""Get the PAYE graduated tax brackets for a given tax year."""
|
|
186
|
+
return _load_brackets()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def calculate_relief(
|
|
190
|
+
gross_annual: float,
|
|
191
|
+
pension_contributing: bool = False,
|
|
192
|
+
nhf_contributing: bool = False,
|
|
193
|
+
rent_paid_annual: float = 0.0,
|
|
194
|
+
disability_status: bool = False,
|
|
195
|
+
tax_year: int | None = None,
|
|
196
|
+
) -> ReliefBreakdown:
|
|
197
|
+
"""Calculate all PAYE reliefs for the given options."""
|
|
198
|
+
# CRA: max(₦200K, 1% of gross) + 20% of gross
|
|
199
|
+
cra_fixed: float = get("paye.cra.fixedAmount") # type: ignore[assignment]
|
|
200
|
+
cra_percent: float = get("paye.cra.percentOfGross") # type: ignore[assignment]
|
|
201
|
+
cra_additional: float = get("paye.cra.additionalPercentOfGross") # type: ignore[assignment]
|
|
202
|
+
consolidated_relief = bankers_round(
|
|
203
|
+
max(cra_fixed, gross_annual * cra_percent) + gross_annual * cra_additional
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Pension relief: 8% of gross (if contributing)
|
|
207
|
+
min_employee_rate: float = get("pension.minimumRates.employee") # type: ignore[assignment]
|
|
208
|
+
pension_relief = bankers_round(gross_annual * min_employee_rate) if pension_contributing else 0.0
|
|
209
|
+
|
|
210
|
+
# NHF relief: 2.5% of gross (if contributing)
|
|
211
|
+
nhf_rate: float = get("statutory.nhf.rate") # type: ignore[assignment]
|
|
212
|
+
nhf_relief = bankers_round(gross_annual * nhf_rate) if nhf_contributing else 0.0
|
|
213
|
+
|
|
214
|
+
# Rent relief: 20% of rent paid, capped at ₦500K
|
|
215
|
+
rent_relief_rate: float = get("paye.rentRelief.rate") # type: ignore[assignment]
|
|
216
|
+
rent_relief_cap: float = get("paye.rentRelief.cap") # type: ignore[assignment]
|
|
217
|
+
rent_relief = (
|
|
218
|
+
bankers_round(min(rent_paid_annual * rent_relief_rate, rent_relief_cap))
|
|
219
|
+
if rent_paid_annual > 0
|
|
220
|
+
else 0.0
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
total = bankers_round(consolidated_relief + pension_relief + nhf_relief + rent_relief)
|
|
224
|
+
|
|
225
|
+
return ReliefBreakdown(
|
|
226
|
+
consolidated_relief=consolidated_relief,
|
|
227
|
+
rent_relief=rent_relief,
|
|
228
|
+
pension_relief=pension_relief,
|
|
229
|
+
nhf_relief=nhf_relief,
|
|
230
|
+
total=total,
|
|
231
|
+
)
|