# app/modules/customers/services/customer_service.py """ Customer management service. Handles customer registration, authentication, and profile management with complete store isolation. """ import logging from datetime import UTC, date, datetime, timedelta from typing import Any from sqlalchemy import and_ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.modules.core.services.auth_service import AuthService from app.modules.customers.exceptions import ( CustomerNotActiveException, CustomerNotFoundException, CustomerValidationException, DuplicateCustomerEmailException, InvalidCustomerCredentialsException, InvalidPasswordResetTokenException, PasswordTooShortException, ) from app.modules.customers.models import Customer, PasswordResetToken from app.modules.customers.schemas import CustomerRegister, CustomerUpdate from app.modules.tenancy.exceptions import ( StoreNotActiveException, StoreNotFoundException, ) from app.modules.tenancy.services.store_service import store_service as _store_service logger = logging.getLogger(__name__) class CustomerService: """Service for managing store-scoped customers.""" def __init__(self): self.auth_service = AuthService() def register_customer( self, db: Session, store_id: int, customer_data: CustomerRegister ) -> Customer: """ Register a new customer for a specific store. Args: db: Database session store_id: Store ID customer_data: Customer registration data Returns: Customer: Created customer object Raises: StoreNotFoundException: If store doesn't exist StoreNotActiveException: If store is not active DuplicateCustomerEmailException: If email already exists for this store CustomerValidationException: If customer data is invalid """ # Verify store exists and is active store = _store_service.get_store_by_id_optional(db, store_id) if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") if not store.is_active: raise StoreNotActiveException(store.store_code) # Check if email already exists for this store existing_customer = ( db.query(Customer) .filter( and_( Customer.store_id == store_id, Customer.email == customer_data.email.lower(), ) ) .first() ) if existing_customer: raise DuplicateCustomerEmailException( customer_data.email, store.store_code ) # Generate unique customer number for this store customer_number = self._generate_customer_number( db, store_id, store.store_code ) # Hash password hashed_password = self.auth_service.hash_password(customer_data.password) # Create customer customer = Customer( store_id=store_id, email=customer_data.email.lower(), hashed_password=hashed_password, first_name=customer_data.first_name, last_name=customer_data.last_name, phone=customer_data.phone, customer_number=customer_number, marketing_consent=( customer_data.marketing_consent if hasattr(customer_data, "marketing_consent") else False ), is_active=True, ) try: db.add(customer) db.flush() db.refresh(customer) logger.info( f"Customer registered successfully: {customer.email} " f"(ID: {customer.id}, Number: {customer.customer_number}) " f"for store {store.store_code}" ) return customer except SQLAlchemyError as e: logger.error(f"Error registering customer: {str(e)}") raise CustomerValidationException( message="Failed to register customer", details={"error": str(e)} ) def login_customer( self, db: Session, store_id: int, credentials ) -> dict[str, Any]: """ Authenticate customer and generate JWT token. Args: db: Database session store_id: Store ID credentials: Login credentials (UserLogin schema) Returns: Dict containing customer and token data Raises: StoreNotFoundException: If store doesn't exist InvalidCustomerCredentialsException: If credentials are invalid CustomerNotActiveException: If customer account is inactive """ # Verify store exists store = _store_service.get_store_by_id_optional(db, store_id) if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") # Find customer by email (store-scoped) customer = ( db.query(Customer) .filter( and_( Customer.store_id == store_id, Customer.email == credentials.email_or_username.lower(), ) ) .first() ) if not customer: raise InvalidCustomerCredentialsException() # Verify password using auth_manager directly if not self.auth_service.auth_manager.verify_password( credentials.password, customer.hashed_password ): raise InvalidCustomerCredentialsException() # Check if customer is active if not customer.is_active: raise CustomerNotActiveException(customer.email) # Generate JWT token with customer context from jose import jwt auth_manager = self.auth_service.auth_manager expires_delta = timedelta(minutes=auth_manager.token_expire_minutes) expire = datetime.now(UTC) + expires_delta payload = { "sub": str(customer.id), "email": customer.email, "store_id": store_id, "type": "customer", "exp": expire, "iat": datetime.now(UTC), } token = jwt.encode( payload, auth_manager.secret_key, algorithm=auth_manager.algorithm ) token_data = { "access_token": token, "token_type": "bearer", "expires_in": auth_manager.token_expire_minutes * 60, } logger.info( f"Customer login successful: {customer.email} " f"for store {store.store_code}" ) return {"customer": customer, "token_data": token_data} def get_customer(self, db: Session, store_id: int, customer_id: int) -> Customer: """ Get customer by ID with store isolation. Args: db: Database session store_id: Store ID customer_id: Customer ID Returns: Customer: Customer object Raises: CustomerNotFoundException: If customer not found """ customer = ( db.query(Customer) .filter(and_(Customer.id == customer_id, Customer.store_id == store_id)) .first() ) if not customer: raise CustomerNotFoundException(str(customer_id)) return customer def get_customer_by_email( self, db: Session, store_id: int, email: str ) -> Customer | None: """ Get customer by email (store-scoped). Args: db: Database session store_id: Store ID email: Customer email Returns: Optional[Customer]: Customer object or None """ return ( db.query(Customer) .filter( and_(Customer.store_id == store_id, Customer.email == email.lower()) ) .first() ) def get_store_customers( self, db: Session, store_id: int, skip: int = 0, limit: int = 100, search: str | None = None, is_active: bool | None = None, ) -> tuple[list[Customer], int]: """ Get all customers for a store with filtering and pagination. Args: db: Database session store_id: Store ID skip: Pagination offset limit: Pagination limit search: Search in name/email is_active: Filter by active status Returns: Tuple of (customers, total_count) """ from sqlalchemy import or_ query = db.query(Customer).filter(Customer.store_id == store_id) if search: search_pattern = f"%{search}%" query = query.filter( or_( Customer.email.ilike(search_pattern), Customer.first_name.ilike(search_pattern), Customer.last_name.ilike(search_pattern), Customer.customer_number.ilike(search_pattern), ) ) if is_active is not None: query = query.filter(Customer.is_active == is_active) # Order by most recent first query = query.order_by(Customer.created_at.desc()) total = query.count() customers = query.offset(skip).limit(limit).all() return customers, total # Note: Customer order methods have been moved to the orders module. # Use orders.services.customer_order_service for: # - get_customer_orders() # Use orders.services.order_metrics.get_customer_order_metrics() for: # - customer order statistics def toggle_customer_status( self, db: Session, store_id: int, customer_id: int ) -> Customer: """ Toggle customer active status. Args: db: Database session store_id: Store ID customer_id: Customer ID Returns: Customer: Updated customer """ customer = self.get_customer(db, store_id, customer_id) customer.is_active = not customer.is_active db.flush() db.refresh(customer) action = "activated" if customer.is_active else "deactivated" logger.info(f"Customer {action}: {customer.email} (ID: {customer.id})") return customer def update_customer( self, db: Session, store_id: int, customer_id: int, customer_data: CustomerUpdate, ) -> Customer: """ Update customer profile. Args: db: Database session store_id: Store ID customer_id: Customer ID customer_data: Updated customer data Returns: Customer: Updated customer object Raises: CustomerNotFoundException: If customer not found CustomerValidationException: If update data is invalid """ customer = self.get_customer(db, store_id, customer_id) # Update fields update_data = customer_data.model_dump(exclude_unset=True) for field, value in update_data.items(): if field == "email" and value: # Check if new email already exists for this store existing = ( db.query(Customer) .filter( and_( Customer.store_id == store_id, Customer.email == value.lower(), Customer.id != customer_id, ) ) .first() ) if existing: raise DuplicateCustomerEmailException(value, "store") setattr(customer, field, value.lower()) elif hasattr(customer, field): setattr(customer, field, value) try: db.flush() db.refresh(customer) logger.info(f"Customer updated: {customer.email} (ID: {customer.id})") return customer except SQLAlchemyError as e: logger.error(f"Error updating customer: {str(e)}") raise CustomerValidationException( message="Failed to update customer", details={"error": str(e)} ) def deactivate_customer( self, db: Session, store_id: int, customer_id: int ) -> Customer: """ Deactivate customer account. Args: db: Database session store_id: Store ID customer_id: Customer ID Returns: Customer: Deactivated customer object Raises: CustomerNotFoundException: If customer not found """ customer = self.get_customer(db, store_id, customer_id) customer.is_active = False db.flush() db.refresh(customer) logger.info(f"Customer deactivated: {customer.email} (ID: {customer.id})") return customer def _generate_customer_number( self, db: Session, store_id: int, store_code: str ) -> str: """ Generate unique customer number for store. Format: {STORE_CODE}-CUST-{SEQUENCE} Example: STOREA-CUST-00001 Args: db: Database session store_id: Store ID store_code: Store code Returns: str: Unique customer number """ # Get count of customers for this store count = db.query(Customer).filter(Customer.store_id == store_id).count() # Generate number with padding sequence = str(count + 1).zfill(5) customer_number = f"{store_code.upper()}-CUST-{sequence}" # Ensure uniqueness (in case of deletions) while ( db.query(Customer) .filter( and_( Customer.store_id == store_id, Customer.customer_number == customer_number, ) ) .first() ): count += 1 sequence = str(count + 1).zfill(5) customer_number = f"{store_code.upper()}-CUST-{sequence}" return customer_number def get_customer_for_password_reset( self, db: Session, store_id: int, email: str ) -> Customer | None: """ Get active customer by email for password reset. Args: db: Database session store_id: Store ID email: Customer email Returns: Customer if found and active, None otherwise """ return ( db.query(Customer) .filter( Customer.store_id == store_id, Customer.email == email.lower(), Customer.is_active == True, # noqa: E712 ) .first() ) def validate_and_reset_password( self, db: Session, store_id: int, reset_token: str, new_password: str, ) -> Customer: """ Validate reset token and update customer password. Args: db: Database session store_id: Store ID reset_token: Password reset token from email new_password: New password Returns: Customer: Updated customer Raises: PasswordTooShortException: If password too short InvalidPasswordResetTokenException: If token invalid/expired CustomerNotActiveException: If customer not active """ # Validate password length if len(new_password) < 8: raise PasswordTooShortException(min_length=8) # Find valid token token_record = PasswordResetToken.find_valid_token(db, reset_token) if not token_record: raise InvalidPasswordResetTokenException() # Get the customer and verify they belong to this store customer = ( db.query(Customer) .filter(Customer.id == token_record.customer_id) .first() ) if not customer or customer.store_id != store_id: raise InvalidPasswordResetTokenException() if not customer.is_active: raise CustomerNotActiveException(customer.email) # Hash the new password and update customer hashed_password = self.auth_service.hash_password(new_password) customer.hashed_password = hashed_password # Mark token as used token_record.mark_used(db) logger.info(f"Password reset completed for customer {customer.id}") # noqa: SEC021 return customer # ======================================================================== # Cross-module public API methods # ======================================================================== def create_customer_for_enrollment( self, db: Session, store_id: int, email: str, first_name: str = "", last_name: str = "", phone: str | None = None, birth_date: date | None = None, ) -> Customer: """ Create a customer for loyalty/external enrollment. Creates a customer with an unusable password hash. Args: db: Database session store_id: Store ID email: Customer email first_name: First name last_name: Last name phone: Phone number birth_date: Date of birth (optional) Returns: Created Customer object """ import secrets unusable_hash = f"!enrollment!{secrets.token_hex(32)}" store_code = "STORE" try: from app.modules.tenancy.services.store_service import store_service store = store_service.get_store_by_id_optional(db, store_id) if store: store_code = store.store_code except SQLAlchemyError: pass cust_number = self._generate_customer_number(db, store_id, store_code) customer = Customer( email=email, first_name=first_name, last_name=last_name, phone=phone, birth_date=birth_date, hashed_password=unusable_hash, customer_number=cust_number, store_id=store_id, is_active=True, ) db.add(customer) db.flush() return customer def get_customer_by_id(self, db: Session, customer_id: int) -> Customer | None: """ Get customer by ID without store scope. Args: db: Database session customer_id: Customer ID Returns: Customer object or None """ return db.query(Customer).filter(Customer.id == customer_id).first() def get_store_customer_count(self, db: Session, store_id: int) -> int: """ Count customers for a store. Args: db: Database session store_id: Store ID Returns: Customer count """ from sqlalchemy import func return ( db.query(func.count(Customer.id)) .filter(Customer.store_id == store_id) .scalar() or 0 ) # Singleton instance customer_service = CustomerService()