Files
orion/tests/unit/services/test_billing_service.py
Samir Boulahtit 9d8d5e7138 feat: add subscription and billing system with Stripe integration
- Add database models for subscription tiers, vendor subscriptions,
  add-ons, billing history, and webhook events
- Implement BillingService for subscription operations
- Implement StripeService for Stripe API operations
- Implement StripeWebhookHandler for webhook event processing
- Add vendor billing API endpoints for subscription management
- Create vendor billing page with Alpine.js frontend
- Add limit enforcement for products and team members
- Add billing exceptions for proper error handling
- Create comprehensive unit tests (40 tests passing)
- Add subscription billing documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:29:44 +01:00

659 lines
21 KiB
Python

# 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