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

@@ -174,6 +174,32 @@ class PlatformContextManager:
)
return platform
# Fallback: Check MerchantDomain for merchant-level domains
from app.modules.tenancy.models.merchant_domain import MerchantDomain
merchant_domain = (
db.query(MerchantDomain)
.filter(
MerchantDomain.domain == domain,
MerchantDomain.is_active.is_(True),
MerchantDomain.is_verified.is_(True),
)
.first()
)
if merchant_domain and merchant_domain.platform_id:
platform = (
db.query(Platform)
.filter(
Platform.id == merchant_domain.platform_id,
Platform.is_active.is_(True),
)
.first()
)
if platform:
logger.debug(
f"[PLATFORM] Platform found via merchant domain: {domain}{platform.name}"
)
return platform
logger.debug(f"[PLATFORM] No platform found for domain: {domain}")
# Method 2: Path-prefix lookup

View File

@@ -149,6 +149,36 @@ class StoreContextManager:
f"[OK] Store found via custom domain: {domain}{store.name}"
)
return store
# Fallback: Try merchant-level domain
from app.modules.tenancy.models.merchant_domain import MerchantDomain
merchant_domain = (
db.query(MerchantDomain)
.filter(
MerchantDomain.domain == domain,
MerchantDomain.is_active.is_(True),
MerchantDomain.is_verified.is_(True),
)
.first()
)
if merchant_domain:
store = (
db.query(Store)
.filter(
Store.merchant_id == merchant_domain.merchant_id,
Store.is_active.is_(True),
)
.order_by(Store.id)
.first()
)
if store:
context["merchant_domain"] = True
context["merchant_id"] = merchant_domain.merchant_id
logger.info(
f"[OK] Store found via merchant domain: {domain}{store.name}"
)
return store
logger.warning(f"No active store found for custom domain: {domain}")
return None