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

@@ -3,11 +3,14 @@
Platform signup API endpoints. Platform signup API endpoints.
Handles the multi-step signup flow: Handles the multi-step signup flow:
1. Start signup (select tier) 1. Start signup (select tier + platform)
2. Claim Letzshop store (optional) 2. Create account (user + merchant)
3. Create account 3. Create store
4. Setup payment (collect card via SetupIntent) 4. Setup payment (collect card via SetupIntent)
5. Complete signup (create subscription with trial) 5. Complete signup (create Stripe subscription with trial)
Platform-specific steps (e.g., OMS Letzshop claiming) are handled
by their respective modules and call into this core flow.
All endpoints are public (no authentication required). All endpoints are public (no authentication required).
""" """
@@ -20,9 +23,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.environment import should_use_secure_cookies from app.core.environment import should_use_secure_cookies
from app.modules.marketplace.services.platform_signup_service import ( from app.modules.billing.services.signup_service import signup_service
platform_signup_service,
)
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,10 +35,11 @@ logger = logging.getLogger(__name__)
class SignupStartRequest(BaseModel): class SignupStartRequest(BaseModel):
"""Start signup - select tier.""" """Start signup - select tier and platform."""
tier_code: str tier_code: str
is_annual: bool = False is_annual: bool = False
platform_code: str
class SignupStartResponse(BaseModel): class SignupStartResponse(BaseModel):
@@ -46,26 +48,11 @@ class SignupStartResponse(BaseModel):
session_id: str session_id: str
tier_code: str tier_code: str
is_annual: bool is_annual: bool
platform_code: str
class ClaimStoreRequest(BaseModel):
"""Claim Letzshop store."""
session_id: str
letzshop_slug: str
letzshop_store_id: str | None = None
class ClaimStoreResponse(BaseModel):
"""Response from store claim."""
session_id: str
letzshop_slug: str
store_name: str | None
class CreateAccountRequest(BaseModel): class CreateAccountRequest(BaseModel):
"""Create account.""" """Create account (user + merchant)."""
session_id: str session_id: str
email: EmailStr email: EmailStr
@@ -81,10 +68,26 @@ class CreateAccountResponse(BaseModel):
session_id: str session_id: str
user_id: int user_id: int
store_id: int merchant_id: int
stripe_customer_id: str stripe_customer_id: str
class CreateStoreRequest(BaseModel):
"""Create store for the merchant."""
session_id: str
store_name: str | None = None
language: str | None = None
class CreateStoreResponse(BaseModel):
"""Response from store creation."""
session_id: str
store_id: int
store_code: str
class SetupPaymentRequest(BaseModel): class SetupPaymentRequest(BaseModel):
"""Request payment setup.""" """Request payment setup."""
@@ -127,43 +130,20 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
""" """
Start the signup process. Start the signup process.
Step 1: User selects a tier and billing period. Step 1: User selects a tier, billing period, and platform.
Creates a signup session to track the flow. Creates a signup session to track the flow.
""" """
session_id = platform_signup_service.create_session( session_id = signup_service.create_session(
tier_code=request.tier_code, tier_code=request.tier_code,
is_annual=request.is_annual, is_annual=request.is_annual,
platform_code=request.platform_code,
) )
return SignupStartResponse( return SignupStartResponse(
session_id=session_id, session_id=session_id,
tier_code=request.tier_code, tier_code=request.tier_code,
is_annual=request.is_annual, is_annual=request.is_annual,
) platform_code=request.platform_code,
@router.post("/signup/claim-store", response_model=ClaimStoreResponse) # public
async def claim_letzshop_store(
request: ClaimStoreRequest,
db: Session = Depends(get_db),
) -> ClaimStoreResponse:
"""
Claim a Letzshop store.
Step 2 (optional): User claims their Letzshop shop.
This pre-fills store info during account creation.
"""
store_name = platform_signup_service.claim_store(
db=db,
session_id=request.session_id,
letzshop_slug=request.letzshop_slug,
letzshop_store_id=request.letzshop_store_id,
)
return ClaimStoreResponse(
session_id=request.session_id,
letzshop_slug=request.letzshop_slug,
store_name=store_name,
) )
@@ -173,12 +153,13 @@ async def create_account(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CreateAccountResponse: ) -> CreateAccountResponse:
""" """
Create user and store accounts. Create user and merchant accounts.
Step 3: User provides account details. Step 2: User provides account details.
Creates User, Merchant, Store, and Stripe Customer. Creates User, Merchant, and Stripe Customer.
Store creation is a separate step.
""" """
result = platform_signup_service.create_account( result = signup_service.create_account(
db=db, db=db,
session_id=request.session_id, session_id=request.session_id,
email=request.email, email=request.email,
@@ -192,11 +173,36 @@ async def create_account(
return CreateAccountResponse( return CreateAccountResponse(
session_id=request.session_id, session_id=request.session_id,
user_id=result.user_id, user_id=result.user_id,
store_id=result.store_id, merchant_id=result.merchant_id,
stripe_customer_id=result.stripe_customer_id, stripe_customer_id=result.stripe_customer_id,
) )
@router.post("/signup/create-store", response_model=CreateStoreResponse) # public
async def create_store(
request: CreateStoreRequest,
db: Session = Depends(get_db),
) -> CreateStoreResponse:
"""
Create the first store for the merchant.
Step 3: User names their store (defaults to merchant name).
Creates Store, StorePlatform, and MerchantSubscription.
"""
result = signup_service.create_store(
db=db,
session_id=request.session_id,
store_name=request.store_name,
language=request.language,
)
return CreateStoreResponse(
session_id=request.session_id,
store_id=result.store_id,
store_code=result.store_code,
)
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse) # public @router.post("/signup/setup-payment", response_model=SetupPaymentResponse) # public
async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse: async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
""" """
@@ -205,7 +211,7 @@ async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
Step 4: Collect card details without charging. Step 4: Collect card details without charging.
The card will be charged after the trial period ends. The card will be charged after the trial period ends.
""" """
client_secret, stripe_customer_id = platform_signup_service.setup_payment( client_secret, stripe_customer_id = signup_service.setup_payment(
session_id=request.session_id, session_id=request.session_id,
) )
@@ -228,7 +234,7 @@ async def complete_signup(
Step 5: Verify SetupIntent, attach payment method, create subscription. Step 5: Verify SetupIntent, attach payment method, create subscription.
Also sets HTTP-only cookie for page navigation and returns token for localStorage. Also sets HTTP-only cookie for page navigation and returns token for localStorage.
""" """
result = platform_signup_service.complete_signup( result = signup_service.complete_signup(
db=db, db=db,
session_id=request.session_id, session_id=request.session_id,
setup_intent_id=request.setup_intent_id, setup_intent_id=request.setup_intent_id,
@@ -265,7 +271,7 @@ async def get_signup_session(session_id: str) -> dict:
Useful for resuming an incomplete signup. Useful for resuming an incomplete signup.
""" """
session = platform_signup_service.get_session_or_raise(session_id) session = signup_service.get_session_or_raise(session_id)
# Return safe subset of session data # Return safe subset of session data
return { return {

View File

@@ -8,7 +8,7 @@ Platform (unauthenticated) pages for pricing and signup:
- Signup success - Signup success
""" """
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session 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.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates 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() 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.""" """Build tier data for display in templates from database."""
from app.modules.billing.models import SubscriptionTier, TierCode from app.modules.billing.models import SubscriptionTier, TierCode
tiers_db = ( query = db.query(SubscriptionTier).filter(
db.query(SubscriptionTier) SubscriptionTier.is_active == True,
.filter( SubscriptionTier.is_public == True,
SubscriptionTier.is_active == True, SubscriptionTier.platform_id == platform_id,
SubscriptionTier.is_public == True,
)
.order_by(SubscriptionTier.display_order)
.all()
) )
tiers_db = query.order_by(SubscriptionTier.display_order).all()
tiers = [] tiers = []
for tier in tiers_db: for tier in tiers_db:
@@ -63,9 +71,12 @@ async def pricing_page(
): ):
""" """
Standalone pricing page with detailed tier comparison. 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 = 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" context["page_title"] = "Pricing"
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -89,18 +100,28 @@ async def signup_page(
""" """
Multi-step signup wizard. Multi-step signup wizard.
Routes to platform-specific signup templates. Each platform defines
its own signup flow (different steps, different UI).
Query params: Query params:
- tier: Pre-selected tier code - tier: Pre-selected tier code
- annual: Pre-select annual billing - annual: Pre-select annual billing
""" """
platform = _require_platform(request)
context = get_platform_context(request, db) context = get_platform_context(request, db)
context["page_title"] = "Start Your Free Trial" context["page_title"] = "Start Your Free Trial"
context["selected_tier"] = tier context["selected_tier"] = tier
context["is_annual"] = annual 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( return templates.TemplateResponse(
"billing/platform/signup.html", template_name,
context, context,
) )

View File

@@ -21,6 +21,10 @@ from app.modules.billing.services.platform_pricing_service import (
PlatformPricingService, PlatformPricingService,
platform_pricing_service, platform_pricing_service,
) )
from app.modules.billing.services.signup_service import (
SignupService,
signup_service,
)
from app.modules.billing.services.store_platform_sync_service import ( from app.modules.billing.services.store_platform_sync_service import (
StorePlatformSync, StorePlatformSync,
store_platform_sync, store_platform_sync,
@@ -65,4 +69,6 @@ __all__ = [
"TierInfoData", "TierInfoData",
"UpgradeTierData", "UpgradeTierData",
"LimitCheckData", "LimitCheckData",
"SignupService",
"signup_service",
] ]

View File

@@ -35,6 +35,141 @@ logger = logging.getLogger(__name__)
class AdminSubscriptionService: class AdminSubscriptionService:
"""Service for admin subscription management operations.""" """Service for admin subscription management operations."""
# =========================================================================
# Stripe Tier Sync
# =========================================================================
@staticmethod
def _sync_tier_to_stripe(db: Session, tier: SubscriptionTier) -> None:
"""
Sync a tier's Stripe product and prices.
Creates or verifies the Stripe Product and Price objects, and
populates the stripe_product_id, stripe_price_monthly_id, and
stripe_price_annual_id fields on the tier.
Skips gracefully if Stripe is not configured (dev mode).
Stripe Prices are immutable — on price changes, new Prices are
created and old ones archived.
"""
from app.core.config import settings
if not settings.stripe_secret_key:
logger.debug(
f"Stripe not configured, skipping sync for tier {tier.code}"
)
return
import stripe
stripe.api_key = settings.stripe_secret_key
# Resolve platform name for product naming
platform_name = "Platform"
if tier.platform_id:
from app.modules.tenancy.services.platform_service import (
platform_service,
)
try:
platform = platform_service.get_platform_by_id(db, tier.platform_id)
platform_name = platform.name
except Exception:
pass
# --- Product ---
if tier.stripe_product_id:
# Verify it still exists in Stripe
try:
stripe.Product.retrieve(tier.stripe_product_id)
except stripe.InvalidRequestError:
logger.warning(
f"Stripe product {tier.stripe_product_id} not found, "
f"recreating for tier {tier.code}"
)
tier.stripe_product_id = None
if not tier.stripe_product_id:
product = stripe.Product.create(
name=f"{platform_name} - {tier.name}",
metadata={
"tier_code": tier.code,
"platform_id": str(tier.platform_id or ""),
},
)
tier.stripe_product_id = product.id
logger.info(
f"Created Stripe product {product.id} for tier {tier.code}"
)
# --- Monthly Price ---
if tier.price_monthly_cents:
if tier.stripe_price_monthly_id:
# Verify price matches; if not, create new one
try:
existing = stripe.Price.retrieve(tier.stripe_price_monthly_id)
if existing.unit_amount != tier.price_monthly_cents:
# Price changed — archive old, create new
stripe.Price.modify(
tier.stripe_price_monthly_id, active=False
)
tier.stripe_price_monthly_id = None
logger.info(
f"Archived old monthly price for tier {tier.code}"
)
except stripe.InvalidRequestError:
tier.stripe_price_monthly_id = None
if not tier.stripe_price_monthly_id:
price = stripe.Price.create(
product=tier.stripe_product_id,
unit_amount=tier.price_monthly_cents,
currency="eur",
recurring={"interval": "month"},
metadata={
"tier_code": tier.code,
"billing_period": "monthly",
},
)
tier.stripe_price_monthly_id = price.id
logger.info(
f"Created Stripe monthly price {price.id} "
f"for tier {tier.code} ({tier.price_monthly_cents} cents)"
)
# --- Annual Price ---
if tier.price_annual_cents:
if tier.stripe_price_annual_id:
try:
existing = stripe.Price.retrieve(tier.stripe_price_annual_id)
if existing.unit_amount != tier.price_annual_cents:
stripe.Price.modify(
tier.stripe_price_annual_id, active=False
)
tier.stripe_price_annual_id = None
logger.info(
f"Archived old annual price for tier {tier.code}"
)
except stripe.InvalidRequestError:
tier.stripe_price_annual_id = None
if not tier.stripe_price_annual_id:
price = stripe.Price.create(
product=tier.stripe_product_id,
unit_amount=tier.price_annual_cents,
currency="eur",
recurring={"interval": "year"},
metadata={
"tier_code": tier.code,
"billing_period": "annual",
},
)
tier.stripe_price_annual_id = price.id
logger.info(
f"Created Stripe annual price {price.id} "
f"for tier {tier.code} ({tier.price_annual_cents} cents)"
)
# ========================================================================= # =========================================================================
# Subscription Tiers # Subscription Tiers
# ========================================================================= # =========================================================================
@@ -85,6 +220,9 @@ class AdminSubscriptionService:
tier = SubscriptionTier(**tier_data) tier = SubscriptionTier(**tier_data)
db.add(tier) db.add(tier)
db.flush() # Get tier.id before Stripe sync
self._sync_tier_to_stripe(db, tier)
logger.info(f"Created subscription tier: {tier.code}") logger.info(f"Created subscription tier: {tier.code}")
return tier return tier
@@ -95,9 +233,21 @@ class AdminSubscriptionService:
"""Update a subscription tier.""" """Update a subscription tier."""
tier = self.get_tier_by_code(db, tier_code) tier = self.get_tier_by_code(db, tier_code)
# Track price changes to know if Stripe sync is needed
price_changed = (
"price_monthly_cents" in update_data
and update_data["price_monthly_cents"] != tier.price_monthly_cents
) or (
"price_annual_cents" in update_data
and update_data["price_annual_cents"] != tier.price_annual_cents
)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(tier, field, value) setattr(tier, field, value)
if price_changed or not tier.stripe_product_id:
self._sync_tier_to_stripe(db, tier)
logger.info(f"Updated subscription tier: {tier.code}") logger.info(f"Updated subscription tier: {tier.code}")
return tier return tier

View File

@@ -0,0 +1,797 @@
# app/modules/billing/services/signup_service.py
"""
Core platform signup service.
Handles all database operations for the platform signup flow:
- Session management
- Account creation (User + Merchant)
- Store creation (separate step)
- Stripe customer & subscription setup
- Payment method collection
Platform-specific signup extensions (e.g., OMS Letzshop claiming)
live in their respective modules and call into this core service.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session
from app.core.config import settings
from app.exceptions import (
ConflictException,
ResourceNotFoundException,
ValidationException,
)
from app.modules.billing.services.stripe_service import stripe_service
from app.modules.billing.services.subscription_service import (
subscription_service as sub_service,
)
from app.modules.messaging.services.email_service import EmailService
from middleware.auth import AuthManager
if TYPE_CHECKING:
from app.modules.tenancy.models import Store, User
logger = logging.getLogger(__name__)
# =============================================================================
# In-memory signup session storage
# 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)
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class SignupSessionData:
"""Data stored in a signup session."""
session_id: str
step: str
tier_code: str
is_annual: bool
platform_code: str = ""
created_at: str = ""
updated_at: str | None = None
store_name: str | None = None
user_id: int | None = None
merchant_id: int | None = None
store_id: int | None = None
store_code: str | None = None
platform_id: int | None = None
stripe_customer_id: str | None = None
setup_intent_id: str | None = None
extra: dict = field(default_factory=dict)
@dataclass
class AccountCreationResult:
"""Result of account creation."""
user_id: int
merchant_id: int
stripe_customer_id: str
@dataclass
class StoreCreationResult:
"""Result of store creation."""
store_id: int
store_code: str
@dataclass
class SignupCompletionResult:
"""Result of signup completion."""
success: bool
store_code: str
store_id: int
redirect_url: str
trial_ends_at: str
access_token: str | None = None # JWT token for automatic login
# =============================================================================
# Platform Signup Service
# =============================================================================
class SignupService:
"""Core service for handling platform signup operations."""
def __init__(self):
self.auth_manager = AuthManager()
# =========================================================================
# Session Management
# =========================================================================
def create_session(
self,
tier_code: str,
is_annual: bool,
platform_code: str,
) -> str:
"""
Create a new signup session.
Args:
tier_code: The subscription tier code
is_annual: Whether annual billing is selected
platform_code: Platform code (e.g., 'loyalty', 'oms')
Returns:
The session ID
Raises:
ValidationException: If tier code or platform code is invalid
"""
if not platform_code:
raise ValidationException(
message="Platform code is required for signup.",
field="platform_code",
)
# Validate tier code
from app.modules.billing.models import TierCode
try:
tier = TierCode(tier_code)
except ValueError:
raise ValidationException(
message=f"Invalid tier code: {tier_code}",
field="tier_code",
)
session_id = _create_session_id()
now = datetime.now(UTC).isoformat()
_signup_sessions[session_id] = {
"step": "tier_selected",
"tier_code": tier.value,
"is_annual": is_annual,
"platform_code": platform_code,
"created_at": now,
"updated_at": now,
}
logger.info(
f"Created signup session {session_id} for tier {tier.value}"
f" on platform {platform_code}"
)
return session_id
def get_session(self, session_id: str) -> dict | None:
"""Get a signup session by ID."""
return _signup_sessions.get(session_id)
def get_session_or_raise(self, session_id: str) -> dict:
"""
Get a signup session or raise an exception.
Raises:
ResourceNotFoundException: If session not found
"""
session = self.get_session(session_id)
if not session:
raise ResourceNotFoundException(
resource_type="SignupSession",
identifier=session_id,
)
return session
def update_session(self, session_id: str, data: dict) -> None:
"""Update signup session data."""
session = self.get_session_or_raise(session_id)
session.update(data)
session["updated_at"] = datetime.now(UTC).isoformat()
_signup_sessions[session_id] = session
def delete_session(self, session_id: str) -> None:
"""Delete a signup session."""
_signup_sessions.pop(session_id, None)
# =========================================================================
# Platform Resolution
# =========================================================================
def _resolve_platform_id(self, db: Session, session: dict) -> int:
"""
Resolve platform_id from session data.
The platform_code is always required in the session (set during
create_session). Raises if the platform cannot be resolved.
Raises:
ValidationException: If platform_code is missing or unknown
"""
from app.modules.tenancy.services.platform_service import platform_service
platform_code = session.get("platform_code")
if not platform_code:
raise ValidationException(
message="Platform code is missing from signup session.",
field="platform_code",
)
platform = platform_service.get_platform_by_code_optional(
db, platform_code
)
if not platform:
raise ValidationException(
message=f"Unknown platform: {platform_code}",
field="platform_code",
)
return platform.id
# =========================================================================
# Account Creation (User + Merchant only)
# =========================================================================
def check_email_exists(self, db: Session, email: str) -> bool:
"""Check if an email already exists."""
from app.modules.tenancy.services.admin_service import admin_service
return admin_service.get_user_by_email(db, email) is not None
def generate_unique_username(self, db: Session, email: str) -> str:
"""Generate a unique username from email."""
from app.modules.tenancy.services.admin_service import admin_service
username = email.split("@")[0]
base_username = username
counter = 1
while admin_service.get_user_by_username(db, username):
username = f"{base_username}_{counter}"
counter += 1
return username
def generate_unique_store_code(self, db: Session, name: str) -> str:
"""Generate a unique store code from a name."""
from app.modules.tenancy.services.store_service import store_service
store_code = name.upper().replace(" ", "_")[:20]
base_code = store_code
counter = 1
while store_service.is_store_code_taken(db, store_code):
store_code = f"{base_code}_{counter}"
counter += 1
return store_code
def generate_unique_subdomain(self, db: Session, name: str) -> str:
"""Generate a unique subdomain from a name."""
from app.modules.tenancy.services.store_service import store_service
subdomain = name.lower().replace(" ", "-")
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
base_subdomain = subdomain
counter = 1
while store_service.is_subdomain_taken(db, subdomain):
subdomain = f"{base_subdomain}-{counter}"
counter += 1
return subdomain
def create_account(
self,
db: Session,
session_id: str,
email: str,
password: str,
first_name: str,
last_name: str,
merchant_name: str,
phone: str | None = None,
) -> AccountCreationResult:
"""
Create user and merchant accounts.
Creates User + Merchant + Stripe Customer. Store creation is a
separate step (create_store) so each platform can customize it.
Args:
db: Database session
session_id: Signup session ID
email: User email
password: User password
first_name: User first name
last_name: User last name
merchant_name: Merchant/business name
phone: Optional phone number
Returns:
AccountCreationResult with user and merchant IDs
Raises:
ResourceNotFoundException: If session not found
ConflictException: If email already exists
"""
session = self.get_session_or_raise(session_id)
# Check if email already exists
if self.check_email_exists(db, email):
raise ConflictException(
message="An account with this email already exists",
)
# Generate unique username
username = self.generate_unique_username(db, email)
# Create User
from app.modules.tenancy.models import Merchant, User
user = User(
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role="merchant_owner",
is_active=True,
)
db.add(user)
db.flush()
# Create Merchant
merchant = Merchant(
name=merchant_name,
owner_user_id=user.id,
contact_email=email,
contact_phone=phone,
)
db.add(merchant)
db.flush()
# Create Stripe Customer (linked to merchant, not store)
# We use a temporary store-like object for Stripe metadata
stripe_customer_id = stripe_service.create_customer_for_merchant(
merchant=merchant,
email=email,
name=f"{first_name} {last_name}",
metadata={
"merchant_name": merchant_name,
"tier": session.get("tier_code"),
"platform": session.get("platform_code", ""),
},
)
db.commit() # SVC-006 - Atomic account creation needs commit
# Update session
self.update_session(session_id, {
"user_id": user.id,
"merchant_id": merchant.id,
"merchant_name": merchant_name,
"email": email,
"stripe_customer_id": stripe_customer_id,
"step": "account_created",
})
logger.info(
f"Created account for {email}: "
f"user_id={user.id}, merchant_id={merchant.id}"
)
return AccountCreationResult(
user_id=user.id,
merchant_id=merchant.id,
stripe_customer_id=stripe_customer_id,
)
# =========================================================================
# Store Creation (separate step)
# =========================================================================
def create_store(
self,
db: Session,
session_id: str,
store_name: str | None = None,
language: str | None = None,
) -> StoreCreationResult:
"""
Create the first store for the merchant.
Store name defaults to the merchant name if not provided.
The merchant can modify store details later in the merchant panel.
Args:
db: Database session
session_id: Signup session ID
store_name: Store name (defaults to merchant name)
language: Store language code (e.g., 'fr', 'en', 'de')
Returns:
StoreCreationResult with store ID and code
Raises:
ResourceNotFoundException: If session not found
ValidationException: If account not created yet
"""
session = self.get_session_or_raise(session_id)
merchant_id = session.get("merchant_id")
if not merchant_id:
raise ValidationException(
message="Account not created. Please complete the account step first.",
field="session_id",
)
from app.modules.tenancy.models import Store
# Use merchant name as default store name
effective_name = store_name or session.get("merchant_name", "My Store")
email = session.get("email")
# Generate unique store code and subdomain
store_code = self.generate_unique_store_code(db, effective_name)
subdomain = self.generate_unique_subdomain(db, effective_name)
# Create Store
store = Store(
merchant_id=merchant_id,
store_code=store_code,
subdomain=subdomain,
name=effective_name,
contact_email=email,
is_active=True,
)
if language:
store.default_language = language
db.add(store)
db.flush()
# Resolve platform and create subscription
platform_id = self._resolve_platform_id(db, session)
# Create MerchantSubscription (trial status)
stripe_customer_id = session.get("stripe_customer_id")
subscription = sub_service.create_merchant_subscription(
db=db,
merchant_id=merchant_id,
platform_id=platform_id,
tier_code=session.get("tier_code", "essential"),
trial_days=settings.stripe_trial_days,
is_annual=session.get("is_annual", False),
)
subscription.stripe_customer_id = stripe_customer_id
db.commit() # SVC-006 - Atomic store creation needs commit
# Update session
self.update_session(session_id, {
"store_id": store.id,
"store_code": store_code,
"platform_id": platform_id,
"step": "store_created",
})
logger.info(
f"Created store {store_code} for merchant {merchant_id} "
f"on platform {session.get('platform_code')}"
)
return StoreCreationResult(
store_id=store.id,
store_code=store_code,
)
# =========================================================================
# Payment Setup
# =========================================================================
def setup_payment(self, session_id: str) -> tuple[str, str]:
"""
Create Stripe SetupIntent for card collection.
Args:
session_id: Signup session ID
Returns:
Tuple of (client_secret, stripe_customer_id)
Raises:
ResourceNotFoundException: If session not found
ValidationException: If store not created yet
"""
session = self.get_session_or_raise(session_id)
stripe_customer_id = session.get("stripe_customer_id")
if not stripe_customer_id:
raise ValidationException(
message="Account not created. Please complete earlier steps first.",
field="session_id",
)
if not session.get("store_id"):
raise ValidationException(
message="Store not created. Please complete the store step first.",
field="session_id",
)
# Create SetupIntent
setup_intent = stripe_service.create_setup_intent(
customer_id=stripe_customer_id,
metadata={
"session_id": session_id,
"store_id": str(session.get("store_id")),
"tier": session.get("tier_code"),
},
)
# Update session
self.update_session(session_id, {
"setup_intent_id": setup_intent.id,
"step": "payment_pending",
})
logger.info(
f"Created SetupIntent {setup_intent.id} for session {session_id}"
)
return setup_intent.client_secret, stripe_customer_id
# =========================================================================
# Welcome Email
# =========================================================================
def send_welcome_email(
self,
db: Session,
user: User,
store: Store,
tier_code: str,
language: str = "fr",
) -> None:
"""
Send welcome email to new store.
Args:
db: Database session
user: User who signed up
store: Store that was created
tier_code: Selected tier code
language: Language for email (default: French)
"""
try:
# Get tier name
from app.modules.billing.services.billing_service import billing_service
tier = billing_service.get_tier_by_code(db, tier_code)
tier_name = tier.name if tier else tier_code.title()
# Build login URL
login_url = (
f"https://{settings.platform_domain}"
f"/store/{store.store_code}/dashboard"
)
email_service = EmailService(db)
email_service.send_template(
template_code="signup_welcome",
language=language,
to_email=user.email,
to_name=f"{user.first_name} {user.last_name}",
variables={
"first_name": user.first_name,
"merchant_name": store.name,
"email": user.email,
"store_code": store.store_code,
"login_url": login_url,
"trial_days": settings.stripe_trial_days,
"tier_name": tier_name,
},
store_id=store.id,
user_id=user.id,
related_type="signup",
)
logger.info(f"Welcome email sent to {user.email}")
except Exception as e: # noqa: EXC003
# Log error but don't fail signup
logger.error(f"Failed to send welcome email to {user.email}: {e}")
# =========================================================================
# Signup Completion
# =========================================================================
def complete_signup(
self,
db: Session,
session_id: str,
setup_intent_id: str,
) -> SignupCompletionResult:
"""
Complete signup after card collection.
Verifies the SetupIntent, attaches the payment method to the Stripe
customer, creates the Stripe Subscription with trial, and generates
a JWT token for automatic login.
Args:
db: Database session
session_id: Signup session ID
setup_intent_id: Stripe SetupIntent ID
Returns:
SignupCompletionResult
Raises:
ResourceNotFoundException: If session not found
ValidationException: If signup incomplete or payment failed
"""
from app.modules.billing.models import SubscriptionTier, TierCode
session = self.get_session_or_raise(session_id)
# Guard against completing signup more than once
if session.get("step") == "completed":
raise ValidationException(
message="Signup already completed.",
field="session_id",
)
store_id = session.get("store_id")
stripe_customer_id = session.get("stripe_customer_id")
if not store_id or not stripe_customer_id:
raise ValidationException(
message="Incomplete signup. Please start again.",
field="session_id",
)
# Retrieve SetupIntent to get payment method
setup_intent = stripe_service.get_setup_intent(setup_intent_id)
if setup_intent.status != "succeeded":
raise ValidationException(
message="Card setup not completed. Please try again.",
field="setup_intent_id",
)
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
subscription = sub_service.get_subscription_for_store(db, store_id)
if subscription:
subscription.stripe_payment_method_id = payment_method_id
# Create the actual Stripe Subscription with trial period
# This is what enables automatic charging after trial ends
if subscription.tier_id:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.id == subscription.tier_id)
.first()
)
if tier:
price_id = (
tier.stripe_price_annual_id
if subscription.is_annual and tier.stripe_price_annual_id
else tier.stripe_price_monthly_id
)
if price_id:
stripe_sub = stripe_service.create_subscription_with_trial(
customer_id=stripe_customer_id,
price_id=price_id,
trial_days=settings.stripe_trial_days,
metadata={
"merchant_id": str(subscription.merchant_id),
"platform_id": str(subscription.platform_id),
"tier_code": tier.code,
},
)
subscription.stripe_subscription_id = stripe_sub.id
logger.info(
f"Created Stripe subscription {stripe_sub.id} "
f"for merchant {subscription.merchant_id}"
)
db.commit() # SVC-006 - Finalize signup needs commit
# Get store info
from app.modules.tenancy.models import Store, User
store = db.query(Store).filter(Store.id == store_id).first()
store_code = store.store_code if store else session.get("store_code")
trial_ends_at = (
subscription.trial_ends_at
if subscription
else datetime.now(UTC) + timedelta(days=30)
)
# Get user for welcome email and token generation
user_id = session.get("user_id")
user = (
db.query(User).filter(User.id == user_id).first() if user_id else None
)
# Generate access token for automatic login after signup
access_token = None
if user and store:
# Create store-scoped JWT token (user is owner since they just signed up)
token_data = self.auth_manager.create_access_token(
user=user,
store_id=store.id,
store_code=store.store_code,
store_role="Owner", # New signup is always the owner
)
access_token = token_data["access_token"]
logger.info(f"Generated access token for new store user {user.email}")
# Send welcome email
if user and store:
tier_code = session.get("tier_code", TierCode.ESSENTIAL.value)
self.send_welcome_email(db, user, store, tier_code)
# Determine redirect based on platform
redirect_url = self._get_post_signup_redirect(db, session, store_code)
# Clean up session
self.delete_session(session_id)
logger.info(f"Completed signup for store {store_id}")
return SignupCompletionResult(
success=True,
store_code=store_code,
store_id=store_id,
redirect_url=redirect_url,
trial_ends_at=trial_ends_at.isoformat(),
access_token=access_token,
)
def _get_post_signup_redirect(
self, db: Session, session: dict, store_code: str
) -> str:
"""
Determine redirect URL after signup based on platform.
Marketplace platforms → onboarding wizard.
Other platforms (loyalty, etc.) → dashboard.
"""
from app.modules.service import module_service
platform_id = session.get("platform_id")
if platform_id:
try:
if module_service.is_module_enabled(db, platform_id, "marketplace"):
return f"/store/{store_code}/onboarding"
except Exception:
pass # If check fails, default to dashboard
return f"/store/{store_code}/dashboard"
# Singleton instance
signup_service = SignupService()

View File

@@ -93,6 +93,38 @@ class StripeService:
) )
return customer.id return customer.id
def create_customer_for_merchant(
self,
merchant,
email: str,
name: str | None = None,
metadata: dict | None = None,
) -> str:
"""
Create a Stripe customer for a merchant (before store exists).
Used during signup when the store hasn't been created yet.
Returns the Stripe customer ID.
"""
self._check_configured()
customer_metadata = {
"merchant_id": str(merchant.id),
"merchant_name": merchant.name,
**(metadata or {}),
}
customer = stripe.Customer.create(
email=email,
name=name or merchant.name,
metadata=customer_metadata,
)
logger.info(
f"Created Stripe customer {customer.id} for merchant {merchant.name}"
)
return customer.id
def get_customer(self, customer_id: str) -> stripe.Customer: def get_customer(self, customer_id: str) -> stripe.Customer:
"""Get a Stripe customer by ID.""" """Get a Stripe customer by ID."""
self._check_configured() self._check_configured()

View File

@@ -0,0 +1,520 @@
{# app/templates/platform/signup-loyalty.html #}
{# Loyalty Platform Signup Wizard — 4 steps: Plan → Account → Store → Payment #}
{% extends "platform/base.html" %}
{% block title %}Start Your Free Trial - RewardFlow{% endblock %}
{% block extra_head %}
{# Stripe.js for payment #}
<script defer src="https://js.stripe.com/v3/"></script>
{% endblock %}
{% block content %}
<div x-data="loyaltySignupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
{# Progress Steps #}
<div class="mb-12">
<div class="flex items-center justify-between">
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Set Up Store', 'Payment']" :key="index">
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
<template x-if="currentStep > index + 1">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</template>
<template x-if="currentStep <= index + 1">
<span x-text="index + 1"></span>
</template>
</div>
<span class="ml-2 text-sm font-medium hidden sm:inline"
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
x-text="stepName"></span>
<template x-if="index < 3">
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-full bg-indigo-600 rounded transition-all"
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
</div>
</template>
</div>
</template>
</div>
</div>
{# Form Card #}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{# ===============================================================
STEP 1: SELECT PLAN
=============================================================== #}
<div x-show="currentStep === 1" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
{# Billing Toggle #}
<div class="flex items-center justify-center mb-8 space-x-4">
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
<button @click="isAnnual = !isAnnual"
class="relative w-12 h-6 rounded-full transition-colors"
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
:class="isAnnual ? 'translate-x-6' : ''"></span>
</button>
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
Annual <span class="text-green-600 text-xs">Save 17%</span>
</span>
</div>
{# Tier Options #}
<div class="space-y-4">
{% for tier in tiers %}
{% if not tier.is_enterprise %}
<label class="block">
<input type="radio" name="tier" value="{{ tier.code }}"
x-model="selectedTier" class="hidden peer"/>
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
border-gray-200 dark:border-gray-700 hover:border-gray-300">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
<p class="text-sm text-gray-500">
{% if tier.products_limit %}{{ tier.products_limit }} loyalty programs{% else %}Unlimited{% endif %}
&bull;
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
</p>
</div>
<div class="text-right">
<template x-if="!isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
</template>
<template x-if="isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
</template>
</div>
</div>
</div>
</label>
{% endif %}
{% endfor %}
</div>
{# Free Trial Note #}
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-sm text-green-800 dark:text-green-300">
<strong>{{ trial_days }}-day free trial.</strong>
We'll collect your payment info, but you won't be charged until the trial ends.
</p>
</div>
<button @click="startSignup()"
:disabled="!selectedTier || loading"
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
Continue
</button>
</div>
{# ===============================================================
STEP 2: CREATE ACCOUNT
=============================================================== #}
<div x-show="currentStep === 2" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span class="text-red-500">*</span> Required fields
</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.firstName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.lastName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.merchantName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="account.email" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-red-500">*</span>
</label>
<input type="password" x-model="account.password" required minlength="8"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<template x-if="accountError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createAccount()"
:disabled="loading || !isAccountValid()"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue
</button>
</div>
</div>
{# ===============================================================
STEP 3: SET UP STORE
=============================================================== #}
<div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Set Up Your Store</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">Your store is where your loyalty programs live. You can change these settings later.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Store Name
</label>
<input type="text" x-model="storeName"
:placeholder="account.merchantName || 'My Store'"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Defaults to your business name if left empty</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Store Language
</label>
<select x-model="storeLanguage"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="lb">Lëtzebuergesch</option>
</select>
</div>
<template x-if="storeError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="storeError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 2"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createStore()"
:disabled="loading"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue to Payment
</button>
</div>
</div>
{# ===============================================================
STEP 4: PAYMENT
=============================================================== #}
<div x-show="currentStep === 4" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
{# Stripe Card Element #}
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 3"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="submitPayment()"
:disabled="loading || paymentProcessing"
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
<template x-if="paymentProcessing">
<span>Processing...</span>
</template>
<template x-if="!paymentProcessing">
<span>Start Free Trial</span>
</template>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function loyaltySignupWizard() {
return {
currentStep: 1,
loading: false,
sessionId: null,
platformCode: '{{ platform.code if platform else "loyalty" }}',
// Step 1: Plan
selectedTier: '{{ selected_tier or "professional" }}',
isAnnual: {{ 'true' if is_annual else 'false' }},
// Step 2: Account
account: {
firstName: '',
lastName: '',
merchantName: '',
email: '',
password: ''
},
accountError: null,
// Step 3: Store
storeName: '',
storeLanguage: 'fr',
storeError: null,
// Step 4: Payment
stripe: null,
cardElement: null,
paymentProcessing: false,
clientSecret: null,
init() {
// Check URL params for pre-selection
const params = new URLSearchParams(window.location.search);
if (params.get('tier')) {
this.selectedTier = params.get('tier');
}
if (params.get('annual') === 'true') {
this.isAnnual = true;
}
// Initialize Stripe when we get to step 4
this.$watch('currentStep', (step) => {
if (step === 4) {
this.initStripe();
}
});
},
async startSignup() {
this.loading = true;
try {
const response = await fetch('/api/v1/platform/signup/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier_code: this.selectedTier,
is_annual: this.isAnnual,
platform_code: this.platformCode
})
});
const data = await response.json();
if (response.ok) {
this.sessionId = data.session_id;
this.currentStep = 2;
} else {
alert(data.detail || 'Failed to start signup');
}
} catch (error) {
console.error('Error:', error);
alert('Failed to start signup. Please try again.');
} finally {
this.loading = false;
}
},
isAccountValid() {
return this.account.firstName.trim() &&
this.account.lastName.trim() &&
this.account.merchantName.trim() &&
this.account.email.trim() &&
this.account.password.length >= 8;
},
async createAccount() {
this.loading = true;
this.accountError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
email: this.account.email,
password: this.account.password,
first_name: this.account.firstName,
last_name: this.account.lastName,
merchant_name: this.account.merchantName
})
});
const data = await response.json();
if (response.ok) {
// Default store name to merchant name
if (!this.storeName) {
this.storeName = this.account.merchantName;
}
this.currentStep = 3;
} else {
this.accountError = data.detail || 'Failed to create account';
}
} catch (error) {
console.error('Error:', error);
this.accountError = 'Failed to create account. Please try again.';
} finally {
this.loading = false;
}
},
async createStore() {
this.loading = true;
this.storeError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
store_name: this.storeName || null,
language: this.storeLanguage
})
});
const data = await response.json();
if (response.ok) {
this.currentStep = 4;
} else {
this.storeError = data.detail || 'Failed to create store';
}
} catch (error) {
console.error('Error:', error);
this.storeError = 'Failed to create store. Please try again.';
} finally {
this.loading = false;
}
},
async initStripe() {
{% if stripe_publishable_key %}
this.stripe = Stripe('{{ stripe_publishable_key }}');
const elements = this.stripe.elements();
this.cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#374151',
'::placeholder': { color: '#9CA3AF' }
}
}
});
this.cardElement.mount('#card-element');
this.cardElement.on('change', (event) => {
const displayError = document.getElementById('card-errors');
displayError.textContent = event.error ? event.error.message : '';
});
// Get SetupIntent
try {
const response = await fetch('/api/v1/platform/signup/setup-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId })
});
const data = await response.json();
if (response.ok) {
this.clientSecret = data.client_secret;
}
} catch (error) {
console.error('Error getting SetupIntent:', error);
}
{% else %}
console.warn('Stripe not configured');
{% endif %}
},
async submitPayment() {
if (!this.stripe || !this.clientSecret) {
alert('Payment not configured. Please contact support.');
return;
}
this.paymentProcessing = true;
try {
const { setupIntent, error } = await this.stripe.confirmCardSetup(
this.clientSecret,
{ payment_method: { card: this.cardElement } }
);
if (error) {
document.getElementById('card-errors').textContent = error.message;
this.paymentProcessing = false;
return;
}
// Complete signup
const response = await fetch('/api/v1/platform/signup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
setup_intent_id: setupIntent.id
})
});
const data = await response.json();
if (response.ok) {
// Store access token for automatic login
if (data.access_token) {
localStorage.setItem('store_token', data.access_token);
localStorage.setItem('storeCode', data.store_code);
}
window.location.href = '/signup/success?store_code=' + data.store_code;
} else {
alert(data.detail || 'Failed to complete signup');
}
} catch (error) {
console.error('Payment error:', error);
alert('Payment failed. Please try again.');
} finally {
this.paymentProcessing = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -326,7 +326,8 @@ function signupWizard() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
tier_code: this.selectedTier, tier_code: this.selectedTier,
is_annual: this.isAnnual is_annual: this.isAnnual,
platform_code: '{{ platform.code }}'
}) })
}); });

View File

@@ -34,13 +34,6 @@ from app.modules.marketplace.services.onboarding_service import (
OnboardingService, OnboardingService,
get_onboarding_service, get_onboarding_service,
) )
from app.modules.marketplace.services.platform_signup_service import (
AccountCreationResult,
PlatformSignupService,
SignupCompletionResult,
SignupSessionData,
platform_signup_service,
)
__all__ = [ __all__ = [
# Export service # Export service
@@ -55,12 +48,6 @@ __all__ = [
# Onboarding service # Onboarding service
"OnboardingService", "OnboardingService",
"get_onboarding_service", "get_onboarding_service",
# Platform signup service
"PlatformSignupService",
"platform_signup_service",
"SignupSessionData",
"AccountCreationResult",
"SignupCompletionResult",
# Letzshop services # Letzshop services
"LetzshopClient", "LetzshopClient",
"LetzshopClientError", "LetzshopClientError",

View File

@@ -1,191 +1,36 @@
# app/modules/marketplace/services/platform_signup_service.py # app/modules/marketplace/services/platform_signup_service.py
""" """
Platform signup service. OMS-specific signup extensions.
Handles all database operations for the platform signup flow: The core signup service has moved to app.modules.billing.services.signup_service.
- Session management This file retains OMS-specific logic (Letzshop store claiming) and provides
- Store claiming backwards-compatible re-exports.
- Account creation
- Subscription setup
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.exceptions import ConflictException
from app.exceptions import ( from app.modules.billing.services.signup_service import (
ConflictException, AccountCreationResult,
ResourceNotFoundException, SignupCompletionResult,
ValidationException, SignupService,
SignupSessionData,
StoreCreationResult,
signup_service,
) )
from app.modules.billing.services.stripe_service import stripe_service
from app.modules.billing.services.subscription_service import (
subscription_service as sub_service,
)
from app.modules.marketplace.exceptions import OnboardingAlreadyCompletedException
from app.modules.marketplace.services.onboarding_service import OnboardingService
from app.modules.messaging.services.email_service import EmailService
from middleware.auth import AuthManager
if TYPE_CHECKING:
from app.modules.tenancy.models import Store, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ============================================================================= class OmsSignupService:
# In-memory signup session storage """OMS-specific signup extensions (Letzshop store claiming)."""
# In production, use Redis or database table
# =============================================================================
_signup_sessions: dict[str, dict] = {} def __init__(self, base_service: SignupService):
self._base = base_service
def _create_session_id() -> str:
"""Generate a secure session ID."""
return secrets.token_urlsafe(32)
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class SignupSessionData:
"""Data stored in a signup session."""
session_id: str
step: str
tier_code: str
is_annual: bool
created_at: str
updated_at: str | None = None
letzshop_slug: str | None = None
letzshop_store_id: str | None = None
store_name: str | None = None
user_id: int | None = None
store_id: int | None = None
store_code: str | None = None
stripe_customer_id: str | None = None
setup_intent_id: str | None = None
@dataclass
class AccountCreationResult:
"""Result of account creation."""
user_id: int
store_id: int
store_code: str
stripe_customer_id: str
@dataclass
class SignupCompletionResult:
"""Result of signup completion."""
success: bool
store_code: str
store_id: int
redirect_url: str
trial_ends_at: str
access_token: str | None = None # JWT token for automatic login
# =============================================================================
# Platform Signup Service
# =============================================================================
class PlatformSignupService:
"""Service for handling platform signup operations."""
def __init__(self):
self.auth_manager = AuthManager()
# =========================================================================
# Session Management
# =========================================================================
def create_session(self, tier_code: str, is_annual: bool) -> str:
"""
Create a new signup session.
Args:
tier_code: The subscription tier code
is_annual: Whether annual billing is selected
Returns:
The session ID
Raises:
ValidationException: If tier code is invalid
"""
# Validate tier code
from app.modules.billing.models import TierCode
try:
tier = TierCode(tier_code)
except ValueError:
raise ValidationException(
message=f"Invalid tier code: {tier_code}",
field="tier_code",
)
session_id = _create_session_id()
now = datetime.now(UTC).isoformat()
_signup_sessions[session_id] = {
"step": "tier_selected",
"tier_code": tier.value,
"is_annual": is_annual,
"created_at": now,
"updated_at": now,
}
logger.info(f"Created signup session {session_id} for tier {tier.value}")
return session_id
def get_session(self, session_id: str) -> dict | None:
"""Get a signup session by ID."""
return _signup_sessions.get(session_id)
def get_session_or_raise(self, session_id: str) -> dict:
"""
Get a signup session or raise an exception.
Raises:
ResourceNotFoundException: If session not found
"""
session = self.get_session(session_id)
if not session:
raise ResourceNotFoundException(
resource_type="SignupSession",
identifier=session_id,
)
return session
def update_session(self, session_id: str, data: dict) -> None:
"""Update signup session data."""
session = self.get_session_or_raise(session_id)
session.update(data)
session["updated_at"] = datetime.now(UTC).isoformat()
_signup_sessions[session_id] = session
def delete_session(self, session_id: str) -> None:
"""Delete a signup session."""
_signup_sessions.pop(session_id, None)
# =========================================================================
# Store Claiming
# =========================================================================
def check_store_claimed(self, db: Session, letzshop_slug: str) -> bool: def check_store_claimed(self, db: Session, letzshop_slug: str) -> bool:
"""Check if a Letzshop store is already claimed.""" """Check if a Letzshop store is already claimed."""
@@ -201,7 +46,7 @@ class PlatformSignupService:
letzshop_store_id: str | None = None, letzshop_store_id: str | None = None,
) -> str: ) -> str:
""" """
Claim a Letzshop store for signup. Claim a Letzshop store for OMS signup.
Args: Args:
db: Database session db: Database session
@@ -216,19 +61,16 @@ class PlatformSignupService:
ResourceNotFoundException: If session not found ResourceNotFoundException: If session not found
ConflictException: If store already claimed ConflictException: If store already claimed
""" """
self.get_session_or_raise(session_id) self._base.get_session_or_raise(session_id)
# Check if store is already claimed
if self.check_store_claimed(db, letzshop_slug): if self.check_store_claimed(db, letzshop_slug):
raise ConflictException( raise ConflictException(
message="This Letzshop store is already claimed", message="This Letzshop store is already claimed",
) )
# Generate store name from slug
store_name = letzshop_slug.replace("-", " ").title() store_name = letzshop_slug.replace("-", " ").title()
# Update session self._base.update_session(session_id, {
self.update_session(session_id, {
"letzshop_slug": letzshop_slug, "letzshop_slug": letzshop_slug,
"letzshop_store_id": letzshop_store_id, "letzshop_store_id": letzshop_store_id,
"store_name": store_name, "store_name": store_name,
@@ -238,422 +80,21 @@ class PlatformSignupService:
logger.info(f"Claimed store {letzshop_slug} for session {session_id}") logger.info(f"Claimed store {letzshop_slug} for session {session_id}")
return store_name return store_name
# =========================================================================
# Account Creation
# =========================================================================
def check_email_exists(self, db: Session, email: str) -> bool: # Singleton
"""Check if an email already exists.""" oms_signup_service = OmsSignupService(signup_service)
from app.modules.tenancy.services.admin_service import admin_service
# Re-exports for backwards compatibility
return admin_service.get_user_by_email(db, email) is not None PlatformSignupService = SignupService
platform_signup_service = signup_service
def generate_unique_username(self, db: Session, email: str) -> str:
"""Generate a unique username from email.""" __all__ = [
from app.modules.tenancy.services.admin_service import admin_service "OmsSignupService",
"oms_signup_service",
username = email.split("@")[0] "PlatformSignupService",
base_username = username "platform_signup_service",
counter = 1 "AccountCreationResult",
while admin_service.get_user_by_username(db, username): "SignupCompletionResult",
username = f"{base_username}_{counter}" "SignupSessionData",
counter += 1 "StoreCreationResult",
return username ]
def generate_unique_store_code(self, db: Session, merchant_name: str) -> str:
"""Generate a unique store code from merchant name."""
from app.modules.tenancy.services.store_service import store_service
store_code = merchant_name.upper().replace(" ", "_")[:20]
base_code = store_code
counter = 1
while store_service.is_store_code_taken(db, store_code):
store_code = f"{base_code}_{counter}"
counter += 1
return store_code
def generate_unique_subdomain(self, db: Session, merchant_name: str) -> str:
"""Generate a unique subdomain from merchant name."""
from app.modules.tenancy.services.store_service import store_service
subdomain = merchant_name.lower().replace(" ", "-")
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
base_subdomain = subdomain
counter = 1
while store_service.is_subdomain_taken(db, subdomain):
subdomain = f"{base_subdomain}-{counter}"
counter += 1
return subdomain
def create_account(
self,
db: Session,
session_id: str,
email: str,
password: str,
first_name: str,
last_name: str,
merchant_name: str,
phone: str | None = None,
) -> AccountCreationResult:
"""
Create user, merchant, store, and Stripe customer.
Args:
db: Database session
session_id: Signup session ID
email: User email
password: User password
first_name: User first name
last_name: User last name
merchant_name: Merchant name
phone: Optional phone number
Returns:
AccountCreationResult with IDs
Raises:
ResourceNotFoundException: If session not found
ConflictException: If email already exists
"""
session = self.get_session_or_raise(session_id)
# Check if email already exists
if self.check_email_exists(db, email):
raise ConflictException(
message="An account with this email already exists",
)
# Generate unique username
username = self.generate_unique_username(db, email)
# Create User
from app.modules.tenancy.models import Merchant, Store, User
user = User(
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role="merchant_owner",
is_active=True,
)
db.add(user)
db.flush()
# Create Merchant
merchant = Merchant(
name=merchant_name,
owner_user_id=user.id,
contact_email=email,
contact_phone=phone,
)
db.add(merchant)
db.flush()
# Generate unique store code and subdomain
store_code = self.generate_unique_store_code(db, merchant_name)
subdomain = self.generate_unique_subdomain(db, merchant_name)
# Create Store
store = Store(
merchant_id=merchant.id,
store_code=store_code,
subdomain=subdomain,
name=merchant_name,
contact_email=email,
contact_phone=phone,
is_active=True,
letzshop_store_slug=session.get("letzshop_slug"),
letzshop_store_id=session.get("letzshop_store_id"),
)
db.add(store)
db.flush()
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
# Create StoreOnboarding record
onboarding_service = OnboardingService(db)
onboarding_service.create_onboarding(store.id)
# Create Stripe Customer
stripe_customer_id = stripe_service.create_customer(
store=store,
email=email,
name=f"{first_name} {last_name}",
metadata={
"merchant_name": merchant_name,
"tier": session.get("tier_code"),
},
)
# Get platform_id for the subscription
from app.modules.tenancy.services.platform_service import platform_service
primary_pid = platform_service.get_primary_platform_id_for_store(db, store.id)
if primary_pid:
platform_id = primary_pid
else:
default_platform = platform_service.get_default_platform(db)
platform_id = default_platform.id if default_platform else 1
# Create MerchantSubscription (trial status)
subscription = sub_service.create_merchant_subscription(
db=db,
merchant_id=merchant.id,
platform_id=platform_id,
tier_code=session.get("tier_code", "essential"),
trial_days=settings.stripe_trial_days,
is_annual=session.get("is_annual", False),
)
subscription.stripe_customer_id = stripe_customer_id
db.commit() # SVC-006 - Atomic account creation needs commit
# Update session
self.update_session(session_id, {
"user_id": user.id,
"store_id": store.id,
"store_code": store_code,
"merchant_id": merchant.id,
"platform_id": platform_id,
"stripe_customer_id": stripe_customer_id,
"step": "account_created",
})
logger.info(
f"Created account for {email}: user_id={user.id}, store_id={store.id}"
)
return AccountCreationResult(
user_id=user.id,
store_id=store.id,
store_code=store_code,
stripe_customer_id=stripe_customer_id,
)
# =========================================================================
# Payment Setup
# =========================================================================
def setup_payment(self, session_id: str) -> tuple[str, str]:
"""
Create Stripe SetupIntent for card collection.
Args:
session_id: Signup session ID
Returns:
Tuple of (client_secret, stripe_customer_id)
Raises:
EntityNotFoundException: If session not found
ValidationException: If account not created yet
"""
session = self.get_session_or_raise(session_id)
if "stripe_customer_id" not in session:
raise ValidationException(
message="Account not created. Please complete step 3 first.",
field="session_id",
)
stripe_customer_id = session["stripe_customer_id"]
# Create SetupIntent
setup_intent = stripe_service.create_setup_intent(
customer_id=stripe_customer_id,
metadata={
"session_id": session_id,
"store_id": str(session.get("store_id")),
"tier": session.get("tier_code"),
},
)
# Update session
self.update_session(session_id, {
"setup_intent_id": setup_intent.id,
"step": "payment_pending",
})
logger.info(f"Created SetupIntent {setup_intent.id} for session {session_id}")
return setup_intent.client_secret, stripe_customer_id
# =========================================================================
# Welcome Email
# =========================================================================
def send_welcome_email(
self,
db: Session,
user: User,
store: Store,
tier_code: str,
language: str = "fr",
) -> None:
"""
Send welcome email to new store.
Args:
db: Database session
user: User who signed up
store: Store that was created
tier_code: Selected tier code
language: Language for email (default: French)
"""
try:
# Get tier name
from app.modules.billing.services.billing_service import billing_service
tier = billing_service.get_tier_by_code(db, tier_code)
tier_name = tier.name if tier else tier_code.title()
# Build login URL
login_url = f"https://{settings.platform_domain}/store/{store.store_code}/dashboard"
email_service = EmailService(db)
email_service.send_template(
template_code="signup_welcome",
language=language,
to_email=user.email,
to_name=f"{user.first_name} {user.last_name}",
variables={
"first_name": user.first_name,
"merchant_name": store.name,
"email": user.email,
"store_code": store.store_code,
"login_url": login_url,
"trial_days": settings.stripe_trial_days,
"tier_name": tier_name,
},
store_id=store.id,
user_id=user.id,
related_type="signup",
)
logger.info(f"Welcome email sent to {user.email}")
except Exception as e: # noqa: EXC003
# Log error but don't fail signup
logger.error(f"Failed to send welcome email to {user.email}: {e}")
# =========================================================================
# Signup Completion
# =========================================================================
def complete_signup(
self,
db: Session,
session_id: str,
setup_intent_id: str,
) -> SignupCompletionResult:
"""
Complete signup after card collection.
Args:
db: Database session
session_id: Signup session ID
setup_intent_id: Stripe SetupIntent ID
Returns:
SignupCompletionResult
Raises:
EntityNotFoundException: If session not found
ValidationException: If signup incomplete or payment failed
"""
session = self.get_session_or_raise(session_id)
# Guard against completing signup more than once
if session.get("step") == "completed":
raise OnboardingAlreadyCompletedException(
store_id=session.get("store_id", 0),
)
store_id = session.get("store_id")
stripe_customer_id = session.get("stripe_customer_id")
if not store_id or not stripe_customer_id:
raise ValidationException(
message="Incomplete signup. Please start again.",
field="session_id",
)
# Retrieve SetupIntent to get payment method
setup_intent = stripe_service.get_setup_intent(setup_intent_id)
if setup_intent.status != "succeeded":
raise ValidationException(
message="Card setup not completed. Please try again.",
field="setup_intent_id",
)
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
subscription = sub_service.get_subscription_for_store(db, store_id)
if subscription:
subscription.card_collected_at = datetime.now(UTC)
subscription.stripe_payment_method_id = payment_method_id
db.commit() # SVC-006 - Finalize signup needs commit
# Get store info
store = db.query(Store).filter(Store.id == store_id).first()
store_code = store.store_code if store else session.get("store_code")
trial_ends_at = (
subscription.trial_ends_at
if subscription
else datetime.now(UTC) + timedelta(days=30)
)
# Get user for welcome email and token generation
user_id = session.get("user_id")
user = db.query(User).filter(User.id == user_id).first() if user_id else None
# Generate access token for automatic login after signup
access_token = None
if user and store:
# Create store-scoped JWT token (user is owner since they just signed up)
token_data = self.auth_manager.create_access_token(
user=user,
store_id=store.id,
store_code=store.store_code,
store_role="Owner", # New signup is always the owner
)
access_token = token_data["access_token"]
logger.info(f"Generated access token for new store user {user.email}")
# Send welcome email
if user and store:
tier_code = session.get("tier_code", TierCode.ESSENTIAL.value)
self.send_welcome_email(db, user, store, tier_code)
# Clean up session
self.delete_session(session_id)
logger.info(f"Completed signup for store {store_id}")
# Redirect to onboarding instead of dashboard
return SignupCompletionResult(
success=True,
store_code=store_code,
store_id=store_id,
redirect_url=f"/store/{store_code}/onboarding",
trial_ends_at=trial_ends_at.isoformat(),
access_token=access_token,
)
# Singleton instance
platform_signup_service = PlatformSignupService()

View File

@@ -1,19 +1,17 @@
"""Unit tests for PlatformSignupService.""" """Unit tests for PlatformSignupService (now in billing module)."""
import pytest import pytest
from app.modules.marketplace.services.platform_signup_service import ( from app.modules.billing.services.signup_service import SignupService
PlatformSignupService,
)
@pytest.mark.unit @pytest.mark.unit
@pytest.mark.marketplace @pytest.mark.marketplace
class TestPlatformSignupService: class TestPlatformSignupService:
"""Test suite for PlatformSignupService.""" """Test suite for SignupService (moved from marketplace to billing)."""
def setup_method(self): def setup_method(self):
self.service = PlatformSignupService() self.service = SignupService()
def test_service_instantiation(self): def test_service_instantiation(self):
"""Service can be instantiated.""" """Service can be instantiated."""

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
Sync Subscription Tiers to Stripe.
Creates Stripe Products and Prices for all subscription tiers that don't
have them yet. Safe to run multiple times (idempotent).
Usage:
python scripts/seed/sync_stripe_products.py
"""
import sys
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from app.core.config import settings
from app.core.database import SessionLocal
from app.modules.billing.models.subscription import SubscriptionTier
from app.modules.billing.services.admin_subscription_service import (
AdminSubscriptionService,
)
def main():
if not settings.stripe_secret_key:
print("ERROR: STRIPE_SECRET_KEY not configured. Set it in .env")
sys.exit(1)
db = SessionLocal()
try:
tiers = db.query(SubscriptionTier).order_by(
SubscriptionTier.platform_id,
SubscriptionTier.display_order,
).all()
if not tiers:
print("No subscription tiers found in database.")
return
print(f"Found {len(tiers)} tiers to sync.\n")
synced = 0
skipped = 0
for tier in tiers:
already_synced = (
tier.stripe_product_id
and tier.stripe_price_monthly_id
and (tier.stripe_price_annual_id or not tier.price_annual_cents)
)
print(f" [{tier.code}] {tier.name} (platform_id={tier.platform_id})")
if already_synced:
print(f" -> Already synced (product={tier.stripe_product_id})")
skipped += 1
continue
AdminSubscriptionService._sync_tier_to_stripe(db, tier)
db.commit()
synced += 1
print(f" -> Product: {tier.stripe_product_id}")
print(f" -> Monthly: {tier.stripe_price_monthly_id}")
if tier.stripe_price_annual_id:
print(f" -> Annual: {tier.stripe_price_annual_id}")
print(f"\nDone. Synced: {synced}, Already up-to-date: {skipped}")
finally:
db.close()
if __name__ == "__main__":
main()