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:
2026-02-28 23:39:42 +01:00
parent f8a2394da5
commit ef9ea29643
26 changed files with 2055 additions and 699 deletions

View File

@@ -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,
)

View File

@@ -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"

View File

@@ -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 %}
&bull;
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
</p>
</div>
<div class="text-right">
<template x-if="!isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
</template>
<template x-if="isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
</template>
</div>
</div>
</div>
</label>
{% endif %}
{% endfor %}
</div>
{# Free Trial Note #}
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-sm text-green-800 dark:text-green-300">
<strong>{{ trial_days }}-day free trial.</strong>
We'll collect your payment info, but you won't be charged until the trial ends.
</p>
</div>
<button @click="startSignup()"
:disabled="!selectedTier || loading"
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
Continue
</button>
</div>
{# ===============================================================
STEP 2: CREATE ACCOUNT
=============================================================== #}
<div x-show="currentStep === 2" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span class="text-red-500">*</span> Required fields
</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.firstName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.lastName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.merchantName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="account.email" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-red-500">*</span>
</label>
<input type="password" x-model="account.password" required minlength="8"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<template x-if="accountError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createAccount()"
:disabled="loading || !isAccountValid()"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue
</button>
</div>
</div>
{# ===============================================================
STEP 3: SET UP STORE
=============================================================== #}
<div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Set Up Your Store</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">Your store is where your loyalty programs live. You can change these settings later.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Store Name
</label>
<input type="text" x-model="storeName"
:placeholder="account.merchantName || 'My Store'"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Defaults to your business name if left empty</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Store Language
</label>
<select x-model="storeLanguage"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="lb">Lëtzebuergesch</option>
</select>
</div>
<template x-if="storeError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="storeError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 2"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createStore()"
:disabled="loading"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue to Payment
</button>
</div>
</div>
{# ===============================================================
STEP 4: PAYMENT
=============================================================== #}
<div x-show="currentStep === 4" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
{# Stripe Card Element #}
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 3"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="submitPayment()"
:disabled="loading || paymentProcessing"
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
<template x-if="paymentProcessing">
<span>Processing...</span>
</template>
<template x-if="!paymentProcessing">
<span>Start Free Trial</span>
</template>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function loyaltySignupWizard() {
return {
currentStep: 1,
loading: false,
sessionId: null,
platformCode: '{{ platform.code if platform else "loyalty" }}',
// Step 1: Plan
selectedTier: '{{ selected_tier or "professional" }}',
isAnnual: {{ 'true' if is_annual else 'false' }},
// Step 2: Account
account: {
firstName: '',
lastName: '',
merchantName: '',
email: '',
password: ''
},
accountError: null,
// Step 3: Store
storeName: '',
storeLanguage: 'fr',
storeError: null,
// Step 4: Payment
stripe: null,
cardElement: null,
paymentProcessing: false,
clientSecret: null,
init() {
// Check URL params for pre-selection
const params = new URLSearchParams(window.location.search);
if (params.get('tier')) {
this.selectedTier = params.get('tier');
}
if (params.get('annual') === 'true') {
this.isAnnual = true;
}
// Initialize Stripe when we get to step 4
this.$watch('currentStep', (step) => {
if (step === 4) {
this.initStripe();
}
});
},
async startSignup() {
this.loading = true;
try {
const response = await fetch('/api/v1/platform/signup/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier_code: this.selectedTier,
is_annual: this.isAnnual,
platform_code: this.platformCode
})
});
const data = await response.json();
if (response.ok) {
this.sessionId = data.session_id;
this.currentStep = 2;
} else {
alert(data.detail || 'Failed to start signup');
}
} catch (error) {
console.error('Error:', error);
alert('Failed to start signup. Please try again.');
} finally {
this.loading = false;
}
},
isAccountValid() {
return this.account.firstName.trim() &&
this.account.lastName.trim() &&
this.account.merchantName.trim() &&
this.account.email.trim() &&
this.account.password.length >= 8;
},
async createAccount() {
this.loading = true;
this.accountError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
email: this.account.email,
password: this.account.password,
first_name: this.account.firstName,
last_name: this.account.lastName,
merchant_name: this.account.merchantName
})
});
const data = await response.json();
if (response.ok) {
// Default store name to merchant name
if (!this.storeName) {
this.storeName = this.account.merchantName;
}
this.currentStep = 3;
} else {
this.accountError = data.detail || 'Failed to create account';
}
} catch (error) {
console.error('Error:', error);
this.accountError = 'Failed to create account. Please try again.';
} finally {
this.loading = false;
}
},
async createStore() {
this.loading = true;
this.storeError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
store_name: this.storeName || null,
language: this.storeLanguage
})
});
const data = await response.json();
if (response.ok) {
this.currentStep = 4;
} else {
this.storeError = data.detail || 'Failed to create store';
}
} catch (error) {
console.error('Error:', error);
this.storeError = 'Failed to create store. Please try again.';
} finally {
this.loading = false;
}
},
async initStripe() {
{% if stripe_publishable_key %}
this.stripe = Stripe('{{ stripe_publishable_key }}');
const elements = this.stripe.elements();
this.cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#374151',
'::placeholder': { color: '#9CA3AF' }
}
}
});
this.cardElement.mount('#card-element');
this.cardElement.on('change', (event) => {
const displayError = document.getElementById('card-errors');
displayError.textContent = event.error ? event.error.message : '';
});
// Get SetupIntent
try {
const response = await fetch('/api/v1/platform/signup/setup-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId })
});
const data = await response.json();
if (response.ok) {
this.clientSecret = data.client_secret;
}
} catch (error) {
console.error('Error getting SetupIntent:', error);
}
{% else %}
console.warn('Stripe not configured');
{% endif %}
},
async submitPayment() {
if (!this.stripe || !this.clientSecret) {
alert('Payment not configured. Please contact support.');
return;
}
this.paymentProcessing = true;
try {
const { setupIntent, error } = await this.stripe.confirmCardSetup(
this.clientSecret,
{ payment_method: { card: this.cardElement } }
);
if (error) {
document.getElementById('card-errors').textContent = error.message;
this.paymentProcessing = false;
return;
}
// Complete signup
const response = await fetch('/api/v1/platform/signup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
setup_intent_id: setupIntent.id
})
});
const data = await response.json();
if (response.ok) {
// Store access token for automatic login
if (data.access_token) {
localStorage.setItem('store_token', data.access_token);
localStorage.setItem('storeCode', data.store_code);
}
window.location.href = '/signup/success?store_code=' + data.store_code;
} else {
alert(data.detail || 'Failed to complete signup');
}
} catch (error) {
console.error('Payment error:', error);
alert('Payment failed. Please try again.');
} finally {
this.paymentProcessing = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -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 }}&euro;/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 }}&euro;/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 {

View 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"