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 {