You won't be charged until your {{ trial_days }}-day trial ends.
-
Back
@@ -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 {
diff --git a/app/modules/billing/tests/unit/test_signup_service.py b/app/modules/billing/tests/unit/test_signup_service.py
new file mode 100644
index 00000000..53748a07
--- /dev/null
+++ b/app/modules/billing/tests/unit/test_signup_service.py
@@ -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"
diff --git a/app/modules/contracts/__init__.py b/app/modules/contracts/__init__.py
index dfc4e690..32bb577e 100644
--- a/app/modules/contracts/__init__.py
+++ b/app/modules/contracts/__init__.py
@@ -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",
diff --git a/app/modules/contracts/onboarding.py b/app/modules/contracts/onboarding.py
new file mode 100644
index 00000000..1113151e
--- /dev/null
+++ b/app/modules/contracts/onboarding.py
@@ -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",
+]
diff --git a/app/modules/core/routes/api/store_dashboard.py b/app/modules/core/routes/api/store_dashboard.py
index c517e8be..b54e4c5d 100644
--- a/app/modules/core/routes/api/store_dashboard.py
+++ b/app/modules/core/routes/api/store_dashboard.py
@@ -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,
+ )
diff --git a/app/modules/core/services/onboarding_aggregator.py b/app/modules/core/services/onboarding_aggregator.py
new file mode 100644
index 00000000..be097db3
--- /dev/null
+++ b/app/modules/core/services/onboarding_aggregator.py
@@ -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"]
diff --git a/app/modules/core/static/store/js/dashboard.js b/app/modules/core/static/store/js/dashboard.js
index 9be05cbc..51128e5c 100644
--- a/app/modules/core/static/store/js/dashboard.js
+++ b/app/modules/core/static/store/js/dashboard.js
@@ -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);
diff --git a/app/modules/core/templates/core/store/dashboard.html b/app/modules/core/templates/core/store/dashboard.html
index addbe00f..75be0a7e 100644
--- a/app/modules/core/templates/core/store/dashboard.html
+++ b/app/modules/core/templates/core/store/dashboard.html
@@ -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() }}
+
+{{ onboarding_banner() }}
+
{{ limit_warning("orders") }}
{{ limit_warning("products") }}
diff --git a/app/modules/core/tests/integration/test_onboarding_routes.py b/app/modules/core/tests/integration/test_onboarding_routes.py
new file mode 100644
index 00000000..af427933
--- /dev/null
+++ b/app/modules/core/tests/integration/test_onboarding_routes.py
@@ -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
diff --git a/app/modules/core/tests/unit/test_onboarding_aggregator.py b/app/modules/core/tests/unit/test_onboarding_aggregator.py
new file mode 100644
index 00000000..76de927b
--- /dev/null
+++ b/app/modules/core/tests/unit/test_onboarding_aggregator.py
@@ -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"
diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py
index 8e2ffc5d..c8f1d9aa 100644
--- a/app/modules/loyalty/definition.py
+++ b/app/modules/loyalty/definition.py
@@ -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,
)
diff --git a/app/modules/loyalty/services/loyalty_onboarding.py b/app/modules/loyalty/services/loyalty_onboarding.py
new file mode 100644
index 00000000..0fecfb57
--- /dev/null
+++ b/app/modules/loyalty/services/loyalty_onboarding.py
@@ -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()
diff --git a/app/modules/loyalty/tests/unit/test_loyalty_onboarding.py b/app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
new file mode 100644
index 00000000..6246f626
--- /dev/null
+++ b/app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
@@ -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
diff --git a/app/modules/marketplace/definition.py b/app/modules/marketplace/definition.py
index 79e1f266..7ed6c7c7 100644
--- a/app/modules/marketplace/definition.py
+++ b/app/modules/marketplace/definition.py
@@ -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,
)
diff --git a/app/modules/marketplace/routes/pages/store.py b/app/modules/marketplace/routes/pages/store.py
index 82d94a0b..5d7785c7 100644
--- a/app/modules/marketplace/routes/pages/store.py
+++ b/app/modules/marketplace/routes/pages/store.py
@@ -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),
diff --git a/app/modules/marketplace/services/marketplace_onboarding.py b/app/modules/marketplace/services/marketplace_onboarding.py
new file mode 100644
index 00000000..385a2742
--- /dev/null
+++ b/app/modules/marketplace/services/marketplace_onboarding.py
@@ -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()
diff --git a/app/modules/marketplace/tests/unit/test_marketplace_onboarding.py b/app/modules/marketplace/tests/unit/test_marketplace_onboarding.py
new file mode 100644
index 00000000..8e6ae3c1
--- /dev/null
+++ b/app/modules/marketplace/tests/unit/test_marketplace_onboarding.py
@@ -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
diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py
index dbe70d3b..9d57310e 100644
--- a/app/modules/tenancy/definition.py
+++ b/app/modules/tenancy/definition.py
@@ -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"]
diff --git a/app/modules/tenancy/services/tenancy_onboarding.py b/app/modules/tenancy/services/tenancy_onboarding.py
new file mode 100644
index 00000000..dd330f8b
--- /dev/null
+++ b/app/modules/tenancy/services/tenancy_onboarding.py
@@ -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()
diff --git a/app/modules/tenancy/tests/unit/test_tenancy_onboarding.py b/app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
new file mode 100644
index 00000000..0a870df4
--- /dev/null
+++ b/app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
@@ -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
diff --git a/app/templates/shared/macros/feature_gate.html b/app/templates/shared/macros/feature_gate.html
index c01ee0d2..67014103 100644
--- a/app/templates/shared/macros/feature_gate.html
+++ b/app/templates/shared/macros/feature_gate.html
@@ -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() %}
+
+ {# Header with progress #}
+
+
+
+
+
Get Started
+
+
+
+
+
+
+
+
+ {# Progress bar #}
+
+
+
+ {# Step checklist #}
+
+
+{% endmacro %}
+
+
{# =============================================================================
Email Settings Warning
Shows warning banner when store email settings are not configured.