Files
orion/app/modules/tenancy/services/merchant_domain_service.py
Samir Boulahtit 0984ff7d17 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>
2026-02-09 22:04:49 +01:00

453 lines
15 KiB
Python

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