feat: module-driven onboarding system + simplified 3-step signup
Add OnboardingProviderProtocol so modules declare their own post-signup onboarding steps. The core OnboardingAggregator discovers enabled providers and exposes a dashboard API (GET /dashboard/onboarding). A session-scoped banner on the store dashboard shows a checklist that guides merchants through setup without blocking signup. Signup is simplified from 4 steps to 3 (Plan → Account → Payment): store creation is merged into account creation, store language is captured from the user's browsing language, and platform-specific template branching is removed. Includes 47 unit and integration tests covering all new providers, the aggregator, the API endpoint, and the signup service changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -114,14 +114,8 @@ async def signup_page(
|
||||
context["is_annual"] = annual
|
||||
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(
|
||||
template_name,
|
||||
"billing/platform/signup.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,11 +84,13 @@ class SignupSessionData:
|
||||
|
||||
@dataclass
|
||||
class AccountCreationResult:
|
||||
"""Result of account creation."""
|
||||
"""Result of account creation (includes auto-created store)."""
|
||||
|
||||
user_id: int
|
||||
merchant_id: int
|
||||
stripe_customer_id: str
|
||||
store_id: int
|
||||
store_code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -131,6 +133,7 @@ class SignupService:
|
||||
tier_code: str,
|
||||
is_annual: bool,
|
||||
platform_code: str,
|
||||
language: str = "fr",
|
||||
) -> str:
|
||||
"""
|
||||
Create a new signup session.
|
||||
@@ -139,6 +142,7 @@ class SignupService:
|
||||
tier_code: The subscription tier code
|
||||
is_annual: Whether annual billing is selected
|
||||
platform_code: Platform code (e.g., 'loyalty', 'oms')
|
||||
language: User's browsing language (from lang cookie)
|
||||
|
||||
Returns:
|
||||
The session ID
|
||||
@@ -171,6 +175,7 @@ class SignupService:
|
||||
"tier_code": tier.value,
|
||||
"is_annual": is_annual,
|
||||
"platform_code": platform_code,
|
||||
"language": language,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
@@ -304,10 +309,10 @@ class SignupService:
|
||||
phone: str | None = None,
|
||||
) -> AccountCreationResult:
|
||||
"""
|
||||
Create user and merchant accounts.
|
||||
Create user, merchant, store, and subscription in a single atomic step.
|
||||
|
||||
Creates User + Merchant + Stripe Customer. Store creation is a
|
||||
separate step (create_store) so each platform can customize it.
|
||||
Creates User + Merchant + Store + Stripe Customer + MerchantSubscription.
|
||||
Store name defaults to merchant_name, language from signup session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -320,7 +325,7 @@ class SignupService:
|
||||
phone: Optional phone number
|
||||
|
||||
Returns:
|
||||
AccountCreationResult with user and merchant IDs
|
||||
AccountCreationResult with user, merchant, and store IDs
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If session not found
|
||||
@@ -338,7 +343,7 @@ class SignupService:
|
||||
username = self.generate_unique_username(db, email)
|
||||
|
||||
# Create User
|
||||
from app.modules.tenancy.models import Merchant, User
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
@@ -362,8 +367,7 @@ class SignupService:
|
||||
db.add(merchant)
|
||||
db.flush()
|
||||
|
||||
# Create Stripe Customer (linked to merchant, not store)
|
||||
# We use a temporary store-like object for Stripe metadata
|
||||
# Create Stripe Customer
|
||||
stripe_customer_id = stripe_service.create_customer_for_merchant(
|
||||
merchant=merchant,
|
||||
email=email,
|
||||
@@ -375,7 +379,38 @@ class SignupService:
|
||||
},
|
||||
)
|
||||
|
||||
db.commit() # SVC-006 - Atomic account creation needs commit
|
||||
# Create Store (name = merchant_name, language from browsing session)
|
||||
store_code = self.generate_unique_store_code(db, merchant_name)
|
||||
subdomain = self.generate_unique_subdomain(db, merchant_name)
|
||||
language = session.get("language", "fr")
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=store_code,
|
||||
subdomain=subdomain,
|
||||
name=merchant_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)
|
||||
|
||||
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 + store creation
|
||||
|
||||
# Update session
|
||||
self.update_session(session_id, {
|
||||
@@ -384,18 +419,24 @@ class SignupService:
|
||||
"merchant_name": merchant_name,
|
||||
"email": email,
|
||||
"stripe_customer_id": stripe_customer_id,
|
||||
"store_id": store.id,
|
||||
"store_code": store_code,
|
||||
"platform_id": platform_id,
|
||||
"step": "account_created",
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"Created account for {email}: "
|
||||
f"user_id={user.id}, merchant_id={merchant.id}"
|
||||
f"Created account + store for {email}: "
|
||||
f"user_id={user.id}, merchant_id={merchant.id}, "
|
||||
f"store_code={store_code}"
|
||||
)
|
||||
|
||||
return AccountCreationResult(
|
||||
user_id=user.id,
|
||||
merchant_id=merchant.id,
|
||||
stripe_customer_id=stripe_customer_id,
|
||||
store_id=store.id,
|
||||
store_code=store_code,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -512,7 +553,7 @@ class SignupService:
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If session not found
|
||||
ValidationException: If store not created yet
|
||||
ValidationException: If account not created yet
|
||||
"""
|
||||
session = self.get_session_or_raise(session_id)
|
||||
|
||||
@@ -523,12 +564,6 @@ class SignupService:
|
||||
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,
|
||||
@@ -775,21 +810,11 @@ class SignupService:
|
||||
self, db: Session, session: dict, store_code: str
|
||||
) -> str:
|
||||
"""
|
||||
Determine redirect URL after signup based on platform.
|
||||
Determine redirect URL after signup.
|
||||
|
||||
Marketplace platforms → onboarding wizard.
|
||||
Other platforms (loyalty, etc.) → dashboard.
|
||||
Always redirects to the store dashboard. Platform-specific onboarding
|
||||
is handled by the dashboard's onboarding banner (module-driven).
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -1,520 +0,0 @@
|
||||
{# 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 %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{# app/templates/platform/signup.html #}
|
||||
{# Multi-step Signup Wizard #}
|
||||
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
||||
{% extends "platform/base.html" %}
|
||||
|
||||
{% block title %}Start Your Free Trial - Orion{% endblock %}
|
||||
@@ -16,8 +16,8 @@
|
||||
{# Progress Steps #}
|
||||
<div class="mb-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
||||
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Payment']" :key="index">
|
||||
<div class="flex items-center" :class="index < 2 ? '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">
|
||||
@@ -32,7 +32,7 @@
|
||||
<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">
|
||||
<template x-if="index < 2">
|
||||
<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>
|
||||
@@ -87,10 +87,10 @@
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -116,52 +116,9 @@
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
||||
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">Connect Your Letzshop Shop</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
x-model="letzshopUrl"
|
||||
placeholder="letzshop.lu/vendors/your-shop"
|
||||
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"
|
||||
/>
|
||||
|
||||
<template x-if="letzshopStore">
|
||||
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||
<p class="text-green-800 dark:text-green-300">
|
||||
Found: <strong x-text="letzshopStore.name"></strong>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="letzshopError">
|
||||
<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="letzshopError"></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="claimStore()"
|
||||
:disabled="loading"
|
||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 3: CREATE ACCOUNT
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 3" 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
|
||||
@@ -187,7 +144,7 @@
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Merchant Name <span class="text-red-500">*</span>
|
||||
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"/>
|
||||
@@ -218,7 +175,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex gap-4">
|
||||
<button @click="currentStep = 2"
|
||||
<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>
|
||||
@@ -231,9 +188,9 @@
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 4: PAYMENT
|
||||
STEP 3: PAYMENT
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 4" class="p-8">
|
||||
<div x-show="currentStep === 3" 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>
|
||||
|
||||
@@ -242,7 +199,7 @@
|
||||
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
||||
|
||||
<div class="mt-8 flex gap-4">
|
||||
<button @click="currentStep = 3"
|
||||
<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>
|
||||
@@ -276,12 +233,7 @@ function signupWizard() {
|
||||
selectedTier: '{{ selected_tier or "professional" }}',
|
||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||
|
||||
// Step 2: Letzshop
|
||||
letzshopUrl: '',
|
||||
letzshopStore: null,
|
||||
letzshopError: null,
|
||||
|
||||
// Step 3: Account
|
||||
// Step 2: Account
|
||||
account: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@@ -291,7 +243,7 @@ function signupWizard() {
|
||||
},
|
||||
accountError: null,
|
||||
|
||||
// Step 4: Payment
|
||||
// Step 3: Payment
|
||||
stripe: null,
|
||||
cardElement: null,
|
||||
paymentProcessing: false,
|
||||
@@ -306,13 +258,10 @@ function signupWizard() {
|
||||
if (params.get('annual') === 'true') {
|
||||
this.isAnnual = true;
|
||||
}
|
||||
if (params.get('letzshop')) {
|
||||
this.letzshopUrl = params.get('letzshop');
|
||||
}
|
||||
|
||||
// Initialize Stripe when we get to step 4
|
||||
// Initialize Stripe when we get to step 3
|
||||
this.$watch('currentStep', (step) => {
|
||||
if (step === 4) {
|
||||
if (step === 3) {
|
||||
this.initStripe();
|
||||
}
|
||||
});
|
||||
@@ -327,7 +276,8 @@ function signupWizard() {
|
||||
body: JSON.stringify({
|
||||
tier_code: this.selectedTier,
|
||||
is_annual: this.isAnnual,
|
||||
platform_code: '{{ platform.code }}'
|
||||
platform_code: '{{ platform.code }}',
|
||||
language: '{{ current_language|default("fr") }}'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -346,59 +296,6 @@ function signupWizard() {
|
||||
}
|
||||
},
|
||||
|
||||
async claimStore() {
|
||||
if (this.letzshopUrl.trim()) {
|
||||
this.loading = true;
|
||||
this.letzshopError = null;
|
||||
|
||||
try {
|
||||
// First lookup the store
|
||||
const lookupResponse = await fetch('/api/v1/platform/letzshop-stores/lookup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: this.letzshopUrl })
|
||||
});
|
||||
|
||||
const lookupData = await lookupResponse.json();
|
||||
|
||||
if (lookupData.found && !lookupData.store.is_claimed) {
|
||||
this.letzshopStore = lookupData.store;
|
||||
|
||||
// Claim the store
|
||||
const claimResponse = await fetch('/api/v1/platform/signup/claim-store', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: this.sessionId,
|
||||
letzshop_slug: lookupData.store.slug
|
||||
})
|
||||
});
|
||||
|
||||
if (claimResponse.ok) {
|
||||
const claimData = await claimResponse.json();
|
||||
this.account.merchantName = claimData.store_name || '';
|
||||
this.currentStep = 3;
|
||||
} else {
|
||||
const error = await claimResponse.json();
|
||||
this.letzshopError = error.detail || 'Failed to claim store';
|
||||
}
|
||||
} else if (lookupData.store?.is_claimed) {
|
||||
this.letzshopError = 'This shop has already been claimed.';
|
||||
} else {
|
||||
this.letzshopError = lookupData.error || 'Shop not found.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
this.letzshopError = 'Failed to lookup store.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
} else {
|
||||
// Skip this step
|
||||
this.currentStep = 3;
|
||||
}
|
||||
},
|
||||
|
||||
isAccountValid() {
|
||||
return this.account.firstName.trim() &&
|
||||
this.account.lastName.trim() &&
|
||||
@@ -427,7 +324,7 @@ function signupWizard() {
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.currentStep = 4;
|
||||
this.currentStep = 3;
|
||||
} else {
|
||||
this.accountError = data.detail || 'Failed to create account';
|
||||
}
|
||||
@@ -516,7 +413,6 @@ function signupWizard() {
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('store_token', data.access_token);
|
||||
localStorage.setItem('storeCode', data.store_code);
|
||||
console.log('Store token stored for automatic login');
|
||||
}
|
||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
||||
} else {
|
||||
|
||||
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
@@ -0,0 +1,364 @@
|
||||
# app/modules/billing/tests/unit/test_signup_service.py
|
||||
"""Unit tests for SignupService (simplified 3-step signup)."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.exceptions import (
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.billing.models import TierCode
|
||||
from app.modules.billing.services.signup_service import SignupService, _signup_sessions
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_sessions():
|
||||
"""Clear in-memory signup sessions before each test."""
|
||||
_signup_sessions.clear()
|
||||
yield
|
||||
_signup_sessions.clear()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestSignupServiceSession:
|
||||
"""Tests for SignupService session management."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = SignupService()
|
||||
|
||||
def test_create_session_stores_language(self):
|
||||
"""create_session stores the user's browsing language."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="loyalty",
|
||||
language="de",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session is not None
|
||||
assert session["language"] == "de"
|
||||
|
||||
def test_create_session_default_language_fr(self):
|
||||
"""create_session defaults to French when no language provided."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="oms",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session["language"] == "fr"
|
||||
|
||||
def test_create_session_stores_platform_code(self):
|
||||
"""create_session stores the platform code."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.PROFESSIONAL.value,
|
||||
is_annual=True,
|
||||
platform_code="loyalty",
|
||||
language="en",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session["platform_code"] == "loyalty"
|
||||
assert session["is_annual"] is True
|
||||
assert session["tier_code"] == TierCode.PROFESSIONAL.value
|
||||
|
||||
def test_create_session_raises_without_platform_code(self):
|
||||
"""create_session raises ValidationException when platform_code is empty."""
|
||||
with pytest.raises(ValidationException):
|
||||
self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="",
|
||||
)
|
||||
|
||||
def test_get_session_or_raise_missing(self):
|
||||
"""get_session_or_raise raises ResourceNotFoundException for invalid session."""
|
||||
with pytest.raises(ResourceNotFoundException):
|
||||
self.service.get_session_or_raise("nonexistent_session_id")
|
||||
|
||||
def test_delete_session(self):
|
||||
"""delete_session removes the session from storage."""
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code="oms",
|
||||
)
|
||||
assert self.service.get_session(session_id) is not None
|
||||
|
||||
self.service.delete_session(session_id)
|
||||
assert self.service.get_session(session_id) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestSignupServiceAccountCreation:
|
||||
"""Tests for SignupService.create_account (merged store creation)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = SignupService()
|
||||
|
||||
def test_create_account_creates_store(self, db):
|
||||
"""create_account creates User + Merchant + Store atomically."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
# Create platform
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
# Create a tier for the platform
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
# Create session
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
language="de",
|
||||
)
|
||||
|
||||
# Mock Stripe
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_test123"
|
||||
|
||||
result = self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||
password="securepass123", # noqa: SEC-001 # noqa: SEC-001
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
merchant_name="John's Shop",
|
||||
)
|
||||
|
||||
# Verify result includes store info
|
||||
assert result.user_id is not None
|
||||
assert result.merchant_id is not None
|
||||
assert result.store_id is not None
|
||||
assert result.store_code is not None
|
||||
assert result.stripe_customer_id == "cus_test123"
|
||||
|
||||
# Verify store was created with correct language
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||
assert store is not None
|
||||
assert store.name == "John's Shop"
|
||||
assert store.default_language == "de"
|
||||
|
||||
def test_create_account_uses_session_language(self, db):
|
||||
"""create_account sets store default_language from the signup session."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
language="en",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_test456"
|
||||
|
||||
result = self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="Jane",
|
||||
last_name="Smith",
|
||||
merchant_name="Jane's Bakery",
|
||||
)
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||
assert store.default_language == "en"
|
||||
|
||||
def test_create_account_rejects_duplicate_email(self, db):
|
||||
"""create_account raises ConflictException for existing email."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
email = f"dup_{uuid.uuid4().hex[:8]}@example.com"
|
||||
|
||||
# Create first account
|
||||
session_id1 = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_first"
|
||||
self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id1,
|
||||
email=email,
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="First",
|
||||
last_name="User",
|
||||
merchant_name="First Shop",
|
||||
)
|
||||
|
||||
# Try to create second account with same email
|
||||
session_id2 = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
with pytest.raises(ConflictException):
|
||||
self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id2,
|
||||
email=email,
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
merchant_name="Second Shop",
|
||||
)
|
||||
|
||||
def test_create_account_updates_session(self, db):
|
||||
"""create_account updates session with user/merchant/store IDs."""
|
||||
from app.modules.billing.models import SubscriptionTier
|
||||
from app.modules.tenancy.models import Platform
|
||||
|
||||
platform = Platform(
|
||||
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||
name="Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
|
||||
tier = SubscriptionTier(
|
||||
code=TierCode.ESSENTIAL.value,
|
||||
name="Essential",
|
||||
platform_id=platform.id,
|
||||
price_monthly_cents=0,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
|
||||
session_id = self.service.create_session(
|
||||
tier_code=TierCode.ESSENTIAL.value,
|
||||
is_annual=False,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.signup_service.stripe_service"
|
||||
) as mock_stripe:
|
||||
mock_stripe.create_customer_for_merchant.return_value = "cus_test789"
|
||||
|
||||
result = self.service.create_account(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||
password="securepass123", # noqa: SEC-001
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
merchant_name="Test Shop",
|
||||
)
|
||||
|
||||
session = self.service.get_session(session_id)
|
||||
assert session["user_id"] == result.user_id
|
||||
assert session["merchant_id"] == result.merchant_id
|
||||
assert session["store_id"] == result.store_id
|
||||
assert session["store_code"] == result.store_code
|
||||
assert session["stripe_customer_id"] == "cus_test789"
|
||||
assert session["step"] == "account_created"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestSignupServicePostRedirect:
|
||||
"""Tests for SignupService._get_post_signup_redirect."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = SignupService()
|
||||
|
||||
def test_always_redirects_to_dashboard(self, db):
|
||||
"""Post-signup redirect always goes to store dashboard."""
|
||||
session = {"platform_code": "loyalty"}
|
||||
url = self.service._get_post_signup_redirect(db, session, "MY_STORE")
|
||||
assert url == "/store/MY_STORE/dashboard"
|
||||
|
||||
def test_redirect_for_oms_platform(self, db):
|
||||
"""OMS platform also redirects to dashboard (not onboarding wizard)."""
|
||||
session = {"platform_code": "oms"}
|
||||
url = self.service._get_post_signup_redirect(db, session, "OMS_STORE")
|
||||
assert url == "/store/OMS_STORE/dashboard"
|
||||
Reference in New Issue
Block a user