refactor: move platform API database queries to service layer

- Create platform_signup_service.py for signup flow operations
- Create platform_pricing_service.py for pricing data operations
- Refactor signup.py, pricing.py, letzshop_vendors.py to use services
- Add # public markers to all platform endpoints
- Update tests for correct mock paths and status codes

Fixes architecture validation errors (API-002, API-003, SVC-006).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 13:30:52 +01:00
parent 83198df3e7
commit 95987d0c1c
6 changed files with 811 additions and 492 deletions

View File

@@ -8,61 +8,23 @@ Handles the multi-step signup flow:
3. Create account
4. Setup payment (collect card via SetupIntent)
5. Complete signup (create subscription with trial)
All endpoints are public (no authentication required).
"""
import logging
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.services.stripe_service import stripe_service
from models.database.subscription import (
SubscriptionStatus,
TierCode,
VendorSubscription,
)
from models.database.vendor import Vendor, VendorUser, VendorUserType
from app.services.platform_signup_service import platform_signup_service
router = APIRouter()
logger = logging.getLogger(__name__)
# =============================================================================
# In-memory signup session storage (for simplicity)
# In production, use Redis or database table
# =============================================================================
_signup_sessions: dict[str, dict] = {}
def create_session_id() -> str:
"""Generate a secure session ID."""
return secrets.token_urlsafe(32)
def get_session(session_id: str) -> dict | None:
"""Get a signup session by ID."""
return _signup_sessions.get(session_id)
def save_session(session_id: str, data: dict) -> None:
"""Save signup session data."""
_signup_sessions[session_id] = {
**data,
"updated_at": datetime.now(UTC).isoformat(),
}
def delete_session(session_id: str) -> None:
"""Delete a signup session."""
_signup_sessions.pop(session_id, None)
# =============================================================================
# Request/Response Schemas
# =============================================================================
@@ -156,45 +118,27 @@ class CompleteSignupResponse(BaseModel):
# =============================================================================
@router.post("/signup/start", response_model=SignupStartResponse)
async def start_signup(
request: SignupStartRequest,
db: Session = Depends(get_db),
) -> SignupStartResponse:
@router.post("/signup/start", response_model=SignupStartResponse) # public
async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
"""
Start the signup process.
Step 1: User selects a tier and billing period.
Creates a signup session to track the flow.
"""
# Validate tier code
try:
tier = TierCode(request.tier_code)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid tier code: {request.tier_code}",
)
# Create session
session_id = create_session_id()
save_session(session_id, {
"step": "tier_selected",
"tier_code": tier.value,
"is_annual": request.is_annual,
"created_at": datetime.now(UTC).isoformat(),
})
logger.info(f"Started signup session {session_id} for tier {tier.value}")
session_id = platform_signup_service.create_session(
tier_code=request.tier_code,
is_annual=request.is_annual,
)
return SignupStartResponse(
session_id=session_id,
tier_code=tier.value,
tier_code=request.tier_code,
is_annual=request.is_annual,
)
@router.post("/signup/claim-vendor", response_model=ClaimVendorResponse)
@router.post("/signup/claim-vendor", response_model=ClaimVendorResponse) # public
async def claim_letzshop_vendor(
request: ClaimVendorRequest,
db: Session = Depends(get_db),
@@ -205,34 +149,12 @@ async def claim_letzshop_vendor(
Step 2 (optional): User claims their Letzshop shop.
This pre-fills vendor info during account creation.
"""
session = get_session(request.session_id)
if not session:
raise HTTPException(status_code=404, detail="Signup session not found")
# Check if vendor is already claimed
existing = db.query(Vendor).filter(
Vendor.letzshop_vendor_slug == request.letzshop_slug,
Vendor.is_active == True,
).first()
if existing:
raise HTTPException(
status_code=400,
detail="This Letzshop vendor is already claimed",
)
# Update session with vendor info
session["letzshop_slug"] = request.letzshop_slug
session["letzshop_vendor_id"] = request.letzshop_vendor_id
session["step"] = "vendor_claimed"
# TODO: Fetch actual vendor name from Letzshop API
vendor_name = request.letzshop_slug.replace("-", " ").title()
session["vendor_name"] = vendor_name
save_session(request.session_id, session)
logger.info(f"Claimed vendor {request.letzshop_slug} for session {request.session_id}")
vendor_name = platform_signup_service.claim_vendor(
db=db,
session_id=request.session_id,
letzshop_slug=request.letzshop_slug,
letzshop_vendor_id=request.letzshop_vendor_id,
)
return ClaimVendorResponse(
session_id=request.session_id,
@@ -241,7 +163,7 @@ async def claim_letzshop_vendor(
)
@router.post("/signup/create-account", response_model=CreateAccountResponse)
@router.post("/signup/create-account", response_model=CreateAccountResponse) # public
async def create_account(
request: CreateAccountRequest,
db: Session = Depends(get_db),
@@ -252,203 +174,45 @@ async def create_account(
Step 3: User provides account details.
Creates User, Company, Vendor, and Stripe Customer.
"""
session = get_session(request.session_id)
if not session:
raise HTTPException(status_code=404, detail="Signup session not found")
result = platform_signup_service.create_account(
db=db,
session_id=request.session_id,
email=request.email,
password=request.password,
first_name=request.first_name,
last_name=request.last_name,
company_name=request.company_name,
phone=request.phone,
)
# Check if email already exists
from models.database.user import User
existing_user = db.query(User).filter(User.email == request.email).first()
if existing_user:
raise HTTPException(
status_code=400,
detail="An account with this email already exists",
)
try:
# Create User first (needed for Company owner)
from middleware.auth import AuthManager
auth_manager = AuthManager()
# Generate username from email
username = request.email.split("@")[0]
base_username = username
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{base_username}_{counter}"
counter += 1
user = User(
email=request.email,
username=username,
hashed_password=auth_manager.hash_password(request.password),
first_name=request.first_name,
last_name=request.last_name,
role="vendor",
is_active=True,
)
db.add(user)
db.flush()
# Create Company (with owner)
from models.database.company import Company
company = Company(
name=request.company_name,
owner_user_id=user.id,
contact_email=request.email,
contact_phone=request.phone,
)
db.add(company)
db.flush()
# Generate vendor code
vendor_code = request.company_name.upper().replace(" ", "_")[:20]
# Ensure unique
base_code = vendor_code
counter = 1
while db.query(Vendor).filter(Vendor.vendor_code == vendor_code).first():
vendor_code = f"{base_code}_{counter}"
counter += 1
# Generate subdomain
subdomain = request.company_name.lower().replace(" ", "-")
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
base_subdomain = subdomain
counter = 1
while db.query(Vendor).filter(Vendor.subdomain == subdomain).first():
subdomain = f"{base_subdomain}-{counter}"
counter += 1
# Create Vendor
vendor = Vendor(
company_id=company.id,
vendor_code=vendor_code,
subdomain=subdomain,
name=request.company_name,
contact_email=request.email,
contact_phone=request.phone,
is_active=True,
letzshop_vendor_slug=session.get("letzshop_slug"),
letzshop_vendor_id=session.get("letzshop_vendor_id"),
)
db.add(vendor)
db.flush()
# Create VendorUser (owner)
vendor_user = VendorUser(
vendor_id=vendor.id,
user_id=user.id,
user_type=VendorUserType.OWNER.value,
is_active=True,
)
db.add(vendor_user)
# Create Stripe Customer
stripe_customer_id = stripe_service.create_customer(
vendor=vendor,
email=request.email,
name=f"{request.first_name} {request.last_name}",
metadata={
"company_name": request.company_name,
"tier": session.get("tier_code"),
},
)
# Create VendorSubscription (in trial status, without Stripe subscription yet)
now = datetime.now(UTC)
trial_end = now + timedelta(days=settings.stripe_trial_days)
subscription = VendorSubscription(
vendor_id=vendor.id,
tier=session.get("tier_code", TierCode.ESSENTIAL.value),
status=SubscriptionStatus.TRIAL.value,
period_start=now,
period_end=trial_end,
trial_ends_at=trial_end,
is_annual=session.get("is_annual", False),
stripe_customer_id=stripe_customer_id,
)
db.add(subscription)
db.commit()
# Update session
session["user_id"] = user.id
session["vendor_id"] = vendor.id
session["vendor_code"] = vendor_code
session["stripe_customer_id"] = stripe_customer_id
session["step"] = "account_created"
save_session(request.session_id, session)
logger.info(
f"Created account for {request.email}: "
f"user_id={user.id}, vendor_id={vendor.id}"
)
return CreateAccountResponse(
session_id=request.session_id,
user_id=user.id,
vendor_id=vendor.id,
stripe_customer_id=stripe_customer_id,
)
except Exception as e:
db.rollback()
logger.error(f"Error creating account: {e}")
raise HTTPException(status_code=500, detail="Failed to create account")
return CreateAccountResponse(
session_id=request.session_id,
user_id=result.user_id,
vendor_id=result.vendor_id,
stripe_customer_id=result.stripe_customer_id,
)
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse)
async def setup_payment(
request: SetupPaymentRequest,
db: Session = Depends(get_db),
) -> SetupPaymentResponse:
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse) # public
async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
"""
Create Stripe SetupIntent for card collection.
Step 4: Collect card details without charging.
The card will be charged after the trial period ends.
"""
session = get_session(request.session_id)
if not session:
raise HTTPException(status_code=404, detail="Signup session not found")
if "stripe_customer_id" not in session:
raise HTTPException(
status_code=400,
detail="Account not created. Please complete step 3 first.",
)
stripe_customer_id = session["stripe_customer_id"]
# Create SetupIntent
setup_intent = stripe_service.create_setup_intent(
customer_id=stripe_customer_id,
metadata={
"session_id": request.session_id,
"vendor_id": str(session.get("vendor_id")),
"tier": session.get("tier_code"),
},
client_secret, stripe_customer_id = platform_signup_service.setup_payment(
session_id=request.session_id,
)
# Update session
session["setup_intent_id"] = setup_intent.id
session["step"] = "payment_pending"
save_session(request.session_id, session)
logger.info(f"Created SetupIntent {setup_intent.id} for session {request.session_id}")
return SetupPaymentResponse(
session_id=request.session_id,
client_secret=setup_intent.client_secret,
client_secret=client_secret,
stripe_customer_id=stripe_customer_id,
)
@router.post("/signup/complete", response_model=CompleteSignupResponse)
@router.post("/signup/complete", response_model=CompleteSignupResponse) # public
async def complete_signup(
request: CompleteSignupRequest,
db: Session = Depends(get_db),
@@ -458,89 +222,29 @@ async def complete_signup(
Step 5: Verify SetupIntent, attach payment method, create subscription.
"""
session = get_session(request.session_id)
if not session:
raise HTTPException(status_code=404, detail="Signup session not found")
result = platform_signup_service.complete_signup(
db=db,
session_id=request.session_id,
setup_intent_id=request.setup_intent_id,
)
vendor_id = session.get("vendor_id")
stripe_customer_id = session.get("stripe_customer_id")
if not vendor_id or not stripe_customer_id:
raise HTTPException(
status_code=400,
detail="Incomplete signup. Please start again.",
)
try:
# Retrieve SetupIntent to get payment method
setup_intent = stripe_service.get_setup_intent(request.setup_intent_id)
if setup_intent.status != "succeeded":
raise HTTPException(
status_code=400,
detail="Card setup not completed. Please try again.",
)
payment_method_id = setup_intent.payment_method
# Attach payment method to customer
stripe_service.attach_payment_method_to_customer(
customer_id=stripe_customer_id,
payment_method_id=payment_method_id,
set_as_default=True,
)
# Update subscription record with card collection time
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if subscription:
subscription.card_collected_at = datetime.now(UTC)
subscription.stripe_payment_method_id = payment_method_id
# TODO: Create actual Stripe subscription with trial
# For now, just mark as trial with card collected
db.commit()
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
vendor_code = vendor.vendor_code if vendor else session.get("vendor_code")
# Clean up session
delete_session(request.session_id)
trial_ends_at = subscription.trial_ends_at if subscription else datetime.now(UTC) + timedelta(days=30)
logger.info(f"Completed signup for vendor {vendor_id}")
return CompleteSignupResponse(
success=True,
vendor_code=vendor_code,
vendor_id=vendor_id,
redirect_url=f"/vendors/{vendor_code}/dashboard",
trial_ends_at=trial_ends_at.isoformat(),
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error completing signup: {e}")
raise HTTPException(status_code=500, detail="Failed to complete signup")
return CompleteSignupResponse(
success=result.success,
vendor_code=result.vendor_code,
vendor_id=result.vendor_id,
redirect_url=result.redirect_url,
trial_ends_at=result.trial_ends_at,
)
@router.get("/signup/session/{session_id}")
@router.get("/signup/session/{session_id}") # public
async def get_signup_session(session_id: str) -> dict:
"""
Get signup session status.
Useful for resuming an incomplete signup.
"""
session = get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
session = platform_signup_service.get_session_or_raise(session_id)
# Return safe subset of session data
return {