# 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 from app.modules.tenancy.models import 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="user", is_active=True, is_email_verified=True, ) 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_merchants( self, db: Session, skip: int = 0, limit: int = 100, search: str | None = None, is_active: bool | None = None, is_verified: bool | None = None, ) -> 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 Returns: Tuple of (merchants list, total count) """ query = select(Merchant).options(joinedload(Merchant.stores)) # 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).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).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 """ merchant = self.get_merchant_by_id(db, merchant_id) # Due to cascade="all, delete-orphan", associated stores will be deleted db.delete(merchant) db.flush() 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 _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()