Files
orion/app/modules/billing/tests/unit/test_billing_service.py
Samir Boulahtit 32acc76b49 feat: platform-aware storefront routing and billing improvements
Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES

Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:42:41 +01:00

820 lines
28 KiB
Python

# app/modules/billing/tests/unit/test_billing_service.py
"""Unit tests for BillingService."""
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from app.modules.billing.exceptions import (
NoActiveSubscriptionException,
PaymentSystemNotConfiguredException,
StripePriceNotConfiguredException,
SubscriptionNotCancelledException,
TierNotFoundException,
)
from app.modules.billing.models import (
AddOnProduct,
BillingHistory,
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.billing.services.billing_service import BillingService
# ============================================================================
# Tier Lookup
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceTiers:
"""Test suite for BillingService tier operations."""
def setup_method(self):
self.service = BillingService()
def test_get_tier_by_code_found(self, db, bs_tier_essential):
"""Returns the active tier."""
tier = self.service.get_tier_by_code(db, "essential")
assert tier.code == "essential"
def test_get_tier_by_code_not_found(self, db):
"""Nonexistent tier raises TierNotFoundException."""
with pytest.raises(TierNotFoundException) as exc_info:
self.service.get_tier_by_code(db, "nonexistent")
assert exc_info.value.tier_code == "nonexistent"
def test_get_tier_by_code_inactive_not_returned(self, db, bs_tier_essential):
"""Inactive tier raises TierNotFoundException (only active tiers returned)."""
bs_tier_essential.is_active = False
db.flush()
with pytest.raises(TierNotFoundException):
self.service.get_tier_by_code(db, "essential")
# ============================================================================
# Available Tiers with Upgrade/Downgrade Flags
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceAvailableTiers:
"""Test suite for get_available_tiers (upgrade/downgrade detection)."""
def setup_method(self):
self.service = BillingService()
def test_get_available_tiers_returns_all(self, db, bs_tiers):
"""Returns all active public tiers."""
tier_list, tier_order = self.service.get_available_tiers(db, None)
assert len(tier_list) == 3
def test_get_available_tiers_marks_current(self, db, bs_tiers):
"""Current tier is marked with is_current=True."""
tier_list, _ = self.service.get_available_tiers(db, bs_tiers[1].id)
current = [t for t in tier_list if t["is_current"]]
assert len(current) == 1
assert current[0]["code"] == "professional"
def test_get_available_tiers_upgrade_flags(self, db, bs_tiers):
"""Tiers with higher display_order have can_upgrade=True."""
tier_list, _ = self.service.get_available_tiers(db, bs_tiers[0].id)
essential = next(t for t in tier_list if t["code"] == "essential")
professional = next(t for t in tier_list if t["code"] == "professional")
business = next(t for t in tier_list if t["code"] == "business")
assert essential["is_current"] is True
assert essential["can_upgrade"] is False
assert professional["can_upgrade"] is True
assert business["can_upgrade"] is True
def test_get_available_tiers_downgrade_flags(self, db, bs_tiers):
"""Tiers with lower display_order have can_downgrade=True."""
tier_list, _ = self.service.get_available_tiers(db, bs_tiers[2].id)
essential = next(t for t in tier_list if t["code"] == "essential")
professional = next(t for t in tier_list if t["code"] == "professional")
business = next(t for t in tier_list if t["code"] == "business")
assert essential["can_downgrade"] is True
assert professional["can_downgrade"] is True
assert business["is_current"] is True
assert business["can_downgrade"] is False
def test_get_available_tiers_no_current_tier(self, db, bs_tiers):
"""When current_tier_id is None, no tier is marked current."""
tier_list, _ = self.service.get_available_tiers(db, None)
assert all(t["is_current"] is False for t in tier_list)
def test_get_available_tiers_returns_tier_order_map(self, db, bs_tiers):
"""Returns tier_order map of code → display_order."""
_, tier_order = self.service.get_available_tiers(db, None)
assert tier_order["essential"] == 1
assert tier_order["professional"] == 2
assert tier_order["business"] == 3
# ============================================================================
# Invoices
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceInvoices:
"""Test suite for BillingService invoice operations."""
def setup_method(self):
self.service = BillingService()
def test_get_invoices_empty(self, db, test_merchant):
"""Returns empty list and zero total when no invoices exist."""
invoices, total = self.service.get_invoices(db, test_merchant.id)
assert invoices == []
assert total == 0
def test_get_invoices_with_data(self, db, bs_billing_history):
"""Returns invoices for the merchant."""
merchant_id = bs_billing_history[0].merchant_id
invoices, total = self.service.get_invoices(db, merchant_id)
assert total == 3
assert len(invoices) == 3
def test_get_invoices_pagination(self, db, bs_billing_history):
"""Pagination limits and offsets results."""
merchant_id = bs_billing_history[0].merchant_id
invoices, total = self.service.get_invoices(db, merchant_id, skip=0, limit=2)
assert total == 3
assert len(invoices) == 2
invoices2, _ = self.service.get_invoices(db, merchant_id, skip=2, limit=2)
assert len(invoices2) == 1
def test_get_invoices_ordered_by_date_desc(self, db, bs_billing_history):
"""Invoices are returned newest first."""
merchant_id = bs_billing_history[0].merchant_id
invoices, _ = self.service.get_invoices(db, merchant_id)
dates = [inv.invoice_date for inv in invoices]
assert dates == sorted(dates, reverse=True)
# ============================================================================
# Add-ons
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceAddons:
"""Test suite for BillingService addon operations."""
def setup_method(self):
self.service = BillingService()
def test_get_available_addons_empty(self, db):
"""Returns empty when no addons exist."""
addons = self.service.get_available_addons(db)
assert addons == []
def test_get_available_addons_with_data(self, db, test_addon_products):
"""Returns all active 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):
"""Filters 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_store_addons_empty(self, db, test_store):
"""Returns empty when store has no purchased addons."""
addons = self.service.get_store_addons(db, test_store.id)
assert addons == []
# ============================================================================
# Subscription with Tier
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceSubscriptionWithTier:
"""Tests for get_subscription_with_tier."""
def setup_method(self):
self.service = BillingService()
def test_get_subscription_with_tier_existing(
self, db, bs_subscription
):
"""Returns (subscription, tier) tuple for existing subscription."""
sub, tier = self.service.get_subscription_with_tier(
db, bs_subscription.merchant_id, bs_subscription.platform_id
)
assert sub.id == bs_subscription.id
assert tier is not None
assert tier.code == "essential"
def test_get_subscription_with_tier_creates_if_missing(
self, db, test_merchant, test_platform, bs_tier_essential
):
"""Creates a trial subscription when none exists (via get_or_create)."""
sub, tier = self.service.get_subscription_with_tier(
db, test_merchant.id, test_platform.id
)
assert sub is not None
assert sub.status == SubscriptionStatus.TRIAL.value
# ============================================================================
# Change Tier
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceChangeTier:
"""Tests for change_tier (the tier upgrade/downgrade flow)."""
def setup_method(self):
self.service = BillingService()
def test_change_tier_no_subscription_raises(self, db, bs_tiers):
"""Raises NoActiveSubscriptionException when no subscription exists."""
with pytest.raises(NoActiveSubscriptionException):
self.service.change_tier(db, 99999, 99999, "professional", False)
def test_change_tier_no_stripe_subscription_raises(
self, db, bs_subscription, bs_tiers
):
"""Raises when subscription has no stripe_subscription_id."""
# bs_subscription has no Stripe IDs
with pytest.raises(NoActiveSubscriptionException):
self.service.change_tier(
db,
bs_subscription.merchant_id,
bs_subscription.platform_id,
"professional",
False,
)
def test_change_tier_nonexistent_tier_raises(
self, db, bs_stripe_subscription
):
"""Raises TierNotFoundException for nonexistent tier."""
with pytest.raises(TierNotFoundException):
self.service.change_tier(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
"nonexistent",
False,
)
def test_change_tier_no_price_id_raises(
self, db, bs_stripe_subscription, bs_tiers
):
"""Raises StripePriceNotConfiguredException when tier has no Stripe price."""
# bs_tiers have no stripe_price_* set
with pytest.raises(StripePriceNotConfiguredException):
self.service.change_tier(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
"professional",
False,
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_change_tier_success(
self, mock_stripe, db, bs_stripe_subscription, bs_tiers_with_stripe
):
"""Successful tier change updates local subscription and calls Stripe."""
mock_stripe.is_configured = True
mock_stripe.update_subscription = MagicMock()
result = self.service.change_tier(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
"professional",
False,
)
assert result["new_tier"] == "professional"
assert result["effective_immediately"] is True
assert bs_stripe_subscription.tier_id == bs_tiers_with_stripe[1].id
mock_stripe.update_subscription.assert_called_once()
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_change_tier_annual_uses_annual_price(
self, mock_stripe, db, bs_stripe_subscription, bs_tiers_with_stripe
):
"""Annual billing selects stripe_price_annual_id."""
mock_stripe.is_configured = True
mock_stripe.update_subscription = MagicMock()
self.service.change_tier(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
"professional",
True,
)
call_args = mock_stripe.update_subscription.call_args
assert call_args.kwargs["new_price_id"] == "price_pro_annual"
# ============================================================================
# _is_upgrade
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceIsUpgrade:
"""Tests for _is_upgrade helper."""
def setup_method(self):
self.service = BillingService()
def test_is_upgrade_true(self, db, bs_tiers):
"""Higher display_order is an upgrade."""
assert self.service._is_upgrade(db, bs_tiers[0].id, bs_tiers[2].id) is True
def test_is_upgrade_false_downgrade(self, db, bs_tiers):
"""Lower display_order is not an upgrade."""
assert self.service._is_upgrade(db, bs_tiers[2].id, bs_tiers[0].id) is False
def test_is_upgrade_same_tier(self, db, bs_tiers):
"""Same tier is not an upgrade."""
assert self.service._is_upgrade(db, bs_tiers[1].id, bs_tiers[1].id) is False
def test_is_upgrade_none_ids(self, db):
"""None tier IDs return False."""
assert self.service._is_upgrade(db, None, None) is False
assert self.service._is_upgrade(db, None, 1) is False
assert self.service._is_upgrade(db, 1, None) is False
# ============================================================================
# Cancel Subscription
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceCancel:
"""Tests for cancel_subscription."""
def setup_method(self):
self.service = BillingService()
def test_cancel_no_subscription_raises(self, db):
"""Raises when no subscription found."""
with pytest.raises(NoActiveSubscriptionException):
self.service.cancel_subscription(db, 99999, 99999, None, False)
def test_cancel_no_stripe_id_raises(self, db, bs_subscription):
"""Raises when subscription has no stripe_subscription_id."""
with pytest.raises(NoActiveSubscriptionException):
self.service.cancel_subscription(
db,
bs_subscription.merchant_id,
bs_subscription.platform_id,
"reason",
False,
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_success(self, mock_stripe, db, bs_stripe_subscription):
"""Cancellation records timestamp and reason."""
mock_stripe.is_configured = True
mock_stripe.cancel_subscription = MagicMock()
result = self.service.cancel_subscription(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
"Too expensive",
False,
)
assert result["message"] == "Subscription cancelled successfully"
assert bs_stripe_subscription.cancelled_at is not None
assert bs_stripe_subscription.cancellation_reason == "Too expensive"
mock_stripe.cancel_subscription.assert_called_once()
# ============================================================================
# Reactivate Subscription
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceReactivate:
"""Tests for reactivate_subscription."""
def setup_method(self):
self.service = BillingService()
def test_reactivate_no_subscription_raises(self, db):
"""Raises when no subscription found."""
with pytest.raises(NoActiveSubscriptionException):
self.service.reactivate_subscription(db, 99999, 99999)
def test_reactivate_not_cancelled_raises(self, db, bs_stripe_subscription):
"""Raises SubscriptionNotCancelledException when not cancelled."""
with pytest.raises(SubscriptionNotCancelledException):
self.service.reactivate_subscription(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_success(self, mock_stripe, db, bs_stripe_subscription):
"""Reactivation clears cancellation and calls Stripe."""
mock_stripe.is_configured = True
mock_stripe.reactivate_subscription = MagicMock()
# Cancel first
bs_stripe_subscription.cancelled_at = datetime.now(UTC)
bs_stripe_subscription.cancellation_reason = "Testing"
db.flush()
result = self.service.reactivate_subscription(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
)
assert result["message"] == "Subscription reactivated successfully"
assert bs_stripe_subscription.cancelled_at is None
assert bs_stripe_subscription.cancellation_reason is None
mock_stripe.reactivate_subscription.assert_called_once()
# ============================================================================
# Checkout Session
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceCheckout:
"""Tests for create_checkout_session."""
def setup_method(self):
self.service = BillingService()
def test_checkout_stripe_not_configured_raises(self, db, bs_tiers_with_stripe):
"""Raises PaymentSystemNotConfiguredException when Stripe is off."""
with patch(
"app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe:
mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredException):
self.service.create_checkout_session(
db, 1, 1, "essential", False, "http://ok", "http://cancel" # noqa: SEC034
)
def test_checkout_nonexistent_tier_raises(self, db):
"""Raises TierNotFoundException for nonexistent tier."""
with patch(
"app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe:
mock_stripe.is_configured = True
with pytest.raises(TierNotFoundException):
self.service.create_checkout_session(
db, 1, 1, "nonexistent", False, "http://ok", "http://cancel" # noqa: SEC034
)
# ============================================================================
# Portal Session
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServicePortal:
"""Tests for create_portal_session."""
def setup_method(self):
self.service = BillingService()
def test_portal_stripe_not_configured_raises(self, db):
"""Raises PaymentSystemNotConfiguredException when Stripe is off."""
with patch(
"app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe:
mock_stripe.is_configured = False
with pytest.raises(PaymentSystemNotConfiguredException):
self.service.create_portal_session(db, 1, 1, "http://return") # noqa: SEC034
def test_portal_no_subscription_raises(self, db):
"""Raises NoActiveSubscriptionException when no subscription found."""
with patch(
"app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe:
mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionException):
self.service.create_portal_session(db, 99999, 99999, "http://return") # noqa: SEC034
def test_portal_no_customer_id_raises(self, db, bs_subscription):
"""Raises when subscription has no stripe_customer_id."""
with patch(
"app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe:
mock_stripe.is_configured = True
with pytest.raises(NoActiveSubscriptionException):
self.service.create_portal_session(
db,
bs_subscription.merchant_id,
bs_subscription.platform_id,
"http://return", # noqa: SEC034
)
# ============================================================================
# Upcoming Invoice
# ============================================================================
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceUpcomingInvoice:
"""Tests for get_upcoming_invoice."""
def setup_method(self):
self.service = BillingService()
def test_upcoming_invoice_no_subscription_raises(self, db):
"""Raises when no subscription exists."""
with pytest.raises(NoActiveSubscriptionException):
self.service.get_upcoming_invoice(db, 99999, 99999)
def test_upcoming_invoice_no_customer_id_raises(self, db, bs_subscription):
"""Raises when subscription has no stripe_customer_id."""
with pytest.raises(NoActiveSubscriptionException):
self.service.get_upcoming_invoice(
db, bs_subscription.merchant_id, bs_subscription.platform_id
)
def test_upcoming_invoice_stripe_not_configured_returns_empty(
self, db, bs_stripe_subscription
):
"""Returns empty invoice when Stripe is not configured."""
with patch(
"app.modules.billing.services.billing_service.stripe_service"
) as mock_stripe:
mock_stripe.is_configured = False
result = self.service.get_upcoming_invoice(
db,
bs_stripe_subscription.merchant_id,
bs_stripe_subscription.platform_id,
)
assert result["amount_due_cents"] == 0
assert result["line_items"] == []
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def bs_tier_essential(db, test_platform):
"""Create essential subscription tier."""
tier = SubscriptionTier(
code="essential",
name="Essential",
description="Essential plan",
price_monthly_cents=4900,
price_annual_cents=49000,
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def bs_tiers(db, test_platform):
"""Create three tiers without Stripe config."""
tiers = [
SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
price_annual_cents=49000,
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="professional",
name="Professional",
price_monthly_cents=9900,
price_annual_cents=99000,
display_order=2,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="business",
name="Business",
price_monthly_cents=19900,
price_annual_cents=199000,
display_order=3,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
]
db.add_all(tiers)
db.commit()
for t in tiers:
db.refresh(t)
return tiers
@pytest.fixture
def bs_tiers_with_stripe(db, test_platform):
"""Create tiers with Stripe price IDs configured."""
tiers = [
SubscriptionTier(
code="essential",
name="Essential",
price_monthly_cents=4900,
price_annual_cents=49000,
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_essential",
stripe_price_monthly_id="price_ess_monthly",
stripe_price_annual_id="price_ess_annual",
),
SubscriptionTier(
code="professional",
name="Professional",
price_monthly_cents=9900,
price_annual_cents=99000,
display_order=2,
is_active=True,
is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_professional",
stripe_price_monthly_id="price_pro_monthly",
stripe_price_annual_id="price_pro_annual",
),
SubscriptionTier(
code="business",
name="Business",
price_monthly_cents=19900,
price_annual_cents=199000,
display_order=3,
is_active=True,
is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_business",
stripe_price_monthly_id="price_biz_monthly",
stripe_price_annual_id="price_biz_annual",
),
]
db.add_all(tiers)
db.commit()
for t in tiers:
db.refresh(t)
return tiers
@pytest.fixture
def bs_subscription(db, test_merchant, test_platform, bs_tier_essential):
"""Create an active merchant subscription (no Stripe IDs)."""
now = datetime.now(UTC)
sub = MerchantSubscription(
merchant_id=test_merchant.id,
platform_id=test_platform.id,
tier_id=bs_tier_essential.id,
status=SubscriptionStatus.ACTIVE.value,
period_start=now,
period_end=now + timedelta(days=30),
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
@pytest.fixture
def bs_stripe_subscription(db, test_merchant, test_platform, bs_tier_essential):
"""Create an active subscription with Stripe IDs."""
now = datetime.now(UTC)
sub = MerchantSubscription(
merchant_id=test_merchant.id,
platform_id=test_platform.id,
tier_id=bs_tier_essential.id,
status=SubscriptionStatus.ACTIVE.value,
stripe_customer_id="cus_test123",
stripe_subscription_id="sub_test123",
period_start=now,
period_end=now + timedelta(days=30),
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
@pytest.fixture
def bs_billing_history(db, test_merchant):
"""Create billing history records for test_merchant."""
records = []
for i in range(3):
record = BillingHistory(
merchant_id=test_merchant.id,
stripe_invoice_id=f"in_bs_test_{i}",
invoice_number=f"BS-{i:03d}",
invoice_date=datetime.now(UTC) - timedelta(days=i * 30),
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()
for r in records:
db.refresh(r)
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