# app/services/customer_service.py """ Customer management service. Handles customer registration, authentication, and profile management with complete vendor isolation. """ import logging from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import and_ from sqlalchemy.orm import Session from app.exceptions.customer import ( CustomerNotActiveException, CustomerNotFoundException, CustomerValidationException, DuplicateCustomerEmailException, InvalidCustomerCredentialsException, ) from app.exceptions.vendor import VendorNotActiveException, VendorNotFoundException from app.services.auth_service import AuthService from models.database.customer import Customer from models.database.vendor import Vendor from models.schema.auth import UserLogin from models.schema.customer import CustomerRegister, CustomerUpdate logger = logging.getLogger(__name__) class CustomerService: """Service for managing vendor-scoped customers.""" def __init__(self): self.auth_service = AuthService() def register_customer( self, db: Session, vendor_id: int, customer_data: CustomerRegister ) -> Customer: """ Register a new customer for a specific vendor. Args: db: Database session vendor_id: Vendor ID customer_data: Customer registration data Returns: Customer: Created customer object Raises: VendorNotFoundException: If vendor doesn't exist VendorNotActiveException: If vendor is not active DuplicateCustomerEmailException: If email already exists for this vendor CustomerValidationException: If customer data is invalid """ # Verify vendor exists and is active vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: raise VendorNotFoundException(str(vendor_id), identifier_type="id") if not vendor.is_active: raise VendorNotActiveException(vendor.vendor_code) # Check if email already exists for this vendor existing_customer = ( db.query(Customer) .filter( and_( Customer.vendor_id == vendor_id, Customer.email == customer_data.email.lower(), ) ) .first() ) if existing_customer: raise DuplicateCustomerEmailException( customer_data.email, vendor.vendor_code ) # Generate unique customer number for this vendor customer_number = self._generate_customer_number( db, vendor_id, vendor.vendor_code ) # Hash password hashed_password = self.auth_service.hash_password(customer_data.password) # Create customer customer = Customer( vendor_id=vendor_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 vendor {vendor.vendor_code}" ) return customer except Exception 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, vendor_id: int, credentials: UserLogin ) -> dict[str, Any]: """ Authenticate customer and generate JWT token. Args: db: Database session vendor_id: Vendor ID credentials: Login credentials Returns: Dict containing customer and token data Raises: VendorNotFoundException: If vendor doesn't exist InvalidCustomerCredentialsException: If credentials are invalid CustomerNotActiveException: If customer account is inactive """ # Verify vendor exists vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: raise VendorNotFoundException(str(vendor_id), identifier_type="id") # Find customer by email (vendor-scoped) customer = ( db.query(Customer) .filter( and_( Customer.vendor_id == vendor_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 # Use auth_manager directly since Customer is not a User model from datetime import datetime 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, "vendor_id": vendor_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 vendor {vendor.vendor_code}" ) return {"customer": customer, "token_data": token_data} def get_customer(self, db: Session, vendor_id: int, customer_id: int) -> Customer: """ Get customer by ID with vendor isolation. Args: db: Database session vendor_id: Vendor 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.vendor_id == vendor_id)) .first() ) if not customer: raise CustomerNotFoundException(str(customer_id)) return customer def get_customer_by_email( self, db: Session, vendor_id: int, email: str ) -> Customer | None: """ Get customer by email (vendor-scoped). Args: db: Database session vendor_id: Vendor ID email: Customer email Returns: Optional[Customer]: Customer object or None """ return ( db.query(Customer) .filter( and_(Customer.vendor_id == vendor_id, Customer.email == email.lower()) ) .first() ) def update_customer( self, db: Session, vendor_id: int, customer_id: int, customer_data: CustomerUpdate, ) -> Customer: """ Update customer profile. Args: db: Database session vendor_id: Vendor 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, vendor_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 vendor existing = ( db.query(Customer) .filter( and_( Customer.vendor_id == vendor_id, Customer.email == value.lower(), Customer.id != customer_id, ) ) .first() ) if existing: raise DuplicateCustomerEmailException(value, "vendor") 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 Exception 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, vendor_id: int, customer_id: int ) -> Customer: """ Deactivate customer account. Args: db: Database session vendor_id: Vendor ID customer_id: Customer ID Returns: Customer: Deactivated customer object Raises: CustomerNotFoundException: If customer not found """ customer = self.get_customer(db, vendor_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 update_customer_stats( self, db: Session, customer_id: int, order_total: float ) -> None: """ Update customer statistics after order. Args: db: Database session customer_id: Customer ID order_total: Order total amount """ customer = db.query(Customer).filter(Customer.id == customer_id).first() if customer: customer.total_orders += 1 customer.total_spent += order_total customer.last_order_date = datetime.utcnow() logger.debug(f"Updated stats for customer {customer.email}") def _generate_customer_number( self, db: Session, vendor_id: int, vendor_code: str ) -> str: """ Generate unique customer number for vendor. Format: {VENDOR_CODE}-CUST-{SEQUENCE} Example: VENDORA-CUST-00001 Args: db: Database session vendor_id: Vendor ID vendor_code: Vendor code Returns: str: Unique customer number """ # Get count of customers for this vendor count = db.query(Customer).filter(Customer.vendor_id == vendor_id).count() # Generate number with padding sequence = str(count + 1).zfill(5) customer_number = f"{vendor_code.upper()}-CUST-{sequence}" # Ensure uniqueness (in case of deletions) while ( db.query(Customer) .filter( and_( Customer.vendor_id == vendor_id, Customer.customer_number == customer_number, ) ) .first() ): count += 1 sequence = str(count + 1).zfill(5) customer_number = f"{vendor_code.upper()}-CUST-{sequence}" return customer_number # Singleton instance customer_service = CustomerService()