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:
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user