Files
orion/app/modules/tenancy/services/merchant_store_service.py
Samir Boulahtit 540205402f
Some checks failed
CI / pytest (push) Waiting to run
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
- Fix IPv6 host parsing with _strip_port() utility
- Remove dangerous StorePlatform→Store.subdomain silent fallback
- Close storefront gate bypass when frontend_type is None
- Add custom subdomain management UI and API for stores
- Add domain health diagnostic tool
- Convert db.add() in loops to db.add_all() (24 PERF-006 fixes)
- Add tests for all new functionality (18 subdomain service tests)
- Add .github templates for validator compliance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:13:01 +01:00

438 lines
14 KiB
Python

# app/modules/tenancy/services/merchant_store_service.py
"""
Merchant store service for store CRUD operations from the merchant portal.
Handles store management operations that merchant owners can perform:
- View store details (with ownership validation)
- Update store settings (name, description, contact info)
- Create new stores (with subscription limit checking)
Follows the service layer pattern — all DB operations go through here.
"""
import logging
from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import (
MerchantNotFoundException,
StoreAlreadyExistsException,
StoreNotFoundException,
StoreValidationException,
)
from app.modules.tenancy.models.merchant import Merchant
from app.modules.tenancy.models.platform import Platform
from app.modules.tenancy.models.store import Role, Store
from app.modules.tenancy.models.store_platform import StorePlatform
logger = logging.getLogger(__name__)
class MerchantStoreService:
"""Service for merchant-initiated store operations."""
def get_store_detail(
self,
db: Session,
merchant_id: int,
store_id: int,
) -> dict:
"""
Get store detail with ownership validation.
Args:
db: Database session
merchant_id: Merchant ID (for ownership check)
store_id: Store ID
Returns:
Dict with store details and platform assignments
Raises:
StoreNotFoundException: If store not found or not owned by merchant
"""
store = (
db.query(Store)
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
raise StoreNotFoundException(store_id, identifier_type="id")
# Get platform assignments
store_platforms = (
db.query(StorePlatform)
.join(Platform, StorePlatform.platform_id == Platform.id)
.filter(StorePlatform.store_id == store.id)
.all()
)
platforms = []
for sp in store_platforms:
platform = db.query(Platform).filter(Platform.id == sp.platform_id).first()
if platform:
platforms.append(
{
"id": platform.id,
"code": platform.code,
"name": platform.name,
"is_active": sp.is_active,
}
)
return {
"id": store.id,
"store_code": store.store_code,
"subdomain": store.subdomain,
"name": store.name,
"description": store.description,
"is_active": store.is_active,
"is_verified": store.is_verified,
"contact_email": store.contact_email,
"contact_phone": store.contact_phone,
"website": store.website,
"business_address": store.business_address,
"tax_number": store.tax_number,
"default_language": store.default_language,
"created_at": store.created_at.isoformat() if store.created_at else None,
"platforms": platforms,
}
def update_store(
self,
db: Session,
merchant_id: int,
store_id: int,
update_data: dict,
) -> dict:
"""
Update store fields (merchant-allowed fields only).
Args:
db: Database session
merchant_id: Merchant ID (for ownership check)
store_id: Store ID
update_data: Dict of fields to update
Returns:
Updated store detail dict
Raises:
StoreNotFoundException: If store not found or not owned by merchant
"""
store = (
db.query(Store)
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
raise StoreNotFoundException(store_id, identifier_type="id")
# Merchant-allowed update fields
allowed_fields = {
"name",
"description",
"contact_email",
"contact_phone",
"website",
"business_address",
"tax_number",
}
for field, value in update_data.items():
if field in allowed_fields:
setattr(store, field, value)
db.flush()
logger.info(
f"Merchant {merchant_id} updated store {store.store_code}: "
f"{list(update_data.keys())}"
)
return self.get_store_detail(db, merchant_id, store_id)
def create_store(
self,
db: Session,
merchant_id: int,
store_data: dict,
) -> dict:
"""
Create a new store under the merchant.
Args:
db: Database session
merchant_id: Merchant ID
store_data: Store creation data (name, store_code, subdomain, description, platform_ids)
Returns:
Created store detail dict
Raises:
MaxStoresReachedException: If store limit reached
MerchantNotFoundException: If merchant not found
StoreAlreadyExistsException: If store code already exists
StoreValidationException: If subdomain taken or validation fails
"""
# Check store creation limits
can_create, message = self.can_create_store(db, merchant_id)
if not can_create:
from app.modules.tenancy.exceptions import MaxStoresReachedException
raise MaxStoresReachedException(max_stores=0)
# Validate merchant exists
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise MerchantNotFoundException(merchant_id, identifier_type="id")
store_code = store_data["store_code"].upper()
subdomain = store_data["subdomain"].lower()
# Check store code uniqueness
existing = (
db.query(Store)
.filter(func.upper(Store.store_code) == store_code)
.first()
)
if existing:
raise StoreAlreadyExistsException(store_code)
# Check subdomain uniqueness
existing_sub = (
db.query(Store)
.filter(func.lower(Store.subdomain) == subdomain)
.first()
)
if existing_sub:
raise StoreValidationException(
f"Subdomain '{subdomain}' is already taken",
field="subdomain",
)
try:
# Create store
store = Store(
merchant_id=merchant_id,
store_code=store_code,
subdomain=subdomain,
name=store_data["name"],
description=store_data.get("description"),
is_active=True,
is_verified=False, # Pending admin verification
)
db.add(store)
db.flush()
# Create default roles
self._create_default_roles(db, store.id)
# Assign to platforms if provided
platform_ids = store_data.get("platform_ids", [])
store_platforms = []
for pid in platform_ids:
platform = db.query(Platform).filter(Platform.id == pid).first()
if platform:
store_platforms.append(StorePlatform(
store_id=store.id,
platform_id=pid,
is_active=True,
))
if store_platforms:
db.add_all(store_platforms)
db.flush()
db.refresh(store)
logger.info(
f"Merchant {merchant_id} created store {store.store_code} "
f"(ID: {store.id}, platforms: {platform_ids})"
)
return self.get_store_detail(db, merchant_id, store.id)
except (
StoreAlreadyExistsException,
MerchantNotFoundException,
StoreValidationException,
):
raise
except SQLAlchemyError as e:
logger.error(f"Failed to create store for merchant {merchant_id}: {e}")
raise StoreValidationException(
f"Failed to create store: {e}",
field="store",
)
def can_create_store(
self,
db: Session,
merchant_id: int,
) -> tuple[bool, str | None]:
"""
Check if merchant can create a new store based on subscription limits.
Returns:
Tuple of (allowed, message). message explains why if not allowed.
"""
try:
from app.modules.billing.services.feature_service import feature_service
return feature_service.check_resource_limit(
db,
feature_code="stores_limit",
merchant_id=merchant_id,
)
except Exception: # noqa: EXC-003
# If billing module not available, allow creation
return True, None
def get_subscribed_platform_ids(
self,
db: Session,
merchant_id: int,
) -> list[dict]:
"""
Get platforms the merchant has active subscriptions on.
Returns:
List of platform dicts with id, code, name
"""
try:
from app.modules.billing.services.subscription_service import (
subscription_service,
)
platform_ids = subscription_service.get_active_subscription_platform_ids(
db, merchant_id
)
except Exception: # noqa: EXC-003
platform_ids = []
platforms = []
for pid in platform_ids:
platform = db.query(Platform).filter(Platform.id == pid).first()
if platform:
platforms.append(
{
"id": platform.id,
"code": platform.code,
"name": platform.name,
}
)
return platforms
def _create_default_roles(self, db: Session, store_id: int):
"""Create default roles for a new store."""
default_roles = [
{"name": "Owner", "permissions": ["*"]},
{
"name": "Manager",
"permissions": [
"products.*",
"orders.*",
"customers.view",
"inventory.*",
"team.view",
],
},
{
"name": "Editor",
"permissions": [
"products.view",
"products.edit",
"orders.view",
"inventory.view",
],
},
{
"name": "Viewer",
"permissions": [
"products.view",
"orders.view",
"customers.view",
"inventory.view",
],
},
]
roles = [
Role(
store_id=store_id,
name=role_data["name"],
permissions=role_data["permissions"],
)
for role_data in default_roles
]
db.add_all(roles)
def get_merchant_team_overview(self, db: Session, merchant_id: int) -> dict:
"""
Get team members across all stores owned by the merchant.
Returns a list of stores with their team members grouped by store.
"""
from app.modules.tenancy.models.store import StoreUser
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise MerchantNotFoundException(merchant_id)
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.order_by(Store.name)
.all()
)
result = []
for store in stores:
members = (
db.query(StoreUser)
.filter(StoreUser.store_id == store.id)
.all()
)
store_team = {
"store_id": store.id,
"store_name": store.name,
"store_code": store.store_code,
"is_active": store.is_active,
"members": [
{
"id": m.id,
"user_id": m.user_id,
"email": m.user.email if m.user else None,
"first_name": m.user.first_name if m.user else None,
"last_name": m.user.last_name if m.user else None,
"role_name": m.role.name if m.role else None,
"is_active": m.is_active,
"invitation_accepted_at": (
m.invitation_accepted_at.isoformat()
if m.invitation_accepted_at
else None
),
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in members
],
"member_count": len(members),
}
result.append(store_team)
return {
"merchant_name": merchant.business_name or merchant.brand_name,
"owner_email": merchant.owner.email if merchant.owner else None,
"stores": result,
"total_members": sum(s["member_count"] for s in result),
}
# Singleton instance
merchant_store_service = MerchantStoreService()
__all__ = ["MerchantStoreService", "merchant_store_service"]