feat(tenancy): add merchant-level domain with store override
Merchants can now register domains (e.g., myloyaltyprogram.lu) that all their stores inherit. Individual stores can override with their own custom domain. Resolution priority: StoreDomain > MerchantDomain > subdomain. - Add MerchantDomain model, schema, service, and admin API endpoints - Add merchant domain fallback in platform and store context middleware - Add Merchant.primary_domain and Store.effective_domain properties - Add Alembic migration for merchant_domains table - Update loyalty user journey docs with subscription & domain setup flow - Add unit tests (50 passing) and integration tests (15 passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,7 @@ tenancy_module = ModuleDefinition(
|
||||
),
|
||||
],
|
||||
},
|
||||
migrations_path="migrations",
|
||||
services_path="app.modules.tenancy.services",
|
||||
models_path="app.modules.tenancy.models",
|
||||
schemas_path="app.modules.tenancy.schemas",
|
||||
|
||||
@@ -933,6 +933,43 @@ class InvalidInvitationTokenException(ValidationException):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Merchant Domain Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MerchantDomainNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a merchant domain is not found."""
|
||||
|
||||
def __init__(self, domain_identifier: str, identifier_type: str = "ID"):
|
||||
if identifier_type.lower() == "domain":
|
||||
message = f"Merchant domain '{domain_identifier}' not found"
|
||||
else:
|
||||
message = f"Merchant domain with ID '{domain_identifier}' not found"
|
||||
|
||||
super().__init__(
|
||||
resource_type="MerchantDomain",
|
||||
identifier=domain_identifier,
|
||||
message=message,
|
||||
error_code="MERCHANT_DOMAIN_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class MerchantDomainAlreadyExistsException(ConflictException):
|
||||
"""Raised when trying to add a domain that already exists."""
|
||||
|
||||
def __init__(self, domain: str, existing_merchant_id: int | None = None):
|
||||
details = {"domain": domain}
|
||||
if existing_merchant_id:
|
||||
details["existing_merchant_id"] = existing_merchant_id
|
||||
|
||||
super().__init__(
|
||||
message=f"Domain '{domain}' is already registered",
|
||||
error_code="MERCHANT_DOMAIN_ALREADY_EXISTS",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class StoreDomainNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a store domain is not found."""
|
||||
|
||||
@@ -1129,6 +1166,9 @@ __all__ = [
|
||||
"TeamValidationException",
|
||||
"InvalidInvitationDataException",
|
||||
"InvalidInvitationTokenException",
|
||||
# Merchant Domain
|
||||
"MerchantDomainNotFoundException",
|
||||
"MerchantDomainAlreadyExistsException",
|
||||
# Store Domain
|
||||
"StoreDomainNotFoundException",
|
||||
"StoreDomainAlreadyExistsException",
|
||||
|
||||
0
app/modules/tenancy/migrations/__init__.py
Normal file
0
app/modules/tenancy/migrations/__init__.py
Normal file
0
app/modules/tenancy/migrations/versions/__init__.py
Normal file
0
app/modules/tenancy/migrations/versions/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""tenancy: add merchant_domains table for merchant-level domain routing
|
||||
|
||||
Revision ID: tenancy_001
|
||||
Revises: dev_tools_001
|
||||
Create Date: 2026-02-09
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "tenancy_001"
|
||||
down_revision = "dev_tools_001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"merchant_domains",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"merchant_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("merchants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("domain", sa.String(255), nullable=False, unique=True, index=True),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("ssl_status", sa.String(50), server_default="pending"),
|
||||
sa.Column("ssl_verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("verification_token", sa.String(100), unique=True, nullable=True),
|
||||
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"platform_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("platforms.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Platform this domain is associated with (for platform context resolution)",
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_domain_platform"),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_merchant_domain_active", "merchant_domains", ["domain", "is_active"]
|
||||
)
|
||||
op.create_index(
|
||||
"idx_merchant_domain_primary", "merchant_domains", ["merchant_id", "is_primary"]
|
||||
)
|
||||
op.create_index(
|
||||
"idx_merchant_domain_platform", "merchant_domains", ["platform_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_merchant_domain_platform", table_name="merchant_domains")
|
||||
op.drop_index("idx_merchant_domain_primary", table_name="merchant_domains")
|
||||
op.drop_index("idx_merchant_domain_active", table_name="merchant_domains")
|
||||
op.drop_table("merchant_domains")
|
||||
@@ -34,6 +34,7 @@ from app.modules.tenancy.models.platform import Platform
|
||||
from app.modules.tenancy.models.platform_module import PlatformModule
|
||||
from app.modules.tenancy.models.user import User, UserRole
|
||||
from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserType
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
from app.modules.tenancy.models.store_domain import StoreDomain
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
@@ -59,6 +60,8 @@ __all__ = [
|
||||
"StoreUser",
|
||||
"StoreUserType",
|
||||
"Role",
|
||||
# Merchant configuration
|
||||
"MerchantDomain",
|
||||
# Store configuration
|
||||
"StoreDomain",
|
||||
"StorePlatform",
|
||||
|
||||
@@ -85,6 +85,13 @@ class Merchant(Base, TimestampMixin):
|
||||
)
|
||||
"""All store brands operated by this merchant."""
|
||||
|
||||
merchant_domains = relationship(
|
||||
"MerchantDomain",
|
||||
back_populates="merchant",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
"""Custom domains registered at the merchant level (inherited by all stores)."""
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of the Merchant object."""
|
||||
return f"<Merchant(id={self.id}, name='{self.name}', stores={len(self.stores) if self.stores else 0})>"
|
||||
@@ -98,6 +105,14 @@ class Merchant(Base, TimestampMixin):
|
||||
"""Get the number of stores belonging to this merchant."""
|
||||
return len(self.stores) if self.stores else 0
|
||||
|
||||
@property
|
||||
def primary_domain(self) -> str | None:
|
||||
"""Get the primary active and verified merchant domain."""
|
||||
for md in self.merchant_domains:
|
||||
if md.is_primary and md.is_active and md.is_verified:
|
||||
return md.domain
|
||||
return None
|
||||
|
||||
@property
|
||||
def active_store_count(self) -> int:
|
||||
"""Get the number of active stores belonging to this merchant."""
|
||||
|
||||
117
app/modules/tenancy/models/merchant_domain.py
Normal file
117
app/modules/tenancy/models/merchant_domain.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# app/modules/tenancy/models/merchant_domain.py
|
||||
"""
|
||||
Merchant Domain Model - Maps custom domains to merchants for merchant-level domain routing.
|
||||
|
||||
When a merchant subscribes to a platform (e.g., loyalty), they can register a domain
|
||||
(e.g., myloyaltyprogram.lu) that serves as the default for all their stores.
|
||||
Individual stores can optionally override this with their own custom StoreDomain.
|
||||
|
||||
Domain Resolution Priority:
|
||||
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||
2. Merchant domain (MerchantDomain) -> inherited default
|
||||
3. Store subdomain ({store.subdomain}.loyalty.lu) -> fallback
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class MerchantDomain(Base, TimestampMixin):
|
||||
"""
|
||||
Maps custom domains to merchants for merchant-level domain routing.
|
||||
|
||||
Examples:
|
||||
- myloyaltyprogram.lu -> Merchant "WizaCorp" (all stores inherit)
|
||||
- Store WIZAMART overrides with StoreDomain -> mysuperloyaltyprogram.lu
|
||||
- Store WIZAGADGETS -> inherits myloyaltyprogram.lu
|
||||
"""
|
||||
|
||||
__tablename__ = "merchant_domains"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
merchant_id = Column(
|
||||
Integer, ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Domain configuration
|
||||
domain = Column(String(255), nullable=False, unique=True, index=True)
|
||||
is_primary = Column(Boolean, default=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# SSL/TLS status (for monitoring)
|
||||
ssl_status = Column(
|
||||
String(50), default="pending"
|
||||
) # pending, active, expired, error
|
||||
ssl_verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# DNS verification (to confirm domain ownership)
|
||||
verification_token = Column(String(100), unique=True, nullable=True)
|
||||
is_verified = Column(Boolean, default=False, nullable=False)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Platform association (for platform context resolution from custom domains)
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Platform this domain is associated with (for platform context resolution)",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
merchant = relationship("Merchant", back_populates="merchant_domains")
|
||||
platform = relationship("Platform")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_domain_platform"),
|
||||
Index("idx_merchant_domain_active", "domain", "is_active"),
|
||||
Index("idx_merchant_domain_primary", "merchant_id", "is_primary"),
|
||||
Index("idx_merchant_domain_platform", "platform_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MerchantDomain(domain='{self.domain}', merchant_id={self.merchant_id})>"
|
||||
|
||||
@property
|
||||
def full_url(self):
|
||||
"""Return full URL with https"""
|
||||
return f"https://{self.domain}"
|
||||
|
||||
@classmethod
|
||||
def normalize_domain(cls, domain: str) -> str:
|
||||
"""
|
||||
Normalize domain for consistent storage.
|
||||
|
||||
Reuses the same logic as StoreDomain.normalize_domain().
|
||||
|
||||
Examples:
|
||||
- https://example.com -> example.com
|
||||
- www.example.com -> example.com
|
||||
- EXAMPLE.COM -> example.com
|
||||
"""
|
||||
# Remove protocol
|
||||
domain = domain.replace("https://", "").replace("http://", "") # noqa: SEC-034
|
||||
|
||||
# Remove trailing slash
|
||||
domain = domain.rstrip("/")
|
||||
|
||||
# Convert to lowercase
|
||||
domain = domain.lower()
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
__all__ = ["MerchantDomain"]
|
||||
@@ -329,6 +329,22 @@ class Store(Base, TimestampMixin):
|
||||
return domain.domain # Return the domain if it's primary and active
|
||||
return None
|
||||
|
||||
@property
|
||||
def effective_domain(self) -> str | None:
|
||||
"""
|
||||
Get effective domain: store override > merchant domain > subdomain fallback.
|
||||
|
||||
Domain Resolution Priority:
|
||||
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||
2. Merchant domain (MerchantDomain) -> inherited default
|
||||
3. Store subdomain ({store.subdomain}.{platform_domain}) -> fallback
|
||||
"""
|
||||
if self.primary_domain:
|
||||
return self.primary_domain
|
||||
if self.merchant and self.merchant.primary_domain:
|
||||
return self.merchant.primary_domain
|
||||
return f"{self.subdomain}.{settings.platform_domain}"
|
||||
|
||||
@property
|
||||
def all_domains(self):
|
||||
"""Get all active domains (subdomain + custom domains)."""
|
||||
|
||||
@@ -25,6 +25,7 @@ from .admin_merchants import admin_merchants_router
|
||||
from .admin_platforms import admin_platforms_router
|
||||
from .admin_stores import admin_stores_router
|
||||
from .admin_store_domains import admin_store_domains_router
|
||||
from .admin_merchant_domains import admin_merchant_domains_router
|
||||
from .admin_modules import router as admin_modules_router
|
||||
from .admin_module_config import router as admin_module_config_router
|
||||
|
||||
@@ -38,5 +39,6 @@ admin_router.include_router(admin_merchants_router, tags=["admin-merchants"])
|
||||
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
|
||||
admin_router.include_router(admin_stores_router, tags=["admin-stores"])
|
||||
admin_router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
|
||||
admin_router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"])
|
||||
admin_router.include_router(admin_modules_router, tags=["admin-modules"])
|
||||
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])
|
||||
|
||||
297
app/modules/tenancy/routes/api/admin_merchant_domains.py
Normal file
297
app/modules/tenancy/routes/api/admin_merchant_domains.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# app/modules/tenancy/routes/api/admin_merchant_domains.py
|
||||
"""
|
||||
Admin endpoints for managing merchant-level custom domains.
|
||||
|
||||
Follows the same pattern as admin_store_domains.py:
|
||||
- Endpoints only handle HTTP layer
|
||||
- Business logic in service layer
|
||||
- Domain exceptions bubble up to global handler
|
||||
- Pydantic schemas for validation
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.services.merchant_domain_service import (
|
||||
merchant_domain_service,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.merchant_domain import (
|
||||
MerchantDomainCreate,
|
||||
MerchantDomainDeletionResponse,
|
||||
MerchantDomainListResponse,
|
||||
MerchantDomainResponse,
|
||||
MerchantDomainUpdate,
|
||||
)
|
||||
from app.modules.tenancy.schemas.store_domain import (
|
||||
DomainVerificationInstructions,
|
||||
DomainVerificationResponse,
|
||||
)
|
||||
|
||||
admin_merchant_domains_router = APIRouter(prefix="/merchants")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.post(
|
||||
"/{merchant_id}/domains", response_model=MerchantDomainResponse
|
||||
)
|
||||
def add_merchant_domain(
|
||||
merchant_id: int = Path(..., description="Merchant ID", gt=0),
|
||||
domain_data: MerchantDomainCreate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Register a merchant-level domain (Admin only).
|
||||
|
||||
The domain serves as the default for all the merchant's stores.
|
||||
Individual stores can override with their own StoreDomain.
|
||||
|
||||
**Domain Resolution Priority:**
|
||||
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||
2. Merchant domain (MerchantDomain) -> inherited default
|
||||
3. Store subdomain ({store.subdomain}.platform.lu) -> fallback
|
||||
|
||||
**Raises:**
|
||||
- 404: Merchant not found
|
||||
- 409: Domain already registered
|
||||
- 422: Invalid domain format or reserved subdomain
|
||||
"""
|
||||
domain = merchant_domain_service.add_domain(
|
||||
db=db, merchant_id=merchant_id, domain_data=domain_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MerchantDomainResponse(
|
||||
id=domain.id,
|
||||
merchant_id=domain.merchant_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
platform_id=domain.platform_id,
|
||||
verification_token=domain.verification_token,
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.get(
|
||||
"/{merchant_id}/domains", response_model=MerchantDomainListResponse
|
||||
)
|
||||
def list_merchant_domains(
|
||||
merchant_id: int = Path(..., description="Merchant ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all domains for a merchant (Admin only).
|
||||
|
||||
Returns domains ordered by:
|
||||
1. Primary domains first
|
||||
2. Creation date (newest first)
|
||||
|
||||
**Raises:**
|
||||
- 404: Merchant not found
|
||||
"""
|
||||
domains = merchant_domain_service.get_merchant_domains(db, merchant_id)
|
||||
|
||||
return MerchantDomainListResponse(
|
||||
domains=[
|
||||
MerchantDomainResponse(
|
||||
id=d.id,
|
||||
merchant_id=d.merchant_id,
|
||||
domain=d.domain,
|
||||
is_primary=d.is_primary,
|
||||
is_active=d.is_active,
|
||||
is_verified=d.is_verified,
|
||||
ssl_status=d.ssl_status,
|
||||
platform_id=d.platform_id,
|
||||
verification_token=d.verification_token if not d.is_verified else None,
|
||||
verified_at=d.verified_at,
|
||||
ssl_verified_at=d.ssl_verified_at,
|
||||
created_at=d.created_at,
|
||||
updated_at=d.updated_at,
|
||||
)
|
||||
for d in domains
|
||||
],
|
||||
total=len(domains),
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.get(
|
||||
"/domains/merchant/{domain_id}", response_model=MerchantDomainResponse
|
||||
)
|
||||
def get_merchant_domain_details(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific merchant domain (Admin only).
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
domain = merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||
|
||||
return MerchantDomainResponse(
|
||||
id=domain.id,
|
||||
merchant_id=domain.merchant_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
platform_id=domain.platform_id,
|
||||
verification_token=(
|
||||
domain.verification_token if not domain.is_verified else None
|
||||
),
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.put(
|
||||
"/domains/merchant/{domain_id}", response_model=MerchantDomainResponse
|
||||
)
|
||||
def update_merchant_domain(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
domain_update: MerchantDomainUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update merchant domain settings (Admin only).
|
||||
|
||||
**Can update:**
|
||||
- `is_primary`: Set as primary domain for merchant
|
||||
- `is_active`: Activate or deactivate domain
|
||||
|
||||
**Important:**
|
||||
- Cannot activate unverified domains
|
||||
- Setting a domain as primary will unset other primary domains
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
- 400: Cannot activate unverified domain
|
||||
"""
|
||||
domain = merchant_domain_service.update_domain(
|
||||
db=db, domain_id=domain_id, domain_update=domain_update
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MerchantDomainResponse(
|
||||
id=domain.id,
|
||||
merchant_id=domain.merchant_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
platform_id=domain.platform_id,
|
||||
verification_token=None,
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.delete(
|
||||
"/domains/merchant/{domain_id}",
|
||||
response_model=MerchantDomainDeletionResponse,
|
||||
)
|
||||
def delete_merchant_domain(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete a merchant domain (Admin only).
|
||||
|
||||
**Warning:** This is permanent and cannot be undone.
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
domain = merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||
merchant_id = domain.merchant_id
|
||||
domain_name = domain.domain
|
||||
|
||||
message = merchant_domain_service.delete_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
return MerchantDomainDeletionResponse(
|
||||
message=message, domain=domain_name, merchant_id=merchant_id
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.post(
|
||||
"/domains/merchant/{domain_id}/verify",
|
||||
response_model=DomainVerificationResponse,
|
||||
)
|
||||
def verify_merchant_domain_ownership(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Verify merchant domain ownership via DNS TXT record (Admin only).
|
||||
|
||||
**Verification Process:**
|
||||
1. Queries DNS for TXT record: `_wizamart-verify.{domain}`
|
||||
2. Checks if verification token matches
|
||||
3. If found, marks domain as verified
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
- 400: Already verified, or verification failed
|
||||
- 502: DNS query failed
|
||||
"""
|
||||
domain, message = merchant_domain_service.verify_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
return DomainVerificationResponse(
|
||||
message=message,
|
||||
domain=domain.domain,
|
||||
verified_at=domain.verified_at,
|
||||
is_verified=domain.is_verified,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.get(
|
||||
"/domains/merchant/{domain_id}/verification-instructions",
|
||||
response_model=DomainVerificationInstructions,
|
||||
)
|
||||
def get_merchant_domain_verification_instructions(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get DNS verification instructions for merchant domain (Admin only).
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
instructions = merchant_domain_service.get_verification_instructions(
|
||||
db, domain_id
|
||||
)
|
||||
|
||||
return DomainVerificationInstructions(
|
||||
domain=instructions["domain"],
|
||||
verification_token=instructions["verification_token"],
|
||||
instructions=instructions["instructions"],
|
||||
txt_record=instructions["txt_record"],
|
||||
common_registrars=instructions["common_registrars"],
|
||||
)
|
||||
109
app/modules/tenancy/schemas/merchant_domain.py
Normal file
109
app/modules/tenancy/schemas/merchant_domain.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# app/modules/tenancy/schemas/merchant_domain.py
|
||||
"""
|
||||
Pydantic schemas for Merchant Domain operations.
|
||||
|
||||
Schemas include:
|
||||
- MerchantDomainCreate: For adding custom domains to merchants
|
||||
- MerchantDomainUpdate: For updating domain settings
|
||||
- MerchantDomainResponse: Standard domain response
|
||||
- MerchantDomainListResponse: Paginated domain list
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class MerchantDomainCreate(BaseModel):
|
||||
"""Schema for adding a custom domain to a merchant."""
|
||||
|
||||
domain: str = Field(
|
||||
...,
|
||||
description="Custom domain (e.g., myloyaltyprogram.lu)",
|
||||
min_length=3,
|
||||
max_length=255,
|
||||
)
|
||||
is_primary: bool = Field(
|
||||
default=True, description="Set as primary domain for the merchant"
|
||||
)
|
||||
platform_id: int | None = Field(None, description="Platform this domain belongs to")
|
||||
|
||||
@field_validator("domain")
|
||||
@classmethod
|
||||
def validate_domain(cls, v: str) -> str:
|
||||
"""Validate and normalize domain."""
|
||||
# Remove protocol if present
|
||||
domain = v.replace("https://", "").replace("http://", "") # noqa: SEC-034
|
||||
|
||||
# Remove trailing slash
|
||||
domain = domain.rstrip("/")
|
||||
|
||||
# Convert to lowercase
|
||||
domain = domain.lower().strip()
|
||||
|
||||
# Basic validation
|
||||
if not domain or "/" in domain:
|
||||
raise ValueError("Invalid domain format")
|
||||
|
||||
if "." not in domain:
|
||||
raise ValueError("Domain must have at least one dot")
|
||||
|
||||
# Check for reserved subdomains
|
||||
reserved = ["www", "admin", "api", "mail", "smtp", "ftp", "cpanel", "webmail"]
|
||||
first_part = domain.split(".")[0]
|
||||
if first_part in reserved:
|
||||
raise ValueError(
|
||||
f"Domain cannot start with reserved subdomain: {first_part}"
|
||||
)
|
||||
|
||||
# Validate domain format (basic regex)
|
||||
domain_pattern = r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$"
|
||||
if not re.match(domain_pattern, domain):
|
||||
raise ValueError("Invalid domain format")
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
class MerchantDomainUpdate(BaseModel):
|
||||
"""Schema for updating merchant domain settings."""
|
||||
|
||||
is_primary: bool | None = Field(None, description="Set as primary domain")
|
||||
is_active: bool | None = Field(None, description="Activate or deactivate domain")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MerchantDomainResponse(BaseModel):
|
||||
"""Standard schema for merchant domain response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
merchant_id: int
|
||||
domain: str
|
||||
is_primary: bool
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
ssl_status: str
|
||||
platform_id: int | None = None
|
||||
verification_token: str | None = None
|
||||
verified_at: datetime | None = None
|
||||
ssl_verified_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MerchantDomainListResponse(BaseModel):
|
||||
"""Schema for paginated merchant domain list."""
|
||||
|
||||
domains: list[MerchantDomainResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class MerchantDomainDeletionResponse(BaseModel):
|
||||
"""Response after merchant domain deletion."""
|
||||
|
||||
message: str
|
||||
domain: str
|
||||
merchant_id: int
|
||||
452
app/modules/tenancy/services/merchant_domain_service.py
Normal file
452
app/modules/tenancy/services/merchant_domain_service.py
Normal file
@@ -0,0 +1,452 @@
|
||||
# app/modules/tenancy/services/merchant_domain_service.py
|
||||
"""
|
||||
Merchant domain service for managing merchant-level custom domain operations.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- Adding and removing merchant domains
|
||||
- Domain verification via DNS
|
||||
- Domain activation and deactivation
|
||||
- Setting primary domains
|
||||
- Domain validation and normalization
|
||||
|
||||
Follows the same pattern as StoreDomainService.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.tenancy.exceptions import (
|
||||
DNSVerificationException,
|
||||
DomainAlreadyVerifiedException,
|
||||
DomainNotVerifiedException,
|
||||
DomainVerificationFailedException,
|
||||
InvalidDomainFormatException,
|
||||
MaxDomainsReachedException,
|
||||
MerchantDomainAlreadyExistsException,
|
||||
MerchantDomainNotFoundException,
|
||||
MerchantNotFoundException,
|
||||
ReservedDomainException,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant
|
||||
from app.modules.tenancy.models.merchant_domain import MerchantDomain
|
||||
from app.modules.tenancy.models.store_domain import StoreDomain
|
||||
from app.modules.tenancy.schemas.merchant_domain import (
|
||||
MerchantDomainCreate,
|
||||
MerchantDomainUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MerchantDomainService:
|
||||
"""Service class for merchant domain operations."""
|
||||
|
||||
def __init__(self):
|
||||
self.max_domains_per_merchant = 5
|
||||
self.reserved_subdomains = [
|
||||
"www",
|
||||
"admin",
|
||||
"api",
|
||||
"mail",
|
||||
"smtp",
|
||||
"ftp",
|
||||
"cpanel",
|
||||
"webmail",
|
||||
]
|
||||
|
||||
def add_domain(
|
||||
self, db: Session, merchant_id: int, domain_data: MerchantDomainCreate
|
||||
) -> MerchantDomain:
|
||||
"""
|
||||
Add a custom domain to a merchant.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID to add domain to
|
||||
domain_data: Domain creation data
|
||||
|
||||
Returns:
|
||||
Created MerchantDomain object
|
||||
|
||||
Raises:
|
||||
MerchantNotFoundException: If merchant not found
|
||||
MerchantDomainAlreadyExistsException: If domain already registered
|
||||
MaxDomainsReachedException: If merchant has reached max domains
|
||||
InvalidDomainFormatException: If domain format is invalid
|
||||
"""
|
||||
try:
|
||||
# Verify merchant exists
|
||||
merchant = self._get_merchant_by_id_or_raise(db, merchant_id)
|
||||
|
||||
# Check domain limit
|
||||
self._check_domain_limit(db, merchant_id)
|
||||
|
||||
# Normalize domain
|
||||
normalized_domain = MerchantDomain.normalize_domain(domain_data.domain)
|
||||
|
||||
# Validate domain format
|
||||
self._validate_domain_format(normalized_domain)
|
||||
|
||||
# Check if domain already exists (in both StoreDomain and MerchantDomain)
|
||||
if self._domain_exists_globally(db, normalized_domain):
|
||||
raise MerchantDomainAlreadyExistsException(normalized_domain)
|
||||
|
||||
# If setting as primary, unset other primary domains
|
||||
if domain_data.is_primary:
|
||||
self._unset_primary_domains(db, merchant_id)
|
||||
|
||||
# Resolve platform_id: use provided value, or auto-resolve from merchant's primary StorePlatform
|
||||
platform_id = domain_data.platform_id
|
||||
if not platform_id:
|
||||
from app.modules.tenancy.models import Store, StorePlatform
|
||||
# Get platform from merchant's first store's primary StorePlatform
|
||||
store_ids = (
|
||||
db.query(Store.id)
|
||||
.filter(Store.merchant_id == merchant_id)
|
||||
.subquery()
|
||||
)
|
||||
primary_sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id.in_(store_ids),
|
||||
StorePlatform.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
platform_id = primary_sp.platform_id if primary_sp else None
|
||||
|
||||
# Create domain record
|
||||
new_domain = MerchantDomain(
|
||||
merchant_id=merchant_id,
|
||||
domain=normalized_domain,
|
||||
is_primary=domain_data.is_primary,
|
||||
platform_id=platform_id,
|
||||
verification_token=secrets.token_urlsafe(32),
|
||||
is_verified=False,
|
||||
is_active=False,
|
||||
ssl_status="pending",
|
||||
)
|
||||
|
||||
db.add(new_domain)
|
||||
db.flush()
|
||||
db.refresh(new_domain)
|
||||
|
||||
logger.info(f"Domain {normalized_domain} added to merchant {merchant_id}")
|
||||
return new_domain
|
||||
|
||||
except (
|
||||
MerchantNotFoundException,
|
||||
MerchantDomainAlreadyExistsException,
|
||||
MaxDomainsReachedException,
|
||||
InvalidDomainFormatException,
|
||||
ReservedDomainException,
|
||||
):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding merchant domain: {str(e)}")
|
||||
raise ValidationException("Failed to add merchant domain")
|
||||
|
||||
def get_merchant_domains(
|
||||
self, db: Session, merchant_id: int
|
||||
) -> list[MerchantDomain]:
|
||||
"""
|
||||
Get all domains for a merchant.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
List of MerchantDomain objects
|
||||
"""
|
||||
try:
|
||||
self._get_merchant_by_id_or_raise(db, merchant_id)
|
||||
|
||||
domains = (
|
||||
db.query(MerchantDomain)
|
||||
.filter(MerchantDomain.merchant_id == merchant_id)
|
||||
.order_by(
|
||||
MerchantDomain.is_primary.desc(),
|
||||
MerchantDomain.created_at.desc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return domains
|
||||
|
||||
except MerchantNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting merchant domains: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve merchant domains")
|
||||
|
||||
def get_domain_by_id(self, db: Session, domain_id: int) -> MerchantDomain:
|
||||
"""
|
||||
Get merchant domain by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
domain_id: Domain ID
|
||||
|
||||
Returns:
|
||||
MerchantDomain object
|
||||
|
||||
Raises:
|
||||
MerchantDomainNotFoundException: If domain not found
|
||||
"""
|
||||
domain = (
|
||||
db.query(MerchantDomain)
|
||||
.filter(MerchantDomain.id == domain_id)
|
||||
.first()
|
||||
)
|
||||
if not domain:
|
||||
raise MerchantDomainNotFoundException(str(domain_id))
|
||||
return domain
|
||||
|
||||
def update_domain(
|
||||
self, db: Session, domain_id: int, domain_update: MerchantDomainUpdate
|
||||
) -> MerchantDomain:
|
||||
"""
|
||||
Update merchant domain settings.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
domain_id: Domain ID
|
||||
domain_update: Update data
|
||||
|
||||
Returns:
|
||||
Updated MerchantDomain object
|
||||
|
||||
Raises:
|
||||
MerchantDomainNotFoundException: If domain not found
|
||||
DomainNotVerifiedException: If trying to activate unverified domain
|
||||
"""
|
||||
try:
|
||||
domain = self.get_domain_by_id(db, domain_id)
|
||||
|
||||
# If setting as primary, unset other primary domains
|
||||
if domain_update.is_primary:
|
||||
self._unset_primary_domains(
|
||||
db, domain.merchant_id, exclude_domain_id=domain_id
|
||||
)
|
||||
domain.is_primary = True
|
||||
|
||||
# If activating, check verification
|
||||
if domain_update.is_active is True and not domain.is_verified:
|
||||
raise DomainNotVerifiedException(domain_id, domain.domain)
|
||||
|
||||
# Update fields
|
||||
if domain_update.is_active is not None:
|
||||
domain.is_active = domain_update.is_active
|
||||
|
||||
db.flush()
|
||||
db.refresh(domain)
|
||||
|
||||
logger.info(f"Merchant domain {domain.domain} updated")
|
||||
return domain
|
||||
|
||||
except (MerchantDomainNotFoundException, DomainNotVerifiedException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating merchant domain: {str(e)}")
|
||||
raise ValidationException("Failed to update merchant domain")
|
||||
|
||||
def delete_domain(self, db: Session, domain_id: int) -> str:
|
||||
"""
|
||||
Delete a merchant domain.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
domain_id: Domain ID
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
domain = self.get_domain_by_id(db, domain_id)
|
||||
domain_name = domain.domain
|
||||
merchant_id = domain.merchant_id
|
||||
|
||||
db.delete(domain)
|
||||
|
||||
logger.info(
|
||||
f"Domain {domain_name} deleted from merchant {merchant_id}"
|
||||
)
|
||||
return f"Domain {domain_name} deleted successfully"
|
||||
|
||||
except MerchantDomainNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting merchant domain: {str(e)}")
|
||||
raise ValidationException("Failed to delete merchant domain")
|
||||
|
||||
def verify_domain(
|
||||
self, db: Session, domain_id: int
|
||||
) -> tuple[MerchantDomain, str]:
|
||||
"""
|
||||
Verify merchant domain ownership via DNS TXT record.
|
||||
|
||||
The merchant must add a TXT record:
|
||||
Name: _wizamart-verify.{domain}
|
||||
Value: {verification_token}
|
||||
"""
|
||||
try:
|
||||
import dns.resolver
|
||||
|
||||
domain = self.get_domain_by_id(db, domain_id)
|
||||
|
||||
if domain.is_verified:
|
||||
raise DomainAlreadyVerifiedException(domain_id, domain.domain)
|
||||
|
||||
try:
|
||||
txt_records = dns.resolver.resolve(
|
||||
f"_wizamart-verify.{domain.domain}", "TXT"
|
||||
)
|
||||
|
||||
for txt in txt_records:
|
||||
txt_value = txt.to_text().strip('"')
|
||||
if txt_value == domain.verification_token:
|
||||
domain.is_verified = True
|
||||
domain.verified_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
db.refresh(domain)
|
||||
|
||||
logger.info(
|
||||
f"Merchant domain {domain.domain} verified successfully"
|
||||
)
|
||||
return (
|
||||
domain,
|
||||
f"Domain {domain.domain} verified successfully",
|
||||
)
|
||||
|
||||
raise DomainVerificationFailedException(
|
||||
domain.domain,
|
||||
"Verification token not found in DNS records",
|
||||
)
|
||||
|
||||
except dns.resolver.NXDOMAIN:
|
||||
raise DomainVerificationFailedException(
|
||||
domain.domain,
|
||||
f"DNS record _wizamart-verify.{domain.domain} not found",
|
||||
)
|
||||
except dns.resolver.NoAnswer:
|
||||
raise DomainVerificationFailedException(
|
||||
domain.domain, "No TXT records found for verification"
|
||||
)
|
||||
except DomainVerificationFailedException:
|
||||
raise
|
||||
except Exception as dns_error:
|
||||
raise DNSVerificationException(domain.domain, str(dns_error))
|
||||
|
||||
except (
|
||||
MerchantDomainNotFoundException,
|
||||
DomainAlreadyVerifiedException,
|
||||
DomainVerificationFailedException,
|
||||
DNSVerificationException,
|
||||
):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying merchant domain: {str(e)}")
|
||||
raise ValidationException("Failed to verify merchant domain")
|
||||
|
||||
def get_verification_instructions(self, db: Session, domain_id: int) -> dict:
|
||||
"""Get DNS verification instructions for a merchant domain."""
|
||||
domain = self.get_domain_by_id(db, domain_id)
|
||||
|
||||
return {
|
||||
"domain": domain.domain,
|
||||
"verification_token": domain.verification_token,
|
||||
"instructions": {
|
||||
"step1": "Go to your domain's DNS settings (at your domain registrar)",
|
||||
"step2": "Add a new TXT record with the following values:",
|
||||
"step3": "Wait for DNS propagation (5-15 minutes)",
|
||||
"step4": "Click 'Verify Domain' button in admin panel",
|
||||
},
|
||||
"txt_record": {
|
||||
"type": "TXT",
|
||||
"name": "_wizamart-verify",
|
||||
"value": domain.verification_token,
|
||||
"ttl": 3600,
|
||||
},
|
||||
"common_registrars": {
|
||||
"Cloudflare": "https://dash.cloudflare.com",
|
||||
"GoDaddy": "https://dcc.godaddy.com/manage/dns",
|
||||
"Namecheap": "https://www.namecheap.com/myaccount/domain-list/",
|
||||
"Google Domains": "https://domains.google.com",
|
||||
},
|
||||
}
|
||||
|
||||
# Private helper methods
|
||||
def _get_merchant_by_id_or_raise(
|
||||
self, db: Session, merchant_id: int
|
||||
) -> Merchant:
|
||||
"""Get merchant by ID or raise exception."""
|
||||
merchant = (
|
||||
db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
)
|
||||
if not merchant:
|
||||
raise MerchantNotFoundException(merchant_id, identifier_type="id")
|
||||
return merchant
|
||||
|
||||
def _check_domain_limit(self, db: Session, merchant_id: int) -> None:
|
||||
"""Check if merchant has reached maximum domain limit."""
|
||||
domain_count = (
|
||||
db.query(MerchantDomain)
|
||||
.filter(MerchantDomain.merchant_id == merchant_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
if domain_count >= self.max_domains_per_merchant:
|
||||
raise MaxDomainsReachedException(
|
||||
merchant_id, self.max_domains_per_merchant
|
||||
)
|
||||
|
||||
def _domain_exists_globally(self, db: Session, domain: str) -> bool:
|
||||
"""Check if domain already exists in system (StoreDomain or MerchantDomain)."""
|
||||
store_exists = (
|
||||
db.query(StoreDomain)
|
||||
.filter(StoreDomain.domain == domain)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if store_exists:
|
||||
return True
|
||||
|
||||
merchant_exists = (
|
||||
db.query(MerchantDomain)
|
||||
.filter(MerchantDomain.domain == domain)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
return merchant_exists
|
||||
|
||||
def _validate_domain_format(self, domain: str) -> None:
|
||||
"""Validate domain format and check for reserved subdomains."""
|
||||
first_part = domain.split(".")[0]
|
||||
if first_part in self.reserved_subdomains:
|
||||
raise ReservedDomainException(domain, first_part)
|
||||
|
||||
def _unset_primary_domains(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
exclude_domain_id: int | None = None,
|
||||
) -> None:
|
||||
"""Unset all primary domains for merchant."""
|
||||
query = db.query(MerchantDomain).filter(
|
||||
MerchantDomain.merchant_id == merchant_id,
|
||||
MerchantDomain.is_primary == True,
|
||||
)
|
||||
|
||||
if exclude_domain_id:
|
||||
query = query.filter(MerchantDomain.id != exclude_domain_id)
|
||||
|
||||
query.update({"is_primary": False})
|
||||
|
||||
|
||||
# Create service instance
|
||||
merchant_domain_service = MerchantDomainService()
|
||||
Reference in New Issue
Block a user