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

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