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

@@ -96,17 +96,6 @@ def create_site(
return _to_response(site)
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
def create_from_prospect(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a hosted site pre-filled from prospect data."""
site = hosted_site_service.create_from_prospect(db, prospect_id)
db.commit()
return _to_response(site)
@router.put("/{site_id}", response_model=HostedSiteResponse)
def update_site(

View File

@@ -3,18 +3,31 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class HostedSiteCreate(BaseModel):
"""Schema for creating a hosted site."""
"""Schema for creating a hosted site.
Either merchant_id or prospect_id must be provided:
- merchant_id: store is created under this merchant
- prospect_id: a merchant is auto-created from prospect data
"""
business_name: str = Field(..., max_length=255)
merchant_id: int | None = None
prospect_id: int | None = None
contact_name: str | None = Field(None, max_length=255)
contact_email: str | None = Field(None, max_length=255)
contact_phone: str | None = Field(None, max_length=50)
internal_notes: str | None = None
@model_validator(mode="after")
def require_merchant_or_prospect(self) -> "HostedSiteCreate":
if not self.merchant_id and not self.prospect_id:
raise ValueError("Either merchant_id or prospect_id is required")
return self
class HostedSiteUpdate(BaseModel):
"""Schema for updating a hosted site."""

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)

View File

@@ -89,6 +89,7 @@ def hosted_site(db, hosting_platform, system_merchant):
db,
{
"business_name": f"Test Business {unique}",
"merchant_id": system_merchant.id,
"contact_name": "John Doe",
"contact_email": f"john-{unique}@example.com",
"contact_phone": "+352 123 456",

View File

@@ -55,6 +55,7 @@ class TestHostedSiteService:
db,
{
"business_name": f"New Business {unique}",
"merchant_id": system_merchant.id,
"contact_email": f"test-{unique}@example.com",
},
)
@@ -72,6 +73,7 @@ class TestHostedSiteService:
db,
{
"business_name": f"Full Business {unique}",
"merchant_id": system_merchant.id,
"contact_name": "Jane Doe",
"contact_email": f"jane-{unique}@example.com",
"contact_phone": "+352 999 888",
@@ -251,75 +253,65 @@ class TestHostedSiteLifecycle:
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteFromProspect:
"""Tests for creating hosted sites from prospects."""
"""Tests for creating hosted sites from prospects via prospect_id."""
def setup_method(self):
self.service = HostedSiteService()
def test_create_from_prospect(self, db, hosting_platform, system_merchant):
"""Test creating a hosted site from a prospect."""
from app.modules.prospecting.models import Prospect
def test_create_with_prospect_id(self, db, hosting_platform):
"""Test creating a hosted site with prospect_id auto-creates merchant."""
from app.modules.prospecting.models import Prospect, ProspectContact
unique = uuid.uuid4().hex[:8]
prospect = Prospect(
channel="digital",
domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu",
domain_name=f"prospect-{unique}.lu",
business_name="Prospect Business",
status="active",
has_website=True,
)
db.add(prospect)
db.flush()
# Add email contact (needed for merchant creation)
db.add(ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value=f"hello-{unique}@test.lu",
is_primary=True,
))
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
site = self.service.create(
db,
{
"business_name": "Prospect Business",
"prospect_id": prospect.id,
"contact_email": f"hello-{unique}@test.lu",
},
)
db.commit()
assert site.id is not None
assert site.prospect_id == prospect.id
assert site.business_name == "Prospect Business"
assert site.store.merchant is not None
def test_create_from_prospect_with_contacts(
self, db, hosting_platform, system_merchant
):
"""Test creating from prospect pre-fills contact info."""
from app.modules.prospecting.models import Prospect, ProspectContact
def test_create_without_merchant_or_prospect_raises(self, db, hosting_platform):
"""Test that creating without merchant_id or prospect_id raises ValueError."""
with pytest.raises(ValueError, match="Either merchant_id or prospect_id"):
self.service.create(
db,
{"business_name": "No Owner"},
)
prospect = Prospect(
channel="digital",
domain_name=f"contacts-{uuid.uuid4().hex[:8]}.lu",
business_name="Contact Business",
status="active",
)
db.add(prospect)
db.flush()
email_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value="hello@test.lu",
is_primary=True,
)
phone_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="phone",
value="+352 111 222",
is_primary=True,
)
db.add_all([email_contact, phone_contact])
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
db.commit()
assert site.contact_email == "hello@test.lu"
assert site.contact_phone == "+352 111 222"
def test_create_from_nonexistent_prospect(
self, db, hosting_platform, system_merchant
):
"""Test creating from non-existent prospect raises exception."""
def test_create_with_nonexistent_prospect_raises(self, db, hosting_platform):
"""Test creating with non-existent prospect raises exception."""
from app.modules.prospecting.exceptions import ProspectNotFoundException
with pytest.raises(ProspectNotFoundException):
self.service.create_from_prospect(db, 99999)
self.service.create(
db,
{"business_name": "Ghost", "prospect_id": 99999},
)