feat: consolidate media service, add merchant users page, fix metrics overlap

- Merge ImageService into MediaService with WebP variant generation,
  DB-backed storage stats, and module-driven media usage discovery
  via new MediaUsageProviderProtocol
- Add merchant users admin page with scoped user listing, stats
  endpoint, template, JS, and i18n strings (de/en/fr/lb)
- Fix merchant user metrics so Owners and Team Members are mutually
  exclusive (filter team_members on user_type="member" and exclude
  owner IDs) ensuring stat cards add up correctly
- Update billing and monitoring services to use media_service
- Update subscription-billing and feature-gating docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 21:17:11 +01:00
parent 4cb2bda575
commit 2250054ba2
30 changed files with 1220 additions and 805 deletions

View File

@@ -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
# =========================================================================

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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."""

View File

@@ -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
# ============================================================================

View File

@@ -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

View File

@@ -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",

View File

@@ -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):
"""

View File

@@ -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",

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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,
),
],
),

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "Benutzerverwaltung",
"merchant_users": "Händler-Benutzer"
},
"team": {
"title": "Team",
"members": "Mitglieder",

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "User Management",
"merchant_users": "Merchant Users"
},
"team": {
"title": "Team",
"members": "Members",

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "Gestion des utilisateurs",
"merchant_users": "Utilisateurs marchands"
},
"team": {
"title": "Équipe",
"members": "Membres",

View File

@@ -1,4 +1,8 @@
{
"menu": {
"user_management": "Benotzerverwaltung",
"merchant_users": "Händler-Benotzer"
},
"team": {
"title": "Team",
"members": "Memberen",

View File

@@ -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)"),

View File

@@ -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)
# ============================================================================

View File

@@ -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()}%"

View File

@@ -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}")

View File

@@ -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');

View File

@@ -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') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Merchant Users -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Merchant Users
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_users_total || 0">
0
</p>
</div>
</div>
<!-- Card: Merchant Owners -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('briefcase', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Owners
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_owners || 0">
0
</p>
</div>
</div>
<!-- Card: Team Members -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Team Members
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_team_members || 0">
0
</p>
</div>
</div>
<!-- Card: Active -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('user-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.merchant_users_active || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters -->
<div x-show="!loading" class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Bar -->
<div class="flex-1 max-w-md">
<div class="relative">
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search by name or email..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
>
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</div>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadMerchantUsers()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Refresh Button -->
<button
@click="loadMerchantUsers(); loadStats()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh merchant users"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Merchant Users Table -->
<div x-show="!loading">
{% call table_wrapper() %}
{{ table_header(['User', 'Email', 'Role', 'Status', 'Last Login', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="merchantUsers.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-lg font-medium">No merchant users found</p>
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search or filters' : 'Merchant users will appear here when merchants are created'"></p>
</div>
</td>
</tr>
</template>
<!-- Merchant User Rows -->
<template x-for="user in merchantUsers" :key="user.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- User Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full flex items-center justify-center text-white font-semibold text-sm bg-blue-500"
x-text="(user.username || user.email || 'U').charAt(0).toUpperCase()">
</div>
</div>
<div>
<p class="font-semibold" x-text="user.full_name || user.username || user.email"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="user.username || ''"></p>
</div>
</div>
</td>
<!-- Email -->
<td class="px-4 py-3 text-sm" x-text="user.email"></td>
<!-- Role -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="user.owned_merchants_count > 0
? 'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100'
: 'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100'"
x-text="user.owned_merchants_count > 0 ? 'Owner' : 'Team Member'">
</span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="user.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="user.is_active ? 'Active' : 'Inactive'">
</span>
</td>
<!-- Last Login -->
<td class="px-4 py-3 text-sm" x-text="user.last_login ? formatDate(user.last_login) : 'Never'"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<!-- View Button -->
<a
:href="'/admin/admin-users/' + user.id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View user details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('tenancy_static', path='admin/js/merchant-users.js') }}"></script>
{% endblock %}

View File

@@ -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
<template x-for="metric in usageMetrics" :key="metric.name">
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span x-text="metric.name"></span>
<span x-text="metric.is_unlimited ? metric.current + ' (Unlimited)' : metric.current + ' / ' + metric.limit"></span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="h-2 rounded-full"
:class="metric.percentage >= 90 ? 'bg-red-600' : metric.percentage >= 70 ? 'bg-yellow-600' : 'bg-purple-600'"
:style="`width: ${Math.min(100, metric.percentage || 0)}%`"></div>
</div>
</div>
</template>
```
### 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
<template x-for="fl in (subscription?.tier?.feature_limits || [])" :key="fl.feature_code">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
<p class="text-xl font-bold" x-text="fl.limit_value || 'Unlimited'"></p>
</div>
</template>
```
### 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

View File

@@ -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

View File

@@ -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."""