- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.) - Added ignore rules for patterns intentional in this codebase: E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from), SIM108/SIM105/SIM117 (readability preferences) - Added per-file ignores for tests and scripts - Excluded broken scripts/rename_terminology.py (has curly quotes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
9.1 KiB
Python
264 lines
9.1 KiB
Python
# 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",
|
|
]
|