# tests/unit/services/test_billing_service.py """Unit tests for BillingService.""" from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from app.exceptions import VendorNotFoundException from app.services.billing_service import ( BillingService, NoActiveSubscriptionError, PaymentSystemNotConfiguredError, StripePriceNotConfiguredError, SubscriptionNotCancelledError, TierNotFoundError, ) from models.database.subscription import ( AddOnProduct, BillingHistory, SubscriptionStatus, SubscriptionTier, VendorAddOn, VendorSubscription, ) @pytest.mark.unit @pytest.mark.billing class TestBillingServiceSubscription: """Test suite for BillingService subscription operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() def test_get_subscription_with_tier_creates_if_not_exists( self, db, test_vendor, test_subscription_tier ): """Test get_subscription_with_tier creates subscription if needed.""" subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id) assert subscription is not None assert subscription.vendor_id == test_vendor.id assert tier is not None assert tier.code == subscription.tier def test_get_subscription_with_tier_returns_existing( self, db, test_vendor, test_subscription ): """Test get_subscription_with_tier returns existing subscription.""" # Note: test_subscription fixture already creates the tier subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id) assert subscription.id == test_subscription.id assert tier.code == test_subscription.tier @pytest.mark.unit @pytest.mark.billing class TestBillingServiceTiers: """Test suite for BillingService tier operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() def test_get_available_tiers(self, db, test_subscription_tiers): """Test getting available tiers.""" tier_list, tier_order = self.service.get_available_tiers(db, "essential") assert len(tier_list) > 0 assert "essential" in tier_order assert "professional" in tier_order # Check tier has expected fields essential_tier = next(t for t in tier_list if t["code"] == "essential") assert essential_tier["is_current"] is True assert essential_tier["can_upgrade"] is False assert essential_tier["can_downgrade"] is False professional_tier = next(t for t in tier_list if t["code"] == "professional") assert professional_tier["can_upgrade"] is True assert professional_tier["can_downgrade"] is False def test_get_tier_by_code_success(self, db, test_subscription_tier): """Test getting tier by code.""" tier = self.service.get_tier_by_code(db, "essential") assert tier.code == "essential" assert tier.is_active is True def test_get_tier_by_code_not_found(self, db): """Test getting non-existent tier raises error.""" with pytest.raises(TierNotFoundError) as exc_info: self.service.get_tier_by_code(db, "nonexistent") assert exc_info.value.tier_code == "nonexistent" @pytest.mark.unit @pytest.mark.billing class TestBillingServiceCheckout: """Test suite for BillingService checkout operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() @patch("app.services.billing_service.stripe_service") def test_create_checkout_session_stripe_not_configured( self, mock_stripe, db, test_vendor, test_subscription_tier ): """Test checkout fails when Stripe not configured.""" mock_stripe.is_configured = False with pytest.raises(PaymentSystemNotConfiguredError): self.service.create_checkout_session( db=db, vendor_id=test_vendor.id, tier_code="essential", is_annual=False, success_url="https://example.com/success", cancel_url="https://example.com/cancel", ) @patch("app.services.billing_service.stripe_service") def test_create_checkout_session_success( self, mock_stripe, db, test_vendor, test_subscription_tier_with_stripe ): """Test successful checkout session creation.""" mock_stripe.is_configured = True mock_session = MagicMock() mock_session.url = "https://checkout.stripe.com/test" mock_session.id = "cs_test_123" mock_stripe.create_checkout_session.return_value = mock_session result = self.service.create_checkout_session( db=db, vendor_id=test_vendor.id, tier_code="essential", is_annual=False, success_url="https://example.com/success", cancel_url="https://example.com/cancel", ) assert result["checkout_url"] == "https://checkout.stripe.com/test" assert result["session_id"] == "cs_test_123" @patch("app.services.billing_service.stripe_service") def test_create_checkout_session_tier_not_found( self, mock_stripe, db, test_vendor ): """Test checkout fails with invalid tier.""" mock_stripe.is_configured = True with pytest.raises(TierNotFoundError): self.service.create_checkout_session( db=db, vendor_id=test_vendor.id, tier_code="nonexistent", is_annual=False, success_url="https://example.com/success", cancel_url="https://example.com/cancel", ) @patch("app.services.billing_service.stripe_service") def test_create_checkout_session_no_price( self, mock_stripe, db, test_vendor, test_subscription_tier ): """Test checkout fails when tier has no Stripe price.""" mock_stripe.is_configured = True with pytest.raises(StripePriceNotConfiguredError): self.service.create_checkout_session( db=db, vendor_id=test_vendor.id, tier_code="essential", is_annual=False, success_url="https://example.com/success", cancel_url="https://example.com/cancel", ) @pytest.mark.unit @pytest.mark.billing class TestBillingServicePortal: """Test suite for BillingService portal operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() @patch("app.services.billing_service.stripe_service") def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_vendor): """Test portal fails when Stripe not configured.""" mock_stripe.is_configured = False with pytest.raises(PaymentSystemNotConfiguredError): self.service.create_portal_session( db=db, vendor_id=test_vendor.id, return_url="https://example.com/billing", ) @patch("app.services.billing_service.stripe_service") def test_create_portal_session_no_subscription(self, mock_stripe, db, test_vendor): """Test portal fails when no subscription exists.""" mock_stripe.is_configured = True with pytest.raises(NoActiveSubscriptionError): self.service.create_portal_session( db=db, vendor_id=test_vendor.id, return_url="https://example.com/billing", ) @patch("app.services.billing_service.stripe_service") def test_create_portal_session_success( self, mock_stripe, db, test_vendor, test_active_subscription ): """Test successful portal session creation.""" mock_stripe.is_configured = True mock_session = MagicMock() mock_session.url = "https://billing.stripe.com/portal" mock_stripe.create_portal_session.return_value = mock_session result = self.service.create_portal_session( db=db, vendor_id=test_vendor.id, return_url="https://example.com/billing", ) assert result["portal_url"] == "https://billing.stripe.com/portal" @pytest.mark.unit @pytest.mark.billing class TestBillingServiceInvoices: """Test suite for BillingService invoice operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() def test_get_invoices_empty(self, db, test_vendor): """Test getting invoices when none exist.""" invoices, total = self.service.get_invoices(db, test_vendor.id) assert invoices == [] assert total == 0 def test_get_invoices_with_data(self, db, test_vendor, test_billing_history): """Test getting invoices returns data.""" invoices, total = self.service.get_invoices(db, test_vendor.id) assert len(invoices) == 1 assert total == 1 assert invoices[0].invoice_number == "INV-001" def test_get_invoices_pagination(self, db, test_vendor, test_multiple_invoices): """Test invoice pagination.""" # Get first page page1, total = self.service.get_invoices(db, test_vendor.id, skip=0, limit=2) assert len(page1) == 2 assert total == 5 # Get second page page2, _ = self.service.get_invoices(db, test_vendor.id, skip=2, limit=2) assert len(page2) == 2 @pytest.mark.unit @pytest.mark.billing class TestBillingServiceAddons: """Test suite for BillingService addon operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() def test_get_available_addons_empty(self, db): """Test getting addons when none exist.""" addons = self.service.get_available_addons(db) assert addons == [] def test_get_available_addons_with_data(self, db, test_addon_products): """Test getting all available addons.""" addons = self.service.get_available_addons(db) assert len(addons) == 3 assert all(addon.is_active for addon in addons) def test_get_available_addons_by_category(self, db, test_addon_products): """Test filtering addons by category.""" domain_addons = self.service.get_available_addons(db, category="domain") assert len(domain_addons) == 1 assert domain_addons[0].category == "domain" def test_get_vendor_addons_empty(self, db, test_vendor): """Test getting vendor addons when none purchased.""" addons = self.service.get_vendor_addons(db, test_vendor.id) assert addons == [] @pytest.mark.unit @pytest.mark.billing class TestBillingServiceCancellation: """Test suite for BillingService cancellation operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() @patch("app.services.billing_service.stripe_service") def test_cancel_subscription_no_subscription( self, mock_stripe, db, test_vendor ): """Test cancel fails when no subscription.""" mock_stripe.is_configured = True with pytest.raises(NoActiveSubscriptionError): self.service.cancel_subscription( db=db, vendor_id=test_vendor.id, reason="Test reason", immediately=False, ) @patch("app.services.billing_service.stripe_service") def test_cancel_subscription_success( self, mock_stripe, db, test_vendor, test_active_subscription ): """Test successful subscription cancellation.""" mock_stripe.is_configured = True result = self.service.cancel_subscription( db=db, vendor_id=test_vendor.id, reason="Too expensive", immediately=False, ) assert result["message"] == "Subscription cancelled successfully" assert test_active_subscription.cancelled_at is not None assert test_active_subscription.cancellation_reason == "Too expensive" @patch("app.services.billing_service.stripe_service") def test_reactivate_subscription_not_cancelled( self, mock_stripe, db, test_vendor, test_active_subscription ): """Test reactivate fails when subscription not cancelled.""" mock_stripe.is_configured = True with pytest.raises(SubscriptionNotCancelledError): self.service.reactivate_subscription(db, test_vendor.id) @patch("app.services.billing_service.stripe_service") def test_reactivate_subscription_success( self, mock_stripe, db, test_vendor, test_cancelled_subscription ): """Test successful subscription reactivation.""" mock_stripe.is_configured = True result = self.service.reactivate_subscription(db, test_vendor.id) assert result["message"] == "Subscription reactivated successfully" assert test_cancelled_subscription.cancelled_at is None assert test_cancelled_subscription.cancellation_reason is None @pytest.mark.unit @pytest.mark.billing class TestBillingServiceVendor: """Test suite for BillingService vendor operations.""" def setup_method(self): """Initialize service instance before each test.""" self.service = BillingService() def test_get_vendor_success(self, db, test_vendor): """Test getting vendor by ID.""" vendor = self.service.get_vendor(db, test_vendor.id) assert vendor.id == test_vendor.id def test_get_vendor_not_found(self, db): """Test getting non-existent vendor raises error.""" with pytest.raises(VendorNotFoundException): self.service.get_vendor(db, 99999) # ==================== Fixtures ==================== @pytest.fixture def test_subscription_tier(db): """Create a basic subscription tier.""" tier = SubscriptionTier( code="essential", name="Essential", description="Essential plan", price_monthly_cents=4900, price_annual_cents=49000, orders_per_month=100, products_limit=200, team_members=1, features=["basic_support"], display_order=1, is_active=True, is_public=True, ) db.add(tier) db.commit() db.refresh(tier) return tier @pytest.fixture def test_subscription_tier_with_stripe(db): """Create a subscription tier with Stripe configuration.""" tier = SubscriptionTier( code="essential", name="Essential", description="Essential plan", price_monthly_cents=4900, price_annual_cents=49000, orders_per_month=100, products_limit=200, team_members=1, features=["basic_support"], display_order=1, is_active=True, is_public=True, stripe_product_id="prod_test123", stripe_price_monthly_id="price_test123", stripe_price_annual_id="price_test456", ) db.add(tier) db.commit() db.refresh(tier) return tier @pytest.fixture def test_subscription_tiers(db): """Create multiple subscription tiers.""" tiers = [ SubscriptionTier( code="essential", name="Essential", price_monthly_cents=4900, display_order=1, is_active=True, is_public=True, ), SubscriptionTier( code="professional", name="Professional", price_monthly_cents=9900, display_order=2, is_active=True, is_public=True, ), SubscriptionTier( code="business", name="Business", price_monthly_cents=19900, display_order=3, is_active=True, is_public=True, ), ] db.add_all(tiers) db.commit() for tier in tiers: db.refresh(tier) return tiers @pytest.fixture def test_subscription(db, test_vendor): """Create a basic subscription for testing.""" # Create tier first tier = SubscriptionTier( code="essential", name="Essential", price_monthly_cents=4900, display_order=1, is_active=True, is_public=True, ) db.add(tier) db.commit() subscription = VendorSubscription( vendor_id=test_vendor.id, tier="essential", status=SubscriptionStatus.ACTIVE, period_start=datetime.now(timezone.utc), period_end=datetime.now(timezone.utc), ) db.add(subscription) db.commit() db.refresh(subscription) return subscription @pytest.fixture def test_active_subscription(db, test_vendor): """Create an active subscription with Stripe IDs.""" # Create tier first if not exists tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first() if not tier: tier = SubscriptionTier( code="essential", name="Essential", price_monthly_cents=4900, display_order=1, is_active=True, is_public=True, ) db.add(tier) db.commit() subscription = VendorSubscription( vendor_id=test_vendor.id, tier="essential", status=SubscriptionStatus.ACTIVE, stripe_customer_id="cus_test123", stripe_subscription_id="sub_test123", period_start=datetime.now(timezone.utc), period_end=datetime.now(timezone.utc), ) db.add(subscription) db.commit() db.refresh(subscription) return subscription @pytest.fixture def test_cancelled_subscription(db, test_vendor): """Create a cancelled subscription.""" # Create tier first if not exists tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first() if not tier: tier = SubscriptionTier( code="essential", name="Essential", price_monthly_cents=4900, display_order=1, is_active=True, is_public=True, ) db.add(tier) db.commit() subscription = VendorSubscription( vendor_id=test_vendor.id, tier="essential", status=SubscriptionStatus.ACTIVE, stripe_customer_id="cus_test123", stripe_subscription_id="sub_test123", period_start=datetime.now(timezone.utc), period_end=datetime.now(timezone.utc), cancelled_at=datetime.now(timezone.utc), cancellation_reason="Too expensive", ) db.add(subscription) db.commit() db.refresh(subscription) return subscription @pytest.fixture def test_billing_history(db, test_vendor): """Create a billing history record.""" record = BillingHistory( vendor_id=test_vendor.id, stripe_invoice_id="in_test123", invoice_number="INV-001", invoice_date=datetime.now(timezone.utc), subtotal_cents=4900, tax_cents=0, total_cents=4900, amount_paid_cents=4900, currency="EUR", status="paid", ) db.add(record) db.commit() db.refresh(record) return record @pytest.fixture def test_multiple_invoices(db, test_vendor): """Create multiple billing history records.""" records = [] for i in range(5): record = BillingHistory( vendor_id=test_vendor.id, stripe_invoice_id=f"in_test{i}", invoice_number=f"INV-{i:03d}", invoice_date=datetime.now(timezone.utc), subtotal_cents=4900, tax_cents=0, total_cents=4900, amount_paid_cents=4900, currency="EUR", status="paid", ) records.append(record) db.add_all(records) db.commit() return records @pytest.fixture def test_addon_products(db): """Create test addon products.""" addons = [ AddOnProduct( code="domain", name="Custom Domain", category="domain", price_cents=1500, billing_period="annual", display_order=1, is_active=True, ), AddOnProduct( code="email_5", name="5 Email Addresses", category="email", price_cents=500, billing_period="monthly", quantity_value=5, display_order=2, is_active=True, ), AddOnProduct( code="email_10", name="10 Email Addresses", category="email", price_cents=900, billing_period="monthly", quantity_value=10, display_order=3, is_active=True, ), ] db.add_all(addons) db.commit() for addon in addons: db.refresh(addon) return addons