From 9684747d0865c31acc2ef1ba750464d68311f312 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 28 Feb 2026 19:16:14 +0100 Subject: [PATCH] feat(billing): end-to-end Stripe subscription signup with platform enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/v1/platform/signup.py | 128 +-- app/modules/billing/routes/pages/platform.py | 47 +- app/modules/billing/services/__init__.py | 6 + .../services/admin_subscription_service.py | 150 ++++ .../billing/services/signup_service.py | 797 ++++++++++++++++++ .../billing/services/stripe_service.py | 32 + .../billing/platform/signup-loyalty.html | 520 ++++++++++++ .../templates/billing/platform/signup.html | 3 +- app/modules/marketplace/services/__init__.py | 13 - .../services/platform_signup_service.py | 631 +------------- .../unit/test_platform_signup_service.py | 10 +- scripts/seed/sync_stripe_products.py | 75 ++ 12 files changed, 1723 insertions(+), 689 deletions(-) create mode 100644 app/modules/billing/services/signup_service.py create mode 100644 app/modules/billing/templates/billing/platform/signup-loyalty.html create mode 100644 scripts/seed/sync_stripe_products.py diff --git a/app/api/v1/platform/signup.py b/app/api/v1/platform/signup.py index 234ec2c4..9cbb4edb 100644 --- a/app/api/v1/platform/signup.py +++ b/app/api/v1/platform/signup.py @@ -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 { diff --git a/app/modules/billing/routes/pages/platform.py b/app/modules/billing/routes/pages/platform.py index 1e2155cc..cdb157d4 100644 --- a/app/modules/billing/routes/pages/platform.py +++ b/app/modules/billing/routes/pages/platform.py @@ -8,7 +8,7 @@ Platform (unauthenticated) pages for pricing and signup: - Signup success """ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session @@ -16,22 +16,30 @@ from app.core.database import get_db from app.modules.core.utils.page_context import get_platform_context from app.templates_config import templates + +def _require_platform(request: Request): + """Get the current platform or raise 404. Platform must always be known.""" + platform = getattr(request.state, "platform", None) + if not platform: + raise HTTPException( + status_code=404, + detail="Platform not detected. Pricing and signup require a known platform.", + ) + return platform + router = APIRouter() -def _get_tiers_data(db: Session) -> list[dict]: +def _get_tiers_data(db: Session, platform_id: int) -> list[dict]: """Build tier data for display in templates from database.""" from app.modules.billing.models import SubscriptionTier, TierCode - tiers_db = ( - db.query(SubscriptionTier) - .filter( - SubscriptionTier.is_active == True, - SubscriptionTier.is_public == True, - ) - .order_by(SubscriptionTier.display_order) - .all() + query = db.query(SubscriptionTier).filter( + SubscriptionTier.is_active == True, + SubscriptionTier.is_public == True, + SubscriptionTier.platform_id == platform_id, ) + tiers_db = query.order_by(SubscriptionTier.display_order).all() tiers = [] for tier in tiers_db: @@ -63,9 +71,12 @@ async def pricing_page( ): """ Standalone pricing page with detailed tier comparison. + + Tiers are filtered by the current platform (detected from domain/path). """ + platform = _require_platform(request) context = get_platform_context(request, db) - context["tiers"] = _get_tiers_data(db) + context["tiers"] = _get_tiers_data(db, platform_id=platform.id) context["page_title"] = "Pricing" return templates.TemplateResponse( @@ -89,18 +100,28 @@ async def signup_page( """ Multi-step signup wizard. + Routes to platform-specific signup templates. Each platform defines + its own signup flow (different steps, different UI). + Query params: - tier: Pre-selected tier code - annual: Pre-select annual billing """ + platform = _require_platform(request) context = get_platform_context(request, db) context["page_title"] = "Start Your Free Trial" context["selected_tier"] = tier context["is_annual"] = annual - context["tiers"] = _get_tiers_data(db) + context["tiers"] = _get_tiers_data(db, platform_id=platform.id) + + # Route to platform-specific signup template + if platform.code == "loyalty": + template_name = "billing/platform/signup-loyalty.html" + else: + template_name = "billing/platform/signup.html" return templates.TemplateResponse( - "billing/platform/signup.html", + template_name, context, ) diff --git a/app/modules/billing/services/__init__.py b/app/modules/billing/services/__init__.py index dcf64902..a9341487 100644 --- a/app/modules/billing/services/__init__.py +++ b/app/modules/billing/services/__init__.py @@ -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", ] diff --git a/app/modules/billing/services/admin_subscription_service.py b/app/modules/billing/services/admin_subscription_service.py index 254dc8d0..b7730666 100644 --- a/app/modules/billing/services/admin_subscription_service.py +++ b/app/modules/billing/services/admin_subscription_service.py @@ -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 diff --git a/app/modules/billing/services/signup_service.py b/app/modules/billing/services/signup_service.py new file mode 100644 index 00000000..63fa7560 --- /dev/null +++ b/app/modules/billing/services/signup_service.py @@ -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() diff --git a/app/modules/billing/services/stripe_service.py b/app/modules/billing/services/stripe_service.py index 425481ba..4a04ca02 100644 --- a/app/modules/billing/services/stripe_service.py +++ b/app/modules/billing/services/stripe_service.py @@ -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() diff --git a/app/modules/billing/templates/billing/platform/signup-loyalty.html b/app/modules/billing/templates/billing/platform/signup-loyalty.html new file mode 100644 index 00000000..204a3687 --- /dev/null +++ b/app/modules/billing/templates/billing/platform/signup-loyalty.html @@ -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 #} + +{% endblock %} + +{% block content %} +
+
+ + {# Progress Steps #} +
+
+ +
+
+ + {# Form Card #} +
+ + {# =============================================================== + STEP 1: SELECT PLAN + =============================================================== #} +
+

Choose Your Plan

+ + {# Billing Toggle #} +
+ Monthly + + + Annual Save 17% + +
+ + {# Tier Options #} +
+ {% for tier in tiers %} + {% if not tier.is_enterprise %} + + {% endif %} + {% endfor %} +
+ + {# Free Trial Note #} +
+

+ {{ trial_days }}-day free trial. + We'll collect your payment info, but you won't be charged until the trial ends. +

+
+ + +
+ + {# =============================================================== + STEP 2: CREATE ACCOUNT + =============================================================== #} +
+

Create Your Account

+

+ * Required fields +

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +

Minimum 8 characters

+
+ + +
+ +
+ + +
+
+ + {# =============================================================== + STEP 3: SET UP STORE + =============================================================== #} +
+

Set Up Your Store

+

Your store is where your loyalty programs live. You can change these settings later.

+ +
+
+ + +

Defaults to your business name if left empty

+
+ +
+ + +
+ + +
+ +
+ + +
+
+ + {# =============================================================== + STEP 4: PAYMENT + =============================================================== #} +
+

Add Payment Method

+

You won't be charged until your {{ trial_days }}-day trial ends.

+ + {# Stripe Card Element #} +
+
+ +
+ + +
+
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/billing/templates/billing/platform/signup.html b/app/modules/billing/templates/billing/platform/signup.html index b7233207..80a7004c 100644 --- a/app/modules/billing/templates/billing/platform/signup.html +++ b/app/modules/billing/templates/billing/platform/signup.html @@ -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 }}' }) }); diff --git a/app/modules/marketplace/services/__init__.py b/app/modules/marketplace/services/__init__.py index 0cfb8477..8668ddd0 100644 --- a/app/modules/marketplace/services/__init__.py +++ b/app/modules/marketplace/services/__init__.py @@ -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", diff --git a/app/modules/marketplace/services/platform_signup_service.py b/app/modules/marketplace/services/platform_signup_service.py index 001be4f2..42f719dd 100644 --- a/app/modules/marketplace/services/platform_signup_service.py +++ b/app/modules/marketplace/services/platform_signup_service.py @@ -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", +] diff --git a/app/modules/marketplace/tests/unit/test_platform_signup_service.py b/app/modules/marketplace/tests/unit/test_platform_signup_service.py index edc70431..b267ad09 100644 --- a/app/modules/marketplace/tests/unit/test_platform_signup_service.py +++ b/app/modules/marketplace/tests/unit/test_platform_signup_service.py @@ -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.""" diff --git a/scripts/seed/sync_stripe_products.py b/scripts/seed/sync_stripe_products.py new file mode 100644 index 00000000..7fd8ec46 --- /dev/null +++ b/scripts/seed/sync_stripe_products.py @@ -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()