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:
@@ -3,11 +3,14 @@
|
|||||||
Platform signup API endpoints.
|
Platform signup API endpoints.
|
||||||
|
|
||||||
Handles the multi-step signup flow:
|
Handles the multi-step signup flow:
|
||||||
1. Start signup (select tier)
|
1. Start signup (select tier + platform)
|
||||||
2. Claim Letzshop store (optional)
|
2. Create account (user + merchant)
|
||||||
3. Create account
|
3. Create store
|
||||||
4. Setup payment (collect card via SetupIntent)
|
4. Setup payment (collect card via SetupIntent)
|
||||||
5. Complete signup (create subscription with trial)
|
5. Complete signup (create Stripe subscription with trial)
|
||||||
|
|
||||||
|
Platform-specific steps (e.g., OMS Letzshop claiming) are handled
|
||||||
|
by their respective modules and call into this core flow.
|
||||||
|
|
||||||
All endpoints are public (no authentication required).
|
All endpoints are public (no authentication required).
|
||||||
"""
|
"""
|
||||||
@@ -20,9 +23,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.environment import should_use_secure_cookies
|
from app.core.environment import should_use_secure_cookies
|
||||||
from app.modules.marketplace.services.platform_signup_service import (
|
from app.modules.billing.services.signup_service import signup_service
|
||||||
platform_signup_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +35,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SignupStartRequest(BaseModel):
|
class SignupStartRequest(BaseModel):
|
||||||
"""Start signup - select tier."""
|
"""Start signup - select tier and platform."""
|
||||||
|
|
||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool = False
|
is_annual: bool = False
|
||||||
|
platform_code: str
|
||||||
|
|
||||||
|
|
||||||
class SignupStartResponse(BaseModel):
|
class SignupStartResponse(BaseModel):
|
||||||
@@ -46,26 +48,11 @@ class SignupStartResponse(BaseModel):
|
|||||||
session_id: str
|
session_id: str
|
||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool
|
is_annual: bool
|
||||||
|
platform_code: str
|
||||||
|
|
||||||
class ClaimStoreRequest(BaseModel):
|
|
||||||
"""Claim Letzshop store."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
letzshop_slug: str
|
|
||||||
letzshop_store_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ClaimStoreResponse(BaseModel):
|
|
||||||
"""Response from store claim."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
letzshop_slug: str
|
|
||||||
store_name: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateAccountRequest(BaseModel):
|
class CreateAccountRequest(BaseModel):
|
||||||
"""Create account."""
|
"""Create account (user + merchant)."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
@@ -81,10 +68,26 @@ class CreateAccountResponse(BaseModel):
|
|||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
user_id: int
|
user_id: int
|
||||||
store_id: int
|
merchant_id: int
|
||||||
stripe_customer_id: str
|
stripe_customer_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStoreRequest(BaseModel):
|
||||||
|
"""Create store for the merchant."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
store_name: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStoreResponse(BaseModel):
|
||||||
|
"""Response from store creation."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
class SetupPaymentRequest(BaseModel):
|
class SetupPaymentRequest(BaseModel):
|
||||||
"""Request payment setup."""
|
"""Request payment setup."""
|
||||||
|
|
||||||
@@ -127,43 +130,20 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
|||||||
"""
|
"""
|
||||||
Start the signup process.
|
Start the signup process.
|
||||||
|
|
||||||
Step 1: User selects a tier and billing period.
|
Step 1: User selects a tier, billing period, and platform.
|
||||||
Creates a signup session to track the flow.
|
Creates a signup session to track the flow.
|
||||||
"""
|
"""
|
||||||
session_id = platform_signup_service.create_session(
|
session_id = signup_service.create_session(
|
||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
|
platform_code=request.platform_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SignupStartResponse(
|
return SignupStartResponse(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
)
|
platform_code=request.platform_code,
|
||||||
|
|
||||||
|
|
||||||
@router.post("/signup/claim-store", response_model=ClaimStoreResponse) # public
|
|
||||||
async def claim_letzshop_store(
|
|
||||||
request: ClaimStoreRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ClaimStoreResponse:
|
|
||||||
"""
|
|
||||||
Claim a Letzshop store.
|
|
||||||
|
|
||||||
Step 2 (optional): User claims their Letzshop shop.
|
|
||||||
This pre-fills store info during account creation.
|
|
||||||
"""
|
|
||||||
store_name = platform_signup_service.claim_store(
|
|
||||||
db=db,
|
|
||||||
session_id=request.session_id,
|
|
||||||
letzshop_slug=request.letzshop_slug,
|
|
||||||
letzshop_store_id=request.letzshop_store_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClaimStoreResponse(
|
|
||||||
session_id=request.session_id,
|
|
||||||
letzshop_slug=request.letzshop_slug,
|
|
||||||
store_name=store_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,12 +153,13 @@ async def create_account(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> CreateAccountResponse:
|
) -> CreateAccountResponse:
|
||||||
"""
|
"""
|
||||||
Create user and store accounts.
|
Create user and merchant accounts.
|
||||||
|
|
||||||
Step 3: User provides account details.
|
Step 2: User provides account details.
|
||||||
Creates User, Merchant, Store, and Stripe Customer.
|
Creates User, Merchant, and Stripe Customer.
|
||||||
|
Store creation is a separate step.
|
||||||
"""
|
"""
|
||||||
result = platform_signup_service.create_account(
|
result = signup_service.create_account(
|
||||||
db=db,
|
db=db,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
email=request.email,
|
email=request.email,
|
||||||
@@ -192,11 +173,36 @@ async def create_account(
|
|||||||
return CreateAccountResponse(
|
return CreateAccountResponse(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
user_id=result.user_id,
|
user_id=result.user_id,
|
||||||
store_id=result.store_id,
|
merchant_id=result.merchant_id,
|
||||||
stripe_customer_id=result.stripe_customer_id,
|
stripe_customer_id=result.stripe_customer_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup/create-store", response_model=CreateStoreResponse) # public
|
||||||
|
async def create_store(
|
||||||
|
request: CreateStoreRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> CreateStoreResponse:
|
||||||
|
"""
|
||||||
|
Create the first store for the merchant.
|
||||||
|
|
||||||
|
Step 3: User names their store (defaults to merchant name).
|
||||||
|
Creates Store, StorePlatform, and MerchantSubscription.
|
||||||
|
"""
|
||||||
|
result = signup_service.create_store(
|
||||||
|
db=db,
|
||||||
|
session_id=request.session_id,
|
||||||
|
store_name=request.store_name,
|
||||||
|
language=request.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateStoreResponse(
|
||||||
|
session_id=request.session_id,
|
||||||
|
store_id=result.store_id,
|
||||||
|
store_code=result.store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse) # public
|
@router.post("/signup/setup-payment", response_model=SetupPaymentResponse) # public
|
||||||
async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
|
async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
|
||||||
"""
|
"""
|
||||||
@@ -205,7 +211,7 @@ async def setup_payment(request: SetupPaymentRequest) -> SetupPaymentResponse:
|
|||||||
Step 4: Collect card details without charging.
|
Step 4: Collect card details without charging.
|
||||||
The card will be charged after the trial period ends.
|
The card will be charged after the trial period ends.
|
||||||
"""
|
"""
|
||||||
client_secret, stripe_customer_id = platform_signup_service.setup_payment(
|
client_secret, stripe_customer_id = signup_service.setup_payment(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,7 +234,7 @@ async def complete_signup(
|
|||||||
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
Step 5: Verify SetupIntent, attach payment method, create subscription.
|
||||||
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
Also sets HTTP-only cookie for page navigation and returns token for localStorage.
|
||||||
"""
|
"""
|
||||||
result = platform_signup_service.complete_signup(
|
result = signup_service.complete_signup(
|
||||||
db=db,
|
db=db,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
setup_intent_id=request.setup_intent_id,
|
setup_intent_id=request.setup_intent_id,
|
||||||
@@ -265,7 +271,7 @@ async def get_signup_session(session_id: str) -> dict:
|
|||||||
|
|
||||||
Useful for resuming an incomplete signup.
|
Useful for resuming an incomplete signup.
|
||||||
"""
|
"""
|
||||||
session = platform_signup_service.get_session_or_raise(session_id)
|
session = signup_service.get_session_or_raise(session_id)
|
||||||
|
|
||||||
# Return safe subset of session data
|
# Return safe subset of session data
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Platform (unauthenticated) pages for pricing and signup:
|
|||||||
- Signup success
|
- Signup success
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,22 +16,30 @@ from app.core.database import get_db
|
|||||||
from app.modules.core.utils.page_context import get_platform_context
|
from app.modules.core.utils.page_context import get_platform_context
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
|
||||||
|
|
||||||
|
def _require_platform(request: Request):
|
||||||
|
"""Get the current platform or raise 404. Platform must always be known."""
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Platform not detected. Pricing and signup require a known platform.",
|
||||||
|
)
|
||||||
|
return platform
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _get_tiers_data(db: Session) -> list[dict]:
|
def _get_tiers_data(db: Session, platform_id: int) -> list[dict]:
|
||||||
"""Build tier data for display in templates from database."""
|
"""Build tier data for display in templates from database."""
|
||||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||||
|
|
||||||
tiers_db = (
|
query = db.query(SubscriptionTier).filter(
|
||||||
db.query(SubscriptionTier)
|
SubscriptionTier.is_active == True,
|
||||||
.filter(
|
SubscriptionTier.is_public == True,
|
||||||
SubscriptionTier.is_active == True,
|
SubscriptionTier.platform_id == platform_id,
|
||||||
SubscriptionTier.is_public == True,
|
|
||||||
)
|
|
||||||
.order_by(SubscriptionTier.display_order)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
tiers_db = query.order_by(SubscriptionTier.display_order).all()
|
||||||
|
|
||||||
tiers = []
|
tiers = []
|
||||||
for tier in tiers_db:
|
for tier in tiers_db:
|
||||||
@@ -63,9 +71,12 @@ async def pricing_page(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Standalone pricing page with detailed tier comparison.
|
Standalone pricing page with detailed tier comparison.
|
||||||
|
|
||||||
|
Tiers are filtered by the current platform (detected from domain/path).
|
||||||
"""
|
"""
|
||||||
|
platform = _require_platform(request)
|
||||||
context = get_platform_context(request, db)
|
context = get_platform_context(request, db)
|
||||||
context["tiers"] = _get_tiers_data(db)
|
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||||
context["page_title"] = "Pricing"
|
context["page_title"] = "Pricing"
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -89,18 +100,28 @@ async def signup_page(
|
|||||||
"""
|
"""
|
||||||
Multi-step signup wizard.
|
Multi-step signup wizard.
|
||||||
|
|
||||||
|
Routes to platform-specific signup templates. Each platform defines
|
||||||
|
its own signup flow (different steps, different UI).
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
- tier: Pre-selected tier code
|
- tier: Pre-selected tier code
|
||||||
- annual: Pre-select annual billing
|
- annual: Pre-select annual billing
|
||||||
"""
|
"""
|
||||||
|
platform = _require_platform(request)
|
||||||
context = get_platform_context(request, db)
|
context = get_platform_context(request, db)
|
||||||
context["page_title"] = "Start Your Free Trial"
|
context["page_title"] = "Start Your Free Trial"
|
||||||
context["selected_tier"] = tier
|
context["selected_tier"] = tier
|
||||||
context["is_annual"] = annual
|
context["is_annual"] = annual
|
||||||
context["tiers"] = _get_tiers_data(db)
|
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||||
|
|
||||||
|
# Route to platform-specific signup template
|
||||||
|
if platform.code == "loyalty":
|
||||||
|
template_name = "billing/platform/signup-loyalty.html"
|
||||||
|
else:
|
||||||
|
template_name = "billing/platform/signup.html"
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"billing/platform/signup.html",
|
template_name,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ from app.modules.billing.services.platform_pricing_service import (
|
|||||||
PlatformPricingService,
|
PlatformPricingService,
|
||||||
platform_pricing_service,
|
platform_pricing_service,
|
||||||
)
|
)
|
||||||
|
from app.modules.billing.services.signup_service import (
|
||||||
|
SignupService,
|
||||||
|
signup_service,
|
||||||
|
)
|
||||||
from app.modules.billing.services.store_platform_sync_service import (
|
from app.modules.billing.services.store_platform_sync_service import (
|
||||||
StorePlatformSync,
|
StorePlatformSync,
|
||||||
store_platform_sync,
|
store_platform_sync,
|
||||||
@@ -65,4 +69,6 @@ __all__ = [
|
|||||||
"TierInfoData",
|
"TierInfoData",
|
||||||
"UpgradeTierData",
|
"UpgradeTierData",
|
||||||
"LimitCheckData",
|
"LimitCheckData",
|
||||||
|
"SignupService",
|
||||||
|
"signup_service",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,6 +35,141 @@ logger = logging.getLogger(__name__)
|
|||||||
class AdminSubscriptionService:
|
class AdminSubscriptionService:
|
||||||
"""Service for admin subscription management operations."""
|
"""Service for admin subscription management operations."""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Stripe Tier Sync
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_tier_to_stripe(db: Session, tier: SubscriptionTier) -> None:
|
||||||
|
"""
|
||||||
|
Sync a tier's Stripe product and prices.
|
||||||
|
|
||||||
|
Creates or verifies the Stripe Product and Price objects, and
|
||||||
|
populates the stripe_product_id, stripe_price_monthly_id, and
|
||||||
|
stripe_price_annual_id fields on the tier.
|
||||||
|
|
||||||
|
Skips gracefully if Stripe is not configured (dev mode).
|
||||||
|
Stripe Prices are immutable — on price changes, new Prices are
|
||||||
|
created and old ones archived.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
if not settings.stripe_secret_key:
|
||||||
|
logger.debug(
|
||||||
|
f"Stripe not configured, skipping sync for tier {tier.code}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
stripe.api_key = settings.stripe_secret_key
|
||||||
|
|
||||||
|
# Resolve platform name for product naming
|
||||||
|
platform_name = "Platform"
|
||||||
|
if tier.platform_id:
|
||||||
|
from app.modules.tenancy.services.platform_service import (
|
||||||
|
platform_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
||||||
|
platform_name = platform.name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Product ---
|
||||||
|
if tier.stripe_product_id:
|
||||||
|
# Verify it still exists in Stripe
|
||||||
|
try:
|
||||||
|
stripe.Product.retrieve(tier.stripe_product_id)
|
||||||
|
except stripe.InvalidRequestError:
|
||||||
|
logger.warning(
|
||||||
|
f"Stripe product {tier.stripe_product_id} not found, "
|
||||||
|
f"recreating for tier {tier.code}"
|
||||||
|
)
|
||||||
|
tier.stripe_product_id = None
|
||||||
|
|
||||||
|
if not tier.stripe_product_id:
|
||||||
|
product = stripe.Product.create(
|
||||||
|
name=f"{platform_name} - {tier.name}",
|
||||||
|
metadata={
|
||||||
|
"tier_code": tier.code,
|
||||||
|
"platform_id": str(tier.platform_id or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tier.stripe_product_id = product.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe product {product.id} for tier {tier.code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Monthly Price ---
|
||||||
|
if tier.price_monthly_cents:
|
||||||
|
if tier.stripe_price_monthly_id:
|
||||||
|
# Verify price matches; if not, create new one
|
||||||
|
try:
|
||||||
|
existing = stripe.Price.retrieve(tier.stripe_price_monthly_id)
|
||||||
|
if existing.unit_amount != tier.price_monthly_cents:
|
||||||
|
# Price changed — archive old, create new
|
||||||
|
stripe.Price.modify(
|
||||||
|
tier.stripe_price_monthly_id, active=False
|
||||||
|
)
|
||||||
|
tier.stripe_price_monthly_id = None
|
||||||
|
logger.info(
|
||||||
|
f"Archived old monthly price for tier {tier.code}"
|
||||||
|
)
|
||||||
|
except stripe.InvalidRequestError:
|
||||||
|
tier.stripe_price_monthly_id = None
|
||||||
|
|
||||||
|
if not tier.stripe_price_monthly_id:
|
||||||
|
price = stripe.Price.create(
|
||||||
|
product=tier.stripe_product_id,
|
||||||
|
unit_amount=tier.price_monthly_cents,
|
||||||
|
currency="eur",
|
||||||
|
recurring={"interval": "month"},
|
||||||
|
metadata={
|
||||||
|
"tier_code": tier.code,
|
||||||
|
"billing_period": "monthly",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tier.stripe_price_monthly_id = price.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe monthly price {price.id} "
|
||||||
|
f"for tier {tier.code} ({tier.price_monthly_cents} cents)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Annual Price ---
|
||||||
|
if tier.price_annual_cents:
|
||||||
|
if tier.stripe_price_annual_id:
|
||||||
|
try:
|
||||||
|
existing = stripe.Price.retrieve(tier.stripe_price_annual_id)
|
||||||
|
if existing.unit_amount != tier.price_annual_cents:
|
||||||
|
stripe.Price.modify(
|
||||||
|
tier.stripe_price_annual_id, active=False
|
||||||
|
)
|
||||||
|
tier.stripe_price_annual_id = None
|
||||||
|
logger.info(
|
||||||
|
f"Archived old annual price for tier {tier.code}"
|
||||||
|
)
|
||||||
|
except stripe.InvalidRequestError:
|
||||||
|
tier.stripe_price_annual_id = None
|
||||||
|
|
||||||
|
if not tier.stripe_price_annual_id:
|
||||||
|
price = stripe.Price.create(
|
||||||
|
product=tier.stripe_product_id,
|
||||||
|
unit_amount=tier.price_annual_cents,
|
||||||
|
currency="eur",
|
||||||
|
recurring={"interval": "year"},
|
||||||
|
metadata={
|
||||||
|
"tier_code": tier.code,
|
||||||
|
"billing_period": "annual",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tier.stripe_price_annual_id = price.id
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe annual price {price.id} "
|
||||||
|
f"for tier {tier.code} ({tier.price_annual_cents} cents)"
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Subscription Tiers
|
# Subscription Tiers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -85,6 +220,9 @@ class AdminSubscriptionService:
|
|||||||
|
|
||||||
tier = SubscriptionTier(**tier_data)
|
tier = SubscriptionTier(**tier_data)
|
||||||
db.add(tier)
|
db.add(tier)
|
||||||
|
db.flush() # Get tier.id before Stripe sync
|
||||||
|
|
||||||
|
self._sync_tier_to_stripe(db, tier)
|
||||||
|
|
||||||
logger.info(f"Created subscription tier: {tier.code}")
|
logger.info(f"Created subscription tier: {tier.code}")
|
||||||
return tier
|
return tier
|
||||||
@@ -95,9 +233,21 @@ class AdminSubscriptionService:
|
|||||||
"""Update a subscription tier."""
|
"""Update a subscription tier."""
|
||||||
tier = self.get_tier_by_code(db, tier_code)
|
tier = self.get_tier_by_code(db, tier_code)
|
||||||
|
|
||||||
|
# Track price changes to know if Stripe sync is needed
|
||||||
|
price_changed = (
|
||||||
|
"price_monthly_cents" in update_data
|
||||||
|
and update_data["price_monthly_cents"] != tier.price_monthly_cents
|
||||||
|
) or (
|
||||||
|
"price_annual_cents" in update_data
|
||||||
|
and update_data["price_annual_cents"] != tier.price_annual_cents
|
||||||
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tier, field, value)
|
setattr(tier, field, value)
|
||||||
|
|
||||||
|
if price_changed or not tier.stripe_product_id:
|
||||||
|
self._sync_tier_to_stripe(db, tier)
|
||||||
|
|
||||||
logger.info(f"Updated subscription tier: {tier.code}")
|
logger.info(f"Updated subscription tier: {tier.code}")
|
||||||
return tier
|
return tier
|
||||||
|
|
||||||
|
|||||||
797
app/modules/billing/services/signup_service.py
Normal file
797
app/modules/billing/services/signup_service.py
Normal 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()
|
||||||
@@ -93,6 +93,38 @@ class StripeService:
|
|||||||
)
|
)
|
||||||
return customer.id
|
return customer.id
|
||||||
|
|
||||||
|
def create_customer_for_merchant(
|
||||||
|
self,
|
||||||
|
merchant,
|
||||||
|
email: str,
|
||||||
|
name: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a Stripe customer for a merchant (before store exists).
|
||||||
|
|
||||||
|
Used during signup when the store hasn't been created yet.
|
||||||
|
Returns the Stripe customer ID.
|
||||||
|
"""
|
||||||
|
self._check_configured()
|
||||||
|
|
||||||
|
customer_metadata = {
|
||||||
|
"merchant_id": str(merchant.id),
|
||||||
|
"merchant_name": merchant.name,
|
||||||
|
**(metadata or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=email,
|
||||||
|
name=name or merchant.name,
|
||||||
|
metadata=customer_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created Stripe customer {customer.id} for merchant {merchant.name}"
|
||||||
|
)
|
||||||
|
return customer.id
|
||||||
|
|
||||||
def get_customer(self, customer_id: str) -> stripe.Customer:
|
def get_customer(self, customer_id: str) -> stripe.Customer:
|
||||||
"""Get a Stripe customer by ID."""
|
"""Get a Stripe customer by ID."""
|
||||||
self._check_configured()
|
self._check_configured()
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
•
|
||||||
|
{% 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 %}
|
||||||
@@ -326,7 +326,8 @@ function signupWizard() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tier_code: this.selectedTier,
|
tier_code: this.selectedTier,
|
||||||
is_annual: this.isAnnual
|
is_annual: this.isAnnual,
|
||||||
|
platform_code: '{{ platform.code }}'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ from app.modules.marketplace.services.onboarding_service import (
|
|||||||
OnboardingService,
|
OnboardingService,
|
||||||
get_onboarding_service,
|
get_onboarding_service,
|
||||||
)
|
)
|
||||||
from app.modules.marketplace.services.platform_signup_service import (
|
|
||||||
AccountCreationResult,
|
|
||||||
PlatformSignupService,
|
|
||||||
SignupCompletionResult,
|
|
||||||
SignupSessionData,
|
|
||||||
platform_signup_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Export service
|
# Export service
|
||||||
@@ -55,12 +48,6 @@ __all__ = [
|
|||||||
# Onboarding service
|
# Onboarding service
|
||||||
"OnboardingService",
|
"OnboardingService",
|
||||||
"get_onboarding_service",
|
"get_onboarding_service",
|
||||||
# Platform signup service
|
|
||||||
"PlatformSignupService",
|
|
||||||
"platform_signup_service",
|
|
||||||
"SignupSessionData",
|
|
||||||
"AccountCreationResult",
|
|
||||||
"SignupCompletionResult",
|
|
||||||
# Letzshop services
|
# Letzshop services
|
||||||
"LetzshopClient",
|
"LetzshopClient",
|
||||||
"LetzshopClientError",
|
"LetzshopClientError",
|
||||||
|
|||||||
@@ -1,191 +1,36 @@
|
|||||||
# app/modules/marketplace/services/platform_signup_service.py
|
# app/modules/marketplace/services/platform_signup_service.py
|
||||||
"""
|
"""
|
||||||
Platform signup service.
|
OMS-specific signup extensions.
|
||||||
|
|
||||||
Handles all database operations for the platform signup flow:
|
The core signup service has moved to app.modules.billing.services.signup_service.
|
||||||
- Session management
|
This file retains OMS-specific logic (Letzshop store claiming) and provides
|
||||||
- Store claiming
|
backwards-compatible re-exports.
|
||||||
- Account creation
|
|
||||||
- Subscription setup
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.exceptions import ConflictException
|
||||||
from app.exceptions import (
|
from app.modules.billing.services.signup_service import (
|
||||||
ConflictException,
|
AccountCreationResult,
|
||||||
ResourceNotFoundException,
|
SignupCompletionResult,
|
||||||
ValidationException,
|
SignupService,
|
||||||
|
SignupSessionData,
|
||||||
|
StoreCreationResult,
|
||||||
|
signup_service,
|
||||||
)
|
)
|
||||||
from app.modules.billing.services.stripe_service import stripe_service
|
|
||||||
from app.modules.billing.services.subscription_service import (
|
|
||||||
subscription_service as sub_service,
|
|
||||||
)
|
|
||||||
from app.modules.marketplace.exceptions import OnboardingAlreadyCompletedException
|
|
||||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
|
||||||
from app.modules.messaging.services.email_service import EmailService
|
|
||||||
from middleware.auth import AuthManager
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.modules.tenancy.models import Store, User
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
class OmsSignupService:
|
||||||
# In-memory signup session storage
|
"""OMS-specific signup extensions (Letzshop store claiming)."""
|
||||||
# In production, use Redis or database table
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_signup_sessions: dict[str, dict] = {}
|
def __init__(self, base_service: SignupService):
|
||||||
|
self._base = base_service
|
||||||
|
|
||||||
def _create_session_id() -> str:
|
|
||||||
"""Generate a secure session ID."""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Data Classes
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SignupSessionData:
|
|
||||||
"""Data stored in a signup session."""
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
step: str
|
|
||||||
tier_code: str
|
|
||||||
is_annual: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str | None = None
|
|
||||||
letzshop_slug: str | None = None
|
|
||||||
letzshop_store_id: str | None = None
|
|
||||||
store_name: str | None = None
|
|
||||||
user_id: int | None = None
|
|
||||||
store_id: int | None = None
|
|
||||||
store_code: str | None = None
|
|
||||||
stripe_customer_id: str | None = None
|
|
||||||
setup_intent_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AccountCreationResult:
|
|
||||||
"""Result of account creation."""
|
|
||||||
|
|
||||||
user_id: int
|
|
||||||
store_id: int
|
|
||||||
store_code: str
|
|
||||||
stripe_customer_id: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SignupCompletionResult:
|
|
||||||
"""Result of signup completion."""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
store_code: str
|
|
||||||
store_id: int
|
|
||||||
redirect_url: str
|
|
||||||
trial_ends_at: str
|
|
||||||
access_token: str | None = None # JWT token for automatic login
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Platform Signup Service
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformSignupService:
|
|
||||||
"""Service for handling platform signup operations."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.auth_manager = AuthManager()
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Session Management
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def create_session(self, tier_code: str, is_annual: bool) -> str:
|
|
||||||
"""
|
|
||||||
Create a new signup session.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tier_code: The subscription tier code
|
|
||||||
is_annual: Whether annual billing is selected
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The session ID
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationException: If tier code is invalid
|
|
||||||
"""
|
|
||||||
# Validate tier code
|
|
||||||
from app.modules.billing.models import TierCode
|
|
||||||
try:
|
|
||||||
tier = TierCode(tier_code)
|
|
||||||
except ValueError:
|
|
||||||
raise ValidationException(
|
|
||||||
message=f"Invalid tier code: {tier_code}",
|
|
||||||
field="tier_code",
|
|
||||||
)
|
|
||||||
|
|
||||||
session_id = _create_session_id()
|
|
||||||
now = datetime.now(UTC).isoformat()
|
|
||||||
|
|
||||||
_signup_sessions[session_id] = {
|
|
||||||
"step": "tier_selected",
|
|
||||||
"tier_code": tier.value,
|
|
||||||
"is_annual": is_annual,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"Created signup session {session_id} for tier {tier.value}")
|
|
||||||
return session_id
|
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> dict | None:
|
|
||||||
"""Get a signup session by ID."""
|
|
||||||
return _signup_sessions.get(session_id)
|
|
||||||
|
|
||||||
def get_session_or_raise(self, session_id: str) -> dict:
|
|
||||||
"""
|
|
||||||
Get a signup session or raise an exception.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ResourceNotFoundException: If session not found
|
|
||||||
"""
|
|
||||||
session = self.get_session(session_id)
|
|
||||||
if not session:
|
|
||||||
raise ResourceNotFoundException(
|
|
||||||
resource_type="SignupSession",
|
|
||||||
identifier=session_id,
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def update_session(self, session_id: str, data: dict) -> None:
|
|
||||||
"""Update signup session data."""
|
|
||||||
session = self.get_session_or_raise(session_id)
|
|
||||||
session.update(data)
|
|
||||||
session["updated_at"] = datetime.now(UTC).isoformat()
|
|
||||||
_signup_sessions[session_id] = session
|
|
||||||
|
|
||||||
def delete_session(self, session_id: str) -> None:
|
|
||||||
"""Delete a signup session."""
|
|
||||||
_signup_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Store Claiming
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def check_store_claimed(self, db: Session, letzshop_slug: str) -> bool:
|
def check_store_claimed(self, db: Session, letzshop_slug: str) -> bool:
|
||||||
"""Check if a Letzshop store is already claimed."""
|
"""Check if a Letzshop store is already claimed."""
|
||||||
@@ -201,7 +46,7 @@ class PlatformSignupService:
|
|||||||
letzshop_store_id: str | None = None,
|
letzshop_store_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Claim a Letzshop store for signup.
|
Claim a Letzshop store for OMS signup.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -216,19 +61,16 @@ class PlatformSignupService:
|
|||||||
ResourceNotFoundException: If session not found
|
ResourceNotFoundException: If session not found
|
||||||
ConflictException: If store already claimed
|
ConflictException: If store already claimed
|
||||||
"""
|
"""
|
||||||
self.get_session_or_raise(session_id)
|
self._base.get_session_or_raise(session_id)
|
||||||
|
|
||||||
# Check if store is already claimed
|
|
||||||
if self.check_store_claimed(db, letzshop_slug):
|
if self.check_store_claimed(db, letzshop_slug):
|
||||||
raise ConflictException(
|
raise ConflictException(
|
||||||
message="This Letzshop store is already claimed",
|
message="This Letzshop store is already claimed",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate store name from slug
|
|
||||||
store_name = letzshop_slug.replace("-", " ").title()
|
store_name = letzshop_slug.replace("-", " ").title()
|
||||||
|
|
||||||
# Update session
|
self._base.update_session(session_id, {
|
||||||
self.update_session(session_id, {
|
|
||||||
"letzshop_slug": letzshop_slug,
|
"letzshop_slug": letzshop_slug,
|
||||||
"letzshop_store_id": letzshop_store_id,
|
"letzshop_store_id": letzshop_store_id,
|
||||||
"store_name": store_name,
|
"store_name": store_name,
|
||||||
@@ -238,422 +80,21 @@ class PlatformSignupService:
|
|||||||
logger.info(f"Claimed store {letzshop_slug} for session {session_id}")
|
logger.info(f"Claimed store {letzshop_slug} for session {session_id}")
|
||||||
return store_name
|
return store_name
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Account Creation
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def check_email_exists(self, db: Session, email: str) -> bool:
|
# Singleton
|
||||||
"""Check if an email already exists."""
|
oms_signup_service = OmsSignupService(signup_service)
|
||||||
from app.modules.tenancy.services.admin_service import admin_service
|
|
||||||
|
# Re-exports for backwards compatibility
|
||||||
return admin_service.get_user_by_email(db, email) is not None
|
PlatformSignupService = SignupService
|
||||||
|
platform_signup_service = signup_service
|
||||||
def generate_unique_username(self, db: Session, email: str) -> str:
|
|
||||||
"""Generate a unique username from email."""
|
__all__ = [
|
||||||
from app.modules.tenancy.services.admin_service import admin_service
|
"OmsSignupService",
|
||||||
|
"oms_signup_service",
|
||||||
username = email.split("@")[0]
|
"PlatformSignupService",
|
||||||
base_username = username
|
"platform_signup_service",
|
||||||
counter = 1
|
"AccountCreationResult",
|
||||||
while admin_service.get_user_by_username(db, username):
|
"SignupCompletionResult",
|
||||||
username = f"{base_username}_{counter}"
|
"SignupSessionData",
|
||||||
counter += 1
|
"StoreCreationResult",
|
||||||
return username
|
]
|
||||||
|
|
||||||
def generate_unique_store_code(self, db: Session, merchant_name: str) -> str:
|
|
||||||
"""Generate a unique store code from merchant name."""
|
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
|
||||||
|
|
||||||
store_code = merchant_name.upper().replace(" ", "_")[:20]
|
|
||||||
base_code = store_code
|
|
||||||
counter = 1
|
|
||||||
while store_service.is_store_code_taken(db, store_code):
|
|
||||||
store_code = f"{base_code}_{counter}"
|
|
||||||
counter += 1
|
|
||||||
return store_code
|
|
||||||
|
|
||||||
def generate_unique_subdomain(self, db: Session, merchant_name: str) -> str:
|
|
||||||
"""Generate a unique subdomain from merchant name."""
|
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
|
||||||
|
|
||||||
subdomain = merchant_name.lower().replace(" ", "-")
|
|
||||||
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
|
|
||||||
base_subdomain = subdomain
|
|
||||||
counter = 1
|
|
||||||
while store_service.is_subdomain_taken(db, subdomain):
|
|
||||||
subdomain = f"{base_subdomain}-{counter}"
|
|
||||||
counter += 1
|
|
||||||
return subdomain
|
|
||||||
|
|
||||||
def create_account(
|
|
||||||
self,
|
|
||||||
db: Session,
|
|
||||||
session_id: str,
|
|
||||||
email: str,
|
|
||||||
password: str,
|
|
||||||
first_name: str,
|
|
||||||
last_name: str,
|
|
||||||
merchant_name: str,
|
|
||||||
phone: str | None = None,
|
|
||||||
) -> AccountCreationResult:
|
|
||||||
"""
|
|
||||||
Create user, merchant, store, and Stripe customer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
session_id: Signup session ID
|
|
||||||
email: User email
|
|
||||||
password: User password
|
|
||||||
first_name: User first name
|
|
||||||
last_name: User last name
|
|
||||||
merchant_name: Merchant name
|
|
||||||
phone: Optional phone number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AccountCreationResult with IDs
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ResourceNotFoundException: If session not found
|
|
||||||
ConflictException: If email already exists
|
|
||||||
"""
|
|
||||||
session = self.get_session_or_raise(session_id)
|
|
||||||
|
|
||||||
# Check if email already exists
|
|
||||||
if self.check_email_exists(db, email):
|
|
||||||
raise ConflictException(
|
|
||||||
message="An account with this email already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate unique username
|
|
||||||
username = self.generate_unique_username(db, email)
|
|
||||||
|
|
||||||
# Create User
|
|
||||||
from app.modules.tenancy.models import Merchant, Store, User
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
email=email,
|
|
||||||
username=username,
|
|
||||||
hashed_password=self.auth_manager.hash_password(password),
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
role="merchant_owner",
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# Create Merchant
|
|
||||||
merchant = Merchant(
|
|
||||||
name=merchant_name,
|
|
||||||
owner_user_id=user.id,
|
|
||||||
contact_email=email,
|
|
||||||
contact_phone=phone,
|
|
||||||
)
|
|
||||||
db.add(merchant)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# Generate unique store code and subdomain
|
|
||||||
store_code = self.generate_unique_store_code(db, merchant_name)
|
|
||||||
subdomain = self.generate_unique_subdomain(db, merchant_name)
|
|
||||||
|
|
||||||
# Create Store
|
|
||||||
store = Store(
|
|
||||||
merchant_id=merchant.id,
|
|
||||||
store_code=store_code,
|
|
||||||
subdomain=subdomain,
|
|
||||||
name=merchant_name,
|
|
||||||
contact_email=email,
|
|
||||||
contact_phone=phone,
|
|
||||||
is_active=True,
|
|
||||||
letzshop_store_slug=session.get("letzshop_slug"),
|
|
||||||
letzshop_store_id=session.get("letzshop_store_id"),
|
|
||||||
)
|
|
||||||
db.add(store)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
|
|
||||||
|
|
||||||
# Create StoreOnboarding record
|
|
||||||
onboarding_service = OnboardingService(db)
|
|
||||||
onboarding_service.create_onboarding(store.id)
|
|
||||||
|
|
||||||
# Create Stripe Customer
|
|
||||||
stripe_customer_id = stripe_service.create_customer(
|
|
||||||
store=store,
|
|
||||||
email=email,
|
|
||||||
name=f"{first_name} {last_name}",
|
|
||||||
metadata={
|
|
||||||
"merchant_name": merchant_name,
|
|
||||||
"tier": session.get("tier_code"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get platform_id for the subscription
|
|
||||||
from app.modules.tenancy.services.platform_service import platform_service
|
|
||||||
|
|
||||||
primary_pid = platform_service.get_primary_platform_id_for_store(db, store.id)
|
|
||||||
if primary_pid:
|
|
||||||
platform_id = primary_pid
|
|
||||||
else:
|
|
||||||
default_platform = platform_service.get_default_platform(db)
|
|
||||||
platform_id = default_platform.id if default_platform else 1
|
|
||||||
|
|
||||||
# Create MerchantSubscription (trial status)
|
|
||||||
subscription = sub_service.create_merchant_subscription(
|
|
||||||
db=db,
|
|
||||||
merchant_id=merchant.id,
|
|
||||||
platform_id=platform_id,
|
|
||||||
tier_code=session.get("tier_code", "essential"),
|
|
||||||
trial_days=settings.stripe_trial_days,
|
|
||||||
is_annual=session.get("is_annual", False),
|
|
||||||
)
|
|
||||||
subscription.stripe_customer_id = stripe_customer_id
|
|
||||||
|
|
||||||
db.commit() # SVC-006 - Atomic account creation needs commit
|
|
||||||
|
|
||||||
# Update session
|
|
||||||
self.update_session(session_id, {
|
|
||||||
"user_id": user.id,
|
|
||||||
"store_id": store.id,
|
|
||||||
"store_code": store_code,
|
|
||||||
"merchant_id": merchant.id,
|
|
||||||
"platform_id": platform_id,
|
|
||||||
"stripe_customer_id": stripe_customer_id,
|
|
||||||
"step": "account_created",
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Created account for {email}: user_id={user.id}, store_id={store.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return AccountCreationResult(
|
|
||||||
user_id=user.id,
|
|
||||||
store_id=store.id,
|
|
||||||
store_code=store_code,
|
|
||||||
stripe_customer_id=stripe_customer_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Payment Setup
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def setup_payment(self, session_id: str) -> tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Create Stripe SetupIntent for card collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: Signup session ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (client_secret, stripe_customer_id)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EntityNotFoundException: If session not found
|
|
||||||
ValidationException: If account not created yet
|
|
||||||
"""
|
|
||||||
session = self.get_session_or_raise(session_id)
|
|
||||||
|
|
||||||
if "stripe_customer_id" not in session:
|
|
||||||
raise ValidationException(
|
|
||||||
message="Account not created. Please complete step 3 first.",
|
|
||||||
field="session_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
stripe_customer_id = session["stripe_customer_id"]
|
|
||||||
|
|
||||||
# Create SetupIntent
|
|
||||||
setup_intent = stripe_service.create_setup_intent(
|
|
||||||
customer_id=stripe_customer_id,
|
|
||||||
metadata={
|
|
||||||
"session_id": session_id,
|
|
||||||
"store_id": str(session.get("store_id")),
|
|
||||||
"tier": session.get("tier_code"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update session
|
|
||||||
self.update_session(session_id, {
|
|
||||||
"setup_intent_id": setup_intent.id,
|
|
||||||
"step": "payment_pending",
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Created SetupIntent {setup_intent.id} for session {session_id}")
|
|
||||||
|
|
||||||
return setup_intent.client_secret, stripe_customer_id
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Welcome Email
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def send_welcome_email(
|
|
||||||
self,
|
|
||||||
db: Session,
|
|
||||||
user: User,
|
|
||||||
store: Store,
|
|
||||||
tier_code: str,
|
|
||||||
language: str = "fr",
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Send welcome email to new store.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
user: User who signed up
|
|
||||||
store: Store that was created
|
|
||||||
tier_code: Selected tier code
|
|
||||||
language: Language for email (default: French)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get tier name
|
|
||||||
from app.modules.billing.services.billing_service import billing_service
|
|
||||||
|
|
||||||
tier = billing_service.get_tier_by_code(db, tier_code)
|
|
||||||
tier_name = tier.name if tier else tier_code.title()
|
|
||||||
|
|
||||||
# Build login URL
|
|
||||||
login_url = f"https://{settings.platform_domain}/store/{store.store_code}/dashboard"
|
|
||||||
|
|
||||||
email_service = EmailService(db)
|
|
||||||
email_service.send_template(
|
|
||||||
template_code="signup_welcome",
|
|
||||||
language=language,
|
|
||||||
to_email=user.email,
|
|
||||||
to_name=f"{user.first_name} {user.last_name}",
|
|
||||||
variables={
|
|
||||||
"first_name": user.first_name,
|
|
||||||
"merchant_name": store.name,
|
|
||||||
"email": user.email,
|
|
||||||
"store_code": store.store_code,
|
|
||||||
"login_url": login_url,
|
|
||||||
"trial_days": settings.stripe_trial_days,
|
|
||||||
"tier_name": tier_name,
|
|
||||||
},
|
|
||||||
store_id=store.id,
|
|
||||||
user_id=user.id,
|
|
||||||
related_type="signup",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Welcome email sent to {user.email}")
|
|
||||||
|
|
||||||
except Exception as e: # noqa: EXC003
|
|
||||||
# Log error but don't fail signup
|
|
||||||
logger.error(f"Failed to send welcome email to {user.email}: {e}")
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Signup Completion
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def complete_signup(
|
|
||||||
self,
|
|
||||||
db: Session,
|
|
||||||
session_id: str,
|
|
||||||
setup_intent_id: str,
|
|
||||||
) -> SignupCompletionResult:
|
|
||||||
"""
|
|
||||||
Complete signup after card collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Database session
|
|
||||||
session_id: Signup session ID
|
|
||||||
setup_intent_id: Stripe SetupIntent ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SignupCompletionResult
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EntityNotFoundException: If session not found
|
|
||||||
ValidationException: If signup incomplete or payment failed
|
|
||||||
"""
|
|
||||||
session = self.get_session_or_raise(session_id)
|
|
||||||
|
|
||||||
# Guard against completing signup more than once
|
|
||||||
if session.get("step") == "completed":
|
|
||||||
raise OnboardingAlreadyCompletedException(
|
|
||||||
store_id=session.get("store_id", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
store_id = session.get("store_id")
|
|
||||||
stripe_customer_id = session.get("stripe_customer_id")
|
|
||||||
|
|
||||||
if not store_id or not stripe_customer_id:
|
|
||||||
raise ValidationException(
|
|
||||||
message="Incomplete signup. Please start again.",
|
|
||||||
field="session_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Retrieve SetupIntent to get payment method
|
|
||||||
setup_intent = stripe_service.get_setup_intent(setup_intent_id)
|
|
||||||
|
|
||||||
if setup_intent.status != "succeeded":
|
|
||||||
raise ValidationException(
|
|
||||||
message="Card setup not completed. Please try again.",
|
|
||||||
field="setup_intent_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
payment_method_id = setup_intent.payment_method
|
|
||||||
|
|
||||||
# Attach payment method to customer
|
|
||||||
stripe_service.attach_payment_method_to_customer(
|
|
||||||
customer_id=stripe_customer_id,
|
|
||||||
payment_method_id=payment_method_id,
|
|
||||||
set_as_default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update subscription record
|
|
||||||
subscription = sub_service.get_subscription_for_store(db, store_id)
|
|
||||||
|
|
||||||
if subscription:
|
|
||||||
subscription.card_collected_at = datetime.now(UTC)
|
|
||||||
subscription.stripe_payment_method_id = payment_method_id
|
|
||||||
db.commit() # SVC-006 - Finalize signup needs commit
|
|
||||||
|
|
||||||
# Get store info
|
|
||||||
store = db.query(Store).filter(Store.id == store_id).first()
|
|
||||||
store_code = store.store_code if store else session.get("store_code")
|
|
||||||
|
|
||||||
trial_ends_at = (
|
|
||||||
subscription.trial_ends_at
|
|
||||||
if subscription
|
|
||||||
else datetime.now(UTC) + timedelta(days=30)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get user for welcome email and token generation
|
|
||||||
user_id = session.get("user_id")
|
|
||||||
user = db.query(User).filter(User.id == user_id).first() if user_id else None
|
|
||||||
|
|
||||||
# Generate access token for automatic login after signup
|
|
||||||
access_token = None
|
|
||||||
if user and store:
|
|
||||||
# Create store-scoped JWT token (user is owner since they just signed up)
|
|
||||||
token_data = self.auth_manager.create_access_token(
|
|
||||||
user=user,
|
|
||||||
store_id=store.id,
|
|
||||||
store_code=store.store_code,
|
|
||||||
store_role="Owner", # New signup is always the owner
|
|
||||||
)
|
|
||||||
access_token = token_data["access_token"]
|
|
||||||
logger.info(f"Generated access token for new store user {user.email}")
|
|
||||||
|
|
||||||
# Send welcome email
|
|
||||||
if user and store:
|
|
||||||
tier_code = session.get("tier_code", TierCode.ESSENTIAL.value)
|
|
||||||
self.send_welcome_email(db, user, store, tier_code)
|
|
||||||
|
|
||||||
# Clean up session
|
|
||||||
self.delete_session(session_id)
|
|
||||||
|
|
||||||
logger.info(f"Completed signup for store {store_id}")
|
|
||||||
|
|
||||||
# Redirect to onboarding instead of dashboard
|
|
||||||
return SignupCompletionResult(
|
|
||||||
success=True,
|
|
||||||
store_code=store_code,
|
|
||||||
store_id=store_id,
|
|
||||||
redirect_url=f"/store/{store_code}/onboarding",
|
|
||||||
trial_ends_at=trial_ends_at.isoformat(),
|
|
||||||
access_token=access_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
|
||||||
platform_signup_service = PlatformSignupService()
|
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
"""Unit tests for PlatformSignupService."""
|
"""Unit tests for PlatformSignupService (now in billing module)."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.modules.marketplace.services.platform_signup_service import (
|
from app.modules.billing.services.signup_service import SignupService
|
||||||
PlatformSignupService,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.marketplace
|
@pytest.mark.marketplace
|
||||||
class TestPlatformSignupService:
|
class TestPlatformSignupService:
|
||||||
"""Test suite for PlatformSignupService."""
|
"""Test suite for SignupService (moved from marketplace to billing)."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
self.service = PlatformSignupService()
|
self.service = SignupService()
|
||||||
|
|
||||||
def test_service_instantiation(self):
|
def test_service_instantiation(self):
|
||||||
"""Service can be instantiated."""
|
"""Service can be instantiated."""
|
||||||
|
|||||||
75
scripts/seed/sync_stripe_products.py
Normal file
75
scripts/seed/sync_stripe_products.py
Normal 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()
|
||||||
Reference in New Issue
Block a user