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:
2026-02-09 22:04:49 +01:00
parent c914e10cb8
commit 0984ff7d17
26 changed files with 2972 additions and 34 deletions

View File

@@ -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",