diff --git a/app/modules/billing/routes/api/admin.py b/app/modules/billing/routes/api/admin.py index 4c641777..bb9591ad 100644 --- a/app/modules/billing/routes/api/admin.py +++ b/app/modules/billing/routes/api/admin.py @@ -67,6 +67,7 @@ def list_subscription_tiers( for t in tiers: resp = SubscriptionTierResponse.model_validate(t) resp.platform_name = platforms_map.get(t.platform_id) if t.platform_id else None + resp.feature_codes = sorted(t.get_feature_codes()) tiers_response.append(resp) return SubscriptionTierListResponse( @@ -83,7 +84,9 @@ def get_subscription_tier( ): """Get a specific subscription tier by code.""" tier = admin_subscription_service.get_tier_by_code(db, tier_code) - return SubscriptionTierResponse.model_validate(tier) + resp = SubscriptionTierResponse.model_validate(tier) + resp.feature_codes = sorted(tier.get_feature_codes()) + return resp @admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201) @@ -96,7 +99,9 @@ def create_subscription_tier( tier = admin_subscription_service.create_tier(db, tier_data.model_dump()) db.commit() db.refresh(tier) - return SubscriptionTierResponse.model_validate(tier) + resp = SubscriptionTierResponse.model_validate(tier) + resp.feature_codes = sorted(tier.get_feature_codes()) + return resp @admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse) @@ -111,7 +116,9 @@ def update_subscription_tier( tier = admin_subscription_service.update_tier(db, tier_code, update_data) db.commit() db.refresh(tier) - return SubscriptionTierResponse.model_validate(tier) + resp = SubscriptionTierResponse.model_validate(tier) + resp.feature_codes = sorted(tier.get_feature_codes()) + return resp @admin_router.delete("/tiers/{tier_code}", status_code=204) diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index ece263d7..3489c5b7 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -44,6 +44,13 @@ def _get_storefront_router(): return storefront_router +def _get_feature_provider(): + """Lazy import of feature provider to avoid circular imports.""" + from app.modules.loyalty.services.loyalty_features import loyalty_feature_provider + + return loyalty_feature_provider + + # Loyalty module definition loyalty_module = ModuleDefinition( code="loyalty", @@ -213,6 +220,8 @@ loyalty_module = ModuleDefinition( options={"queue": "scheduled"}, ), ], + # Feature provider for billing feature gating + feature_provider=_get_feature_provider, ) diff --git a/app/modules/loyalty/services/loyalty_features.py b/app/modules/loyalty/services/loyalty_features.py new file mode 100644 index 00000000..38f66c6d --- /dev/null +++ b/app/modules/loyalty/services/loyalty_features.py @@ -0,0 +1,184 @@ +# app/modules/loyalty/services/loyalty_features.py +""" +Loyalty feature provider for the billing feature system. + +Declares loyalty-related billable features (stamps, points, cards, wallets, etc.) +for feature gating. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from app.modules.contracts.features import ( + FeatureDeclaration, + FeatureScope, + FeatureType, + FeatureUsage, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +class LoyaltyFeatureProvider: + """Feature provider for the loyalty module. + + Declares: + - loyalty_stamps: stamp-based loyalty programs + - loyalty_points: points-based loyalty programs + - loyalty_hybrid: combined stamps and points + - loyalty_cards: customer card management + - loyalty_enrollment: customer enrollment + - loyalty_staff_pins: staff PIN management + - loyalty_anti_fraud: cooldown and daily limits + - loyalty_google_wallet: Google Wallet passes + - loyalty_apple_wallet: Apple Wallet passes + - loyalty_stats: dashboard statistics + - loyalty_reports: transaction reports + """ + + @property + def feature_category(self) -> str: + return "loyalty" + + def get_feature_declarations(self) -> list[FeatureDeclaration]: + return [ + FeatureDeclaration( + code="loyalty_stamps", + name_key="loyalty.features.loyalty_stamps.name", + description_key="loyalty.features.loyalty_stamps.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="stamp", + display_order=10, + ), + FeatureDeclaration( + code="loyalty_points", + name_key="loyalty.features.loyalty_points.name", + description_key="loyalty.features.loyalty_points.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="coins", + display_order=20, + ), + FeatureDeclaration( + code="loyalty_hybrid", + name_key="loyalty.features.loyalty_hybrid.name", + description_key="loyalty.features.loyalty_hybrid.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="layers", + display_order=30, + ), + FeatureDeclaration( + code="loyalty_cards", + name_key="loyalty.features.loyalty_cards.name", + description_key="loyalty.features.loyalty_cards.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="credit-card", + display_order=40, + ), + FeatureDeclaration( + code="loyalty_enrollment", + name_key="loyalty.features.loyalty_enrollment.name", + description_key="loyalty.features.loyalty_enrollment.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="user-plus", + display_order=50, + ), + FeatureDeclaration( + code="loyalty_staff_pins", + name_key="loyalty.features.loyalty_staff_pins.name", + description_key="loyalty.features.loyalty_staff_pins.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="shield", + display_order=60, + ), + FeatureDeclaration( + code="loyalty_anti_fraud", + name_key="loyalty.features.loyalty_anti_fraud.name", + description_key="loyalty.features.loyalty_anti_fraud.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="lock", + display_order=70, + ), + FeatureDeclaration( + code="loyalty_google_wallet", + name_key="loyalty.features.loyalty_google_wallet.name", + description_key="loyalty.features.loyalty_google_wallet.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="smartphone", + display_order=80, + ), + FeatureDeclaration( + code="loyalty_apple_wallet", + name_key="loyalty.features.loyalty_apple_wallet.name", + description_key="loyalty.features.loyalty_apple_wallet.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="smartphone", + display_order=85, + ), + FeatureDeclaration( + code="loyalty_stats", + name_key="loyalty.features.loyalty_stats.name", + description_key="loyalty.features.loyalty_stats.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="bar-chart", + display_order=90, + ), + FeatureDeclaration( + code="loyalty_reports", + name_key="loyalty.features.loyalty_reports.name", + description_key="loyalty.features.loyalty_reports.description", + category="loyalty", + feature_type=FeatureType.BINARY, + scope=FeatureScope.MERCHANT, + ui_icon="file-text", + display_order=100, + ), + ] + + def get_store_usage( + self, + db: Session, + store_id: int, + ) -> list[FeatureUsage]: + return [] + + def get_merchant_usage( + self, + db: Session, + merchant_id: int, + platform_id: int, + ) -> list[FeatureUsage]: + return [] + + +# Singleton instance for module registration +loyalty_feature_provider = LoyaltyFeatureProvider() + +__all__ = [ + "LoyaltyFeatureProvider", + "loyalty_feature_provider", +] diff --git a/app/modules/tenancy/routes/api/admin_merchants.py b/app/modules/tenancy/routes/api/admin_merchants.py index 26538505..831f32a6 100644 --- a/app/modules/tenancy/routes/api/admin_merchants.py +++ b/app/modules/tenancy/routes/api/admin_merchants.py @@ -109,6 +109,7 @@ def get_all_merchants( name=c.name, description=c.description, owner_user_id=c.owner_user_id, + owner_email=c.owner.email if c.owner else None, contact_email=c.contact_email, contact_phone=c.contact_phone, website=c.website, @@ -118,6 +119,7 @@ def get_all_merchants( is_verified=c.is_verified, created_at=c.created_at.isoformat(), updated_at=c.updated_at.isoformat(), + store_count=c.store_count, ) for c in merchants ], diff --git a/app/modules/tenancy/schemas/merchant.py b/app/modules/tenancy/schemas/merchant.py index 61f55289..3688897c 100644 --- a/app/modules/tenancy/schemas/merchant.py +++ b/app/modules/tenancy/schemas/merchant.py @@ -90,6 +90,7 @@ class MerchantResponse(BaseModel): # Owner information owner_user_id: int + owner_email: str | None = Field(None, description="Owner's email address") # Contact Information contact_email: str @@ -108,6 +109,9 @@ class MerchantResponse(BaseModel): created_at: str updated_at: str + # Store statistics + store_count: int = Field(0, description="Number of stores under this merchant") + class MerchantDetailResponse(MerchantResponse): """ @@ -117,11 +121,9 @@ class MerchantDetailResponse(MerchantResponse): """ # Owner details (from related User) - owner_email: str | None = Field(None, description="Owner's email address") owner_username: str | None = Field(None, description="Owner's username") # Store statistics - store_count: int = Field(0, description="Number of stores under this merchant") active_store_count: int = Field( 0, description="Number of active stores under this merchant" ) diff --git a/app/modules/tenancy/services/merchant_service.py b/app/modules/tenancy/services/merchant_service.py index bc7314e3..6729e10c 100644 --- a/app/modules/tenancy/services/merchant_service.py +++ b/app/modules/tenancy/services/merchant_service.py @@ -148,7 +148,10 @@ class MerchantService: Returns: Tuple of (merchants list, total count) """ - query = select(Merchant).options(joinedload(Merchant.stores)) + query = select(Merchant).options( + joinedload(Merchant.stores), + joinedload(Merchant.owner), + ) # Apply filters if search: diff --git a/app/modules/tenancy/templates/tenancy/admin/merchant-detail.html b/app/modules/tenancy/templates/tenancy/admin/merchant-detail.html index 216feeac..c2dc184d 100644 --- a/app/modules/tenancy/templates/tenancy/admin/merchant-detail.html +++ b/app/modules/tenancy/templates/tenancy/admin/merchant-detail.html @@ -331,12 +331,20 @@