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

@@ -0,0 +1,62 @@
"""tenancy: add merchant_domains table for merchant-level domain routing
Revision ID: tenancy_001
Revises: dev_tools_001
Create Date: 2026-02-09
"""
from alembic import op
import sqlalchemy as sa
revision = "tenancy_001"
down_revision = "dev_tools_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"merchant_domains",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"merchant_id",
sa.Integer(),
sa.ForeignKey("merchants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("domain", sa.String(255), nullable=False, unique=True, index=True),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("ssl_status", sa.String(50), server_default="pending"),
sa.Column("ssl_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("verification_token", sa.String(100), unique=True, nullable=True),
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"platform_id",
sa.Integer(),
sa.ForeignKey("platforms.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Platform this domain is associated with (for platform context resolution)",
),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_domain_platform"),
)
op.create_index(
"idx_merchant_domain_active", "merchant_domains", ["domain", "is_active"]
)
op.create_index(
"idx_merchant_domain_primary", "merchant_domains", ["merchant_id", "is_primary"]
)
op.create_index(
"idx_merchant_domain_platform", "merchant_domains", ["platform_id"]
)
def downgrade() -> None:
op.drop_index("idx_merchant_domain_platform", table_name="merchant_domains")
op.drop_index("idx_merchant_domain_primary", table_name="merchant_domains")
op.drop_index("idx_merchant_domain_active", table_name="merchant_domains")
op.drop_table("merchant_domains")