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

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