Files
orion/app/modules/tenancy/models/merchant_domain.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

118 lines
3.7 KiB
Python

# 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"]