# 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"])