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.
@@ -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*
@@ -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
+ )