Files
orion/app/modules/billing/routes/pages/platform.py
Samir Boulahtit 9684747d08 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>
2026-02-28 19:16:14 +01:00

150 lines
4.6 KiB
Python

# app/modules/billing/routes/pages/platform.py
"""
Billing Platform Page Routes (HTML rendering).
Platform (unauthenticated) pages for pricing and signup:
- Pricing page
- Signup wizard
- Signup success
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
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, platform_id: int) -> list[dict]:
"""Build tier data for display in templates from database."""
from app.modules.billing.models import SubscriptionTier, TierCode
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:
feature_codes = sorted(tier.get_feature_codes())
tiers.append({
"code": tier.code,
"name": tier.name,
"price_monthly": tier.price_monthly_cents / 100,
"price_annual": (tier.price_annual_cents / 100) if tier.price_annual_cents else None,
"feature_codes": feature_codes,
"products_limit": tier.get_limit_for_feature("products_limit"),
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
"team_members": tier.get_limit_for_feature("team_members"),
"is_popular": tier.code == TierCode.PROFESSIONAL.value,
"is_enterprise": tier.code == TierCode.ENTERPRISE.value,
})
return tiers
# ============================================================================
# PRICING PAGE
# ============================================================================
@router.get("/pricing", response_class=HTMLResponse, name="platform_pricing")
async def pricing_page(
request: Request,
db: Session = Depends(get_db),
):
"""
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, platform_id=platform.id)
context["page_title"] = "Pricing"
return templates.TemplateResponse(
"billing/platform/pricing.html",
context,
)
# ============================================================================
# SIGNUP WIZARD
# ============================================================================
@router.get("/signup", response_class=HTMLResponse, name="platform_signup")
async def signup_page(
request: Request,
tier: str | None = None,
annual: bool = False,
db: Session = Depends(get_db),
):
"""
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, 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(
template_name,
context,
)
@router.get(
"/signup/success", response_class=HTMLResponse, name="platform_signup_success"
)
async def signup_success_page(
request: Request,
store_code: str | None = None,
db: Session = Depends(get_db),
):
"""
Signup success page.
Shown after successful account creation.
"""
context = get_platform_context(request, db)
context["page_title"] = "Welcome to Orion!"
context["store_code"] = store_code
return templates.TemplateResponse(
"billing/platform/signup-success.html",
context,
)