- API-004: Add noqa for factory-pattern auth in user_account routes and payments admin - MDL-003: Add from_attributes to MerchantStoreDetailResponse schema - EXC-003: Suppress broad except in merchant_store_service and admin_subscription_service (intentional fallbacks for optional billing module) - NAM-002: Rename onboarding files to *_service.py suffix and update all imports - JS-001: Add file-level noqa for dev-toolbar.js (console interceptor by design) - JS-005: Add init guards to dashboard.js and customer-detail.js - IMPORT-004: Break circular deps by removing orders from inventory requires and marketplace from orders requires; add IMPORT-002 suppression for lazy cross-imports - MOD-025: Remove unused OnboardingAlreadyCompletedException Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
436 lines
14 KiB
Python
436 lines
14 KiB
Python
# app/modules/tenancy/services/merchant_store_service.py
|
|
"""
|
|
Merchant store service for store CRUD operations from the merchant portal.
|
|
|
|
Handles store management operations that merchant owners can perform:
|
|
- View store details (with ownership validation)
|
|
- Update store settings (name, description, contact info)
|
|
- Create new stores (with subscription limit checking)
|
|
|
|
Follows the service layer pattern — all DB operations go through here.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.tenancy.exceptions import (
|
|
MerchantNotFoundException,
|
|
StoreAlreadyExistsException,
|
|
StoreNotFoundException,
|
|
StoreValidationException,
|
|
)
|
|
from app.modules.tenancy.models.merchant import Merchant
|
|
from app.modules.tenancy.models.platform import Platform
|
|
from app.modules.tenancy.models.store import Role, Store
|
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MerchantStoreService:
|
|
"""Service for merchant-initiated store operations."""
|
|
|
|
def get_store_detail(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
store_id: int,
|
|
) -> dict:
|
|
"""
|
|
Get store detail with ownership validation.
|
|
|
|
Args:
|
|
db: Database session
|
|
merchant_id: Merchant ID (for ownership check)
|
|
store_id: Store ID
|
|
|
|
Returns:
|
|
Dict with store details and platform assignments
|
|
|
|
Raises:
|
|
StoreNotFoundException: If store not found or not owned by merchant
|
|
"""
|
|
store = (
|
|
db.query(Store)
|
|
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
|
|
.first()
|
|
)
|
|
if not store:
|
|
raise StoreNotFoundException(store_id, identifier_type="id")
|
|
|
|
# Get platform assignments
|
|
store_platforms = (
|
|
db.query(StorePlatform)
|
|
.join(Platform, StorePlatform.platform_id == Platform.id)
|
|
.filter(StorePlatform.store_id == store.id)
|
|
.all()
|
|
)
|
|
|
|
platforms = []
|
|
for sp in store_platforms:
|
|
platform = db.query(Platform).filter(Platform.id == sp.platform_id).first()
|
|
if platform:
|
|
platforms.append(
|
|
{
|
|
"id": platform.id,
|
|
"code": platform.code,
|
|
"name": platform.name,
|
|
"is_active": sp.is_active,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"id": store.id,
|
|
"store_code": store.store_code,
|
|
"subdomain": store.subdomain,
|
|
"name": store.name,
|
|
"description": store.description,
|
|
"is_active": store.is_active,
|
|
"is_verified": store.is_verified,
|
|
"contact_email": store.contact_email,
|
|
"contact_phone": store.contact_phone,
|
|
"website": store.website,
|
|
"business_address": store.business_address,
|
|
"tax_number": store.tax_number,
|
|
"default_language": store.default_language,
|
|
"created_at": store.created_at.isoformat() if store.created_at else None,
|
|
"platforms": platforms,
|
|
}
|
|
|
|
def update_store(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
store_id: int,
|
|
update_data: dict,
|
|
) -> dict:
|
|
"""
|
|
Update store fields (merchant-allowed fields only).
|
|
|
|
Args:
|
|
db: Database session
|
|
merchant_id: Merchant ID (for ownership check)
|
|
store_id: Store ID
|
|
update_data: Dict of fields to update
|
|
|
|
Returns:
|
|
Updated store detail dict
|
|
|
|
Raises:
|
|
StoreNotFoundException: If store not found or not owned by merchant
|
|
"""
|
|
store = (
|
|
db.query(Store)
|
|
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
|
|
.first()
|
|
)
|
|
if not store:
|
|
raise StoreNotFoundException(store_id, identifier_type="id")
|
|
|
|
# Merchant-allowed update fields
|
|
allowed_fields = {
|
|
"name",
|
|
"description",
|
|
"contact_email",
|
|
"contact_phone",
|
|
"website",
|
|
"business_address",
|
|
"tax_number",
|
|
}
|
|
|
|
for field, value in update_data.items():
|
|
if field in allowed_fields:
|
|
setattr(store, field, value)
|
|
|
|
db.flush()
|
|
logger.info(
|
|
f"Merchant {merchant_id} updated store {store.store_code}: "
|
|
f"{list(update_data.keys())}"
|
|
)
|
|
|
|
return self.get_store_detail(db, merchant_id, store_id)
|
|
|
|
def create_store(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
store_data: dict,
|
|
) -> dict:
|
|
"""
|
|
Create a new store under the merchant.
|
|
|
|
Args:
|
|
db: Database session
|
|
merchant_id: Merchant ID
|
|
store_data: Store creation data (name, store_code, subdomain, description, platform_ids)
|
|
|
|
Returns:
|
|
Created store detail dict
|
|
|
|
Raises:
|
|
MaxStoresReachedException: If store limit reached
|
|
MerchantNotFoundException: If merchant not found
|
|
StoreAlreadyExistsException: If store code already exists
|
|
StoreValidationException: If subdomain taken or validation fails
|
|
"""
|
|
# Check store creation limits
|
|
can_create, message = self.can_create_store(db, merchant_id)
|
|
if not can_create:
|
|
from app.modules.tenancy.exceptions import MaxStoresReachedException
|
|
|
|
raise MaxStoresReachedException(max_stores=0)
|
|
|
|
# Validate merchant exists
|
|
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
|
if not merchant:
|
|
raise MerchantNotFoundException(merchant_id, identifier_type="id")
|
|
|
|
store_code = store_data["store_code"].upper()
|
|
subdomain = store_data["subdomain"].lower()
|
|
|
|
# Check store code uniqueness
|
|
existing = (
|
|
db.query(Store)
|
|
.filter(func.upper(Store.store_code) == store_code)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise StoreAlreadyExistsException(store_code)
|
|
|
|
# Check subdomain uniqueness
|
|
existing_sub = (
|
|
db.query(Store)
|
|
.filter(func.lower(Store.subdomain) == subdomain)
|
|
.first()
|
|
)
|
|
if existing_sub:
|
|
raise StoreValidationException(
|
|
f"Subdomain '{subdomain}' is already taken",
|
|
field="subdomain",
|
|
)
|
|
|
|
try:
|
|
# Create store
|
|
store = Store(
|
|
merchant_id=merchant_id,
|
|
store_code=store_code,
|
|
subdomain=subdomain,
|
|
name=store_data["name"],
|
|
description=store_data.get("description"),
|
|
is_active=True,
|
|
is_verified=False, # Pending admin verification
|
|
)
|
|
db.add(store)
|
|
db.flush()
|
|
|
|
# Create default roles
|
|
self._create_default_roles(db, store.id)
|
|
|
|
# Assign to platforms if provided
|
|
platform_ids = store_data.get("platform_ids", [])
|
|
for pid in platform_ids:
|
|
platform = db.query(Platform).filter(Platform.id == pid).first()
|
|
if platform:
|
|
sp = StorePlatform(
|
|
store_id=store.id,
|
|
platform_id=pid,
|
|
is_active=True,
|
|
)
|
|
db.add(sp)
|
|
|
|
db.flush()
|
|
db.refresh(store)
|
|
|
|
logger.info(
|
|
f"Merchant {merchant_id} created store {store.store_code} "
|
|
f"(ID: {store.id}, platforms: {platform_ids})"
|
|
)
|
|
|
|
return self.get_store_detail(db, merchant_id, store.id)
|
|
|
|
except (
|
|
StoreAlreadyExistsException,
|
|
MerchantNotFoundException,
|
|
StoreValidationException,
|
|
):
|
|
raise
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Failed to create store for merchant {merchant_id}: {e}")
|
|
raise StoreValidationException(
|
|
f"Failed to create store: {e}",
|
|
field="store",
|
|
)
|
|
|
|
def can_create_store(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
) -> tuple[bool, str | None]:
|
|
"""
|
|
Check if merchant can create a new store based on subscription limits.
|
|
|
|
Returns:
|
|
Tuple of (allowed, message). message explains why if not allowed.
|
|
"""
|
|
try:
|
|
from app.modules.billing.services.feature_service import feature_service
|
|
|
|
return feature_service.check_resource_limit(
|
|
db,
|
|
feature_code="stores_limit",
|
|
merchant_id=merchant_id,
|
|
)
|
|
except Exception: # noqa: EXC-003
|
|
# If billing module not available, allow creation
|
|
return True, None
|
|
|
|
def get_subscribed_platform_ids(
|
|
self,
|
|
db: Session,
|
|
merchant_id: int,
|
|
) -> list[dict]:
|
|
"""
|
|
Get platforms the merchant has active subscriptions on.
|
|
|
|
Returns:
|
|
List of platform dicts with id, code, name
|
|
"""
|
|
try:
|
|
from app.modules.billing.services.subscription_service import (
|
|
subscription_service,
|
|
)
|
|
|
|
platform_ids = subscription_service.get_active_subscription_platform_ids(
|
|
db, merchant_id
|
|
)
|
|
except Exception: # noqa: EXC-003
|
|
platform_ids = []
|
|
|
|
platforms = []
|
|
for pid in platform_ids:
|
|
platform = db.query(Platform).filter(Platform.id == pid).first()
|
|
if platform:
|
|
platforms.append(
|
|
{
|
|
"id": platform.id,
|
|
"code": platform.code,
|
|
"name": platform.name,
|
|
}
|
|
)
|
|
return platforms
|
|
|
|
def _create_default_roles(self, db: Session, store_id: int):
|
|
"""Create default roles for a new store."""
|
|
default_roles = [
|
|
{"name": "Owner", "permissions": ["*"]},
|
|
{
|
|
"name": "Manager",
|
|
"permissions": [
|
|
"products.*",
|
|
"orders.*",
|
|
"customers.view",
|
|
"inventory.*",
|
|
"team.view",
|
|
],
|
|
},
|
|
{
|
|
"name": "Editor",
|
|
"permissions": [
|
|
"products.view",
|
|
"products.edit",
|
|
"orders.view",
|
|
"inventory.view",
|
|
],
|
|
},
|
|
{
|
|
"name": "Viewer",
|
|
"permissions": [
|
|
"products.view",
|
|
"orders.view",
|
|
"customers.view",
|
|
"inventory.view",
|
|
],
|
|
},
|
|
]
|
|
|
|
roles = [
|
|
Role(
|
|
store_id=store_id,
|
|
name=role_data["name"],
|
|
permissions=role_data["permissions"],
|
|
)
|
|
for role_data in default_roles
|
|
]
|
|
db.add_all(roles)
|
|
|
|
|
|
def get_merchant_team_overview(self, db: Session, merchant_id: int) -> dict:
|
|
"""
|
|
Get team members across all stores owned by the merchant.
|
|
|
|
Returns a list of stores with their team members grouped by store.
|
|
"""
|
|
from app.modules.tenancy.models.store import StoreUser
|
|
|
|
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
|
if not merchant:
|
|
raise MerchantNotFoundException(merchant_id)
|
|
|
|
stores = (
|
|
db.query(Store)
|
|
.filter(Store.merchant_id == merchant_id)
|
|
.order_by(Store.name)
|
|
.all()
|
|
)
|
|
|
|
result = []
|
|
for store in stores:
|
|
members = (
|
|
db.query(StoreUser)
|
|
.filter(StoreUser.store_id == store.id)
|
|
.all()
|
|
)
|
|
|
|
store_team = {
|
|
"store_id": store.id,
|
|
"store_name": store.name,
|
|
"store_code": store.store_code,
|
|
"is_active": store.is_active,
|
|
"members": [
|
|
{
|
|
"id": m.id,
|
|
"user_id": m.user_id,
|
|
"email": m.user.email if m.user else None,
|
|
"first_name": m.user.first_name if m.user else None,
|
|
"last_name": m.user.last_name if m.user else None,
|
|
"role_name": m.role.name if m.role else None,
|
|
"is_active": m.is_active,
|
|
"invitation_accepted_at": (
|
|
m.invitation_accepted_at.isoformat()
|
|
if m.invitation_accepted_at
|
|
else None
|
|
),
|
|
"created_at": m.created_at.isoformat() if m.created_at else None,
|
|
}
|
|
for m in members
|
|
],
|
|
"member_count": len(members),
|
|
}
|
|
result.append(store_team)
|
|
|
|
return {
|
|
"merchant_name": merchant.business_name or merchant.brand_name,
|
|
"owner_email": merchant.owner.email if merchant.owner else None,
|
|
"stores": result,
|
|
"total_members": sum(s["member_count"] for s in result),
|
|
}
|
|
|
|
|
|
# Singleton instance
|
|
merchant_store_service = MerchantStoreService()
|
|
|
|
__all__ = ["MerchantStoreService", "merchant_store_service"]
|