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

@@ -5,8 +5,7 @@ import pytest
from app.modules.billing.exceptions import FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError
from app.modules.billing.services.feature_service import FeatureService, feature_service
from app.modules.billing.models import Feature
from app.modules.billing.models import SubscriptionTier, VendorSubscription
from app.modules.billing.models import SubscriptionTier, MerchantSubscription
@pytest.mark.unit
@@ -18,27 +17,27 @@ class TestFeatureServiceAvailability:
"""Initialize service instance before each test."""
self.service = FeatureService()
def test_has_feature_true(self, db, test_vendor_with_subscription):
def test_has_feature_true(self, db, test_store_with_subscription):
"""Test has_feature returns True for available feature."""
vendor_id = test_vendor_with_subscription.id
result = self.service.has_feature(db, vendor_id, "basic_reports")
store_id = test_store_with_subscription.id
result = self.service.has_feature(db, store_id, "basic_reports")
assert result is True
def test_has_feature_false(self, db, test_vendor_with_subscription):
def test_has_feature_false(self, db, test_store_with_subscription):
"""Test has_feature returns False for unavailable feature."""
vendor_id = test_vendor_with_subscription.id
result = self.service.has_feature(db, vendor_id, "api_access")
store_id = test_store_with_subscription.id
result = self.service.has_feature(db, store_id, "api_access")
assert result is False
def test_has_feature_no_subscription(self, db, test_vendor):
"""Test has_feature returns False for vendor without subscription."""
result = self.service.has_feature(db, test_vendor.id, "basic_reports")
def test_has_feature_no_subscription(self, db, test_store):
"""Test has_feature returns False for store without subscription."""
result = self.service.has_feature(db, test_store.id, "basic_reports")
assert result is False
def test_get_vendor_feature_codes(self, db, test_vendor_with_subscription):
"""Test getting all feature codes for vendor."""
vendor_id = test_vendor_with_subscription.id
features = self.service.get_vendor_feature_codes(db, vendor_id)
def test_get_store_feature_codes(self, db, test_store_with_subscription):
"""Test getting all feature codes for store."""
store_id = test_store_with_subscription.id
features = self.service.get_store_feature_codes(db, store_id)
assert isinstance(features, set)
assert "basic_reports" in features
@@ -54,10 +53,10 @@ class TestFeatureServiceListing:
"""Initialize service instance before each test."""
self.service = FeatureService()
def test_get_vendor_features(self, db, test_vendor_with_subscription, test_features):
def test_get_store_features(self, db, test_store_with_subscription, test_features):
"""Test getting all features with availability."""
vendor_id = test_vendor_with_subscription.id
features = self.service.get_vendor_features(db, vendor_id)
store_id = test_store_with_subscription.id
features = self.service.get_store_features(db, store_id)
assert len(features) > 0
basic_reports = next((f for f in features if f.code == "basic_reports"), None)
@@ -68,30 +67,30 @@ class TestFeatureServiceListing:
assert api_access is not None
assert api_access.is_available is False
def test_get_vendor_features_by_category(
self, db, test_vendor_with_subscription, test_features
def test_get_store_features_by_category(
self, db, test_store_with_subscription, test_features
):
"""Test filtering features by category."""
vendor_id = test_vendor_with_subscription.id
features = self.service.get_vendor_features(db, vendor_id, category="analytics")
store_id = test_store_with_subscription.id
features = self.service.get_store_features(db, store_id, category="analytics")
assert all(f.category == "analytics" for f in features)
def test_get_vendor_features_available_only(
self, db, test_vendor_with_subscription, test_features
def test_get_store_features_available_only(
self, db, test_store_with_subscription, test_features
):
"""Test getting only available features."""
vendor_id = test_vendor_with_subscription.id
features = self.service.get_vendor_features(
db, vendor_id, include_unavailable=False
store_id = test_store_with_subscription.id
features = self.service.get_store_features(
db, store_id, include_unavailable=False
)
assert all(f.is_available for f in features)
def test_get_available_feature_codes(self, db, test_vendor_with_subscription):
def test_get_available_feature_codes(self, db, test_store_with_subscription):
"""Test getting simple list of available codes."""
vendor_id = test_vendor_with_subscription.id
codes = self.service.get_available_feature_codes(db, vendor_id)
store_id = test_store_with_subscription.id
codes = self.service.get_available_feature_codes(db, store_id)
assert isinstance(codes, list)
assert "basic_reports" in codes
@@ -159,28 +158,28 @@ class TestFeatureServiceCache:
"""Initialize service instance before each test."""
self.service = FeatureService()
def test_cache_invalidation(self, db, test_vendor_with_subscription):
"""Test cache invalidation for vendor."""
vendor_id = test_vendor_with_subscription.id
def test_cache_invalidation(self, db, test_store_with_subscription):
"""Test cache invalidation for store."""
store_id = test_store_with_subscription.id
# Prime the cache
self.service.get_vendor_feature_codes(db, vendor_id)
assert self.service._cache.get(vendor_id) is not None
self.service.get_store_feature_codes(db, store_id)
assert self.service._cache.get(store_id) is not None
# Invalidate
self.service.invalidate_vendor_cache(vendor_id)
assert self.service._cache.get(vendor_id) is None
self.service.invalidate_store_cache(store_id)
assert self.service._cache.get(store_id) is None
def test_cache_invalidate_all(self, db, test_vendor_with_subscription):
def test_cache_invalidate_all(self, db, test_store_with_subscription):
"""Test invalidating entire cache."""
vendor_id = test_vendor_with_subscription.id
store_id = test_store_with_subscription.id
# Prime the cache
self.service.get_vendor_feature_codes(db, vendor_id)
self.service.get_store_feature_codes(db, store_id)
# Invalidate all
self.service.invalidate_all_cache()
assert self.service._cache.get(vendor_id) is None
assert self.service._cache.get(store_id) is None
@pytest.mark.unit
@@ -301,14 +300,14 @@ def test_subscription_tiers(db):
@pytest.fixture
def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
"""Create a vendor with an active subscription."""
def test_store_with_subscription(db, test_store, test_subscription_tiers):
"""Create a store with an active subscription."""
from datetime import datetime, timezone
essential_tier = test_subscription_tiers[0] # Use the essential tier from tiers list
now = datetime.now(timezone.utc)
subscription = VendorSubscription(
vendor_id=test_vendor.id,
subscription = StoreSubscription(
store_id=test_store.id,
tier="essential",
tier_id=essential_tier.id,
status="active",
@@ -318,8 +317,8 @@ def test_vendor_with_subscription(db, test_vendor, test_subscription_tiers):
)
db.add(subscription)
db.commit()
db.refresh(test_vendor)
return test_vendor
db.refresh(test_store)
return test_store
@pytest.fixture