Files
orion/app/modules/tenancy/services/merchant_service.py
Samir Boulahtit 9bceeaac9c feat(arch): implement soft delete for business-critical models
Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.

Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.

Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain

Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade

Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores

Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:08:07 +01:00

427 lines
13 KiB
Python

# 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()