# app/modules/tenancy/services/merchant_service.py """ Merchant service for managing merchant operations. This service handles CRUD operations for merchants and merchant-store relationships. """ import logging import secrets import string from sqlalchemy import func, select from sqlalchemy.orm import Session, joinedload from app.modules.tenancy.exceptions import ( MerchantNotFoundException, UserNotFoundException, ) from app.modules.tenancy.models import Merchant, Store, User from app.modules.tenancy.schemas.merchant import ( MerchantCreate, MerchantTransferOwnership, MerchantUpdate, ) logger = logging.getLogger(__name__) class MerchantService: """Service for managing merchants.""" def __init__(self): """Initialize merchant service.""" def create_merchant_with_owner( self, db: Session, merchant_data: MerchantCreate ) -> tuple[Merchant, User, str]: """ Create a new merchant with an owner user account. Args: db: Database session merchant_data: Merchant creation data Returns: Tuple of (merchant, owner_user, temporary_password) """ # Import AuthManager for password hashing (same pattern as admin_service) from middleware.auth import AuthManager auth_manager = AuthManager() # Check if owner email already exists existing_user = db.execute( select(User).where(User.email == merchant_data.owner_email) ).scalar_one_or_none() if existing_user: # Use existing user as owner owner_user = existing_user temp_password = None logger.info(f"Using existing user {owner_user.email} as merchant owner") else: # Generate temporary password for owner temp_password = self._generate_temp_password() # Create new owner user owner_user = User( username=merchant_data.owner_email.split("@")[0], email=merchant_data.owner_email, hashed_password=auth_manager.hash_password(temp_password), role="merchant_owner", is_active=True, is_email_verified=False, ) db.add(owner_user) db.flush() # Get owner_user.id logger.info(f"Created new owner user: {owner_user.email}") # Create merchant merchant = Merchant( name=merchant_data.name, description=merchant_data.description, owner_user_id=owner_user.id, contact_email=merchant_data.contact_email, contact_phone=merchant_data.contact_phone, website=merchant_data.website, business_address=merchant_data.business_address, tax_number=merchant_data.tax_number, is_active=True, is_verified=False, ) db.add(merchant) db.flush() logger.info(f"Created merchant: {merchant.name} (ID: {merchant.id})") return merchant, owner_user, temp_password def get_merchant_by_id(self, db: Session, merchant_id: int) -> Merchant: """ Get merchant by ID. Args: db: Database session merchant_id: Merchant ID Returns: Merchant object Raises: MerchantNotFoundException: If merchant not found """ merchant = ( db.execute( select(Merchant) .where(Merchant.id == merchant_id) .options(joinedload(Merchant.stores)) ) .unique() .scalar_one_or_none() ) if not merchant: raise MerchantNotFoundException(merchant_id) return merchant def get_merchant_by_id_optional( self, db: Session, merchant_id: int ) -> Merchant | None: """ Get merchant by ID, returns None if not found. Args: db: Database session merchant_id: Merchant ID Returns: Merchant object or None """ return db.query(Merchant).filter(Merchant.id == merchant_id).first() def get_merchants( self, db: Session, skip: int = 0, limit: int = 100, search: str | None = None, is_active: bool | None = None, is_verified: bool | None = None, include_deleted: bool = False, only_deleted: bool = False, ) -> tuple[list[Merchant], int]: """ Get paginated list of merchants with optional filters. Args: db: Database session skip: Number of records to skip limit: Maximum number of records to return search: Search term for merchant name is_active: Filter by active status is_verified: Filter by verified status include_deleted: Include soft-deleted merchants only_deleted: Show only soft-deleted merchants (trash view) Returns: Tuple of (merchants list, total count) """ exec_opts = {} if include_deleted or only_deleted: exec_opts["include_deleted"] = True query = select(Merchant).options( joinedload(Merchant.stores), joinedload(Merchant.owner), ) # Soft-delete filter if only_deleted: query = query.where(Merchant.deleted_at.isnot(None)) # Apply filters if search: query = query.where(Merchant.name.ilike(f"%{search}%")) if is_active is not None: query = query.where(Merchant.is_active == is_active) if is_verified is not None: query = query.where(Merchant.is_verified == is_verified) # Get total count count_query = select(func.count()).select_from(query.subquery()) total = db.execute(count_query, execution_options=exec_opts).scalar() # Apply pagination and order query = query.order_by(Merchant.name).offset(skip).limit(limit) # Use unique() when using joinedload with collections to avoid duplicate rows merchants = list(db.execute(query, execution_options=exec_opts).scalars().unique().all()) return merchants, total def update_merchant( self, db: Session, merchant_id: int, merchant_data: MerchantUpdate ) -> Merchant: """ Update merchant information. Args: db: Database session merchant_id: Merchant ID merchant_data: Updated merchant data Returns: Updated merchant Raises: MerchantNotFoundException: If merchant not found """ merchant = self.get_merchant_by_id(db, merchant_id) # Update only provided fields update_data = merchant_data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(merchant, field, value) db.flush() logger.info(f"Updated merchant ID {merchant_id}") return merchant def delete_merchant(self, db: Session, merchant_id: int) -> None: """ Delete a merchant and all associated stores. Args: db: Database session merchant_id: Merchant ID Raises: MerchantNotFoundException: If merchant not found """ from app.core.soft_delete import soft_delete_cascade merchant = self.get_merchant_by_id(db, merchant_id) MERCHANT_CASCADE = [ ("stores", [ ("products", []), ("customers", []), ("orders", []), ("store_users", []), ]), ] soft_delete_cascade(db, merchant, deleted_by_id=None, cascade_rels=MERCHANT_CASCADE) logger.info(f"Deleted merchant ID {merchant_id} and associated stores") def toggle_verification( self, db: Session, merchant_id: int, is_verified: bool ) -> Merchant: """ Toggle merchant verification status. Args: db: Database session merchant_id: Merchant ID is_verified: New verification status Returns: Updated merchant Raises: MerchantNotFoundException: If merchant not found """ merchant = self.get_merchant_by_id(db, merchant_id) merchant.is_verified = is_verified db.flush() logger.info(f"Merchant ID {merchant_id} verification set to {is_verified}") return merchant def toggle_active(self, db: Session, merchant_id: int, is_active: bool) -> Merchant: """ Toggle merchant active status. Args: db: Database session merchant_id: Merchant ID is_active: New active status Returns: Updated merchant Raises: MerchantNotFoundException: If merchant not found """ merchant = self.get_merchant_by_id(db, merchant_id) merchant.is_active = is_active db.flush() logger.info(f"Merchant ID {merchant_id} active status set to {is_active}") return merchant def transfer_ownership( self, db: Session, merchant_id: int, transfer_data: MerchantTransferOwnership, ) -> tuple[Merchant, User, User]: """ Transfer merchant ownership to another user. This is a critical operation that: - Changes the merchant's owner_user_id - All stores under the merchant automatically inherit the new owner - Logs the transfer for audit purposes Args: db: Database session merchant_id: Merchant ID transfer_data: Transfer ownership data Returns: Tuple of (merchant, old_owner, new_owner) Raises: MerchantNotFoundException: If merchant not found UserNotFoundException: If new owner user not found ValueError: If trying to transfer to current owner """ # Get merchant merchant = self.get_merchant_by_id(db, merchant_id) old_owner_id = merchant.owner_user_id # Get old owner old_owner = db.execute( select(User).where(User.id == old_owner_id) ).scalar_one_or_none() if not old_owner: raise UserNotFoundException(str(old_owner_id)) # Get new owner new_owner = db.execute( select(User).where(User.id == transfer_data.new_owner_user_id) ).scalar_one_or_none() if not new_owner: raise UserNotFoundException(str(transfer_data.new_owner_user_id)) # Prevent transferring to same owner if old_owner_id == transfer_data.new_owner_user_id: raise ValueError("Cannot transfer ownership to the current owner") # Update merchant owner (stores inherit ownership via merchant relationship) merchant.owner_user_id = new_owner.id db.flush() logger.info( f"Merchant {merchant.id} ({merchant.name}) ownership transferred " f"from user {old_owner.id} ({old_owner.email}) " f"to user {new_owner.id} ({new_owner.email}). " f"Reason: {transfer_data.transfer_reason or 'Not specified'}" ) return merchant, old_owner, new_owner def get_merchant_by_owner_id( self, db: Session, owner_user_id: int ) -> Merchant | None: """ Get merchant by owner user ID. Args: db: Database session owner_user_id: Owner user ID Returns: First active merchant owned by the user, or None """ return ( db.query(Merchant) .filter( Merchant.owner_user_id == owner_user_id, Merchant.is_active == True, # noqa: E712 ) .first() ) def get_merchant_count_for_owner( self, db: Session, owner_user_id: int, active_only: bool = True ) -> int: """ Count merchants owned by a user. Args: db: Database session owner_user_id: Owner user ID active_only: Only count active merchants Returns: Number of merchants """ query = db.query(func.count(Merchant.id)).filter( Merchant.owner_user_id == owner_user_id ) if active_only: query = query.filter(Merchant.is_active == True) # noqa: E712 return query.scalar() or 0 def get_merchant_stores( self, db: Session, merchant_id: int, skip: int = 0, limit: int = 100 ) -> tuple[list, int]: """Get paginated stores for a merchant.""" query = db.query(Store).filter(Store.merchant_id == merchant_id) total = query.count() stores = query.order_by(Store.id).offset(skip).limit(limit).all() return stores, total def _generate_temp_password(self, length: int = 12) -> str: """Generate secure temporary password.""" alphabet = string.ascii_letters + string.digits + "!@#$%^&*" return "".join(secrets.choice(alphabet) for _ in range(length)) # Create service instance following the same pattern as other services merchant_service = MerchantService()