feat(billing): migrate frontend templates to feature provider system

Replace hardcoded subscription fields (orders_limit, products_limit,
team_members_limit) across 5 frontend pages with dynamic feature
provider APIs. Add admin convenience endpoint for store subscription
lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature,
TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases.

Pages updated:
- Admin subscriptions: dynamic feature overrides editor
- Admin tiers: correct feature catalog/limits API URLs
- Store billing: usage metrics from /store/billing/usage
- Merchant subscription detail: tier.feature_limits rendering
- Admin store detail: new GET /admin/subscriptions/store/{id} endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 15:18:16 +01:00
parent 922616c9e3
commit 1db7e8a087
19 changed files with 2508 additions and 1205 deletions

View File

@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.tenancy.exceptions import StoreNotFoundException
from app.modules.billing.services.billing_service import (
BillingService,
NoActiveSubscriptionError,
@@ -18,10 +18,10 @@ from app.modules.billing.services.billing_service import (
from app.modules.billing.models import (
AddOnProduct,
BillingHistory,
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
VendorAddOn,
VendorSubscription,
StoreAddOn,
)
@@ -35,22 +35,22 @@ class TestBillingServiceSubscription:
self.service = BillingService()
def test_get_subscription_with_tier_creates_if_not_exists(
self, db, test_vendor, test_subscription_tier
self, db, test_store, test_subscription_tier
):
"""Test get_subscription_with_tier creates subscription if needed."""
subscription, tier = self.service.get_subscription_with_tier(db, test_vendor.id)
subscription, tier = self.service.get_subscription_with_tier(db, test_store.id)
assert subscription is not None
assert subscription.vendor_id == test_vendor.id
assert subscription.store_id == test_store.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
self, db, test_store, 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)
subscription, tier = self.service.get_subscription_with_tier(db, test_store.id)
assert subscription.id == test_subscription.id
assert tier.code == test_subscription.tier
@@ -109,7 +109,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_stripe_not_configured(
self, mock_stripe, db, test_vendor, test_subscription_tier
self, mock_stripe, db, test_store, test_subscription_tier
):
"""Test checkout fails when Stripe not configured."""
mock_stripe.is_configured = False
@@ -117,7 +117,7 @@ class TestBillingServiceCheckout:
with pytest.raises(PaymentSystemNotConfiguredError):
self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
@@ -126,7 +126,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_success(
self, mock_stripe, db, test_vendor, test_subscription_tier_with_stripe
self, mock_stripe, db, test_store, test_subscription_tier_with_stripe
):
"""Test successful checkout session creation."""
mock_stripe.is_configured = True
@@ -137,7 +137,7 @@ class TestBillingServiceCheckout:
result = self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
@@ -149,7 +149,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_tier_not_found(
self, mock_stripe, db, test_vendor
self, mock_stripe, db, test_store
):
"""Test checkout fails with invalid tier."""
mock_stripe.is_configured = True
@@ -157,7 +157,7 @@ class TestBillingServiceCheckout:
with pytest.raises(TierNotFoundError):
self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
tier_code="nonexistent",
is_annual=False,
success_url="https://example.com/success",
@@ -166,7 +166,7 @@ class TestBillingServiceCheckout:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_checkout_session_no_price(
self, mock_stripe, db, test_vendor, test_subscription_tier
self, mock_stripe, db, test_store, test_subscription_tier
):
"""Test checkout fails when tier has no Stripe price."""
mock_stripe.is_configured = True
@@ -174,7 +174,7 @@ class TestBillingServiceCheckout:
with pytest.raises(StripePriceNotConfiguredError):
self.service.create_checkout_session(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
tier_code="essential",
is_annual=False,
success_url="https://example.com/success",
@@ -192,32 +192,32 @@ class TestBillingServicePortal:
self.service = BillingService()
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_vendor):
def test_create_portal_session_stripe_not_configured(self, mock_stripe, db, test_store):
"""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,
store_id=test_store.id,
return_url="https://example.com/billing",
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_portal_session_no_subscription(self, mock_stripe, db, test_vendor):
def test_create_portal_session_no_subscription(self, mock_stripe, db, test_store):
"""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,
store_id=test_store.id,
return_url="https://example.com/billing",
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_create_portal_session_success(
self, mock_stripe, db, test_vendor, test_active_subscription
self, mock_stripe, db, test_store, test_active_subscription
):
"""Test successful portal session creation."""
mock_stripe.is_configured = True
@@ -227,7 +227,7 @@ class TestBillingServicePortal:
result = self.service.create_portal_session(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
return_url="https://example.com/billing",
)
@@ -243,30 +243,30 @@ class TestBillingServiceInvoices:
"""Initialize service instance before each test."""
self.service = BillingService()
def test_get_invoices_empty(self, db, test_vendor):
def test_get_invoices_empty(self, db, test_store):
"""Test getting invoices when none exist."""
invoices, total = self.service.get_invoices(db, test_vendor.id)
invoices, total = self.service.get_invoices(db, test_store.id)
assert invoices == []
assert total == 0
def test_get_invoices_with_data(self, db, test_vendor, test_billing_history):
def test_get_invoices_with_data(self, db, test_store, test_billing_history):
"""Test getting invoices returns data."""
invoices, total = self.service.get_invoices(db, test_vendor.id)
invoices, total = self.service.get_invoices(db, test_store.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):
def test_get_invoices_pagination(self, db, test_store, test_multiple_invoices):
"""Test invoice pagination."""
# Get first page
page1, total = self.service.get_invoices(db, test_vendor.id, skip=0, limit=2)
page1, total = self.service.get_invoices(db, test_store.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)
page2, _ = self.service.get_invoices(db, test_store.id, skip=2, limit=2)
assert len(page2) == 2
@@ -298,9 +298,9 @@ class TestBillingServiceAddons:
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)
def test_get_store_addons_empty(self, db, test_store):
"""Test getting store addons when none purchased."""
addons = self.service.get_store_addons(db, test_store.id)
assert addons == []
@@ -315,7 +315,7 @@ class TestBillingServiceCancellation:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_subscription_no_subscription(
self, mock_stripe, db, test_vendor
self, mock_stripe, db, test_store
):
"""Test cancel fails when no subscription."""
mock_stripe.is_configured = True
@@ -323,21 +323,21 @@ class TestBillingServiceCancellation:
with pytest.raises(NoActiveSubscriptionError):
self.service.cancel_subscription(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
reason="Test reason",
immediately=False,
)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_cancel_subscription_success(
self, mock_stripe, db, test_vendor, test_active_subscription
self, mock_stripe, db, test_store, test_active_subscription
):
"""Test successful subscription cancellation."""
mock_stripe.is_configured = True
result = self.service.cancel_subscription(
db=db,
vendor_id=test_vendor.id,
store_id=test_store.id,
reason="Too expensive",
immediately=False,
)
@@ -348,22 +348,22 @@ class TestBillingServiceCancellation:
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_subscription_not_cancelled(
self, mock_stripe, db, test_vendor, test_active_subscription
self, mock_stripe, db, test_store, 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)
self.service.reactivate_subscription(db, test_store.id)
@patch("app.modules.billing.services.billing_service.stripe_service")
def test_reactivate_subscription_success(
self, mock_stripe, db, test_vendor, test_cancelled_subscription
self, mock_stripe, db, test_store, test_cancelled_subscription
):
"""Test successful subscription reactivation."""
mock_stripe.is_configured = True
result = self.service.reactivate_subscription(db, test_vendor.id)
result = self.service.reactivate_subscription(db, test_store.id)
assert result["message"] == "Subscription reactivated successfully"
assert test_cancelled_subscription.cancelled_at is None
@@ -372,23 +372,23 @@ class TestBillingServiceCancellation:
@pytest.mark.unit
@pytest.mark.billing
class TestBillingServiceVendor:
"""Test suite for BillingService vendor operations."""
class TestBillingServiceStore:
"""Test suite for BillingService store 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)
def test_get_store_success(self, db, test_store):
"""Test getting store by ID."""
store = self.service.get_store(db, test_store.id)
assert vendor.id == test_vendor.id
assert store.id == test_store.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)
def test_get_store_not_found(self, db):
"""Test getting non-existent store raises error."""
with pytest.raises(StoreNotFoundException):
self.service.get_store(db, 99999)
# ==================== Fixtures ====================
@@ -480,7 +480,7 @@ def test_subscription_tiers(db):
@pytest.fixture
def test_subscription(db, test_vendor):
def test_subscription(db, test_store):
"""Create a basic subscription for testing."""
# Create tier first
tier = SubscriptionTier(
@@ -494,8 +494,8 @@ def test_subscription(db, test_vendor):
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
subscription = MerchantSubscription(
store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
period_start=datetime.now(timezone.utc),
@@ -508,7 +508,7 @@ def test_subscription(db, test_vendor):
@pytest.fixture
def test_active_subscription(db, test_vendor):
def test_active_subscription(db, test_store):
"""Create an active subscription with Stripe IDs."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -524,8 +524,8 @@ def test_active_subscription(db, test_vendor):
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
subscription = MerchantSubscription(
store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
@@ -540,7 +540,7 @@ def test_active_subscription(db, test_vendor):
@pytest.fixture
def test_cancelled_subscription(db, test_vendor):
def test_cancelled_subscription(db, test_store):
"""Create a cancelled subscription."""
# Create tier first if not exists
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == "essential").first()
@@ -556,8 +556,8 @@ def test_cancelled_subscription(db, test_vendor):
db.add(tier)
db.commit()
subscription = VendorSubscription(
vendor_id=test_vendor.id,
subscription = MerchantSubscription(
store_id=test_store.id,
tier="essential",
status=SubscriptionStatus.ACTIVE,
stripe_customer_id="cus_test123",
@@ -574,10 +574,10 @@ def test_cancelled_subscription(db, test_vendor):
@pytest.fixture
def test_billing_history(db, test_vendor):
def test_billing_history(db, test_store):
"""Create a billing history record."""
record = BillingHistory(
vendor_id=test_vendor.id,
store_id=test_store.id,
stripe_invoice_id="in_test123",
invoice_number="INV-001",
invoice_date=datetime.now(timezone.utc),
@@ -595,12 +595,12 @@ def test_billing_history(db, test_vendor):
@pytest.fixture
def test_multiple_invoices(db, test_vendor):
def test_multiple_invoices(db, test_store):
"""Create multiple billing history records."""
records = []
for i in range(5):
record = BillingHistory(
vendor_id=test_vendor.id,
store_id=test_store.id,
stripe_invoice_id=f"in_test{i}",
invoice_number=f"INV-{i:03d}",
invoice_date=datetime.now(timezone.utc),