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

@@ -40,6 +40,7 @@ class SignupStartRequest(BaseModel):
tier_code: str
is_annual: bool = False
platform_code: str
language: str = "fr"
class SignupStartResponse(BaseModel):
@@ -64,12 +65,14 @@ class CreateAccountRequest(BaseModel):
class CreateAccountResponse(BaseModel):
"""Response from account creation."""
"""Response from account creation (includes auto-created store)."""
session_id: str
user_id: int
merchant_id: int
stripe_customer_id: str
store_id: int
store_code: str
class CreateStoreRequest(BaseModel):
@@ -137,6 +140,7 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
tier_code=request.tier_code,
is_annual=request.is_annual,
platform_code=request.platform_code,
language=request.language,
)
return SignupStartResponse(
@@ -175,6 +179,8 @@ async def create_account(
user_id=result.user_id,
merchant_id=result.merchant_id,
stripe_customer_id=result.stripe_customer_id,
store_id=result.store_id,
store_code=result.store_code,
)

View File

@@ -49,6 +49,7 @@ if TYPE_CHECKING:
from app.modules.contracts.cms import MediaUsageProviderProtocol
from app.modules.contracts.features import FeatureProviderProtocol
from app.modules.contracts.metrics import MetricsProviderProtocol
from app.modules.contracts.onboarding import OnboardingProviderProtocol
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
from app.modules.enums import FrontendType
@@ -486,6 +487,29 @@ class ModuleDefinition:
# to report where media is being used.
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
# =========================================================================
# Onboarding Provider (Module-Driven Post-Signup Onboarding)
# =========================================================================
# Callable that returns an OnboardingProviderProtocol implementation.
# Modules declare onboarding steps (what needs to be configured after signup)
# and provide completion checks. The core module's OnboardingAggregator
# discovers and aggregates all providers into a dashboard checklist banner.
#
# Example:
# def _get_onboarding_provider():
# from app.modules.marketplace.services.marketplace_onboarding import (
# marketplace_onboarding_provider,
# )
# return marketplace_onboarding_provider
#
# marketplace_module = ModuleDefinition(
# code="marketplace",
# onboarding_provider=_get_onboarding_provider,
# )
#
# The provider will be discovered by core's OnboardingAggregator service.
onboarding_provider: "Callable[[], OnboardingProviderProtocol] | None" = None
# =========================================================================
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
# =========================================================================
@@ -955,6 +979,24 @@ class ModuleDefinition:
return None
return self.media_usage_provider()
# =========================================================================
# Onboarding Provider Methods
# =========================================================================
def has_onboarding_provider(self) -> bool:
"""Check if this module has an onboarding provider."""
return self.onboarding_provider is not None
def get_onboarding_provider_instance(self) -> "OnboardingProviderProtocol | None":
"""Get the onboarding provider instance for this module.
Returns:
OnboardingProviderProtocol instance, or None
"""
if self.onboarding_provider is None:
return None
return self.onboarding_provider()
# =========================================================================
# Magic Methods
# =========================================================================

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"

View File

@@ -63,6 +63,11 @@ from app.modules.contracts.metrics import (
MetricsProviderProtocol,
MetricValue,
)
from app.modules.contracts.onboarding import (
OnboardingProviderProtocol,
OnboardingStepDefinition,
OnboardingStepStatus,
)
from app.modules.contracts.widgets import (
BreakdownWidget,
DashboardWidget,
@@ -92,6 +97,10 @@ __all__ = [
"MetricValue",
"MetricsContext",
"MetricsProviderProtocol",
# Onboarding protocols
"OnboardingStepDefinition",
"OnboardingStepStatus",
"OnboardingProviderProtocol",
# Widget protocols
"WidgetContext",
"WidgetListItem",

View File

@@ -0,0 +1,154 @@
# app/modules/contracts/onboarding.py
"""
Onboarding provider protocol for module-driven post-signup onboarding.
Each module defines its own onboarding steps (what needs to be configured)
and provides completion checks. The core module's OnboardingAggregator
discovers and aggregates all providers into a dashboard checklist.
Benefits:
- Modules own their onboarding steps (billing module doesn't need to know)
- Steps appear/disappear based on which modules are enabled
- Easy to add new steps (just implement protocol in your module)
- Dashboard banner guides merchants without blocking signup
Usage:
# 1. Implement the protocol in your module
class MarketplaceOnboardingProvider:
@property
def onboarding_category(self) -> str:
return "marketplace"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="marketplace.connect_api",
title_key="onboarding.marketplace.connect_api.title",
description_key="onboarding.marketplace.connect_api.description",
icon="plug",
route_template="/store/{store_code}/letzshop",
order=200,
)
]
def is_step_completed(self, db, store_id, step_key) -> bool:
...
# 2. Register in module definition
def _get_onboarding_provider():
from app.modules.marketplace.services.marketplace_onboarding import (
marketplace_onboarding_provider,
)
return marketplace_onboarding_provider
marketplace_module = ModuleDefinition(
code="marketplace",
onboarding_provider=_get_onboarding_provider,
# ...
)
# 3. Steps appear automatically in dashboard when module is enabled
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from sqlalchemy.orm import Session
@dataclass
class OnboardingStepDefinition:
"""
Definition of a single onboarding step.
Attributes:
key: Unique identifier (e.g., "marketplace.connect_api")
Format: "{module}.{step_name}" for consistency
title_key: i18n key for display title
description_key: i18n key for description text
icon: Lucide icon name for UI display (e.g., "plug", "package")
route_template: URL template with {store_code} placeholder
order: Display sort order (lower = first). Default 100.
category: Grouping category (typically matches module code)
"""
key: str
title_key: str
description_key: str
icon: str
route_template: str
order: int = 100
category: str = ""
@dataclass
class OnboardingStepStatus:
"""
An onboarding step paired with its completion status.
Attributes:
step: The step definition
completed: Whether the step has been completed
"""
step: OnboardingStepDefinition
completed: bool
@runtime_checkable
class OnboardingProviderProtocol(Protocol):
"""
Protocol for modules that provide onboarding steps.
Each module implements this to declare what setup steps are needed
after signup. The core module's OnboardingAggregator discovers and
aggregates all providers into a dashboard checklist banner.
Implementation Notes:
- Providers should be stateless (all data via db session)
- Return empty list from get_onboarding_steps() if no steps needed
- is_step_completed() should be efficient (called per step per page load)
- Use consistent key format: "{category}.{step_name}"
"""
@property
def onboarding_category(self) -> str:
"""
Category name for this provider's onboarding steps.
Should match the module code (e.g., "marketplace", "tenancy").
"""
...
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
"""
Get onboarding step definitions provided by this module.
Returns:
List of OnboardingStepDefinition objects
"""
...
def is_step_completed(
self, db: "Session", store_id: int, step_key: str
) -> bool:
"""
Check if a specific onboarding step is completed for a store.
Args:
db: Database session for queries
store_id: ID of the store to check
step_key: The step key to check (from OnboardingStepDefinition.key)
Returns:
True if the step is completed
"""
...
__all__ = [
"OnboardingStepDefinition",
"OnboardingStepStatus",
"OnboardingProviderProtocol",
]

View File

@@ -24,6 +24,7 @@ from app.modules.core.schemas.dashboard import (
StoreProductStats,
StoreRevenueStats,
)
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.exceptions import StoreNotActiveException
from app.modules.tenancy.schemas.auth import UserContext
@@ -124,3 +125,27 @@ def get_store_dashboard_stats(
this_month=float(revenue_this_month),
),
)
@store_dashboard_router.get("/onboarding")
def get_onboarding_status(
request: Request,
current_user: UserContext = Depends(get_current_store_api),
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
Get onboarding checklist status for the current store.
Returns steps from all enabled modules with completion status,
progress percentage, and whether all steps are completed.
"""
store_id = current_user.token_store_id
store = store_service.get_store_by_id(db, store_id)
return onboarding_aggregator.get_onboarding_summary(
db=db,
store_id=store_id,
platform_id=platform.id,
store_code=store.store_code,
)

View File

@@ -0,0 +1,157 @@
# app/modules/core/services/onboarding_aggregator.py
"""
Onboarding aggregator service for collecting onboarding steps from all modules.
This service lives in core because the dashboard onboarding banner is core
functionality. It discovers OnboardingProviders from all enabled modules and
provides a unified interface for the dashboard checklist.
Usage:
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
summary = onboarding_aggregator.get_onboarding_summary(
db=db, store_id=123, platform_id=1, store_code="my-store"
)
# Returns: {steps, total_steps, completed_steps, progress_percentage, all_completed}
"""
import logging
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import (
OnboardingProviderProtocol,
OnboardingStepStatus,
)
if TYPE_CHECKING:
from app.modules.base import ModuleDefinition
logger = logging.getLogger(__name__)
class OnboardingAggregatorService:
"""
Aggregates onboarding steps from all module providers.
Discovers OnboardingProviders from enabled modules and provides
a unified interface for the dashboard onboarding banner.
"""
def _get_enabled_providers(
self, db: Session, platform_id: int
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
"""
Get onboarding providers from enabled modules.
Returns:
List of (module, provider) tuples for enabled modules with providers
"""
from app.modules.registry import MODULES
from app.modules.service import module_service
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
for module in MODULES.values():
if not module.has_onboarding_provider():
continue
# Core modules are always enabled, check others
if not module.is_core:
try:
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
except Exception as e:
logger.warning(
f"Failed to check if module {module.code} is enabled: {e}"
)
continue
try:
provider = module.get_onboarding_provider_instance()
if provider is not None:
providers.append((module, provider))
except Exception as e:
logger.warning(
f"Failed to get onboarding provider for module {module.code}: {e}"
)
return providers
def get_onboarding_steps(
self, db: Session, store_id: int, platform_id: int
) -> list[OnboardingStepStatus]:
"""
Get all onboarding steps with completion status, sorted by order.
Returns:
Sorted list of OnboardingStepStatus objects
"""
providers = self._get_enabled_providers(db, platform_id)
steps: list[OnboardingStepStatus] = []
for module, provider in providers:
try:
step_defs = provider.get_onboarding_steps()
for step_def in step_defs:
try:
completed = provider.is_step_completed(db, store_id, step_def.key)
except Exception as e:
logger.warning(
f"Failed to check step {step_def.key} from {module.code}: {e}"
)
completed = False
steps.append(OnboardingStepStatus(step=step_def, completed=completed))
except Exception as e:
logger.warning(
f"Failed to get onboarding steps from module {module.code}: {e}"
)
steps.sort(key=lambda s: s.step.order)
return steps
def get_onboarding_summary(
self,
db: Session,
store_id: int,
platform_id: int,
store_code: str = "",
) -> dict[str, Any]:
"""
Get onboarding summary for the dashboard API.
Returns:
Dict with: steps[], total_steps, completed_steps, progress_percentage, all_completed
"""
step_statuses = self.get_onboarding_steps(db, store_id, platform_id)
total = len(step_statuses)
completed = sum(1 for s in step_statuses if s.completed)
steps_data = []
for status in step_statuses:
route = status.step.route_template.replace("{store_code}", store_code)
steps_data.append({
"key": status.step.key,
"title_key": status.step.title_key,
"description_key": status.step.description_key,
"icon": status.step.icon,
"route": route,
"completed": status.completed,
"category": status.step.category,
})
return {
"steps": steps_data,
"total_steps": total,
"completed_steps": completed,
"progress_percentage": round((completed / total * 100) if total > 0 else 0),
"all_completed": completed == total and total > 0,
}
# Singleton instance
onboarding_aggregator = OnboardingAggregatorService()
__all__ = ["OnboardingAggregatorService", "onboarding_aggregator"]

View File

@@ -10,6 +10,47 @@ const storeDashLog = window.LogConfig.loggers.dashboard ||
storeDashLog.info('Loading...');
storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
/**
* Onboarding banner component.
* Fetches onboarding steps from API, supports session-scoped dismiss.
*/
function onboardingBanner() {
return {
visible: false,
steps: [],
totalSteps: 0,
completedSteps: 0,
progressPercentage: 0,
async init() {
// Check session-scoped dismiss
if (sessionStorage.getItem('onboarding_dismissed')) {
return;
}
try {
const response = await apiClient.get('/store/dashboard/onboarding');
this.steps = response.steps || [];
this.totalSteps = response.total_steps || 0;
this.completedSteps = response.completed_steps || 0;
this.progressPercentage = response.progress_percentage || 0;
// Show banner only if there are incomplete steps
if (this.totalSteps > 0 && !response.all_completed) {
this.visible = true;
}
} catch (error) {
storeDashLog.error('Failed to load onboarding status', error);
}
},
dismiss() {
sessionStorage.setItem('onboarding_dismissed', 'true');
this.visible = false;
}
};
}
function storeDashboard() {
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);

View File

@@ -8,12 +8,15 @@
{% block alpine_data %}storeDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
{% block content %}
<!-- Email Settings Warning -->
{{ email_settings_warning() }}
<!-- Onboarding Banner -->
{{ onboarding_banner() }}
<!-- Limit Warnings -->
{{ limit_warning("orders") }}
{{ limit_warning("products") }}

View File

@@ -0,0 +1,215 @@
# app/modules/core/tests/integration/test_onboarding_routes.py
"""
Integration tests for the onboarding API endpoint.
Tests the store dashboard onboarding endpoint at:
GET /api/v1/store/dashboard/onboarding
Verifies:
- Endpoint returns onboarding steps with completion status
- Endpoint requires store authentication
- Response structure matches expected schema
"""
import uuid
import pytest
from app.api.deps import get_current_store_api
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def onb_owner(db):
"""Create a store owner user for onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"onbroute_{uuid.uuid4().hex[:8]}@test.com",
username=f"onbroute_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def onb_platform(db):
"""Create a platform for onboarding tests."""
platform = Platform(
code=f"onbp_{uuid.uuid4().hex[:8]}",
name="Onboarding Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def onb_merchant(db, onb_owner):
"""Create a merchant for onboarding tests."""
merchant = Merchant(
name="Onboarding Route Test Merchant",
owner_user_id=onb_owner.id,
contact_email=onb_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def onb_store(db, onb_merchant):
"""Create a store for onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=onb_merchant.id,
store_code=f"ONBROUTE_{uid.upper()}",
subdomain=f"onbroute{uid.lower()}",
name="Onboarding Route Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def onb_store_platform(db, onb_store, onb_platform):
"""Link store to platform."""
sp = StorePlatform(
store_id=onb_store.id,
platform_id=onb_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def onb_auth(onb_owner, onb_store):
"""Override auth dependency for store API auth."""
user_context = UserContext(
id=onb_owner.id,
email=onb_owner.email,
username=onb_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=onb_store.id,
token_store_code=onb_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_api] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_api, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestOnboardingEndpoint:
"""Tests for GET /api/v1/store/dashboard/onboarding."""
def test_returns_onboarding_summary(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Endpoint returns onboarding summary with expected structure."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
assert "steps" in data
assert "total_steps" in data
assert "completed_steps" in data
assert "progress_percentage" in data
assert "all_completed" in data
assert isinstance(data["steps"], list)
assert isinstance(data["total_steps"], int)
assert isinstance(data["completed_steps"], int)
def test_includes_tenancy_step(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Response includes the tenancy customize_store step (always present)."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
step_keys = [s["key"] for s in data["steps"]]
assert "tenancy.customize_store" in step_keys
def test_step_data_has_required_fields(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Each step has key, title_key, description_key, icon, route, completed."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "key" in step
assert "title_key" in step
assert "description_key" in step
assert "icon" in step
assert "route" in step
assert "completed" in step
assert isinstance(step["completed"], bool)
def test_routes_contain_store_code(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Step routes have {store_code} replaced with the actual store code."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "{store_code}" not in step["route"]
if "/store/" in step["route"]:
assert onb_store.store_code in step["route"]
def test_requires_auth(self, client):
"""Returns 401 without authentication."""
app.dependency_overrides.pop(get_current_store_api, None)
response = client.get(
"/api/v1/store/dashboard/onboarding",
)
assert response.status_code == 401

View File

@@ -0,0 +1,275 @@
# app/modules/core/tests/unit/test_onboarding_aggregator.py
"""Unit tests for OnboardingAggregatorService."""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.contracts.onboarding import (
OnboardingStepDefinition,
OnboardingStepStatus,
)
from app.modules.core.services.onboarding_aggregator import OnboardingAggregatorService
def _make_step(key: str, order: int = 100, category: str = "test") -> OnboardingStepDefinition:
return OnboardingStepDefinition(
key=key,
title_key=f"{key}.title",
description_key=f"{key}.desc",
icon="circle",
route_template="/store/{store_code}/test",
order=order,
category=category,
)
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSteps:
"""Tests for OnboardingAggregatorService.get_onboarding_steps."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_aggregates_steps_from_multiple_providers(self, db):
"""Collects steps from all enabled module providers."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 2
assert steps[0].step.key == "tenancy.customize"
assert steps[0].completed is True
assert steps[1].step.key == "marketplace.connect"
assert steps[1].completed is False
def test_steps_sorted_by_order(self, db):
"""Steps are returned sorted by order regardless of provider order."""
step_high = _make_step("loyalty.program", order=300)
step_low = _make_step("tenancy.customize", order=100)
provider_high = MagicMock()
provider_high.onboarding_category = "loyalty"
provider_high.get_onboarding_steps.return_value = [step_high]
provider_high.is_step_completed.return_value = False
provider_low = MagicMock()
provider_low.onboarding_category = "tenancy"
provider_low.get_onboarding_steps.return_value = [step_low]
provider_low.is_step_completed.return_value = False
module_high = MagicMock()
module_high.code = "loyalty"
module_low = MagicMock()
module_low.code = "tenancy"
# Provide high-order first
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_high, provider_high), (module_low, provider_low)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps[0].step.key == "tenancy.customize"
assert steps[1].step.key == "loyalty.program"
def test_empty_when_no_providers(self, db):
"""Returns empty list when no modules have onboarding providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_provider_error_gracefully(self, db):
"""Skips a provider that raises an exception."""
provider = MagicMock()
provider.onboarding_category = "broken"
provider.get_onboarding_steps.side_effect = RuntimeError("DB error")
module = MagicMock()
module.code = "broken"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_step_completion_check_error(self, db):
"""Marks step as incomplete when completion check raises."""
step = _make_step("test.step")
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.side_effect = RuntimeError("query error")
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 1
assert steps[0].completed is False
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSummary:
"""Tests for OnboardingAggregatorService.get_onboarding_summary."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_summary_with_mixed_completion(self, db):
"""Returns correct progress for partially completed onboarding."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
step_c = _make_step("marketplace.import", order=210, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b, step_c]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MYSTORE"
)
assert summary["total_steps"] == 3
assert summary["completed_steps"] == 1
assert summary["progress_percentage"] == 33
assert summary["all_completed"] is False
assert len(summary["steps"]) == 3
def test_summary_all_completed(self, db):
"""Returns all_completed=True when every step is done."""
step = _make_step("tenancy.customize")
provider = MagicMock()
provider.onboarding_category = "tenancy"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "tenancy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["all_completed"] is True
assert summary["progress_percentage"] == 100
def test_summary_empty_providers(self, db):
"""Returns zeros and all_completed=False when no providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["total_steps"] == 0
assert summary["completed_steps"] == 0
assert summary["progress_percentage"] == 0
assert summary["all_completed"] is False
def test_summary_replaces_store_code_in_routes(self, db):
"""Route templates have {store_code} replaced with actual store code."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="circle",
route_template="/store/{store_code}/settings",
order=100,
category="test",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = False
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MY_STORE"
)
assert summary["steps"][0]["route"] == "/store/MY_STORE/settings"
def test_summary_step_data_includes_all_fields(self, db):
"""Each step in summary includes key, title_key, description_key, icon, route, completed, category."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="settings",
route_template="/store/{store_code}/test",
order=100,
category="test_cat",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="S1"
)
step_data = summary["steps"][0]
assert step_data["key"] == "test.step"
assert step_data["title_key"] == "test.title"
assert step_data["description_key"] == "test.desc"
assert step_data["icon"] == "settings"
assert step_data["route"] == "/store/S1/test"
assert step_data["completed"] is True
assert step_data["category"] == "test_cat"

View File

@@ -58,6 +58,15 @@ def _get_feature_provider():
return loyalty_feature_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_onboarding import (
loyalty_onboarding_provider,
)
return loyalty_onboarding_provider
# Loyalty module definition
loyalty_module = ModuleDefinition(
code="loyalty",
@@ -260,6 +269,8 @@ loyalty_module = ModuleDefinition(
],
# Feature provider for billing feature gating
feature_provider=_get_feature_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
)

View File

@@ -0,0 +1,61 @@
# app/modules/loyalty/services/loyalty_onboarding.py
"""
Onboarding provider for the loyalty module.
Provides the "Create your first loyalty program" step.
Completed when at least 1 LoyaltyProgram exists for the store's merchant.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import OnboardingStepDefinition
logger = logging.getLogger(__name__)
class LoyaltyOnboardingProvider:
"""Onboarding provider for loyalty module."""
@property
def onboarding_category(self) -> str:
return "loyalty"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="loyalty.create_program",
title_key="onboarding.loyalty.create_program.title",
description_key="onboarding.loyalty.create_program.description",
icon="gift",
route_template="/store/{store_code}/loyalty/programs",
order=300,
category="loyalty",
),
]
def is_step_completed(
self, db: Session, store_id: int, step_key: str
) -> bool:
if step_key != "loyalty.create_program":
return False
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram
from app.modules.tenancy.models.store import Store
# Programs belong to merchant, not store — join through store
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return False
count = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
.limit(1)
.count()
)
return count > 0
loyalty_onboarding_provider = LoyaltyOnboardingProvider()

View File

@@ -0,0 +1,125 @@
# app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
"""Unit tests for LoyaltyOnboardingProvider."""
import uuid
import pytest
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram, LoyaltyType
from app.modules.loyalty.services.loyalty_onboarding import LoyaltyOnboardingProvider
from app.modules.tenancy.models import Merchant, Store, User
@pytest.fixture
def loy_owner(db):
"""Create a store owner for loyalty onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"loyonb_{uuid.uuid4().hex[:8]}@test.com",
username=f"loyonb_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def loy_merchant(db, loy_owner):
"""Create a merchant for loyalty onboarding tests."""
merchant = Merchant(
name="Loyalty Onboarding Merchant",
owner_user_id=loy_owner.id,
contact_email=loy_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def loy_store(db, loy_merchant):
"""Create a store (no loyalty program) for loyalty onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=loy_merchant.id,
store_code=f"LOYONB_{uid.upper()}",
subdomain=f"loyonb{uid.lower()}",
name="Loyalty Onboarding Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.loyalty
class TestLoyaltyOnboardingProvider:
"""Tests for LoyaltyOnboardingProvider."""
def setup_method(self):
self.provider = LoyaltyOnboardingProvider()
def test_category(self):
"""Returns 'loyalty' as the onboarding category."""
assert self.provider.onboarding_category == "loyalty"
def test_get_onboarding_steps_returns_one_step(self):
"""Returns exactly one step: create_program."""
steps = self.provider.get_onboarding_steps()
assert len(steps) == 1
assert steps[0].key == "loyalty.create_program"
assert steps[0].order == 300
def test_incomplete_without_program(self, db, loy_store):
"""Step is incomplete when merchant has no loyalty programs."""
result = self.provider.is_step_completed(
db, loy_store.id, "loyalty.create_program"
)
assert result is False
def test_complete_with_program(self, db, loy_store, loy_merchant):
"""Step is complete when merchant has at least one loyalty program."""
program = LoyaltyProgram(
merchant_id=loy_merchant.id,
loyalty_type=LoyaltyType.STAMPS.value,
stamps_target=10,
cooldown_minutes=0,
max_daily_stamps=10,
require_staff_pin=False,
card_name="Test Card",
card_color="#FF0000",
is_active=True,
)
db.add(program)
db.commit()
result = self.provider.is_step_completed(
db, loy_store.id, "loyalty.create_program"
)
assert result is True
def test_nonexistent_store_returns_false(self, db):
"""Returns False for a store ID that doesn't exist."""
result = self.provider.is_step_completed(
db, 999999, "loyalty.create_program"
)
assert result is False
def test_unknown_step_key_returns_false(self, db, loy_store):
"""Returns False for an unrecognized step key."""
result = self.provider.is_step_completed(
db, loy_store.id, "loyalty.unknown_step"
)
assert result is False

View File

@@ -59,6 +59,15 @@ def _get_feature_provider():
return marketplace_feature_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.marketplace.services.marketplace_onboarding import (
marketplace_onboarding_provider,
)
return marketplace_onboarding_provider
# Marketplace module definition
marketplace_module = ModuleDefinition(
code="marketplace",
@@ -186,6 +195,8 @@ marketplace_module = ModuleDefinition(
widget_provider=_get_widget_provider,
# Feature provider for feature flags
feature_provider=_get_feature_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
)

View File

@@ -95,15 +95,7 @@ async def store_marketplace_page(
"""
Render marketplace import page.
JavaScript loads import jobs and products via API.
Redirects to onboarding if not completed.
"""
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse(
"marketplace/store/marketplace.html",
get_store_context(request, db, current_user, store_code),
@@ -127,15 +119,7 @@ async def store_letzshop_page(
"""
Render Letzshop integration page.
JavaScript loads orders, credentials status, and handles fulfillment operations.
Redirects to onboarding if not completed.
"""
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse(
"marketplace/store/letzshop.html",
get_store_context(request, db, current_user, store_code),

View File

@@ -0,0 +1,79 @@
# app/modules/marketplace/services/marketplace_onboarding.py
"""
Onboarding provider for the marketplace module.
Provides two onboarding steps:
1. Connect Letzshop API - completed when StoreLetzshopCredentials exists with api_key
2. Import products - completed when at least 1 product exists in catalog
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import OnboardingStepDefinition
logger = logging.getLogger(__name__)
class MarketplaceOnboardingProvider:
"""Onboarding provider for marketplace module."""
@property
def onboarding_category(self) -> str:
return "marketplace"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="marketplace.connect_api",
title_key="onboarding.marketplace.connect_api.title",
description_key="onboarding.marketplace.connect_api.description",
icon="plug",
route_template="/store/{store_code}/letzshop",
order=200,
category="marketplace",
),
OnboardingStepDefinition(
key="marketplace.import_products",
title_key="onboarding.marketplace.import_products.title",
description_key="onboarding.marketplace.import_products.description",
icon="package",
route_template="/store/{store_code}/marketplace",
order=210,
category="marketplace",
),
]
def is_step_completed(
self, db: Session, store_id: int, step_key: str
) -> bool:
if step_key == "marketplace.connect_api":
return self._has_letzshop_credentials(db, store_id)
if step_key == "marketplace.import_products":
return self._has_products(db, store_id)
return False
def _has_letzshop_credentials(self, db: Session, store_id: int) -> bool:
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
creds = (
db.query(StoreLetzshopCredentials)
.filter(StoreLetzshopCredentials.store_id == store_id)
.first()
)
return bool(creds and creds.api_key_encrypted)
def _has_products(self, db: Session, store_id: int) -> bool:
from app.modules.catalog.models.product import Product
count = (
db.query(Product)
.filter(Product.store_id == store_id)
.limit(1)
.count()
)
return count > 0
marketplace_onboarding_provider = MarketplaceOnboardingProvider()

View File

@@ -0,0 +1,140 @@
# app/modules/marketplace/tests/unit/test_marketplace_onboarding.py
"""Unit tests for MarketplaceOnboardingProvider."""
import uuid
import pytest
from app.modules.marketplace.services.marketplace_onboarding import (
MarketplaceOnboardingProvider,
)
from app.modules.tenancy.models import Merchant, Store, User
@pytest.fixture
def mp_owner(db):
"""Create a store owner for marketplace onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"mpowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def mp_merchant(db, mp_owner):
"""Create a merchant for marketplace onboarding tests."""
merchant = Merchant(
name="MP Onboarding Merchant",
owner_user_id=mp_owner.id,
contact_email=mp_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def mp_store(db, mp_merchant):
"""Create a store for marketplace onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=mp_merchant.id,
store_code=f"MPSTORE_{uid.upper()}",
subdomain=f"mpstore{uid.lower()}",
name="MP Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.marketplace
class TestMarketplaceOnboardingProvider:
"""Tests for MarketplaceOnboardingProvider."""
def setup_method(self):
self.provider = MarketplaceOnboardingProvider()
def test_category(self):
"""Returns 'marketplace' as the onboarding category."""
assert self.provider.onboarding_category == "marketplace"
def test_get_onboarding_steps_returns_two_steps(self):
"""Returns two steps: connect_api and import_products."""
steps = self.provider.get_onboarding_steps()
assert len(steps) == 2
assert steps[0].key == "marketplace.connect_api"
assert steps[0].order == 200
assert steps[1].key == "marketplace.import_products"
assert steps[1].order == 210
def test_connect_api_incomplete_without_credentials(self, db, mp_store):
"""connect_api step is incomplete when no credentials exist."""
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.connect_api"
)
assert result is False
def test_connect_api_complete_with_credentials(self, db, mp_store):
"""connect_api step is complete when StoreLetzshopCredentials has api_key."""
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
creds = StoreLetzshopCredentials(
store_id=mp_store.id,
api_key_encrypted="encrypted_key_here",
)
db.add(creds)
db.commit()
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.connect_api"
)
assert result is True
def test_import_products_incomplete_without_products(self, db, mp_store):
"""import_products step is incomplete when store has no products."""
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.import_products"
)
assert result is False
def test_import_products_complete_with_products(self, db, mp_store):
"""import_products step is complete when store has at least one product."""
from app.modules.catalog.models.product import Product
product = Product(
store_id=mp_store.id,
store_sku=f"SKU-{uuid.uuid4().hex[:8]}",
is_active=True,
)
db.add(product)
db.commit()
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.import_products"
)
assert result is True
def test_unknown_step_key_returns_false(self, db, mp_store):
"""Returns False for unrecognized step key."""
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.unknown"
)
assert result is False

View File

@@ -36,6 +36,15 @@ def _get_feature_provider():
return tenancy_feature_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.tenancy.services.tenancy_onboarding import (
tenancy_onboarding_provider,
)
return tenancy_onboarding_provider
tenancy_module = ModuleDefinition(
code="tenancy",
name="Tenancy Management",
@@ -233,6 +242,7 @@ tenancy_module = ModuleDefinition(
# Widget provider for dashboard widgets
widget_provider=_get_widget_provider,
feature_provider=_get_feature_provider,
onboarding_provider=_get_onboarding_provider,
)
__all__ = ["tenancy_module"]

View File

@@ -0,0 +1,55 @@
# app/modules/tenancy/services/tenancy_onboarding.py
"""
Onboarding provider for the tenancy module.
Provides the "Customize your store" onboarding step that is always present
for all platforms. Completed when the store has a description or a logo.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import OnboardingStepDefinition
logger = logging.getLogger(__name__)
class TenancyOnboardingProvider:
"""Onboarding provider for tenancy module (always present)."""
@property
def onboarding_category(self) -> str:
return "tenancy"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="tenancy.customize_store",
title_key="onboarding.tenancy.customize_store.title",
description_key="onboarding.tenancy.customize_store.description",
icon="settings",
route_template="/store/{store_code}/settings",
order=100,
category="tenancy",
),
]
def is_step_completed(
self, db: Session, store_id: int, step_key: str
) -> bool:
if step_key != "tenancy.customize_store":
return False
from app.modules.tenancy.models.store import Store
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return False
has_description = bool(store.description and store.description.strip())
has_logo = bool(store.get_logo_url())
return has_description or has_logo
tenancy_onboarding_provider = TenancyOnboardingProvider()

View File

@@ -0,0 +1,115 @@
# app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
"""Unit tests for TenancyOnboardingProvider."""
import uuid
import pytest
from app.modules.tenancy.models import Merchant, Store, User
from app.modules.tenancy.services.tenancy_onboarding import TenancyOnboardingProvider
@pytest.fixture
def onb_owner(db):
"""Create a store owner for onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"onbowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"onbowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def onb_merchant(db, onb_owner):
"""Create a merchant for onboarding tests."""
merchant = Merchant(
name="Onboarding Test Merchant",
owner_user_id=onb_owner.id,
contact_email=onb_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def onb_store(db, onb_merchant):
"""Create a store with no description and no logo."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=onb_merchant.id,
store_code=f"ONBSTORE_{uid.upper()}",
subdomain=f"onbstore{uid.lower()}",
name="Onboarding Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.tenancy
class TestTenancyOnboardingProvider:
"""Tests for TenancyOnboardingProvider."""
def setup_method(self):
self.provider = TenancyOnboardingProvider()
def test_category(self):
"""Returns 'tenancy' as the onboarding category."""
assert self.provider.onboarding_category == "tenancy"
def test_get_onboarding_steps_returns_one_step(self):
"""Returns exactly one step: customize_store."""
steps = self.provider.get_onboarding_steps()
assert len(steps) == 1
assert steps[0].key == "tenancy.customize_store"
assert steps[0].route_template == "/store/{store_code}/settings"
assert steps[0].order == 100
def test_incomplete_when_no_description_no_logo(self, db, onb_store):
"""Step is not completed when store has no description and no logo."""
assert onb_store.description is None or onb_store.description.strip() == ""
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
assert result is False
def test_completed_when_store_has_description(self, db, onb_store):
"""Step is completed when store has a non-empty description."""
onb_store.description = "We sell great coffee!"
db.commit()
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
assert result is True
def test_incomplete_when_description_is_whitespace(self, db, onb_store):
"""Step is not completed when description is whitespace only."""
onb_store.description = " "
db.commit()
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
assert result is False
def test_incomplete_for_nonexistent_store(self, db):
"""Returns False for a store ID that doesn't exist."""
result = self.provider.is_step_completed(db, 999999, "tenancy.customize_store")
assert result is False
def test_incomplete_for_unknown_step_key(self, db, onb_store):
"""Returns False for an unrecognized step key."""
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.unknown_step")
assert result is False

View File

@@ -337,6 +337,85 @@
{% endmacro %}
{# =============================================================================
Onboarding Banner
Shows a checklist of post-signup onboarding steps from all enabled modules.
Steps are fetched from the API, dismiss is session-scoped (sessionStorage).
Usage:
{{ onboarding_banner() }}
============================================================================= #}
{% macro onboarding_banner() %}
<div x-data="onboardingBanner()"
x-show="visible"
x-cloak
class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
{# Header with progress #}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('rocket', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="t('onboarding.banner.title')">Get Started</h3>
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="`${completedSteps}/${totalSteps}`"></span>
</div>
<button @click="dismiss()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
:title="t('onboarding.banner.dismiss')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{# Progress bar #}
<div class="mt-3 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-purple-600 h-2 rounded-full transition-all duration-500"
:style="`width: ${progressPercentage}%`"></div>
</div>
</div>
{# Step checklist #}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="step in steps" :key="step.key">
<a :href="step.route"
class="flex items-center px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
{# Completion indicator #}
<div class="flex-shrink-0 mr-4">
<template x-if="step.completed">
<div class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
</template>
<template x-if="!step.completed">
<div class="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600 group-hover:border-purple-400 transition-colors"></div>
</template>
</div>
{# Step icon + text #}
<div class="flex-shrink-0 mr-3">
<span x-html="$icon(step.icon, 'w-5 h-5 text-gray-400 dark:text-gray-500')" class="block"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white"
:class="{ 'line-through text-gray-400 dark:text-gray-500': step.completed }"
x-text="t(step.title_key)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"
x-text="t(step.description_key)"></p>
</div>
{# Arrow #}
<template x-if="!step.completed">
<svg class="w-4 h-4 text-gray-400 group-hover:text-purple-500 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</template>
</a>
</template>
</div>
</div>
{% endmacro %}
{# =============================================================================
Email Settings Warning
Shows warning banner when store email settings are not configured.