# tests/unit/services/test_invoice_service.py """Unit tests for InvoiceService.""" import uuid from decimal import Decimal import pytest from app.exceptions import ValidationException from app.exceptions.invoice import ( InvoiceNotFoundException, InvoiceSettingsNotFoundException, ) from app.services.invoice_service import ( EU_VAT_RATES, InvoiceService, LU_VAT_RATES, ) from models.database.invoice import ( Invoice, InvoiceStatus, VATRegime, VendorInvoiceSettings, ) from models.schema.invoice import ( VendorInvoiceSettingsCreate, VendorInvoiceSettingsUpdate, ) @pytest.mark.unit @pytest.mark.invoice class TestInvoiceServiceVATCalculation: """Test suite for InvoiceService VAT calculation methods.""" def setup_method(self): """Initialize service instance before each test.""" self.service = InvoiceService() # ==================== VAT Rate Lookup Tests ==================== def test_get_vat_rate_for_luxembourg(self): """Test Luxembourg VAT rate is 17%.""" rate = self.service.get_vat_rate_for_country("LU") assert rate == Decimal("17.00") def test_get_vat_rate_for_germany(self): """Test Germany VAT rate is 19%.""" rate = self.service.get_vat_rate_for_country("DE") assert rate == Decimal("19.00") def test_get_vat_rate_for_france(self): """Test France VAT rate is 20%.""" rate = self.service.get_vat_rate_for_country("FR") assert rate == Decimal("20.00") def test_get_vat_rate_for_non_eu_country(self): """Test non-EU country returns 0% VAT.""" rate = self.service.get_vat_rate_for_country("US") assert rate == Decimal("0.00") def test_get_vat_rate_lowercase_country(self): """Test VAT rate lookup works with lowercase country codes.""" rate = self.service.get_vat_rate_for_country("de") assert rate == Decimal("19.00") # ==================== VAT Rate Label Tests ==================== def test_get_vat_rate_label_luxembourg(self): """Test VAT rate label for Luxembourg.""" label = self.service.get_vat_rate_label("LU", Decimal("17.00")) assert "Luxembourg" in label assert "17" in label def test_get_vat_rate_label_germany(self): """Test VAT rate label for Germany.""" label = self.service.get_vat_rate_label("DE", Decimal("19.00")) assert "Germany" in label assert "19" in label # ==================== VAT Regime Determination Tests ==================== def test_determine_vat_regime_domestic(self): """Test domestic sales (same country) use domestic VAT.""" regime, rate, dest = self.service.determine_vat_regime( seller_country="LU", buyer_country="LU", buyer_vat_number=None, seller_oss_registered=False, ) assert regime == VATRegime.DOMESTIC assert rate == Decimal("17.00") assert dest is None def test_determine_vat_regime_reverse_charge(self): """Test B2B with valid VAT number uses reverse charge.""" regime, rate, dest = self.service.determine_vat_regime( seller_country="LU", buyer_country="DE", buyer_vat_number="DE123456789", seller_oss_registered=False, ) assert regime == VATRegime.REVERSE_CHARGE assert rate == Decimal("0.00") assert dest == "DE" def test_determine_vat_regime_oss_registered(self): """Test B2C cross-border with OSS uses destination VAT.""" regime, rate, dest = self.service.determine_vat_regime( seller_country="LU", buyer_country="DE", buyer_vat_number=None, seller_oss_registered=True, ) assert regime == VATRegime.OSS assert rate == Decimal("19.00") # German VAT assert dest == "DE" def test_determine_vat_regime_no_oss(self): """Test B2C cross-border without OSS uses origin VAT.""" regime, rate, dest = self.service.determine_vat_regime( seller_country="LU", buyer_country="DE", buyer_vat_number=None, seller_oss_registered=False, ) assert regime == VATRegime.ORIGIN assert rate == Decimal("17.00") # Luxembourg VAT assert dest == "DE" def test_determine_vat_regime_non_eu_exempt(self): """Test non-EU sales are VAT exempt.""" regime, rate, dest = self.service.determine_vat_regime( seller_country="LU", buyer_country="US", buyer_vat_number=None, seller_oss_registered=True, ) assert regime == VATRegime.EXEMPT assert rate == Decimal("0.00") assert dest == "US" @pytest.mark.unit @pytest.mark.invoice class TestInvoiceServiceSettings: """Test suite for InvoiceService settings management.""" def setup_method(self): """Initialize service instance before each test.""" self.service = InvoiceService() # ==================== Get Settings Tests ==================== def test_get_settings_not_found(self, db, test_vendor): """Test getting settings for vendor without settings returns None.""" settings = self.service.get_settings(db, test_vendor.id) assert settings is None def test_get_settings_or_raise_not_found(self, db, test_vendor): """Test get_settings_or_raise raises when settings don't exist.""" with pytest.raises(InvoiceSettingsNotFoundException): self.service.get_settings_or_raise(db, test_vendor.id) # ==================== Create Settings Tests ==================== def test_create_settings_success(self, db, test_vendor): """Test creating invoice settings successfully.""" data = VendorInvoiceSettingsCreate( company_name="Test Company S.A.", company_address="123 Test Street", company_city="Luxembourg", company_postal_code="L-1234", company_country="LU", vat_number="LU12345678", ) settings = self.service.create_settings(db, test_vendor.id, data) assert settings.vendor_id == test_vendor.id assert settings.company_name == "Test Company S.A." assert settings.company_country == "LU" assert settings.vat_number == "LU12345678" assert settings.invoice_prefix == "INV" assert settings.invoice_next_number == 1 def test_create_settings_with_custom_prefix(self, db, test_vendor): """Test creating settings with custom invoice prefix.""" data = VendorInvoiceSettingsCreate( company_name="Custom Prefix Company", invoice_prefix="FAC", invoice_number_padding=6, ) settings = self.service.create_settings(db, test_vendor.id, data) assert settings.invoice_prefix == "FAC" assert settings.invoice_number_padding == 6 def test_create_settings_duplicate_raises(self, db, test_vendor): """Test creating duplicate settings raises ValidationException.""" data = VendorInvoiceSettingsCreate(company_name="First Settings") self.service.create_settings(db, test_vendor.id, data) with pytest.raises(ValidationException) as exc_info: self.service.create_settings(db, test_vendor.id, data) assert "already exist" in str(exc_info.value) # ==================== Update Settings Tests ==================== def test_update_settings_success(self, db, test_vendor): """Test updating invoice settings.""" # Create initial settings create_data = VendorInvoiceSettingsCreate( company_name="Original Company" ) self.service.create_settings(db, test_vendor.id, create_data) # Update settings update_data = VendorInvoiceSettingsUpdate( company_name="Updated Company", bank_iban="LU123456789012345678", ) settings = self.service.update_settings(db, test_vendor.id, update_data) assert settings.company_name == "Updated Company" assert settings.bank_iban == "LU123456789012345678" def test_update_settings_not_found(self, db, test_vendor): """Test updating non-existent settings raises exception.""" update_data = VendorInvoiceSettingsUpdate(company_name="Updated") with pytest.raises(InvoiceSettingsNotFoundException): self.service.update_settings(db, test_vendor.id, update_data) # ==================== Invoice Number Generation Tests ==================== def test_get_next_invoice_number(self, db, test_vendor): """Test invoice number generation and increment.""" create_data = VendorInvoiceSettingsCreate( company_name="Test Company", invoice_prefix="INV", invoice_number_padding=5, ) settings = self.service.create_settings(db, test_vendor.id, create_data) # Generate first invoice number num1 = self.service._get_next_invoice_number(db, settings) assert num1 == "INV00001" assert settings.invoice_next_number == 2 # Generate second invoice number num2 = self.service._get_next_invoice_number(db, settings) assert num2 == "INV00002" assert settings.invoice_next_number == 3 @pytest.mark.unit @pytest.mark.invoice class TestInvoiceServiceCRUD: """Test suite for InvoiceService CRUD operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = InvoiceService() # ==================== Get Invoice Tests ==================== def test_get_invoice_not_found(self, db, test_vendor): """Test getting non-existent invoice returns None.""" invoice = self.service.get_invoice(db, test_vendor.id, 99999) assert invoice is None def test_get_invoice_or_raise_not_found(self, db, test_vendor): """Test get_invoice_or_raise raises for non-existent invoice.""" with pytest.raises(InvoiceNotFoundException): self.service.get_invoice_or_raise(db, test_vendor.id, 99999) def test_get_invoice_wrong_vendor(self, db, test_vendor, test_invoice_settings): """Test cannot get invoice from different vendor.""" # Create an invoice invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() # Try to get with different vendor ID result = self.service.get_invoice(db, 99999, invoice.id) assert result is None # ==================== List Invoices Tests ==================== def test_list_invoices_empty(self, db, test_vendor): """Test listing invoices when none exist.""" invoices, total = self.service.list_invoices(db, test_vendor.id) assert invoices == [] assert total == 0 def test_list_invoices_with_status_filter( self, db, test_vendor, test_invoice_settings ): """Test listing invoices filtered by status.""" # Create invoices with different statuses for status in [InvoiceStatus.DRAFT, InvoiceStatus.ISSUED, InvoiceStatus.PAID]: invoice = Invoice( vendor_id=test_vendor.id, invoice_number=f"INV-{status.value}", invoice_date=test_invoice_settings.created_at, status=status.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() # Filter by draft drafts, total = self.service.list_invoices( db, test_vendor.id, status="draft" ) assert total == 1 assert all(inv.status == "draft" for inv in drafts) def test_list_invoices_pagination(self, db, test_vendor, test_invoice_settings): """Test invoice listing pagination.""" # Create 5 invoices for i in range(5): invoice = Invoice( vendor_id=test_vendor.id, invoice_number=f"INV0000{i+1}", invoice_date=test_invoice_settings.created_at, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() # Get first page page1, total = self.service.list_invoices( db, test_vendor.id, page=1, per_page=2 ) assert len(page1) == 2 assert total == 5 # Get second page page2, _ = self.service.list_invoices( db, test_vendor.id, page=2, per_page=2 ) assert len(page2) == 2 @pytest.mark.unit @pytest.mark.invoice class TestInvoiceServiceStatusManagement: """Test suite for InvoiceService status management.""" def setup_method(self): """Initialize service instance before each test.""" self.service = InvoiceService() def test_update_status_draft_to_issued( self, db, test_vendor, test_invoice_settings ): """Test updating invoice status from draft to issued.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.DRAFT.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() updated = self.service.update_status( db, test_vendor.id, invoice.id, "issued" ) assert updated.status == "issued" def test_update_status_issued_to_paid( self, db, test_vendor, test_invoice_settings ): """Test updating invoice status from issued to paid.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.ISSUED.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() updated = self.service.update_status( db, test_vendor.id, invoice.id, "paid" ) assert updated.status == "paid" def test_update_status_cancelled_cannot_change( self, db, test_vendor, test_invoice_settings ): """Test that cancelled invoices cannot have status changed.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.CANCELLED.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() with pytest.raises(ValidationException) as exc_info: self.service.update_status(db, test_vendor.id, invoice.id, "issued") assert "cancelled" in str(exc_info.value).lower() def test_update_status_invalid_status( self, db, test_vendor, test_invoice_settings ): """Test updating with invalid status raises ValidationException.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.DRAFT.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() with pytest.raises(ValidationException) as exc_info: self.service.update_status( db, test_vendor.id, invoice.id, "invalid_status" ) assert "Invalid status" in str(exc_info.value) def test_mark_as_issued(self, db, test_vendor, test_invoice_settings): """Test mark_as_issued helper method.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.DRAFT.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() updated = self.service.mark_as_issued(db, test_vendor.id, invoice.id) assert updated.status == InvoiceStatus.ISSUED.value def test_mark_as_paid(self, db, test_vendor, test_invoice_settings): """Test mark_as_paid helper method.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.ISSUED.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() updated = self.service.mark_as_paid(db, test_vendor.id, invoice.id) assert updated.status == InvoiceStatus.PAID.value def test_cancel_invoice(self, db, test_vendor, test_invoice_settings): """Test cancel_invoice helper method.""" invoice = Invoice( vendor_id=test_vendor.id, invoice_number="INV00001", invoice_date=test_invoice_settings.created_at, status=InvoiceStatus.DRAFT.value, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=10000, vat_amount_cents=1700, total_cents=11700, ) db.add(invoice) db.commit() updated = self.service.cancel_invoice(db, test_vendor.id, invoice.id) assert updated.status == InvoiceStatus.CANCELLED.value @pytest.mark.unit @pytest.mark.invoice class TestInvoiceServiceStatistics: """Test suite for InvoiceService statistics.""" def setup_method(self): """Initialize service instance before each test.""" self.service = InvoiceService() def test_get_invoice_stats_empty(self, db, test_vendor): """Test stats when no invoices exist.""" stats = self.service.get_invoice_stats(db, test_vendor.id) assert stats["total_invoices"] == 0 assert stats["total_revenue_cents"] == 0 assert stats["draft_count"] == 0 assert stats["paid_count"] == 0 def test_get_invoice_stats_with_invoices( self, db, test_vendor, test_invoice_settings ): """Test stats calculation with multiple invoices.""" # Create invoices statuses = [ (InvoiceStatus.DRAFT.value, 10000), (InvoiceStatus.ISSUED.value, 20000), (InvoiceStatus.PAID.value, 30000), (InvoiceStatus.CANCELLED.value, 5000), ] for i, (status, total) in enumerate(statuses): invoice = Invoice( vendor_id=test_vendor.id, invoice_number=f"INV0000{i+1}", invoice_date=test_invoice_settings.created_at, status=status, seller_details={"company_name": "Test"}, buyer_details={"name": "Buyer"}, line_items=[], vat_rate=Decimal("17.00"), subtotal_cents=total, vat_amount_cents=int(total * 0.17), total_cents=total + int(total * 0.17), ) db.add(invoice) db.commit() stats = self.service.get_invoice_stats(db, test_vendor.id) assert stats["total_invoices"] == 4 # Revenue only counts issued and paid expected_revenue = 20000 + int(20000 * 0.17) + 30000 + int(30000 * 0.17) assert stats["total_revenue_cents"] == expected_revenue assert stats["draft_count"] == 1 assert stats["paid_count"] == 1 # ==================== Fixtures ==================== @pytest.fixture def test_invoice_settings(db, test_vendor): """Create test invoice settings.""" settings = VendorInvoiceSettings( vendor_id=test_vendor.id, company_name="Test Invoice Company", company_country="LU", invoice_prefix="INV", invoice_next_number=1, invoice_number_padding=5, ) db.add(settings) db.commit() db.refresh(settings) return settings