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:
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Benutzerverwaltung",
|
||||
"merchant_users": "Händler-Benutzer"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Mitglieder",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "User Management",
|
||||
"merchant_users": "Merchant Users"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Members",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Gestion des utilisateurs",
|
||||
"merchant_users": "Utilisateurs marchands"
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
"members": "Membres",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"menu": {
|
||||
"user_management": "Benotzerverwaltung",
|
||||
"merchant_users": "Händler-Benotzer"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"members": "Memberen",
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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()}%"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
230
app/modules/tenancy/static/admin/js/merchant-users.js
Normal file
230
app/modules/tenancy/static/admin/js/merchant-users.js
Normal 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');
|
||||
211
app/modules/tenancy/templates/tenancy/admin/merchant-users.html
Normal file
211
app/modules/tenancy/templates/tenancy/admin/merchant-users.html
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user