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
566 lines
18 KiB
Python
566 lines
18 KiB
Python
# 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"])
|