Some checks failed
Phase 1 of the loyalty production launch plan: config & security hardening, dropped-data fix, DB integrity guards, rate limiting, and constant-time auth compare. 362 tests pass. - 1.4 Persist customer birth_date (new column + migration). Enrollment form was collecting it but the value was silently dropped because create_customer_for_enrollment never received it. Backfills existing customers without overwriting. - 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file must exist and be readable; ~ expanded). Adds is_google_wallet_enabled and is_apple_wallet_enabled derived flags. Prod path documented as ~/apps/orion/google-wallet-sa.json. - 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count non-negative) and loyalty_programs (min_purchase, points_per_euro, welcome_bonus non-negative; stamps_target >= 1). Mirrored as CheckConstraint in models. Pre-flight scan showed zero violations. - 1.3 @rate_limit on store mutating endpoints: stamp 60/min, redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min. - 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token (pulled forward from Phase 9 — code is safe whenever Apple ships). See app/modules/loyalty/docs/production-launch-plan.md for the full launch plan and remaining phases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
654 lines
19 KiB
Python
654 lines
19 KiB
Python
# 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()
|