fix(hosting): require merchant or prospect for site creation

- Schema: add merchant_id/prospect_id with model_validator requiring
  at least one. Remove from-prospect endpoint (unified into POST /sites)
- Service: rewrite create() — if merchant_id use it directly, if
  prospect_id auto-create merchant from prospect data. Remove system
  merchant hack entirely. Extract _create_merchant_from_prospect helper.
- Simplify accept_proposal() — merchant already exists at creation,
  only creates subscription and marks prospect converted
- Tests: update all create calls with merchant_id, replace from-prospect
  tests with prospect_id + validation tests

Closes docs/proposals/hosting-site-creation-fix.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:14:47 +02:00
parent 2bc03ed97c
commit 59b0d8977a
5 changed files with 112 additions and 124 deletions

View File

@@ -88,12 +88,19 @@ class HostedSiteService:
return sites, total
def create(self, db: Session, data: dict) -> HostedSite:
"""Create a hosted site with an auto-created Store on the hosting platform."""
from app.modules.tenancy.models import Platform
"""Create a hosted site with an auto-created Store on the hosting platform.
Requires either merchant_id or prospect_id in data:
- merchant_id: store created under this merchant
- prospect_id: merchant auto-created from prospect data
"""
from app.modules.tenancy.models import Merchant, Platform, Store
from app.modules.tenancy.schemas.store import StoreCreate
from app.modules.tenancy.services.admin_service import admin_service
business_name = data["business_name"]
merchant_id = data.get("merchant_id")
prospect_id = data.get("prospect_id")
slug = _slugify(business_name)
# Find hosting platform
@@ -101,37 +108,25 @@ class HostedSiteService:
if not platform:
raise ValueError("Hosting platform not found. Run init_production first.")
# Create a temporary merchant-less store requires a merchant_id.
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
# Use the platform's own admin store or create under a system merchant.
# For now, create store via AdminService which handles defaults.
store_code = slug.upper().replace("-", "_")[:50]
subdomain = slug
# Resolve merchant
if merchant_id:
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
elif prospect_id:
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
else:
raise ValueError("Either merchant_id or prospect_id is required")
# Check for duplicate subdomain
from app.modules.tenancy.models import Store
subdomain = slug
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
if existing:
raise DuplicateSlugException(subdomain)
# We need a system merchant for POC sites.
# Look for one or create if needed.
from app.modules.tenancy.models import Merchant
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
if not system_merchant:
system_merchant = Merchant(
name="HostWizard System",
contact_email="system@hostwizard.lu",
is_active=True,
is_verified=True,
)
db.add(system_merchant)
db.flush()
store_code = slug.upper().replace("-", "_")[:50]
store_data = StoreCreate(
merchant_id=system_merchant.id,
merchant_id=merchant.id,
store_code=store_code,
subdomain=subdomain,
name=business_name,
@@ -142,7 +137,7 @@ class HostedSiteService:
site = HostedSite(
store_id=store.id,
prospect_id=data.get("prospect_id"),
prospect_id=prospect_id,
status=HostedSiteStatus.DRAFT,
business_name=business_name,
contact_name=data.get("contact_name"),
@@ -153,12 +148,14 @@ class HostedSiteService:
db.add(site)
db.flush()
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
return site
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
"""Create a hosted site pre-filled from prospect data."""
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
"""Create a merchant from prospect data."""
from app.modules.prospecting.models import Prospect
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
if not prospect:
@@ -166,20 +163,29 @@ class HostedSiteService:
raise ProspectNotFoundException(str(prospect_id))
# Get primary contact info from prospect contacts
# Get contact info: prefer form data, fall back to prospect contacts
contacts = prospect.contacts or []
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
contact_name = next((c.label for c in contacts if c.label), None)
email = (
data.get("contact_email")
or next((c.value for c in contacts if c.contact_type == "email"), None)
or f"contact-{prospect_id}@hostwizard.lu"
)
phone = data.get("contact_phone") or next(
(c.value for c in contacts if c.contact_type == "phone"), None
)
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
data = {
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
"contact_name": contact_name,
"contact_email": primary_email,
"contact_phone": primary_phone,
"prospect_id": prospect.id,
}
return self.create(db, data)
merchant_data = MerchantCreate(
name=business_name,
contact_email=email,
contact_phone=phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
return merchant
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
site = self.get_by_id(db, site_id)
@@ -227,37 +233,25 @@ class HostedSiteService:
def accept_proposal(
self, db: Session, site_id: int, merchant_id: int | None = None
) -> HostedSite:
"""Accept proposal: create or link merchant, create subscription, mark converted."""
"""Accept proposal: create subscription, mark prospect converted.
The merchant already exists (assigned at site creation time).
Optionally pass merchant_id to reassign to a different merchant.
"""
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
site.proposal_accepted_at = datetime.now(UTC)
from app.modules.tenancy.models import Merchant, Platform
# Use provided merchant_id to reassign, or keep existing store merchant
if merchant_id:
# Link to existing merchant
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
site.store.merchant_id = merchant.id
db.flush()
else:
# Create new merchant from contact info
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
merchant_data = MerchantCreate(
name=site.business_name,
contact_email=email,
contact_phone=site.contact_phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s for site %d", merchant.name, site_id)
# Re-assign store to the real merchant
site.store.merchant_id = merchant.id
db.flush()
merchant = site.store.merchant
# Create MerchantSubscription on hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
@@ -286,7 +280,6 @@ class HostedSiteService:
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
if prospect and prospect.status != ProspectStatus.CONVERTED:
prospect.status = ProspectStatus.CONVERTED
db.flush()
db.flush()
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)