# app/modules/contracts/features.py """ Feature provider protocol for cross-module feature declaration and usage tracking. This module defines the protocol that modules implement to declare their billable features. The billing module's FeatureAggregator discovers and aggregates all providers. Benefits: - Each module owns its feature declarations (no cross-module coupling) - Billing is feature-agnostic (discovers features dynamically) - Adding features only requires changes in the owning module - Scope-aware usage tracking (per-store or per-merchant) Usage: # 1. Implement the protocol in your module class CatalogFeatureProvider: @property def feature_category(self) -> str: return "catalog" def get_feature_declarations(self) -> list[FeatureDeclaration]: return [ FeatureDeclaration( code="products_limit", name_key="catalog.features.products_limit.name", description_key="catalog.features.products_limit.description", category="catalog", feature_type=FeatureType.QUANTITATIVE, scope=FeatureScope.STORE, default_limit=200, unit_key="catalog.features.products_limit.unit", ) ] def get_store_usage(self, db, store_id) -> list[FeatureUsage]: count = db.query(Product).filter(Product.store_id == store_id).count() return [FeatureUsage(feature_code="products_limit", current_count=count, label="Products")] def get_merchant_usage(self, db, merchant_id, platform_id) -> list[FeatureUsage]: count = db.query(Product).join(Store).filter( Store.merchant_id == merchant_id ).count() return [FeatureUsage(feature_code="products_limit", current_count=count, label="Products")] # 2. Register in module definition def _get_feature_provider(): from app.modules.catalog.services.catalog_features import catalog_feature_provider return catalog_feature_provider catalog_module = ModuleDefinition( code="catalog", feature_provider=_get_feature_provider, # ... ) # 3. Features appear automatically in billing when module is enabled """ import enum from dataclasses import dataclass from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from sqlalchemy.orm import Session class FeatureType(str, enum.Enum): """Type of feature for billing purposes.""" BINARY = "binary" # On/off feature (e.g., "analytics_dashboard") QUANTITATIVE = "quantitative" # Countable resource with limits (e.g., "products_limit") class FeatureScope(str, enum.Enum): """Scope at which quantitative limits apply.""" STORE = "store" # Limit applies per-store (e.g., 200 products per store) MERCHANT = "merchant" # Limit applies across all merchant's stores (e.g., 3 team members total) @dataclass class FeatureDeclaration: """ Static declaration of a billable feature. Returned by feature providers at startup. Contains no database state, only metadata about what a feature is and how it should be billed. Attributes: code: Unique identifier (e.g., "products_limit", "analytics_dashboard") name_key: i18n key for display name (e.g., "catalog.features.products_limit.name") description_key: i18n key for description category: Grouping category (matches module domain, e.g., "catalog") feature_type: BINARY or QUANTITATIVE scope: STORE or MERCHANT (for QUANTITATIVE; BINARY always merchant-level) default_limit: Sensible default for QUANTITATIVE (None = unlimited) unit_key: i18n key for unit label (e.g., "catalog.features.products_limit.unit") is_per_period: Whether the limit resets each billing period (e.g., orders/month) ui_icon: Lucide icon name for display display_order: Sort order within category (lower = higher priority) Example: FeatureDeclaration( code="products_limit", name_key="catalog.features.products_limit.name", description_key="catalog.features.products_limit.description", category="catalog", feature_type=FeatureType.QUANTITATIVE, scope=FeatureScope.STORE, default_limit=200, unit_key="catalog.features.products_limit.unit", ) """ code: str name_key: str description_key: str category: str feature_type: FeatureType scope: FeatureScope = FeatureScope.MERCHANT default_limit: int | None = None unit_key: str | None = None is_per_period: bool = False ui_icon: str | None = None display_order: int = 0 @dataclass class FeatureUsage: """ Current usage count for a quantitative feature. Returned by feature providers when asked about current resource consumption. Attributes: feature_code: Which feature this usage relates to current_count: Current count of the resource label: Human-readable label for display """ feature_code: str current_count: int label: str @runtime_checkable class FeatureProviderProtocol(Protocol): """ Protocol for modules that declare billable features. Each module implements this to declare what features it provides and how to measure current usage. The billing module's FeatureAggregator discovers all providers and uses them for feature gating. Implementation Notes: - get_feature_declarations() must be static (no DB needed) - Usage methods should use efficient aggregation queries - Return empty list if no usage data available (don't raise) Example Implementation: class CatalogFeatureProvider: @property def feature_category(self) -> str: return "catalog" def get_feature_declarations(self) -> list[FeatureDeclaration]: return [ FeatureDeclaration( code="products_limit", name_key="catalog.features.products_limit.name", description_key="catalog.features.products_limit.description", category="catalog", feature_type=FeatureType.QUANTITATIVE, scope=FeatureScope.STORE, default_limit=200, unit_key="catalog.features.products_limit.unit", ), ] def get_store_usage(self, db, store_id) -> list[FeatureUsage]: count = db.query(Product).filter(Product.store_id == store_id).count() return [FeatureUsage(feature_code="products_limit", current_count=count, label="Products")] def get_merchant_usage(self, db, merchant_id, platform_id) -> list[FeatureUsage]: count = db.query(Product).join(Store).filter(Store.merchant_id == merchant_id).count() return [FeatureUsage(feature_code="products_limit", current_count=count, label="Products")] """ @property def feature_category(self) -> str: """ Category name for this provider's features. Should match the module's domain (e.g., "catalog", "orders", "tenancy"). Returns: Category string used for grouping features """ ... def get_feature_declarations(self) -> list[FeatureDeclaration]: """ Get static feature declarations for this module. Called at startup to build the feature catalog. No database access needed. These declarations describe what features exist and how they're billed. Returns: List of FeatureDeclaration objects for this module """ ... def get_store_usage( self, db: "Session", store_id: int, ) -> list[FeatureUsage]: """ Get current usage counts for a specific store. Called when checking quantitative limits scoped to STORE. Should only count resources belonging to the specified store. Args: db: Database session for queries store_id: ID of the store to get usage for Returns: List of FeatureUsage objects with current counts """ ... def get_merchant_usage( self, db: "Session", merchant_id: int, platform_id: int, ) -> list[FeatureUsage]: """ Get current usage counts aggregated across all merchant's stores. Called when checking quantitative limits scoped to MERCHANT. Should aggregate usage across all stores owned by the merchant on the specified platform. Args: db: Database session for queries merchant_id: ID of the merchant platform_id: ID of the platform Returns: List of FeatureUsage objects with aggregated counts """ ... __all__ = [ "FeatureType", "FeatureScope", "FeatureDeclaration", "FeatureUsage", "FeatureProviderProtocol", ]