Initial commit
Some checks failed
CI/CD Pipeline / Code Quality & Linting (push) Has been cancelled
CI/CD Pipeline / Policy Validation (push) Has been cancelled
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-firm-connectors) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-forms) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-hmrc) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ingestion) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-normalize-map) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-ocr) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-indexer) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-reason) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (svc-rpa) (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (ui-review) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-coverage) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-extract) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-kg) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (svc-rag-retriever) (push) Has been cancelled
CI/CD Pipeline / Security Scanning (ui-review) (push) Has been cancelled
CI/CD Pipeline / Generate SBOM (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Notifications (push) Has been cancelled

This commit is contained in:
harkon
2025-10-11 08:41:36 +01:00
commit b324ff09ef
276 changed files with 55220 additions and 0 deletions

View File

@@ -0,0 +1,565 @@
# FILE: tests/unit/test_calculators.py
# Unit tests for tax calculation logic
import os
import sys
from decimal import Decimal
from typing import Any
import pytest
# Add libs to path for testing
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "libs"))
# Mock the calculation functions since they're in the service
# In a real implementation, these would be extracted to shared libs
# pylint: disable=wrong-import-position,import-error,too-few-public-methods,global-statement
# pylint: disable=raise-missing-from,unused-argument,too-many-arguments,too-many-positional-arguments
# pylint: disable=too-many-locals,import-outside-toplevel
# mypy: disable-error-code=union-attr
# mypy: disable-error-code=no-untyped-def
class MockTaxCalculator:
"""Mock tax calculator for testing"""
def __init__(self, tax_year: str = "2023-24"):
self.tax_year = tax_year
self.precision = 2
def compute_sa103_self_employment(
self, income_items: list[dict[str, Any]], expense_items: list[dict[str, Any]]
) -> dict[str, Any]:
"""Compute SA103 self-employment schedule"""
total_turnover = Decimal("0")
total_expenses = Decimal("0")
evidence_trail = []
# Sum income
for income in income_items:
if income.get("type") == "self_employment":
amount = Decimal(str(income.get("gross", 0)))
total_turnover += amount
evidence_trail.append(
{
"box": "20",
"source_entity": income.get("income_id"),
"amount": float(amount),
"description": f"Income: {income.get('description', 'Unknown')}",
}
)
# Sum expenses
for expense in expense_items:
if expense.get("allowable", True):
amount = Decimal(str(expense.get("amount", 0)))
total_expenses += amount
evidence_trail.append(
{
"box": "31",
"source_entity": expense.get("expense_id"),
"amount": float(amount),
"description": f"Expense: {expense.get('description', 'Unknown')}",
}
)
# Calculate net profit
net_profit = total_turnover - total_expenses
# Create form boxes
form_boxes = {
"20": {
"value": float(total_turnover),
"description": "Total turnover",
"confidence": 0.9,
},
"31": {
"value": float(total_expenses),
"description": "Total allowable business expenses",
"confidence": 0.9,
},
"32": {
"value": float(net_profit),
"description": "Net profit",
"confidence": 0.9,
},
}
return {
"form_boxes": form_boxes,
"evidence_trail": evidence_trail,
"total_turnover": float(total_turnover),
"total_expenses": float(total_expenses),
"net_profit": float(net_profit),
}
def compute_sa105_property(
self, income_items: list[dict[str, Any]], expense_items: list[dict[str, Any]]
) -> dict[str, Any]:
"""Compute SA105 property income schedule"""
total_rents = Decimal("0")
total_property_expenses = Decimal("0")
evidence_trail = []
# Sum property income
for income in income_items:
if income.get("type") == "property":
amount = Decimal(str(income.get("gross", 0)))
total_rents += amount
evidence_trail.append(
{
"box": "20",
"source_entity": income.get("income_id"),
"amount": float(amount),
"description": f"Property income: {income.get('description', 'Unknown')}",
}
)
# Sum property expenses
for expense in expense_items:
if expense.get("type") == "property" and expense.get("allowable", True):
amount = Decimal(str(expense.get("amount", 0)))
total_property_expenses += amount
# Map to appropriate SA105 box based on expense category
box = self._map_property_expense_to_box(
expense.get("category", "other")
)
evidence_trail.append(
{
"box": box,
"source_entity": expense.get("expense_id"),
"amount": float(amount),
"description": f"Property expense: {expense.get('description', 'Unknown')}",
}
)
# Calculate net property income
net_property_income = total_rents - total_property_expenses
form_boxes = {
"20": {
"value": float(total_rents),
"description": "Total rents and other income",
"confidence": 0.9,
},
"38": {
"value": float(total_property_expenses),
"description": "Total property expenses",
"confidence": 0.9,
},
"net_income": {
"value": float(net_property_income),
"description": "Net property income",
"confidence": 0.9,
},
}
return {
"form_boxes": form_boxes,
"evidence_trail": evidence_trail,
"total_rents": float(total_rents),
"total_expenses": float(total_property_expenses),
"net_income": float(net_property_income),
}
def _map_property_expense_to_box(self, category: str) -> str:
"""Map property expense category to SA105 box"""
mapping = {
"rent_rates_insurance": "31",
"property_management": "32",
"services_wages": "33",
"repairs_maintenance": "34",
"finance_costs": "35",
"professional_fees": "36",
"costs_of_services": "37",
"other": "38",
}
return mapping.get(category, "38")
class TestSA103SelfEmployment:
"""Test SA103 self-employment calculations"""
@pytest.fixture
def calculator(self):
return MockTaxCalculator("2023-24")
@pytest.fixture
def sample_income_items(self):
return [
{
"income_id": "income_1",
"type": "self_employment",
"gross": 75000,
"description": "Consulting income",
},
{
"income_id": "income_2",
"type": "self_employment",
"gross": 25000,
"description": "Training income",
},
]
@pytest.fixture
def sample_expense_items(self):
return [
{
"expense_id": "expense_1",
"type": "self_employment",
"amount": 5000,
"allowable": True,
"description": "Office rent",
},
{
"expense_id": "expense_2",
"type": "self_employment",
"amount": 2000,
"allowable": True,
"description": "Equipment",
},
{
"expense_id": "expense_3",
"type": "self_employment",
"amount": 1000,
"allowable": False,
"description": "Entertainment (not allowable)",
},
]
def test_basic_calculation(
self, calculator, sample_income_items, sample_expense_items
):
"""Test basic SA103 calculation"""
result = calculator.compute_sa103_self_employment(
sample_income_items, sample_expense_items
)
# Check totals
assert result["total_turnover"] == 100000 # 75000 + 25000
assert result["total_expenses"] == 7000 # 5000 + 2000 (excluding non-allowable)
assert result["net_profit"] == 93000 # 100000 - 7000
# Check form boxes
form_boxes = result["form_boxes"]
assert form_boxes["20"]["value"] == 100000
assert form_boxes["31"]["value"] == 7000
assert form_boxes["32"]["value"] == 93000
# Check evidence trail
evidence_trail = result["evidence_trail"]
assert len(evidence_trail) == 4 # 2 income + 2 allowable expenses
def test_zero_income(self, calculator):
"""Test calculation with zero income"""
result = calculator.compute_sa103_self_employment([], [])
assert result["total_turnover"] == 0
assert result["total_expenses"] == 0
assert result["net_profit"] == 0
form_boxes = result["form_boxes"]
assert form_boxes["20"]["value"] == 0
assert form_boxes["31"]["value"] == 0
assert form_boxes["32"]["value"] == 0
def test_loss_scenario(self, calculator):
"""Test calculation resulting in a loss"""
income_items = [
{
"income_id": "income_1",
"type": "self_employment",
"gross": 10000,
"description": "Low income year",
}
]
expense_items = [
{
"expense_id": "expense_1",
"type": "self_employment",
"amount": 15000,
"allowable": True,
"description": "High expenses",
}
]
result = calculator.compute_sa103_self_employment(income_items, expense_items)
assert result["total_turnover"] == 10000
assert result["total_expenses"] == 15000
assert result["net_profit"] == -5000 # Loss
form_boxes = result["form_boxes"]
assert form_boxes["32"]["value"] == -5000
def test_non_allowable_expenses_excluded(self, calculator, sample_income_items):
"""Test that non-allowable expenses are excluded"""
expense_items = [
{
"expense_id": "expense_1",
"type": "self_employment",
"amount": 5000,
"allowable": True,
"description": "Allowable expense",
},
{
"expense_id": "expense_2",
"type": "self_employment",
"amount": 3000,
"allowable": False,
"description": "Non-allowable expense",
},
]
result = calculator.compute_sa103_self_employment(
sample_income_items, expense_items
)
# Only allowable expenses should be included
assert result["total_expenses"] == 5000
# Evidence trail should only include allowable expenses
expense_evidence = [e for e in result["evidence_trail"] if e["box"] == "31"]
assert len(expense_evidence) == 1
assert expense_evidence[0]["amount"] == 5000
class TestSA105Property:
"""Test SA105 property income calculations"""
@pytest.fixture
def calculator(self):
return MockTaxCalculator("2023-24")
@pytest.fixture
def sample_property_income(self):
return [
{
"income_id": "prop_income_1",
"type": "property",
"gross": 24000,
"description": "Rental income - Property 1",
},
{
"income_id": "prop_income_2",
"type": "property",
"gross": 18000,
"description": "Rental income - Property 2",
},
]
@pytest.fixture
def sample_property_expenses(self):
return [
{
"expense_id": "prop_expense_1",
"type": "property",
"amount": 3000,
"allowable": True,
"category": "rent_rates_insurance",
"description": "Insurance and rates",
},
{
"expense_id": "prop_expense_2",
"type": "property",
"amount": 2000,
"allowable": True,
"category": "repairs_maintenance",
"description": "Repairs and maintenance",
},
{
"expense_id": "prop_expense_3",
"type": "property",
"amount": 1500,
"allowable": True,
"category": "property_management",
"description": "Property management fees",
},
]
def test_basic_property_calculation(
self, calculator, sample_property_income, sample_property_expenses
):
"""Test basic SA105 property calculation"""
result = calculator.compute_sa105_property(
sample_property_income, sample_property_expenses
)
# Check totals
assert result["total_rents"] == 42000 # 24000 + 18000
assert result["total_expenses"] == 6500 # 3000 + 2000 + 1500
assert result["net_income"] == 35500 # 42000 - 6500
# Check form boxes
form_boxes = result["form_boxes"]
assert form_boxes["20"]["value"] == 42000
assert form_boxes["38"]["value"] == 6500
assert form_boxes["net_income"]["value"] == 35500
def test_property_expense_mapping(self, calculator):
"""Test property expense category mapping to form boxes"""
# Test different expense categories
test_cases = [
("rent_rates_insurance", "31"),
("property_management", "32"),
("services_wages", "33"),
("repairs_maintenance", "34"),
("finance_costs", "35"),
("professional_fees", "36"),
("costs_of_services", "37"),
("other", "38"),
("unknown_category", "38"), # Should default to 38
]
for category, expected_box in test_cases:
actual_box = calculator._map_property_expense_to_box(category)
assert (
actual_box == expected_box
), f"Category {category} should map to box {expected_box}"
def test_property_loss(self, calculator):
"""Test property calculation resulting in a loss"""
income_items = [
{
"income_id": "prop_income_1",
"type": "property",
"gross": 12000,
"description": "Low rental income",
}
]
expense_items = [
{
"expense_id": "prop_expense_1",
"type": "property",
"amount": 15000,
"allowable": True,
"category": "repairs_maintenance",
"description": "Major repairs",
}
]
result = calculator.compute_sa105_property(income_items, expense_items)
assert result["total_rents"] == 12000
assert result["total_expenses"] == 15000
assert result["net_income"] == -3000 # Loss
form_boxes = result["form_boxes"]
assert form_boxes["net_income"]["value"] == -3000
class TestCalculationEdgeCases:
"""Test edge cases and error conditions"""
@pytest.fixture
def calculator(self):
return MockTaxCalculator("2023-24")
def test_decimal_precision(self, calculator):
"""Test decimal precision handling"""
income_items = [
{
"income_id": "income_1",
"type": "self_employment",
"gross": 33333.33,
"description": "Precise income",
}
]
expense_items = [
{
"expense_id": "expense_1",
"type": "self_employment",
"amount": 11111.11,
"allowable": True,
"description": "Precise expense",
}
]
result = calculator.compute_sa103_self_employment(income_items, expense_items)
# Check that calculations maintain precision
assert result["total_turnover"] == 33333.33
assert result["total_expenses"] == 11111.11
assert result["net_profit"] == 22222.22
def test_string_amounts(self, calculator):
"""Test handling of string amounts"""
income_items = [
{
"income_id": "income_1",
"type": "self_employment",
"gross": "50000.00", # String amount
"description": "String income",
}
]
expense_items = [
{
"expense_id": "expense_1",
"type": "self_employment",
"amount": "10000.00", # String amount
"allowable": True,
"description": "String expense",
}
]
result = calculator.compute_sa103_self_employment(income_items, expense_items)
assert result["total_turnover"] == 50000.0
assert result["total_expenses"] == 10000.0
assert result["net_profit"] == 40000.0
def test_missing_fields(self, calculator):
"""Test handling of missing fields"""
income_items = [
{
"income_id": "income_1",
"type": "self_employment",
# Missing 'gross' field
"description": "Income without amount",
}
]
expense_items = [
{
"expense_id": "expense_1",
"type": "self_employment",
# Missing 'amount' field
"allowable": True,
"description": "Expense without amount",
}
]
result = calculator.compute_sa103_self_employment(income_items, expense_items)
# Should handle missing fields gracefully
assert result["total_turnover"] == 0
assert result["total_expenses"] == 0
assert result["net_profit"] == 0
if __name__ == "__main__":
# Run the tests
pytest.main([__file__, "-v"])