feat(billing): end-to-end Stripe subscription signup with platform enforcement

Move core signup service from marketplace to billing module, add
automatic Stripe product/price sync for tiers, create loyalty-specific
signup wizard, and enforce that platform is always explicitly known
(no silent defaulting to primary/hardcoded ID).

Key changes:
- New billing SignupService with separated account/store creation steps
- Stripe auto-sync on tier create/update (new prices, archive old)
- Loyalty signup template (Plan → Account → Store → Payment)
- platform_code is now required throughout the signup flow
- Pricing/signup pages return 404 if platform not detected
- OMS-specific logic (Letzshop claiming) stays in marketplace module
- Bootstrap script: scripts/seed/sync_stripe_products.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 19:16:14 +01:00
parent 2078ce35b2
commit 9684747d08
12 changed files with 1723 additions and 689 deletions

View File

@@ -8,7 +8,7 @@ Platform (unauthenticated) pages for pricing and signup:
- Signup success
"""
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
@@ -16,22 +16,30 @@ from app.core.database import get_db
from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates
def _require_platform(request: Request):
"""Get the current platform or raise 404. Platform must always be known."""
platform = getattr(request.state, "platform", None)
if not platform:
raise HTTPException(
status_code=404,
detail="Platform not detected. Pricing and signup require a known platform.",
)
return platform
router = APIRouter()
def _get_tiers_data(db: Session) -> list[dict]:
def _get_tiers_data(db: Session, platform_id: int) -> list[dict]:
"""Build tier data for display in templates from database."""
from app.modules.billing.models import SubscriptionTier, TierCode
tiers_db = (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.is_active == True,
SubscriptionTier.is_public == True,
)
.order_by(SubscriptionTier.display_order)
.all()
query = db.query(SubscriptionTier).filter(
SubscriptionTier.is_active == True,
SubscriptionTier.is_public == True,
SubscriptionTier.platform_id == platform_id,
)
tiers_db = query.order_by(SubscriptionTier.display_order).all()
tiers = []
for tier in tiers_db:
@@ -63,9 +71,12 @@ async def pricing_page(
):
"""
Standalone pricing page with detailed tier comparison.
Tiers are filtered by the current platform (detected from domain/path).
"""
platform = _require_platform(request)
context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data(db)
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
context["page_title"] = "Pricing"
return templates.TemplateResponse(
@@ -89,18 +100,28 @@ async def signup_page(
"""
Multi-step signup wizard.
Routes to platform-specific signup templates. Each platform defines
its own signup flow (different steps, different UI).
Query params:
- tier: Pre-selected tier code
- annual: Pre-select annual billing
"""
platform = _require_platform(request)
context = get_platform_context(request, db)
context["page_title"] = "Start Your Free Trial"
context["selected_tier"] = tier
context["is_annual"] = annual
context["tiers"] = _get_tiers_data(db)
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
# Route to platform-specific signup template
if platform.code == "loyalty":
template_name = "billing/platform/signup-loyalty.html"
else:
template_name = "billing/platform/signup.html"
return templates.TemplateResponse(
"billing/platform/signup.html",
template_name,
context,
)