diff --git a/app/modules/base.py b/app/modules/base.py index 0c45bbbc..d2645ed3 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from pydantic import BaseModel from app.modules.contracts.audit import AuditProviderProtocol + from app.modules.contracts.cms import MediaUsageProviderProtocol from app.modules.contracts.features import FeatureProviderProtocol from app.modules.contracts.metrics import MetricsProviderProtocol from app.modules.contracts.widgets import DashboardWidgetProviderProtocol @@ -477,6 +478,14 @@ class ModuleDefinition: # The provider will be discovered by billing's FeatureAggregator service. feature_provider: "Callable[[], FeatureProviderProtocol] | None" = None + # ========================================================================= + # Media Usage Provider (Module-Driven Media Usage Tracking) + # ========================================================================= + # Callable that returns a MediaUsageProviderProtocol implementation. + # Modules that use media files (catalog, etc.) can register a provider + # to report where media is being used. + media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None + # ========================================================================= # Menu Item Methods (Legacy - uses menu_items dict of IDs) # ========================================================================= @@ -929,6 +938,24 @@ class ModuleDefinition: return None return self.feature_provider() + # ========================================================================= + # Media Usage Provider Methods + # ========================================================================= + + def has_media_usage_provider(self) -> bool: + """Check if this module has a media usage provider.""" + return self.media_usage_provider is not None + + def get_media_usage_provider_instance(self) -> "MediaUsageProviderProtocol | None": + """Get the media usage provider instance for this module. + + Returns: + MediaUsageProviderProtocol instance, or None + """ + if self.media_usage_provider is None: + return None + return self.media_usage_provider() + # ========================================================================= # Magic Methods # ========================================================================= diff --git a/app/modules/billing/services/capacity_forecast_service.py b/app/modules/billing/services/capacity_forecast_service.py index d817ebd0..b5735107 100644 --- a/app/modules/billing/services/capacity_forecast_service.py +++ b/app/modules/billing/services/capacity_forecast_service.py @@ -47,7 +47,7 @@ class CapacityForecastService: Should be called by a daily background job. """ - from app.modules.core.services.image_service import image_service + from app.modules.cms.services.media_service import media_service from app.modules.monitoring.services.platform_health_service import platform_health_service now = datetime.now(UTC) @@ -108,7 +108,7 @@ class CapacityForecastService: # Storage metrics try: - image_stats = image_service.get_storage_stats() + image_stats = media_service.get_storage_stats(db) storage_gb = image_stats.get("total_size_gb", 0) except Exception: storage_gb = 0 diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 4906b7b7..aa914b82 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -43,6 +43,13 @@ def _get_feature_provider(): return catalog_feature_provider +def _get_media_usage_provider(): + """Lazy import of media usage provider to avoid circular imports.""" + from app.modules.catalog.services.product_media_service import product_media_service + + return product_media_service + + # Catalog module definition catalog_module = ModuleDefinition( code="catalog", @@ -122,6 +129,7 @@ catalog_module = ModuleDefinition( # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, feature_provider=_get_feature_provider, + media_usage_provider=_get_media_usage_provider, ) diff --git a/app/modules/catalog/services/product_media_service.py b/app/modules/catalog/services/product_media_service.py index 577760c3..45601ec8 100644 --- a/app/modules/catalog/services/product_media_service.py +++ b/app/modules/catalog/services/product_media_service.py @@ -231,6 +231,26 @@ class ProductMediaService: "total_count": len(associations), } + def get_media_usage(self, db: Session, media_id: int) -> list[dict]: + """Implement MediaUsageProviderProtocol. + + Returns list of usage records for a given media file across products. + """ + rows = ( + db.query(ProductMedia) + .filter(ProductMedia.media_id == media_id) + .all() + ) + return [ + { + "entity_type": "product", + "entity_id": r.product_id, + "entity_name": r.product.name if r.product else "Unknown", + "usage_type": r.usage_type, + } + for r in rows + ] + def set_main_image( self, db: Session, diff --git a/app/modules/cms/routes/api/admin_images.py b/app/modules/cms/routes/api/admin_images.py index e5f5d00e..379c3b62 100644 --- a/app/modules/cms/routes/api/admin_images.py +++ b/app/modules/cms/routes/api/admin_images.py @@ -3,97 +3,33 @@ Admin image management endpoints. Provides: -- Image upload with automatic processing -- Image deletion -- Storage statistics +- Storage statistics (delegates to MediaService) """ import logging -from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api -from app.modules.core.services.image_service import image_service +from app.core.database import get_db +from app.modules.cms.services.media_service import media_service from models.schema.auth import UserContext -from app.modules.cms.schemas.image import ( - ImageDeleteResponse, - ImageStorageStats, - ImageUploadResponse, -) +from app.modules.cms.schemas.image import ImageStorageStats admin_images_router = APIRouter(prefix="/images") logger = logging.getLogger(__name__) -@admin_images_router.post("/upload", response_model=ImageUploadResponse) -async def upload_image( - file: UploadFile = File(...), - store_id: int = Form(...), - product_id: int | None = Form(None), - current_admin: UserContext = Depends(get_current_admin_api), -): - """Upload and process an image. - - The image will be: - - Converted to WebP format - - Resized to multiple variants (original, 800px, 200px) - - Stored in a sharded directory structure - - Args: - file: Image file to upload - store_id: Store ID for the image - product_id: Optional product ID - - Returns: - Image URLs and metadata - """ - # Read file content - content = await file.read() - - # Delegate all validation and processing to service - result = image_service.upload_product_image( - file_content=content, - filename=file.filename or "image.jpg", - content_type=file.content_type, - store_id=store_id, - product_id=product_id, - ) - - logger.info(f"Image uploaded: {result['id']} for store {store_id}") - - return ImageUploadResponse(success=True, image=result) - - -@admin_images_router.delete("/{image_hash}", response_model=ImageDeleteResponse) -async def delete_image( - image_hash: str, - current_admin: UserContext = Depends(get_current_admin_api), -): - """Delete an image and all its variants. - - Args: - image_hash: The image ID/hash - - Returns: - Deletion status - """ - deleted = image_service.delete_product_image(image_hash) - - if deleted: - logger.info(f"Image deleted: {image_hash}") - return ImageDeleteResponse(success=True, message="Image deleted successfully") - else: - return ImageDeleteResponse(success=False, message="Image not found") - - @admin_images_router.get("/stats", response_model=ImageStorageStats) async def get_storage_stats( current_admin: UserContext = Depends(get_current_admin_api), + db: Session = Depends(get_db), ): """Get image storage statistics. Returns: Storage metrics including file counts, sizes, and directory info """ - stats = image_service.get_storage_stats() + stats = media_service.get_storage_stats(db) return ImageStorageStats(**stats) diff --git a/app/modules/cms/routes/api/store_media.py b/app/modules/cms/routes/api/store_media.py index f59d72f2..6cecba75 100644 --- a/app/modules/cms/routes/api/store_media.py +++ b/app/modules/cms/routes/api/store_media.py @@ -257,17 +257,29 @@ def get_media_usage( """ Get where this media file is being used. - - Check products using this media - - Return list of usage + Discovers usage from all registered module providers. """ - # Service will raise MediaNotFoundException if not found - usage = media_service.get_media_usage( + # Verify media belongs to store (raises MediaNotFoundException if not found) + media_service.get_media( db=db, store_id=current_user.token_store_id, media_id=media_id, ) - return MediaUsageResponse(**usage) + # Discover usage from registered providers + from app.modules.registry import MODULES + + usage = [] + for module in MODULES.values(): + provider = module.get_media_usage_provider_instance() + if provider: + usage.extend(provider.get_media_usage(db, media_id)) + + return MediaUsageResponse( + media_id=media_id, + usage=usage, + total_usage_count=len(usage), + ) @store_media_router.post("/optimize/{media_id}", response_model=OptimizationResultResponse) diff --git a/app/modules/cms/schemas/__init__.py b/app/modules/cms/schemas/__init__.py index 21212a47..e192c164 100644 --- a/app/modules/cms/schemas/__init__.py +++ b/app/modules/cms/schemas/__init__.py @@ -43,20 +43,17 @@ from app.modules.cms.schemas.media import ( MediaListResponse, MediaMetadataUpdate, MediaUploadResponse, + MediaUsageItem, MediaUsageResponse, MessageResponse, MultipleUploadResponse, OptimizationResultResponse, - ProductUsageInfo, UploadedFileInfo, ) # Image schemas from app.modules.cms.schemas.image import ( - ImageDeleteResponse, ImageStorageStats, - ImageUploadResponse, - ImageUrls, ) # Theme schemas @@ -109,13 +106,10 @@ __all__ = [ "MessageResponse", "MultipleUploadResponse", "OptimizationResultResponse", - "ProductUsageInfo", + "MediaUsageItem", "UploadedFileInfo", # Image - "ImageDeleteResponse", "ImageStorageStats", - "ImageUploadResponse", - "ImageUrls", # Theme "ThemeDeleteResponse", "ThemePresetListResponse", diff --git a/app/modules/cms/schemas/image.py b/app/modules/cms/schemas/image.py index 0813b380..00967b89 100644 --- a/app/modules/cms/schemas/image.py +++ b/app/modules/cms/schemas/image.py @@ -6,33 +6,6 @@ Pydantic schemas for image operations. from pydantic import BaseModel -class ImageUrls(BaseModel): - """URLs for image variants.""" - - original: str - medium: str | None = None # 800px variant - thumb: str | None = None # 200px variant - - # Allow arbitrary keys for flexibility - class Config: - extra = "allow" - - -class ImageUploadResponse(BaseModel): - """Response from image upload.""" - - success: bool - image: dict | None = None - error: str | None = None - - -class ImageDeleteResponse(BaseModel): - """Response from image deletion.""" - - success: bool - message: str - - class ImageStorageStats(BaseModel): """Image storage statistics.""" diff --git a/app/modules/cms/schemas/media.py b/app/modules/cms/schemas/media.py index f18e5512..91dcd2bc 100644 --- a/app/modules/cms/schemas/media.py +++ b/app/modules/cms/schemas/media.py @@ -164,22 +164,21 @@ class MediaMetadataUpdate(BaseModel): # ============================================================================ -class ProductUsageInfo(BaseModel): - """Information about product using this media.""" +class MediaUsageItem(BaseModel): + """Information about an entity using this media.""" - product_id: int - product_name: str - usage_type: str # main_image, gallery, variant, etc. + entity_type: str # Defined by provider (e.g. "product") + entity_id: int + entity_name: str + usage_type: str # Defined by provider (e.g. "main_image", "gallery") class MediaUsageResponse(BaseModel): """Response showing where media is being used.""" media_id: int | None = None - products: list[ProductUsageInfo] = [] - other_usage: list[dict[str, Any]] = [] + usage: list[MediaUsageItem] = [] total_usage_count: int = 0 - message: str | None = None # ============================================================================ diff --git a/app/modules/cms/services/media_service.py b/app/modules/cms/services/media_service.py index 8936f7cb..d96c52f0 100644 --- a/app/modules/cms/services/media_service.py +++ b/app/modules/cms/services/media_service.py @@ -18,7 +18,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any -from sqlalchemy import or_ +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.modules.cms.exceptions import ( @@ -69,6 +69,14 @@ MAX_FILE_SIZES = { # Thumbnail settings THUMBNAIL_SIZE = (200, 200) +# Image variant settings (ported from ImageService) +IMAGE_VARIANT_QUALITY = 85 +IMAGE_MAX_DIMENSION = 2000 +IMAGE_VARIANT_SIZES = { + "800": 800, + "200": 200, +} + class MediaService: """Service for store media library operations.""" @@ -139,38 +147,80 @@ class MediaService: logger.warning(f"Could not get image dimensions: {e}") return None - def _generate_thumbnail( - self, source_path: Path, store_id: int - ) -> str | None: - """Generate thumbnail for image file.""" + def _resize_image(self, img: "Image.Image", max_dimension: int) -> "Image.Image": + """Resize image while maintaining aspect ratio. + + Args: + img: PIL Image + max_dimension: Maximum width or height + + Returns: + Resized PIL Image (or original if already smaller) + """ + width, height = img.size + if width <= max_dimension and height <= max_dimension: + return img + + if width > height: + new_width = max_dimension + new_height = int(height * (max_dimension / width)) + else: + new_height = max_dimension + new_width = int(width * (max_dimension / height)) + + from PIL import Image + return img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + def _generate_image_variants( + self, source_path: Path, filename_stem: str + ) -> dict[str, str]: + """Generate WebP image variants at multiple sizes. + + Args: + source_path: Path to the original image file + filename_stem: UUID stem for naming variants + + Returns: + Dict of size_name -> relative path (from UPLOAD_DIR) + """ try: from PIL import Image - # Create thumbnails directory - thumb_dir = self._get_store_upload_path(store_id, "thumbnails") - self._ensure_upload_dir(thumb_dir) + variants: dict[str, str] = {} + parent_dir = source_path.parent - # Generate thumbnail filename - thumb_filename = f"thumb_{source_path.name}" - thumb_path = thumb_dir / thumb_filename - - # Create thumbnail with Image.open(source_path) as img: - img.thumbnail(THUMBNAIL_SIZE) # Convert to RGB if needed (for PNG with transparency) if img.mode in ("RGBA", "P"): img = img.convert("RGB") - img.save(thumb_path, "JPEG", quality=85) - # Return relative path - return str(thumb_path.relative_to(UPLOAD_DIR)) + # Resize original if larger than max dimension + img = self._resize_image(img, IMAGE_MAX_DIMENSION) + + for size_name, max_dim in IMAGE_VARIANT_SIZES.items(): + variant_img = self._resize_image(img.copy(), max_dim) + variant_filename = f"{filename_stem}_{size_name}.webp" + variant_path = parent_dir / variant_filename + + variant_img.save( + variant_path, "WEBP", quality=IMAGE_VARIANT_QUALITY + ) + + variants[size_name] = str( + variant_path.relative_to(UPLOAD_DIR) + ) + logger.debug( + f"Generated {size_name}px variant: {variant_path}" + ) + + return variants except ImportError: - logger.debug("PIL not available, skipping thumbnail generation") - return None + logger.debug("PIL not available, skipping variant generation") + return {} except Exception as e: - logger.warning(f"Could not generate thumbnail: {e}") - return None + logger.warning(f"Could not generate image variants: {e}") + return {} async def upload_file( self, @@ -214,15 +264,24 @@ class MediaService: # Get MIME type mime_type, _ = mimetypes.guess_type(filename) - # Get image dimensions and generate thumbnail + # Get image dimensions and generate variants width, height = None, None thumbnail_path = None + extra_metadata: dict[str, Any] = {} if media_type == "image": dimensions = self._get_image_dimensions(file_path) if dimensions: width, height = dimensions - thumbnail_path = self._generate_thumbnail(file_path, store_id) + + # Generate WebP variants (800px, 200px) + stem = unique_filename.rsplit(".", 1)[0] if "." in unique_filename else unique_filename + variants = self._generate_image_variants(file_path, stem) + if variants: + extra_metadata["variants"] = variants + # Use the 200px variant as thumbnail + if "200" in variants: + thumbnail_path = variants["200"] # Create database record media_file = MediaFile( @@ -237,6 +296,7 @@ class MediaService: height=height, thumbnail_path=thumbnail_path, folder=folder, + extra_metadata=extra_metadata if extra_metadata else None, ) db.add(media_file) @@ -410,6 +470,13 @@ class MediaService: if thumb_path.exists(): thumb_path.unlink() + # Delete variant files + if media.extra_metadata and "variants" in media.extra_metadata: + for variant_path in media.extra_metadata["variants"].values(): + vpath = UPLOAD_DIR / variant_path + if vpath.exists(): + vpath.unlink() + # Delete database record db.delete(media) @@ -417,9 +484,38 @@ class MediaService: return True - # Note: Product-specific methods (attach_to_product, detach_from_product, - # get_media_usage) have been moved to catalog.services.product_media_service - # CMS media_service is now consumer-agnostic. + def get_storage_stats(self, db: Session) -> dict: + """Get storage statistics from MediaFile records. + + Returns: + Dict with storage metrics for health monitoring. + """ + total_files = db.query(func.count(MediaFile.id)).scalar() or 0 + total_size = db.query(func.sum(MediaFile.file_size)).scalar() or 0 + + # Count distinct folders as proxy for directory count + directory_count = ( + db.query(func.count(func.distinct(MediaFile.folder))).scalar() or 0 + ) + + # Image-specific stats + image_count = ( + db.query(func.count(MediaFile.id)) + .filter(MediaFile.media_type == "image") + .scalar() + or 0 + ) + + return { + "total_files": total_files, + "total_size_bytes": total_size, + "total_size_mb": round(total_size / (1024 * 1024), 2) if total_size else 0, + "total_size_gb": round(total_size / (1024 * 1024 * 1024), 3) if total_size else 0, + "directory_count": directory_count, + "max_files_per_dir": 0, # Not applicable for DB-backed tracking + "avg_files_per_dir": round(total_files / directory_count, 1) if directory_count else 0, + "products_estimated": image_count, + } # Create service instance diff --git a/app/modules/contracts/__init__.py b/app/modules/contracts/__init__.py index 6415aa98..09c31dfe 100644 --- a/app/modules/contracts/__init__.py +++ b/app/modules/contracts/__init__.py @@ -50,7 +50,7 @@ from app.modules.contracts.audit import ( AuditProviderProtocol, ) from app.modules.contracts.base import ServiceProtocol -from app.modules.contracts.cms import ContentServiceProtocol +from app.modules.contracts.cms import ContentServiceProtocol, MediaUsageProviderProtocol from app.modules.contracts.features import ( FeatureDeclaration, FeatureProviderProtocol, @@ -78,6 +78,7 @@ __all__ = [ "ServiceProtocol", # CMS protocols "ContentServiceProtocol", + "MediaUsageProviderProtocol", # Audit protocols "AuditEvent", "AuditProviderProtocol", diff --git a/app/modules/contracts/cms.py b/app/modules/contracts/cms.py index e0e09c61..b99b5b70 100644 --- a/app/modules/contracts/cms.py +++ b/app/modules/contracts/cms.py @@ -122,6 +122,22 @@ class ContentServiceProtocol(Protocol): ... +@runtime_checkable +class MediaUsageProviderProtocol(Protocol): + """Protocol for modules that track media file usage.""" + + def get_media_usage(self, db: "Session", media_id: int) -> list[dict]: + """Return list of usage records. + + Each dict should contain: + entity_type: str (e.g. "product") + entity_id: int + entity_name: str + usage_type: str (e.g. "main_image", "gallery") + """ + ... + + @runtime_checkable class MediaServiceProtocol(Protocol): """ diff --git a/app/modules/core/services/__init__.py b/app/modules/core/services/__init__.py index 1a099207..6fe9e01d 100644 --- a/app/modules/core/services/__init__.py +++ b/app/modules/core/services/__init__.py @@ -5,7 +5,6 @@ Core module services. Provides foundational services used across the platform: - auth_service: Authentication and authorization - menu_service: Menu visibility and configuration -- image_service: Image upload and management - storage_service: Storage abstraction (local/R2) - admin_settings_service: Platform-wide admin settings - platform_settings_service: Platform settings with resolution chain @@ -16,7 +15,6 @@ from app.modules.core.services.admin_settings_service import ( admin_settings_service, ) from app.modules.core.services.auth_service import AuthService, auth_service -from app.modules.core.services.image_service import ImageService, image_service from app.modules.core.services.menu_service import MenuItemConfig, MenuService, menu_service from app.modules.core.services.menu_discovery_service import ( DiscoveredMenuItem, @@ -57,9 +55,6 @@ __all__ = [ "DiscoveredMenuItem", "DiscoveredMenuSection", "menu_discovery_service", - # Image - "ImageService", - "image_service", # Storage "StorageBackend", "LocalStorageBackend", diff --git a/app/modules/core/services/image_service.py b/app/modules/core/services/image_service.py deleted file mode 100644 index f95694a8..00000000 --- a/app/modules/core/services/image_service.py +++ /dev/null @@ -1,307 +0,0 @@ -# app/modules/core/services/image_service.py -""" -Image upload and management service. - -Provides: -- Image upload with automatic optimization -- WebP conversion -- Multiple size variant generation -- Sharded directory structure for performance -""" - -import hashlib -import logging -import os -from datetime import datetime -from io import BytesIO -from pathlib import Path - -from PIL import Image - -from app.exceptions import ValidationException - -logger = logging.getLogger(__name__) - -# Maximum upload size (10MB) -MAX_UPLOAD_SIZE = 10 * 1024 * 1024 - - -class ImageService: - """Service for image upload and management.""" - - # Supported image formats - ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} - ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} - - # Size variants to generate - SIZES = { - "original": None, # No max dimension, just optimize - "800": 800, # Medium size for product cards - "200": 200, # Thumbnail for grids - } - - # Quality settings - QUALITY = 85 - MAX_DIMENSION = 2000 # Max dimension for original - - def __init__(self, upload_dir: str = "static/uploads"): - """Initialize image service. - - Args: - upload_dir: Base directory for uploads (relative to project root) - """ - self.upload_dir = Path(upload_dir) - self.products_dir = self.upload_dir / "products" - - # Ensure directories exist - self.products_dir.mkdir(parents=True, exist_ok=True) - - def upload_product_image( - self, - file_content: bytes, - filename: str, - store_id: int, - product_id: int | None = None, - content_type: str | None = None, - ) -> dict: - """Upload and process a product image. - - Args: - file_content: Raw file bytes - filename: Original filename - store_id: Store ID for path generation - product_id: Optional product ID - content_type: MIME type of the uploaded file - - Returns: - Dict with image info and URLs - - Raises: - ValidationException: If file is too large or invalid type - """ - # Validate file size - if len(file_content) > MAX_UPLOAD_SIZE: - raise ValidationException( - f"File too large. Maximum size: {MAX_UPLOAD_SIZE // (1024*1024)}MB" - ) - - # Validate content type - if not content_type or not content_type.startswith("image/"): - raise ValidationException("Invalid file type. Only images are allowed.") - - # Validate file extension - ext = self._get_extension(filename) - if ext not in self.ALLOWED_EXTENSIONS: - raise ValidationException( - f"Invalid file type: {ext}. Allowed: {', '.join(self.ALLOWED_EXTENSIONS)}" - ) - - # Generate unique hash for this image - image_hash = self._generate_hash(store_id, product_id, filename) - - # Determine sharded directory path - shard_path = self._get_shard_path(image_hash) - full_dir = self.products_dir / shard_path - full_dir.mkdir(parents=True, exist_ok=True) - - # Load and process image - try: - img = Image.open(BytesIO(file_content)) - - # Convert to RGB if necessary (for PNG with alpha) - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - - # Get original dimensions - original_width, original_height = img.size - - # Process and save variants - urls = {} - total_size = 0 - - for size_name, max_dim in self.SIZES.items(): - processed_img = self._resize_image(img.copy(), max_dim) - file_path = full_dir / f"{image_hash}_{size_name}.webp" - - # Save as WebP - processed_img.save(file_path, "WEBP", quality=self.QUALITY) - - # Track size - file_size = file_path.stat().st_size - total_size += file_size - - # Generate URL path (relative to static) - url_path = f"/static/uploads/products/{shard_path}/{image_hash}_{size_name}.webp" - urls[size_name] = url_path - - logger.debug(f"Saved {size_name}: {file_path} ({file_size} bytes)") - - logger.info( - f"Uploaded image {image_hash} for store {store_id}: " - f"{len(urls)} variants, {total_size} bytes total" - ) - - return { - "id": image_hash, - "urls": urls, - "size_bytes": total_size, - "dimensions": { - "width": original_width, - "height": original_height, - }, - "path": str(shard_path), - } - - except Exception as e: - logger.error(f"Failed to process image: {e}") - raise ValueError(f"Failed to process image: {e}") - - def delete_product_image(self, image_hash: str) -> bool: - """Delete all variants of a product image. - - Args: - image_hash: The image hash/ID - - Returns: - True if deleted, False if not found - """ - shard_path = self._get_shard_path(image_hash) - full_dir = self.products_dir / shard_path - - if not full_dir.exists(): - return False - - deleted = False - for size_name in self.SIZES: - file_path = full_dir / f"{image_hash}_{size_name}.webp" - if file_path.exists(): - file_path.unlink() - deleted = True - logger.debug(f"Deleted: {file_path}") - - # Clean up empty directories - self._cleanup_empty_dirs(full_dir) - - if deleted: - logger.info(f"Deleted image {image_hash}") - - return deleted - - def get_storage_stats(self) -> dict: - """Get storage statistics. - - Returns: - Dict with storage metrics - """ - total_files = 0 - total_size = 0 - max_files_per_dir = 0 - dir_count = 0 - - for root, dirs, files in os.walk(self.products_dir): - webp_files = [f for f in files if f.endswith(".webp")] - file_count = len(webp_files) - total_files += file_count - - if file_count > 0: - dir_count += 1 - max_files_per_dir = max(max_files_per_dir, file_count) - - for f in webp_files: - file_path = Path(root) / f - total_size += file_path.stat().st_size - - # Calculate average files per directory - avg_files_per_dir = total_files / dir_count if dir_count > 0 else 0 - - return { - "total_files": total_files, - "total_size_bytes": total_size, - "total_size_mb": round(total_size / (1024 * 1024), 2), - "total_size_gb": round(total_size / (1024 * 1024 * 1024), 3), - "directory_count": dir_count, - "max_files_per_dir": max_files_per_dir, - "avg_files_per_dir": round(avg_files_per_dir, 1), - "products_estimated": total_files // 3, # 3 variants per image - } - - def _generate_hash( - self, store_id: int, product_id: int | None, filename: str - ) -> str: - """Generate unique hash for image. - - Args: - store_id: Store ID - product_id: Product ID (optional) - filename: Original filename - - Returns: - 8-character hex hash - """ - timestamp = datetime.utcnow().isoformat() - content = f"{store_id}:{product_id}:{timestamp}:{filename}" - return hashlib.md5(content.encode()).hexdigest()[:8] # noqa: SEC-041 - - def _get_shard_path(self, image_hash: str) -> str: - """Get sharded directory path from hash. - - Uses first 4 characters to create 2-level directory structure. - This creates 256 possible directories at each level. - - Args: - image_hash: 8-character hash - - Returns: - Path like "0a/1b" - """ - return f"{image_hash[:2]}/{image_hash[2:4]}" - - def _get_extension(self, filename: str) -> str: - """Get lowercase file extension.""" - return filename.rsplit(".", 1)[-1].lower() if "." in filename else "" - - def _resize_image(self, img: Image.Image, max_dimension: int | None) -> Image.Image: - """Resize image while maintaining aspect ratio. - - Args: - img: PIL Image - max_dimension: Maximum width or height (None = use MAX_DIMENSION) - - Returns: - Resized PIL Image - """ - if max_dimension is None: - max_dimension = self.MAX_DIMENSION - - width, height = img.size - - # Only resize if larger than max - if width <= max_dimension and height <= max_dimension: - return img - - # Calculate new dimensions maintaining aspect ratio - if width > height: - new_width = max_dimension - new_height = int(height * (max_dimension / width)) - else: - new_height = max_dimension - new_width = int(width * (max_dimension / height)) - - return img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - def _cleanup_empty_dirs(self, dir_path: Path): - """Remove empty directories up the tree.""" - try: - # Try to remove the directory and its parents if empty - while dir_path != self.products_dir: - if dir_path.exists() and not any(dir_path.iterdir()): - dir_path.rmdir() - dir_path = dir_path.parent - else: - break - except OSError: - pass # Directory not empty or other error - - -# Create service instance -image_service = ImageService() diff --git a/app/modules/messaging/services/message_attachment_service.py b/app/modules/messaging/services/message_attachment_service.py index fad02d8d..6c6269a6 100644 --- a/app/modules/messaging/services/message_attachment_service.py +++ b/app/modules/messaging/services/message_attachment_service.py @@ -206,8 +206,8 @@ class MessageAttachmentService: by the static file handler or a dedicated download endpoint. """ # Convert local path to URL path - # Assumes files are served from /static/uploads or similar - return f"/static/{file_path}" + # Files are served via the /uploads mount in main.py + return f"/{file_path}" def get_file_content(self, file_path: str) -> bytes | None: """Read file content from storage.""" diff --git a/app/modules/monitoring/services/platform_health_service.py b/app/modules/monitoring/services/platform_health_service.py index 57b57785..9ec2b76e 100644 --- a/app/modules/monitoring/services/platform_health_service.py +++ b/app/modules/monitoring/services/platform_health_service.py @@ -16,7 +16,7 @@ import psutil from sqlalchemy import func, text from sqlalchemy.orm import Session -from app.modules.core.services.image_service import image_service +from app.modules.cms.services.media_service import media_service from app.modules.inventory.models import Inventory from app.modules.orders.models import Order from app.modules.catalog.models import Product @@ -108,9 +108,9 @@ class PlatformHealthService: "inventory_count": inventory_count, } - def get_image_storage_metrics(self) -> dict: + def get_image_storage_metrics(self, db: Session) -> dict: """Get image storage statistics.""" - stats = image_service.get_storage_stats() + stats = media_service.get_storage_stats(db) return { "total_files": stats["total_files"], "total_size_mb": stats["total_size_mb"], @@ -134,7 +134,7 @@ class PlatformHealthService: products_by_store = {name or "Unknown": count for name, count in store_counts} # Image storage - image_stats = image_service.get_storage_stats() + image_stats = media_service.get_storage_stats(db) # Database size db_size = self._get_database_size(db) @@ -282,7 +282,7 @@ class PlatformHealthService: database = self.get_database_metrics(db) # Image storage metrics - image_storage = self.get_image_storage_metrics() + image_storage = self.get_image_storage_metrics(db) # Subscription capacity subscription_capacity = self.get_subscription_capacity(db) diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index 008a2a34..5c2c3316 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -86,6 +86,7 @@ tenancy_module = ModuleDefinition( "merchants", "stores", "admin-users", + "merchant-users", ], FrontendType.STORE: [ "team", @@ -95,11 +96,10 @@ tenancy_module = ModuleDefinition( menus={ FrontendType.ADMIN: [ MenuSectionDefinition( - id="superAdmin", - label_key="tenancy.menu.super_admin", - icon="shield", + id="userManagement", + label_key="tenancy.menu.user_management", + icon="users", order=10, - is_super_admin_only=True, items=[ MenuItemDefinition( id="admin-users", @@ -108,6 +108,15 @@ tenancy_module = ModuleDefinition( route="/admin/admin-users", order=10, is_mandatory=True, + is_super_admin_only=True, + ), + MenuItemDefinition( + id="merchant-users", + label_key="tenancy.menu.merchant_users", + icon="user-group", + route="/admin/merchant-users", + order=20, + is_mandatory=True, ), ], ), diff --git a/app/modules/tenancy/locales/de.json b/app/modules/tenancy/locales/de.json index 02ef750f..0c1c0792 100644 --- a/app/modules/tenancy/locales/de.json +++ b/app/modules/tenancy/locales/de.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "Benutzerverwaltung", + "merchant_users": "Händler-Benutzer" + }, "team": { "title": "Team", "members": "Mitglieder", diff --git a/app/modules/tenancy/locales/en.json b/app/modules/tenancy/locales/en.json index c199fda2..0fce670f 100644 --- a/app/modules/tenancy/locales/en.json +++ b/app/modules/tenancy/locales/en.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "User Management", + "merchant_users": "Merchant Users" + }, "team": { "title": "Team", "members": "Members", diff --git a/app/modules/tenancy/locales/fr.json b/app/modules/tenancy/locales/fr.json index 32c8aa65..75fe9229 100644 --- a/app/modules/tenancy/locales/fr.json +++ b/app/modules/tenancy/locales/fr.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "Gestion des utilisateurs", + "merchant_users": "Utilisateurs marchands" + }, "team": { "title": "Équipe", "members": "Membres", diff --git a/app/modules/tenancy/locales/lb.json b/app/modules/tenancy/locales/lb.json index 694123a1..cc902b67 100644 --- a/app/modules/tenancy/locales/lb.json +++ b/app/modules/tenancy/locales/lb.json @@ -1,4 +1,8 @@ { + "menu": { + "user_management": "Benotzerverwaltung", + "merchant_users": "Händler-Benotzer" + }, "team": { "title": "Team", "members": "Memberen", diff --git a/app/modules/tenancy/routes/api/admin_platform_users.py b/app/modules/tenancy/routes/api/admin_platform_users.py index b14ccec3..f14651e3 100644 --- a/app/modules/tenancy/routes/api/admin_platform_users.py +++ b/app/modules/tenancy/routes/api/admin_platform_users.py @@ -38,6 +38,7 @@ def get_all_users( per_page: int = Query(10, ge=1, le=100), search: str = Query("", description="Search by username or email"), role: str = Query("", description="Filter by role"), + scope: str = Query("", description="Filter scope: 'merchant' for merchant owners and team members"), is_active: str = Query("", description="Filter by active status"), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), @@ -54,11 +55,35 @@ def get_all_users( per_page=per_page, search=search if search else None, role=role if role else None, + scope=scope if scope else None, is_active=is_active_bool, ) + if scope == "merchant": + items = [ + UserDetailResponse( + id=user.id, + email=user.email, + username=user.username, + role=user.role, + is_active=user.is_active, + last_login=user.last_login, + created_at=user.created_at, + updated_at=user.updated_at, + first_name=user.first_name, + last_name=user.last_name, + full_name=user.full_name, + is_email_verified=user.is_email_verified, + owned_merchants_count=len(user.owned_merchants) if user.owned_merchants else 0, + store_memberships_count=len(user.store_memberships) if user.store_memberships else 0, + ) + for user in users + ] + else: + items = [UserResponse.model_validate(user) for user in users] + return UserListResponse( - items=[UserResponse.model_validate(user) for user in users], + items=items, total=total, page=page, per_page=per_page, @@ -161,6 +186,42 @@ def get_user_statistics( return stats +@admin_platform_users_router.get("/merchant-stats") +def get_merchant_user_statistics( + request: Request, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get merchant user statistics for admin dashboard. + + Uses the stats_aggregator to get merchant user metrics from the tenancy + module's MetricsProvider. + """ + platform_id = _get_platform_id(request, current_admin) + + metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) + tenancy_metrics = metrics.get("tenancy", []) + + stats = { + "merchant_users_total": 0, + "merchant_users_active": 0, + "merchant_owners": 0, + "merchant_team_members": 0, + } + + for metric in tenancy_metrics: + if metric.key == "tenancy.merchant_users_total": + stats["merchant_users_total"] = int(metric.value) + elif metric.key == "tenancy.merchant_users_active": + stats["merchant_users_active"] = int(metric.value) + elif metric.key == "tenancy.merchant_owners": + stats["merchant_owners"] = int(metric.value) + elif metric.key == "tenancy.merchant_team_members": + stats["merchant_team_members"] = int(metric.value) + + return stats + + @admin_platform_users_router.get("/search", response_model=UserSearchResponse) def search_users( q: str = Query(..., min_length=2, description="Search query (username or email)"), diff --git a/app/modules/tenancy/routes/pages/admin.py b/app/modules/tenancy/routes/pages/admin.py index 0299171c..52a91eef 100644 --- a/app/modules/tenancy/routes/pages/admin.py +++ b/app/modules/tenancy/routes/pages/admin.py @@ -243,6 +243,30 @@ async def admin_store_theme_page( ) +# ============================================================================ +# MERCHANT USER MANAGEMENT ROUTES (All Admins) +# ============================================================================ + + +@router.get("/merchant-users", response_class=HTMLResponse, include_in_schema=False) +async def admin_merchant_users_list_page( + request: Request, + current_user: User = Depends( + require_menu_access("merchant-users", FrontendType.ADMIN) + ), + db: Session = Depends(get_db), +): + """ + Render merchant users management page. + Shows list of all merchant users (owners and store team members). + Visible to all admins. + """ + return templates.TemplateResponse( + "tenancy/admin/merchant-users.html", + get_admin_context(request, db, current_user), + ) + + # ============================================================================ # ADMIN USER MANAGEMENT ROUTES (Super Admin Only) # ============================================================================ diff --git a/app/modules/tenancy/services/admin_service.py b/app/modules/tenancy/services/admin_service.py index 7fb5a521..c23f784b 100644 --- a/app/modules/tenancy/services/admin_service.py +++ b/app/modules/tenancy/services/admin_service.py @@ -107,18 +107,31 @@ class AdminService: per_page: int = 10, search: str | None = None, role: str | None = None, + scope: str | None = None, is_active: bool | None = None, ) -> tuple[list[User], int, int]: """ Get paginated list of users with filtering. + Args: + scope: Optional scope filter. 'merchant' returns users who are + merchant owners or store team members. + Returns: Tuple of (users, total_count, total_pages) """ import math + from app.modules.tenancy.models import Merchant, StoreUser + query = db.query(User) + # Apply scope filter + if scope == "merchant": + owner_ids = db.query(Merchant.owner_user_id).distinct() + team_ids = db.query(StoreUser.user_id).distinct() + query = query.filter(User.id.in_(owner_ids.union(team_ids))) + # Apply filters if search: search_term = f"%{search.lower()}%" diff --git a/app/modules/tenancy/services/tenancy_metrics.py b/app/modules/tenancy/services/tenancy_metrics.py index 2511e8e7..0170b4a4 100644 --- a/app/modules/tenancy/services/tenancy_metrics.py +++ b/app/modules/tenancy/services/tenancy_metrics.py @@ -12,6 +12,7 @@ Provides metrics for: import logging from typing import TYPE_CHECKING +from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.contracts.metrics import ( @@ -123,7 +124,7 @@ class TenancyMetricsProvider: - Total users - Active users """ - from app.modules.tenancy.models import AdminPlatform, User, Store, StorePlatform + from app.modules.tenancy.models import AdminPlatform, Merchant, StoreUser, User, Store, StorePlatform try: # Store metrics - using StorePlatform junction table @@ -216,6 +217,40 @@ class TenancyMetricsProvider: inactive_users = total_users - active_users + # Merchant user metrics + # Owners: distinct users who own a merchant + merchant_owners = ( + db.query(func.count(func.distinct(Merchant.owner_user_id))).scalar() or 0 + ) + + # Team members: distinct StoreUser users who are NOT merchant owners + # Uses user_type="member" AND excludes owner user IDs to avoid overlap + team_members = ( + db.query(func.count(func.distinct(StoreUser.user_id))) + .filter( + StoreUser.user_type == "member", + ~StoreUser.user_id.in_(db.query(Merchant.owner_user_id)), + ) + .scalar() or 0 + ) + + # Total: union of both sets (deduplicated) + owner_ids = db.query(Merchant.owner_user_id).distinct() + team_ids = db.query(StoreUser.user_id).distinct() + merchant_users_total = ( + db.query(func.count(func.distinct(User.id))) + .filter(User.id.in_(owner_ids.union(team_ids))) + .scalar() or 0 + ) + merchant_users_active = ( + db.query(func.count(func.distinct(User.id))) + .filter( + User.id.in_(owner_ids.union(team_ids)), + User.is_active == True, + ) + .scalar() or 0 + ) + # Calculate rates verification_rate = ( (verified_stores / total_stores * 100) if total_stores > 0 else 0 @@ -317,6 +352,39 @@ class TenancyMetricsProvider: unit="%", description="Percentage of users that are active", ), + # Merchant user metrics + MetricValue( + key="tenancy.merchant_users_total", + value=merchant_users_total, + label="Total Merchant Users", + category="tenancy", + icon="users", + description="Total merchant-related users (owners and team members)", + ), + MetricValue( + key="tenancy.merchant_users_active", + value=merchant_users_active, + label="Active Merchant Users", + category="tenancy", + icon="user-check", + description="Active merchant users", + ), + MetricValue( + key="tenancy.merchant_owners", + value=merchant_owners, + label="Merchant Owners", + category="tenancy", + icon="briefcase", + description="Distinct merchant owners", + ), + MetricValue( + key="tenancy.merchant_team_members", + value=team_members, + label="Team Members", + category="tenancy", + icon="user-group", + description="Distinct store team members", + ), ] except Exception as e: logger.warning(f"Failed to get tenancy platform metrics: {e}") diff --git a/app/modules/tenancy/static/admin/js/merchant-users.js b/app/modules/tenancy/static/admin/js/merchant-users.js new file mode 100644 index 00000000..4a6c2dc9 --- /dev/null +++ b/app/modules/tenancy/static/admin/js/merchant-users.js @@ -0,0 +1,230 @@ +// noqa: js-006 - async init pattern is safe, loadData has try/catch +// static/admin/js/merchant-users.js + +// Create custom logger for merchant users +const merchantUsersLog = window.LogConfig.createLogger('MERCHANT-USERS'); + +function merchantUsersPage() { + return { + // Inherit base layout functionality + ...data(), + + // Set page identifier + currentPage: 'merchant-users', + + // State + merchantUsers: [], + loading: false, + error: null, + filters: { + search: '', + is_active: '' + }, + stats: { + merchant_users_total: 0, + merchant_owners: 0, + merchant_team_members: 0, + merchant_users_active: 0 + }, + pagination: { + page: 1, + per_page: 20, + total: 0, + pages: 0 + }, + + // Initialization + async init() { + // Load i18n translations + await I18n.loadModule('tenancy'); + + merchantUsersLog.info('=== MERCHANT USERS PAGE INITIALIZING ==='); + + // Prevent multiple initializations + if (window._merchantUsersInitialized) { + merchantUsersLog.warn('Merchant users page already initialized, skipping...'); + return; + } + window._merchantUsersInitialized = true; + + // Load platform settings for rows per page + if (window.PlatformSettings) { + this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); + } + + await this.loadMerchantUsers(); + await this.loadStats(); + + merchantUsersLog.info('=== MERCHANT USERS PAGE INITIALIZATION COMPLETE ==='); + }, + + // Format date helper + formatDate(dateString) { + if (!dateString) return '-'; + return Utils.formatDate(dateString); + }, + + // Computed: Total number of pages + get totalPages() { + return this.pagination.pages; + }, + + // Computed: Start index for pagination display + get startIndex() { + if (this.pagination.total === 0) return 0; + return (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + // Computed: End index for pagination display + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + // Computed: Generate page numbers array with ellipsis + get pageNumbers() { + const pages = []; + const totalPages = this.totalPages; + const current = this.pagination.page; + + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + + if (current > 3) { + pages.push('...'); + } + + const start = Math.max(2, current - 1); + const end = Math.min(totalPages - 1, current + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (current < totalPages - 2) { + pages.push('...'); + } + + pages.push(totalPages); + } + + return pages; + }, + + // Load merchant users from API + async loadMerchantUsers() { + merchantUsersLog.info('Loading merchant users...'); + this.loading = true; + this.error = null; + + try { + const params = new URLSearchParams(); + params.append('page', this.pagination.page); + params.append('per_page', this.pagination.per_page); + params.append('scope', 'merchant'); + + if (this.filters.search) { + params.append('search', this.filters.search); + } + if (this.filters.is_active !== '') { + params.append('is_active', this.filters.is_active); + } + + const url = `/admin/users?${params}`; + window.LogConfig.logApiCall('GET', url, null, 'request'); + + const startTime = performance.now(); + const response = await apiClient.get(url); + const duration = performance.now() - startTime; + + window.LogConfig.logApiCall('GET', url, response, 'response'); + window.LogConfig.logPerformance('Load Merchant Users', duration); + + this.merchantUsers = (response.items || []).map(user => ({ + ...user, + full_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null + })); + + this.pagination.total = response.total || 0; + this.pagination.pages = response.pages || Math.ceil(this.pagination.total / this.pagination.per_page) || 1; + + merchantUsersLog.info(`Loaded ${this.merchantUsers.length} merchant users`); + } catch (error) { + window.LogConfig.logError(error, 'Load Merchant Users'); + this.error = error.message || 'Failed to load merchant users'; + Utils.showToast('Failed to load merchant users', 'error'); + } finally { + this.loading = false; + } + }, + + // Load statistics from metrics provider + async loadStats() { + merchantUsersLog.info('Loading merchant user statistics...'); + + try { + const url = '/admin/users/merchant-stats'; + window.LogConfig.logApiCall('GET', url, null, 'request'); + + const response = await apiClient.get(url); + + window.LogConfig.logApiCall('GET', url, response, 'response'); + + this.stats = { + merchant_users_total: response.merchant_users_total || 0, + merchant_owners: response.merchant_owners || 0, + merchant_team_members: response.merchant_team_members || 0, + merchant_users_active: response.merchant_users_active || 0 + }; + + merchantUsersLog.debug('Stats loaded:', this.stats); + } catch (error) { + window.LogConfig.logError(error, 'Load Merchant Stats'); + // Stats are non-critical, don't show error toast + } + }, + + // Search with debounce + debouncedSearch() { + if (this._searchTimeout) { + clearTimeout(this._searchTimeout); + } + this._searchTimeout = setTimeout(() => { + merchantUsersLog.info('Search triggered:', this.filters.search); + this.pagination.page = 1; + this.loadMerchantUsers(); + }, 300); + }, + + // Pagination + nextPage() { + if (this.pagination.page < this.pagination.pages) { + this.pagination.page++; + merchantUsersLog.info('Next page:', this.pagination.page); + this.loadMerchantUsers(); + } + }, + + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + merchantUsersLog.info('Previous page:', this.pagination.page); + this.loadMerchantUsers(); + } + }, + + goToPage(pageNum) { + if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) { + this.pagination.page = pageNum; + merchantUsersLog.info('Go to page:', this.pagination.page); + this.loadMerchantUsers(); + } + } + }; +} + +merchantUsersLog.info('Merchant users module loaded'); diff --git a/app/modules/tenancy/templates/tenancy/admin/merchant-users.html b/app/modules/tenancy/templates/tenancy/admin/merchant-users.html new file mode 100644 index 00000000..d994c0b5 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/admin/merchant-users.html @@ -0,0 +1,211 @@ +{# app/templates/admin/merchant-users.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} + +{% block title %}Merchant Users{% endblock %} + +{% block alpine_data %}merchantUsersPage(){% endblock %} + +{% block content %} +{{ page_header('Merchant User Management', subtitle='View merchant owners and store team members') }} + +{{ loading_state('Loading merchant users...') }} + +{{ error_state('Error loading merchant users') }} + + +
+ +
+
+ +
+
+

+ Total Merchant Users +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Owners +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Team Members +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Active +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ {% call table_wrapper() %} + {{ table_header(['User', 'Email', 'Role', 'Status', 'Last Login', 'Actions']) }} + + + + + + + + {% endcall %} + + {{ pagination() }} +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/docs/features/subscription-billing.md b/docs/features/subscription-billing.md index 5e6b3f7c..8235d71d 100644 --- a/docs/features/subscription-billing.md +++ b/docs/features/subscription-billing.md @@ -1,65 +1,131 @@ # Subscription & Billing System -The platform provides a comprehensive subscription and billing system for managing store subscriptions, usage limits, and payments through Stripe. +The platform provides a comprehensive subscription and billing system for managing merchant subscriptions, feature-based usage limits, and payments through Stripe. ## Overview The billing system enables: -- **Subscription Tiers**: Database-driven tier definitions with configurable limits -- **Usage Tracking**: Orders, products, and team member limits per tier +- **Subscription Tiers**: Database-driven tier definitions with configurable feature limits +- **Feature Provider Pattern**: Modules declare features and usage via `FeatureProviderProtocol`, aggregated by `FeatureAggregatorService` +- **Dynamic Usage Tracking**: Quantitative features (orders, products, team members) tracked per merchant with dynamic limits from `TierFeatureLimit` +- **Binary Feature Gating**: Toggle-based features (analytics, API access, white-label) controlled per tier +- **Merchant-Level Billing**: Subscriptions are per merchant+platform, not per store - **Stripe Integration**: Checkout sessions, customer portal, and webhook handling -- **Self-Service Billing**: Store-facing billing page for subscription management - **Add-ons**: Optional purchasable items (domains, SSL, email packages) - **Capacity Forecasting**: Growth trends and scaling recommendations - **Background Jobs**: Automated subscription lifecycle management ## Architecture +### Key Concepts + +The billing system uses a **feature provider pattern** where: + +1. **`TierFeatureLimit`** replaces hardcoded tier columns (`orders_per_month`, `products_limit`, `team_members`). Each feature limit is a row linking a tier to a feature code with a `limit_value`. +2. **`MerchantFeatureOverride`** provides per-merchant exceptions to tier defaults. +3. **Module feature providers** implement `FeatureProviderProtocol` to supply current usage data. +4. **`FeatureAggregatorService`** collects usage from all providers and combines it with tier limits to produce `FeatureSummary` records. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Frontend Page Request │ +│ (Store Billing, Admin Subscriptions, Admin Store Detail) │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ FeatureAggregatorService │ +│ (app/modules/billing/services/feature_service.py) │ +│ │ +│ • Collects feature providers from all enabled modules │ +│ • Queries TierFeatureLimit for limit values │ +│ • Queries MerchantFeatureOverride for per-merchant limits │ +│ • Calls provider.get_current_usage() for live counts │ +│ • Returns FeatureSummary[] with current/limit/percentage │ +└──────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ catalog module │ │ orders module │ │ tenancy module │ + │ products count │ │ orders count │ │ team members │ + └────────────────┘ └────────────────┘ └────────────────┘ +``` + ### Database Models -All subscription models are defined in `models/database/subscription.py`: +All subscription models are in `app/modules/billing/models/`: | Model | Purpose | |-------|---------| -| `SubscriptionTier` | Tier definitions with limits and Stripe price IDs | -| `StoreSubscription` | Per-store subscription status and usage | +| `SubscriptionTier` | Tier definitions with Stripe price IDs and feature codes | +| `TierFeatureLimit` | Per-tier feature limits (feature_code + limit_value) | +| `MerchantSubscription` | Per-merchant+platform subscription status | +| `MerchantFeatureOverride` | Per-merchant feature limit overrides | | `AddOnProduct` | Purchasable add-ons (domains, SSL, email) | | `StoreAddOn` | Add-ons purchased by each store | | `StripeWebhookEvent` | Idempotency tracking for webhooks | | `BillingHistory` | Invoice and payment history | | `CapacitySnapshot` | Daily platform capacity metrics for forecasting | +### Feature Types + +Features come in two types: + +| Type | Description | Example | +|------|-------------|---------| +| **Quantitative** | Has a numeric limit with usage tracking | `max_products` (limit: 200, current: 150) | +| **Binary** | Toggle-based, either enabled or disabled | `analytics_dashboard` (enabled/disabled) | + +### FeatureSummary Dataclass + +The core data structure returned by the feature system: + +```python +@dataclass +class FeatureSummary: + code: str # e.g., "max_products" + name_key: str # i18n key for display name + limit: int | None # None = unlimited + current: int # Current usage count + remaining: int # Remaining before limit + percent_used: float # 0.0 to 100.0 + feature_type: str # "quantitative" or "binary" + scope: str # "tier" or "merchant_override" +``` + ### Services | Service | Location | Purpose | |---------|----------|---------| -| `BillingService` | `app/services/billing_service.py` | Subscription operations, checkout, portal | -| `SubscriptionService` | `app/services/subscription_service.py` | Limit checks, usage tracking, tier info | -| `StripeService` | `app/services/stripe_service.py` | Core Stripe API operations | -| `CapacityForecastService` | `app/services/capacity_forecast_service.py` | Growth trends, projections | -| `PlatformHealthService` | `app/services/platform_health_service.py` | Subscription capacity aggregation | +| `FeatureAggregatorService` | `app/modules/billing/services/feature_service.py` | Aggregates usage from module providers, resolves tier limits + overrides | +| `BillingService` | `app/modules/billing/services/billing_service.py` | Subscription operations, checkout, portal | +| `SubscriptionService` | `app/modules/billing/services/subscription_service.py` | Subscription CRUD, tier lookups | +| `AdminSubscriptionService` | `app/modules/billing/services/admin_subscription_service.py` | Admin subscription management | +| `StripeService` | `app/modules/billing/services/stripe_service.py` | Core Stripe API operations | +| `CapacityForecastService` | `app/modules/billing/services/capacity_forecast_service.py` | Growth trends, projections | ### Background Tasks | Task | Location | Schedule | Purpose | |------|----------|----------|---------| -| `reset_period_counters` | `app/tasks/subscription_tasks.py` | Daily | Reset order counters at period end | -| `check_trial_expirations` | `app/tasks/subscription_tasks.py` | Daily | Expire trials without payment method | -| `sync_stripe_status` | `app/tasks/subscription_tasks.py` | Hourly | Sync status with Stripe | -| `cleanup_stale_subscriptions` | `app/tasks/subscription_tasks.py` | Weekly | Clean up old cancelled subscriptions | -| `capture_capacity_snapshot` | `app/tasks/subscription_tasks.py` | Daily | Capture capacity metrics snapshot | +| `reset_period_counters` | `app/modules/billing/tasks/subscription.py` | Daily | Reset order counters at period end | +| `check_trial_expirations` | `app/modules/billing/tasks/subscription.py` | Daily | Expire trials without payment method | +| `sync_stripe_status` | `app/modules/billing/tasks/subscription.py` | Hourly | Sync status with Stripe | +| `cleanup_stale_subscriptions` | `app/modules/billing/tasks/subscription.py` | Weekly | Clean up old cancelled subscriptions | +| `capture_capacity_snapshot` | `app/modules/billing/tasks/subscription.py` | Daily | Capture capacity metrics snapshot | -### API Endpoints +## API Endpoints -#### Store Billing API +### Store Billing API -All billing endpoints are under `/api/v1/store/billing`: +Base: `/api/v1/store/billing` | Endpoint | Method | Purpose | |----------|--------|---------| -| `/billing/subscription` | GET | Current subscription status & usage | +| `/billing/subscription` | GET | Current subscription status | | `/billing/tiers` | GET | Available tiers for upgrade | +| `/billing/usage` | GET | Dynamic usage metrics (from feature providers) | | `/billing/checkout` | POST | Create Stripe checkout session | | `/billing/portal` | POST | Create Stripe customer portal session | | `/billing/invoices` | GET | Invoice history | @@ -72,7 +138,117 @@ All billing endpoints are under `/api/v1/store/billing`: | `/billing/cancel` | POST | Cancel subscription | | `/billing/reactivate` | POST | Reactivate cancelled subscription | -#### Admin Platform Health API +The `/billing/usage` endpoint returns `UsageMetric[]`: + +```json +[ + { + "name": "Products", + "current": 150, + "limit": 200, + "percentage": 75.0, + "is_unlimited": false, + "is_at_limit": false, + "is_approaching_limit": true + } +] +``` + +### Admin Subscription API + +Base: `/api/v1/admin/subscriptions` + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/tiers` | GET | List all subscription tiers | +| `/tiers` | POST | Create a new tier | +| `/tiers/{code}` | PATCH | Update a tier | +| `/tiers/{code}` | DELETE | Delete a tier | +| `/stats` | GET | Subscription statistics | +| `/merchants/{id}/platforms/{pid}` | GET | Get merchant subscription | +| `/merchants/{id}/platforms/{pid}` | PUT | Update merchant subscription | +| `/store/{store_id}` | GET | Convenience: get subscription + usage for a store | + +### Admin Feature Management API + +Base: `/api/v1/admin/subscriptions/features` + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/catalog` | GET | Feature catalog grouped by category | +| `/tiers/{code}/limits` | GET | Get feature limits for a tier | +| `/tiers/{code}/limits` | PUT | Upsert feature limits for a tier | +| `/merchants/{id}/overrides` | GET | Get merchant feature overrides | +| `/merchants/{id}/overrides` | PUT | Upsert merchant feature overrides | + +The **feature catalog** returns features grouped by category: + +```json +{ + "features": { + "analytics": [ + {"code": "basic_analytics", "name": "Basic Analytics", "feature_type": "binary", "category": "analytics"}, + {"code": "analytics_dashboard", "name": "Analytics Dashboard", "feature_type": "binary", "category": "analytics"} + ], + "limits": [ + {"code": "max_products", "name": "Product Limit", "feature_type": "quantitative", "category": "limits"}, + {"code": "max_orders_per_month", "name": "Orders per Month", "feature_type": "quantitative", "category": "limits"} + ] + } +} +``` + +**Tier feature limits** use `TierFeatureLimitEntry[]` format: + +```json +[ + {"feature_code": "max_products", "limit_value": 200, "enabled": true}, + {"feature_code": "max_orders_per_month", "limit_value": 100, "enabled": true}, + {"feature_code": "analytics_dashboard", "limit_value": null, "enabled": true} +] +``` + +### Admin Store Convenience Endpoint + +`GET /api/v1/admin/subscriptions/store/{store_id}` resolves a store to its merchant and returns subscription + usage in one call: + +```json +{ + "subscription": { + "tier": "professional", + "status": "active", + "period_start": "2026-01-01T00:00:00Z", + "period_end": "2026-02-01T00:00:00Z" + }, + "tier": { + "code": "professional", + "name": "Professional", + "price_monthly_cents": 9900 + }, + "features": [ + { + "name": "Products", + "current": 150, + "limit": null, + "percentage": 0, + "is_unlimited": true, + "is_at_limit": false, + "is_approaching_limit": false + }, + { + "name": "Orders per Month", + "current": 320, + "limit": 500, + "percentage": 64.0, + "is_unlimited": false, + "is_at_limit": false, + "is_approaching_limit": false + } + ] +} +``` + +### Admin Platform Health API Capacity endpoints under `/api/v1/admin/platform-health`: @@ -87,97 +263,140 @@ Capacity endpoints under `/api/v1/admin/platform-health`: ## Subscription Tiers -### Default Tiers +### Tier Structure -| Tier | Price | Products | Orders/mo | Team | -|------|-------|----------|-----------|------| -| Essential | €49/mo | 200 | 100 | 1 | -| Professional | €99/mo | Unlimited | 500 | 3 | -| Business | €199/mo | Unlimited | 2000 | 10 | -| Enterprise | Custom | Unlimited | Unlimited | Unlimited | +Tiers are stored in the `subscription_tiers` table. Feature limits are stored separately in `tier_feature_limits`: -### Database-Driven Tiers - -Tiers are stored in the `subscription_tiers` table and queried via `SubscriptionService`: - -```python -# Get tier from database with legacy fallback -tier_info = subscription_service.get_tier_info("professional", db=db) - -# Query all active tiers -all_tiers = subscription_service.get_all_tiers(db=db) ``` +SubscriptionTier (essential) + ├── TierFeatureLimit: max_products = 200 + ├── TierFeatureLimit: max_orders_per_month = 100 + ├── TierFeatureLimit: max_team_members = 1 + ├── TierFeatureLimit: basic_support (binary, enabled) + └── TierFeatureLimit: basic_analytics (binary, enabled) -The service maintains backward compatibility with a legacy fallback: - -```python -def get_tier_info(self, tier_code: str, db: Session | None = None) -> TierInfo: - """Get full tier information. Queries database if db session provided.""" - if db is not None: - db_tier = self.get_tier_by_code(db, tier_code) - if db_tier: - return TierInfo(...) - - # Fallback to hardcoded TIER_LIMITS during migration - return self._get_tier_from_legacy(tier_code) -``` - -### Tier Features - -Each tier includes specific features stored in the `features` JSON column: - -```python -tier.features = [ - "basic_support", # Essential - "priority_support", # Professional+ - "analytics", # Business+ - "api_access", # Business+ - "white_label", # Enterprise - "custom_integrations", # Enterprise -] +SubscriptionTier (professional) + ├── TierFeatureLimit: max_products = NULL (unlimited) + ├── TierFeatureLimit: max_orders_per_month = 500 + ├── TierFeatureLimit: max_team_members = 3 + ├── TierFeatureLimit: priority_support (binary, enabled) + ├── TierFeatureLimit: analytics_dashboard (binary, enabled) + └── ... ``` ### Admin Tier Management -Administrators can manage subscription tiers at `/admin/subscription-tiers`: +Administrators manage tiers at `/admin/subscription-tiers`: **Capabilities:** + - View all tiers with stats (total, active, public, MRR) -- Create new tiers with custom pricing and limits -- Edit tier properties (name, pricing, limits, Stripe IDs) +- Create/edit tiers with pricing and Stripe IDs - Activate/deactivate tiers -- Assign features to tiers via slide-over panel +- Assign features to tiers via slide-over panel with: + - Binary features: checkbox toggles grouped by category + - Quantitative features: checkbox + numeric limit input + - Select all / Deselect all per category -**Feature Assignment:** -1. Click the puzzle-piece icon on any tier row -2. Features are displayed grouped by category -3. Use checkboxes to select/deselect features -4. Use "Select all" / "Deselect all" per category -5. Click "Save Features" to update +**Feature Panel API Flow:** -See [Feature Gating System](../implementation/feature-gating-system.md#admin-tier-management-ui) for technical details. +1. Open panel: `GET /admin/subscriptions/features/catalog` (all available features) +2. Open panel: `GET /admin/subscriptions/features/tiers/{code}/limits` (current tier limits) +3. Save: `PUT /admin/subscriptions/features/tiers/{code}/limits` (upsert limits) -## Limit Enforcement +### Per-Merchant Overrides -Limits are enforced at the service layer: +Admins can override tier limits for individual merchants via the subscription edit modal: -### Orders -```python -# app/services/order_service.py -subscription_service.check_order_limit(db, store_id) +1. Open edit modal for a subscription +2. Fetches feature catalog + current merchant overrides +3. Shows each quantitative feature with override input (or "Tier default" placeholder) +4. Save sends `PUT /admin/subscriptions/features/merchants/{id}/overrides` + +## Frontend Pages + +### Store Billing Page + +**Location:** `/store/{store_code}/billing` + +**Template:** `app/modules/billing/templates/billing/store/billing.html` +**JS:** `app/modules/billing/static/store/js/billing.js` + +The billing page fetches usage metrics dynamically from `GET /store/billing/usage` and renders them with Alpine.js: + +```html + ``` -### Products -```python -# app/api/v1/store/products.py -subscription_service.check_product_limit(db, store_id) +**Page sections:** + +1. **Current Plan**: Tier name, status, next billing date +2. **Usage Meters**: Dynamic usage bars from feature providers +3. **Change Plan**: Tier cards showing `feature_codes` list +4. **Payment Method**: Link to Stripe portal +5. **Invoice History**: Recent invoices with PDF links +6. **Add-ons**: Available and purchased add-ons + +### Admin Subscriptions Page + +**Location:** `/admin/subscriptions` + +**Template:** `app/modules/billing/templates/billing/admin/subscriptions.html` +**JS:** `app/modules/billing/static/admin/js/subscriptions.js` + +Lists all merchant subscriptions with: + +- Tier, status, merchant info, period dates +- Features count column (from `feature_codes.length`) +- Edit modal with dynamic feature override editor + +### Admin Subscription Tiers Page + +**Location:** `/admin/subscription-tiers` + +**Template:** `app/modules/billing/templates/billing/admin/subscription-tiers.html` +**JS:** `app/modules/billing/static/admin/js/subscription-tiers.js` + +Manages tier definitions with: + +- Stats cards (total, active, public, MRR) +- Tier table (code, name, pricing, features count, status) +- Create/edit modal (pricing, Stripe IDs, description, toggles) +- Feature assignment slide-over panel (binary toggles + quantitative limit inputs) + +### Merchant Subscription Detail Page + +**Template:** `app/modules/billing/templates/billing/merchant/subscription-detail.html` + +Shows subscription details with plan limits rendered dynamically from `tier.feature_limits`: + +```html + ``` -### Team Members -```python -# app/services/store_team_service.py -subscription_service.can_add_team_member(db, store_id) -``` +### Admin Store Detail Page + +**Template:** `app/modules/tenancy/templates/tenancy/admin/store-detail.html` +**JS:** `app/modules/tenancy/static/admin/js/store-detail.js` + +The subscription section uses the convenience endpoint `GET /admin/subscriptions/store/{store_id}` to load subscription + usage metrics in one call, rendering dynamic usage bars. ## Stripe Integration @@ -239,7 +458,7 @@ stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe Create subscription products for each tier: 1. Go to [Stripe Products](https://dashboard.stripe.com/products) -2. Create products for each tier (Starter, Professional, Business, Enterprise) +2. Create products for each tier (Essential, Professional, Business, Enterprise) 3. Add monthly and annual prices for each 4. Copy the Price IDs (`price_...`) and update your tier configuration @@ -252,7 +471,7 @@ The system handles these Stripe events: | `checkout.session.completed` | Activates subscription, links customer | | `customer.subscription.updated` | Updates tier, status, period | | `customer.subscription.deleted` | Marks subscription cancelled | -| `invoice.paid` | Records payment, resets counters | +| `invoice.paid` | Records payment in billing history | | `invoice.payment_failed` | Marks past due, increments retry count | ### Webhook Endpoint @@ -262,65 +481,11 @@ Webhooks are received at `/api/v1/webhooks/stripe`: ```python # Uses signature verification for security event = stripe_service.construct_event(payload, stripe_signature) +handler = StripeWebhookHandler() +result = handler.handle_event(db, event) ``` -## Store Billing Page - -The store billing page is at `/store/{store_code}/billing`: - -### Page Sections - -1. **Current Plan**: Tier name, status, next billing date -2. **Usage Meters**: Products, orders, team members with limits -3. **Change Plan**: Upgrade/downgrade options -4. **Payment Method**: Link to Stripe portal -5. **Invoice History**: Recent invoices with PDF links -6. **Add-ons**: Available and purchased add-ons - -### JavaScript Component - -The billing page uses Alpine.js (`static/store/js/billing.js`): - -```javascript -function billingData() { - return { - subscription: null, - tiers: [], - invoices: [], - addons: [], - myAddons: [], - - async init() { - await this.loadData(); - }, - - async selectTier(tier) { - const response = await this.apiPost('/billing/checkout', { - tier_code: tier.code, - is_annual: false - }); - window.location.href = response.checkout_url; - }, - - async openPortal() { - const response = await this.apiPost('/billing/portal', {}); - window.location.href = response.portal_url; - }, - - async purchaseAddon(addon) { - const response = await this.apiPost('/billing/addons/purchase', { - addon_code: addon.code - }); - window.location.href = response.checkout_url; - }, - - async cancelAddon(addon) { - await this.apiDelete(`/billing/addons/${addon.id}`); - await this.loadMyAddons(); - } - }; -} -``` +The handler implements **idempotency** via `StripeWebhookEvent` records. Duplicate events (same `event_id`) are skipped. ## Add-ons @@ -341,58 +506,6 @@ function billingData() { 3. Create Stripe checkout session with add-on price 4. On webhook success: create `StoreAddOn` record -## Background Tasks - -### Task Definitions - -```python -# app/tasks/subscription_tasks.py - -async def reset_period_counters(): - """ - Reset order counters for subscriptions whose billing period has ended. - Should run daily. Resets orders_this_period to 0 and updates period dates. - """ - -async def check_trial_expirations(): - """ - Check for expired trials and update their status. - Trials without payment method -> expired - Trials with payment method -> active - """ - -async def sync_stripe_status(): - """ - Sync subscription status with Stripe. - Fetches current status and updates local records. - """ - -async def cleanup_stale_subscriptions(): - """ - Clean up subscriptions in inconsistent states. - Marks old cancelled subscriptions as expired. - """ - -async def capture_capacity_snapshot(): - """ - Capture a daily snapshot of platform capacity metrics. - Used for growth trending and capacity forecasting. - """ -``` - -### Scheduling - -Configure with your scheduler of choice (Celery, APScheduler, cron): - -```python -# Example with APScheduler -scheduler.add_job(reset_period_counters, 'cron', hour=0, minute=5) -scheduler.add_job(check_trial_expirations, 'cron', hour=1, minute=0) -scheduler.add_job(sync_stripe_status, 'cron', minute=0) # Every hour -scheduler.add_job(cleanup_stale_subscriptions, 'cron', day_of_week=0) # Weekly -scheduler.add_job(capture_capacity_snapshot, 'cron', hour=0, minute=0) -``` - ## Capacity Forecasting ### Subscription-Based Capacity @@ -421,11 +534,6 @@ capacity = platform_health_service.get_subscription_capacity(db) "actual": 45000, "theoretical_limit": 300000, "utilization_percent": 15.0 - }, - "team_members": { - "actual": 320, - "theoretical_limit": 1500, - "utilization_percent": 21.3 } } ``` @@ -437,34 +545,11 @@ Analyze growth over time: ```python trends = capacity_forecast_service.get_growth_trends(db, days=30) -# Returns: -{ - "period_days": 30, - "snapshots_available": 30, - "trends": { - "stores": { - "start_value": 140, - "current_value": 150, - "change": 10, - "growth_rate_percent": 7.14, - "daily_growth_rate": 0.238, - "monthly_projection": 161 - }, - "products": { - "start_value": 115000, - "current_value": 125000, - "change": 10000, - "growth_rate_percent": 8.7, - "monthly_projection": 136000 - } - } -} +# Returns growth rates, daily projections, monthly projections ``` ### Scaling Recommendations -Get automated scaling advice: - ```python recommendations = capacity_forecast_service.get_scaling_recommendations(db) @@ -476,13 +561,6 @@ recommendations = capacity_forecast_service.get_scaling_recommendations(db) "title": "Product capacity approaching limit", "description": "Currently at 85% of theoretical product capacity", "action": "Consider upgrading store tiers or adding capacity" - }, - { - "category": "infrastructure", - "severity": "info", - "title": "Current tier: Medium", - "description": "Next upgrade trigger: 300 stores", - "action": "Monitor growth and plan for infrastructure scaling" } ] ``` @@ -500,15 +578,15 @@ recommendations = capacity_forecast_service.get_scaling_recommendations(db) ## Exception Handling -Custom exceptions for billing operations (`app/exceptions/billing.py`): +Custom exceptions for billing operations (`app/modules/billing/exceptions.py`): | Exception | HTTP Status | Description | |-----------|-------------|-------------| -| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured | -| `TierNotFoundException` | 404 | Invalid tier code | -| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier | -| `NoActiveSubscriptionException` | 400 | Operation requires subscription | -| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription | +| `PaymentSystemNotConfiguredError` | 503 | Stripe not configured | +| `TierNotFoundError` | 404 | Invalid tier code | +| `StripePriceNotConfiguredError` | 400 | No Stripe price for tier | +| `NoActiveSubscriptionError` | 400 | Operation requires subscription | +| `SubscriptionNotCancelledError` | 400 | Cannot reactivate active subscription | ## Testing @@ -520,86 +598,20 @@ pytest tests/unit/services/test_billing_service.py -v # Run webhook handler tests pytest tests/unit/services/test_stripe_webhook_handler.py -v + +# Run feature service tests +pytest tests/unit/services/test_feature_service.py -v + +# Run usage service tests +pytest tests/unit/services/test_usage_service.py -v ``` ### Test Coverage -- `BillingService`: Subscription queries, checkout, portal, cancellation -- `StripeWebhookHandler`: Event idempotency, checkout completion, invoice handling - -## Migration - -### Creating Tiers - -Tiers are seeded via migration: - -```python -# alembic/versions/xxx_add_subscription_billing_tables.py -def seed_subscription_tiers(op): - op.bulk_insert(subscription_tiers_table, [ - { - "code": "essential", - "name": "Essential", - "price_monthly_cents": 4900, - "orders_per_month": 100, - "products_limit": 200, - "team_members": 1, - }, - # ... more tiers - ]) -``` - -### Capacity Snapshots Table - -The `capacity_snapshots` table stores daily metrics: - -```python -# alembic/versions/l0a1b2c3d4e5_add_capacity_snapshots_table.py -class CapacitySnapshot(Base): - id: int - snapshot_date: datetime # Unique per day - - # Store metrics - total_stores: int - active_stores: int - trial_stores: int - - # Subscription metrics - total_subscriptions: int - active_subscriptions: int - - # Resource metrics - total_products: int - total_orders_month: int - total_team_members: int - - # Storage metrics - storage_used_gb: Decimal - db_size_mb: Decimal - - # Capacity metrics - theoretical_products_limit: int - theoretical_orders_limit: int - theoretical_team_limit: int - - # Tier distribution - tier_distribution: dict # JSON -``` - -### Setting Up Stripe - -1. Create products and prices in Stripe Dashboard -2. Update `SubscriptionTier` records with Stripe IDs: - -```python -tier.stripe_product_id = "prod_xxx" -tier.stripe_price_monthly_id = "price_xxx" -tier.stripe_price_annual_id = "price_yyy" -``` - -3. Configure webhook endpoint in Stripe Dashboard: - - URL: `https://yourdomain.com/api/v1/webhooks/stripe` - - Events: `checkout.session.completed`, `customer.subscription.*`, `invoice.*` +- `BillingService`: Tier queries, invoices, add-ons +- `StripeWebhookHandler`: Event idempotency, checkout completion, status mapping +- `FeatureService`: Feature aggregation, tier limits, merchant overrides +- `UsageService`: Usage tracking, limit checks ## Security Considerations @@ -611,6 +623,9 @@ tier.stripe_price_annual_id = "price_yyy" ## Related Documentation +- [Feature Gating System](../implementation/feature-gating-system.md) - Feature access control and UI integration +- [Metrics Provider Pattern](../architecture/metrics-provider-pattern.md) - Protocol-based metrics from modules - [Capacity Monitoring](../operations/capacity-monitoring.md) - Detailed monitoring guide - [Capacity Planning](../architecture/capacity-planning.md) - Infrastructure sizing -- [Stripe Integration](../deployment/stripe-integration.md) - Payment setup for stores +- [Stripe Integration](../deployment/stripe-integration.md) - Payment setup +- [Subscription Tier Management](../guides/subscription-tier-management.md) - User guide for tier management diff --git a/docs/implementation/feature-gating-system.md b/docs/implementation/feature-gating-system.md index d2518189..2786785a 100644 --- a/docs/implementation/feature-gating-system.md +++ b/docs/implementation/feature-gating-system.md @@ -354,7 +354,6 @@ The subscription tiers admin page provides full CRUD functionality for managing - Code (colored badge by tier) - Name - Monthly/Annual pricing - - Limits (orders, products, team members) - Feature count - Status (Active/Private/Inactive) - Actions (Edit Features, Edit, Activate/Deactivate) @@ -362,7 +361,6 @@ The subscription tiers admin page provides full CRUD functionality for managing 3. **Create/Edit Modal**: Form with all tier fields: - Code and Name - Monthly and Annual pricing (in cents) - - Order, Product, and Team member limits - Display order - Stripe IDs (optional) - Description @@ -371,9 +369,10 @@ The subscription tiers admin page provides full CRUD functionality for managing 4. **Feature Assignment Slide-over Panel**: - Opens when clicking the puzzle-piece icon - Shows all features grouped by category - - Checkbox selection with Select all/Deselect all per category + - Binary features: checkbox selection with Select all/Deselect all per category + - Quantitative features: checkbox + numeric limit input for `limit_value` - Feature count in footer - - Save to update tier's feature assignments + - Save to update tier's feature assignments via `TierFeatureLimitEntry[]` ### Files @@ -392,10 +391,9 @@ The subscription tiers admin page provides full CRUD functionality for managing | Create tier | POST | `/api/v1/admin/subscriptions/tiers` | | Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` | | Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` | -| Load features | GET | `/api/v1/admin/features` | -| Load categories | GET | `/api/v1/admin/features/categories` | -| Get tier features | GET | `/api/v1/admin/features/tiers/{code}/features` | -| Update tier features | PUT | `/api/v1/admin/features/tiers/{code}/features` | +| Load feature catalog | GET | `/api/v1/admin/subscriptions/features/catalog` | +| Get tier feature limits | GET | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` | +| Update tier feature limits | PUT | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` | ## Migration diff --git a/tests/unit/services/test_message_attachment_service.py b/tests/unit/services/test_message_attachment_service.py index d3575a31..a5f40919 100644 --- a/tests/unit/services/test_message_attachment_service.py +++ b/tests/unit/services/test_message_attachment_service.py @@ -338,7 +338,7 @@ class TestMessageAttachmentServiceFileOperations: def test_get_download_url(self, attachment_service): """Test download URL generation.""" url = attachment_service.get_download_url("uploads/messages/2025/01/1/abc.pdf") - assert url == "/static/uploads/messages/2025/01/1/abc.pdf" + assert url == "/uploads/messages/2025/01/1/abc.pdf" def test_get_file_content_success(self, attachment_service): """Test reading file content."""