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.
Handles the multi-step signup flow:
1. Start signup (select tier)
2. Claim Letzshop store (optional)
3. Create account
1. Start signup (select tier + platform)
2. Create account (user + merchant)
3. Create store
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).
"""
@@ -20,9 +23,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.marketplace.services.platform_signup_service import (
platform_signup_service,
)
from app.modules.billing.services.signup_service import signup_service
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -34,10 +35,11 @@ logger = logging.getLogger(__name__)
class SignupStartRequest(BaseModel):
"""Start signup - select tier."""
"""Start signup - select tier and platform."""
tier_code: str
is_annual: bool = False
platform_code: str
class SignupStartResponse(BaseModel):
@@ -46,26 +48,11 @@ class SignupStartResponse(BaseModel):
session_id: str
tier_code: str
is_annual: bool
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
platform_code: str
class CreateAccountRequest(BaseModel):
"""Create account."""
"""Create account (user + merchant)."""
session_id: str
email: EmailStr
@@ -81,10 +68,26 @@ class CreateAccountResponse(BaseModel):
session_id: str
user_id: int
store_id: int
merchant_id: int
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):
"""Request payment setup."""
@@ -127,43 +130,20 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
"""
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.
"""
session_id = platform_signup_service.create_session(
session_id = signup_service.create_session(
tier_code=request.tier_code,
is_annual=request.is_annual,
platform_code=request.platform_code,
)
return SignupStartResponse(
session_id=session_id,
tier_code=request.tier_code,
is_annual=request.is_annual,
)
@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,
platform_code=request.platform_code,
)
@@ -173,12 +153,13 @@ async def create_account(
db: Session = Depends(get_db),
) -> CreateAccountResponse:
"""
Create user and store accounts.
Create user and merchant accounts.
Step 3: User provides account details.
Creates User, Merchant, Store, and Stripe Customer.
Step 2: User provides account details.
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,
session_id=request.session_id,
email=request.email,
@@ -192,11 +173,36 @@ async def create_account(
return CreateAccountResponse(
session_id=request.session_id,
user_id=result.user_id,
store_id=result.store_id,
merchant_id=result.merchant_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
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.
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,
)
@@ -228,7 +234,7 @@ async def complete_signup(
Step 5: Verify SetupIntent, attach payment method, create subscription.
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,
session_id=request.session_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.
"""
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 {

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(
query = db.query(SubscriptionTier).filter(
SubscriptionTier.is_active == True,
SubscriptionTier.is_public == True,
SubscriptionTier.platform_id == platform_id,
)
.order_by(SubscriptionTier.display_order)
.all()
)
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,
)

View File

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

View File

@@ -35,6 +35,141 @@ logger = logging.getLogger(__name__)
class AdminSubscriptionService:
"""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
# =========================================================================
@@ -85,6 +220,9 @@ class AdminSubscriptionService:
tier = SubscriptionTier(**tier_data)
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}")
return tier
@@ -95,9 +233,21 @@ class AdminSubscriptionService:
"""Update a subscription tier."""
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():
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}")
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
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:
"""Get a Stripe customer by ID."""
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' },
body: JSON.stringify({
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,
get_onboarding_service,
)
from app.modules.marketplace.services.platform_signup_service import (
AccountCreationResult,
PlatformSignupService,
SignupCompletionResult,
SignupSessionData,
platform_signup_service,
)
__all__ = [
# Export service
@@ -55,12 +48,6 @@ __all__ = [
# Onboarding service
"OnboardingService",
"get_onboarding_service",
# Platform signup service
"PlatformSignupService",
"platform_signup_service",
"SignupSessionData",
"AccountCreationResult",
"SignupCompletionResult",
# Letzshop services
"LetzshopClient",
"LetzshopClientError",

View File

@@ -1,191 +1,36 @@
# app/modules/marketplace/services/platform_signup_service.py
"""
Platform signup service.
OMS-specific signup extensions.
Handles all database operations for the platform signup flow:
- Session management
- Store claiming
- Account creation
- Subscription setup
The core signup service has moved to app.modules.billing.services.signup_service.
This file retains OMS-specific logic (Letzshop store claiming) and provides
backwards-compatible re-exports.
"""
from __future__ import annotations
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 app.core.config import settings
from app.exceptions import (
ConflictException,
ResourceNotFoundException,
ValidationException,
from app.exceptions import ConflictException
from app.modules.billing.services.signup_service import (
AccountCreationResult,
SignupCompletionResult,
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__)
# =============================================================================
# In-memory signup session storage
# In production, use Redis or database table
# =============================================================================
class OmsSignupService:
"""OMS-specific signup extensions (Letzshop store claiming)."""
_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
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 __init__(self, base_service: SignupService):
self._base = base_service
def check_store_claimed(self, db: Session, letzshop_slug: str) -> bool:
"""Check if a Letzshop store is already claimed."""
@@ -201,7 +46,7 @@ class PlatformSignupService:
letzshop_store_id: str | None = None,
) -> str:
"""
Claim a Letzshop store for signup.
Claim a Letzshop store for OMS signup.
Args:
db: Database session
@@ -216,19 +61,16 @@ class PlatformSignupService:
ResourceNotFoundException: If session not found
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):
raise ConflictException(
message="This Letzshop store is already claimed",
)
# Generate store name from slug
store_name = letzshop_slug.replace("-", " ").title()
# Update session
self.update_session(session_id, {
self._base.update_session(session_id, {
"letzshop_slug": letzshop_slug,
"letzshop_store_id": letzshop_store_id,
"store_name": store_name,
@@ -238,422 +80,21 @@ class PlatformSignupService:
logger.info(f"Claimed store {letzshop_slug} for session {session_id}")
return store_name
# =========================================================================
# Account Creation
# =========================================================================
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, 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()
# Singleton
oms_signup_service = OmsSignupService(signup_service)
# Re-exports for backwards compatibility
PlatformSignupService = SignupService
platform_signup_service = signup_service
__all__ = [
"OmsSignupService",
"oms_signup_service",
"PlatformSignupService",
"platform_signup_service",
"AccountCreationResult",
"SignupCompletionResult",
"SignupSessionData",
"StoreCreationResult",
]

View File

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