# 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"]