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

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