diff --git a/frontend/src/api/types/auth.ts b/frontend/src/api/types/auth.ts index 64f092e7..9d248dfe 100644 --- a/frontend/src/api/types/auth.ts +++ b/frontend/src/api/types/auth.ts @@ -28,6 +28,9 @@ export interface UserRegistration { phone?: string; language?: string; timezone?: string; + subscription_plan?: string; + use_trial?: boolean; + payment_method_id?: string; } export interface UserLogin { diff --git a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx index 090424be..efc58a5d 100644 --- a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx @@ -104,10 +104,14 @@ export const OnboardingWizard: React.FC = () => { console.log('🔄 Auto-completing user_registered step for new user...'); setAutoCompletionAttempted(true); + // Merge with any existing data (e.g., subscription_plan from registration) + const existingData = userRegisteredStep?.data || {}; + markStepCompleted.mutate({ userId: user.id, stepName: 'user_registered', data: { + ...existingData, // Preserve existing data like subscription_plan auto_completed: true, completed_at: new Date().toISOString(), source: 'onboarding_wizard_auto_completion' diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts index 2670167c..9f82161a 100644 --- a/frontend/src/stores/auth.store.ts +++ b/frontend/src/stores/auth.store.ts @@ -29,7 +29,15 @@ export interface AuthState { // Actions login: (email: string, password: string) => Promise; - register: (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => Promise; + register: (userData: { + email: string; + password: string; + full_name: string; + tenant_name?: string; + subscription_plan?: string; + use_trial?: boolean; + payment_method_id?: string; + }) => Promise; logout: () => void; refreshAuth: () => Promise; updateUser: (updates: Partial) => void; @@ -93,7 +101,15 @@ export const useAuthStore = create()( } }, - register: async (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => { + register: async (userData: { + email: string; + password: string; + full_name: string; + tenant_name?: string; + subscription_plan?: string; + use_trial?: boolean; + payment_method_id?: string; + }) => { try { set({ isLoading: true, error: null }); diff --git a/services/auth/app/api/onboarding.py b/services/auth/app/api/onboarding.py index c985e4a8..3d7a0d7f 100644 --- a/services/auth/app/api/onboarding.py +++ b/services/auth/app/api/onboarding.py @@ -360,12 +360,12 @@ async def get_user_progress( db: AsyncSession = Depends(get_db) ): """Get current user's onboarding progress""" - + try: onboarding_service = OnboardingService(db) progress = await onboarding_service.get_user_progress(current_user["user_id"]) return progress - + except Exception as e: logger.error(f"Get onboarding progress error: {e}") raise HTTPException( @@ -373,6 +373,39 @@ async def get_user_progress( detail="Failed to get onboarding progress" ) +@router.get("/{user_id}/onboarding/progress", response_model=UserProgress) +async def get_user_progress_by_id( + user_id: str, + current_user: Dict[str, Any] = Depends(get_current_user_dep), + db: AsyncSession = Depends(get_db) +): + """ + Get onboarding progress for a specific user + Available for service-to-service calls and admin users + """ + + # Allow service tokens or admin users + user_type = current_user.get("type", "user") + user_role = current_user.get("role", "user") + + if user_type != "service" and user_role not in ["admin", "super_admin"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions to access other users' onboarding progress" + ) + + try: + onboarding_service = OnboardingService(db) + progress = await onboarding_service.get_user_progress(user_id) + return progress + + except Exception as e: + logger.error(f"Get onboarding progress error for user {user_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get onboarding progress" + ) + @router.put("/me/onboarding/step", response_model=UserProgress) async def update_onboarding_step( update_request: UpdateStepRequest, diff --git a/services/auth/app/repositories/onboarding_repository.py b/services/auth/app/repositories/onboarding_repository.py index a51b6a1c..ec4bef88 100644 --- a/services/auth/app/repositories/onboarding_repository.py +++ b/services/auth/app/repositories/onboarding_repository.py @@ -179,6 +179,57 @@ class OnboardingRepository: await self.db.rollback() return False + async def save_step_data( + self, + user_id: str, + step_name: str, + step_data: Dict[str, Any] + ) -> UserOnboardingProgress: + """Save data for a specific step without marking it as completed""" + try: + # Get existing step or create new one + existing_step = await self.get_user_step(user_id, step_name) + + if existing_step: + # Update existing step data (merge with existing data) + merged_data = {**(existing_step.step_data or {}), **step_data} + + stmt = update(UserOnboardingProgress).where( + and_( + UserOnboardingProgress.user_id == user_id, + UserOnboardingProgress.step_name == step_name + ) + ).values( + step_data=merged_data, + updated_at=datetime.now(timezone.utc) + ).returning(UserOnboardingProgress) + + result = await self.db.execute(stmt) + await self.db.commit() + return result.scalars().first() + else: + # Create new step with data but not completed + return await self.upsert_user_step( + user_id=user_id, + step_name=step_name, + completed=False, + step_data=step_data + ) + + except Exception as e: + logger.error(f"Error saving step data for {step_name}, user {user_id}: {e}") + await self.db.rollback() + raise + + async def get_step_data(self, user_id: str, step_name: str) -> Optional[Dict[str, Any]]: + """Get data for a specific step""" + try: + step = await self.get_user_step(user_id, step_name) + return step.step_data if step else None + except Exception as e: + logger.error(f"Error getting step data for {step_name}, user {user_id}: {e}") + return None + async def get_completion_stats(self) -> Dict[str, Any]: """Get completion statistics across all users""" try: @@ -187,7 +238,7 @@ class OnboardingRepository: select(UserOnboardingSummary).count() ) total_users = total_result.scalar() - + # Get completed users completed_result = await self.db.execute( select(UserOnboardingSummary) @@ -195,13 +246,13 @@ class OnboardingRepository: .count() ) completed_users = completed_result.scalar() - + return { "total_users_in_onboarding": total_users, "fully_completed_users": completed_users, "completion_rate": (completed_users / total_users * 100) if total_users > 0 else 0 } - + except Exception as e: logger.error(f"Error getting completion stats: {e}") return { diff --git a/services/auth/app/schemas/auth.py b/services/auth/app/schemas/auth.py index a3ac0276..07cc11ac 100644 --- a/services/auth/app/schemas/auth.py +++ b/services/auth/app/schemas/auth.py @@ -19,6 +19,9 @@ class UserRegistration(BaseModel): full_name: str = Field(..., min_length=1, max_length=255) tenant_name: Optional[str] = Field(None, max_length=255) role: Optional[str] = Field("admin", pattern=r'^(user|admin|manager|super_admin)$') + subscription_plan: Optional[str] = Field("starter", description="Selected subscription plan (starter, professional, enterprise)") + use_trial: Optional[bool] = Field(False, description="Whether to use trial period") + payment_method_id: Optional[str] = Field(None, description="Stripe payment method ID") class UserLogin(BaseModel): """User login request""" diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index f9430407..0f1e27e8 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -111,7 +111,33 @@ class EnhancedAuthService: # Commit transaction await uow.commit() - + + # Store subscription plan selection in onboarding progress for later retrieval + if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id: + try: + from app.repositories.onboarding_repository import OnboardingRepository + from app.models.onboarding import UserOnboardingProgress + + onboarding_repo = OnboardingRepository(db_session) + plan_data = { + "subscription_plan": user_data.subscription_plan or "starter", + "use_trial": user_data.use_trial or False, + "payment_method_id": user_data.payment_method_id, + "saved_at": datetime.now(timezone.utc).isoformat() + } + + await onboarding_repo.save_step_data( + str(new_user.id), + "user_registered", + plan_data + ) + + logger.info("Subscription plan saved to onboarding progress", + user_id=new_user.id, + plan=user_data.subscription_plan) + except Exception as e: + logger.warning("Failed to save subscription plan to onboarding progress", error=str(e)) + # Publish registration event (non-blocking) try: await publish_user_registered({ @@ -119,7 +145,8 @@ class EnhancedAuthService: "email": new_user.email, "full_name": new_user.full_name, "role": new_user.role, - "registered_at": datetime.now(timezone.utc).isoformat() + "registered_at": datetime.now(timezone.utc).isoformat(), + "subscription_plan": user_data.subscription_plan or "starter" }) except Exception as e: logger.warning("Failed to publish registration event", error=str(e)) diff --git a/services/tenant/app/services/tenant_service.py b/services/tenant/app/services/tenant_service.py index 14518ecb..b2f3c1fe 100644 --- a/services/tenant/app/services/tenant_service.py +++ b/services/tenant/app/services/tenant_service.py @@ -83,15 +83,39 @@ class EnhancedTenantService: } owner_membership = await member_repo.create_membership(membership_data) - - # Create starter subscription + + # Get subscription plan from user's registration using standardized auth client + selected_plan = "starter" # Default fallback + try: + from shared.clients.auth_client import AuthServiceClient + from app.core.config import settings + + auth_client = AuthServiceClient(settings) + selected_plan = await auth_client.get_subscription_plan_from_registration(owner_id) + + logger.info("Retrieved subscription plan from registration", + tenant_id=tenant.id, + owner_id=owner_id, + plan=selected_plan) + + except Exception as e: + logger.warning("Could not retrieve subscription plan from auth service, using default", + error=str(e), + owner_id=owner_id, + default_plan=selected_plan) + + # Create subscription with selected or default plan subscription_data = { "tenant_id": str(tenant.id), - "plan": "starter", + "plan": selected_plan, "status": "active" } - + subscription = await subscription_repo.create_subscription(subscription_data) + + logger.info("Subscription created", + tenant_id=tenant.id, + plan=selected_plan) # Commit the transaction await uow.commit() diff --git a/shared/clients/__init__.py b/shared/clients/__init__.py index 75c5c12b..8b4b83ce 100644 --- a/shared/clients/__init__.py +++ b/shared/clients/__init__.py @@ -5,6 +5,7 @@ Provides easy access to all service clients """ from .base_service_client import BaseServiceClient, ServiceAuthenticator +from .auth_client import AuthServiceClient from .training_client import TrainingServiceClient from .sales_client import SalesServiceClient from .external_client import ExternalServiceClient @@ -209,7 +210,8 @@ def get_service_clients(config: BaseServiceSettings = None, service_name: str = # Export all classes for direct import __all__ = [ 'BaseServiceClient', - 'ServiceAuthenticator', + 'ServiceAuthenticator', + 'AuthServiceClient', 'TrainingServiceClient', 'SalesServiceClient', 'ExternalServiceClient', @@ -222,7 +224,7 @@ __all__ = [ 'ServiceClients', 'get_training_client', 'get_sales_client', - 'get_external_client', + 'get_external_client', 'get_forecast_client', 'get_inventory_client', 'get_orders_client', diff --git a/shared/clients/auth_client.py b/shared/clients/auth_client.py new file mode 100644 index 00000000..af1b84c8 --- /dev/null +++ b/shared/clients/auth_client.py @@ -0,0 +1,134 @@ +# shared/clients/auth_client.py +""" +Auth Service Client for Inter-Service Communication +Provides methods to interact with the authentication/onboarding service +""" + +from typing import Optional, Dict, Any, List +import structlog + +from shared.clients.base_service_client import BaseServiceClient +from shared.config.base import BaseServiceSettings + +logger = structlog.get_logger() + + +class AuthServiceClient(BaseServiceClient): + """Client for interacting with the Auth Service""" + + def __init__(self, config: BaseServiceSettings): + super().__init__("auth", config) + + def get_service_base_path(self) -> str: + """Return the base path for auth service APIs""" + return "/api/v1/users" + + async def get_user_onboarding_progress(self, user_id: str) -> Optional[Dict[str, Any]]: + """ + Get user's onboarding progress including step data + + Args: + user_id: User ID to fetch progress for + + Returns: + Dict with user progress including steps with data, or None if failed + """ + try: + # Use the service endpoint that accepts user_id as parameter + result = await self.get(f"/{user_id}/onboarding/progress") + + if result: + logger.info("Retrieved user onboarding progress", + user_id=user_id, + current_step=result.get("current_step")) + return result + else: + logger.warning("No onboarding progress found", + user_id=user_id) + return None + + except Exception as e: + logger.error("Failed to get user onboarding progress", + user_id=user_id, + error=str(e)) + return None + + async def get_user_step_data(self, user_id: str, step_name: str) -> Optional[Dict[str, Any]]: + """ + Get data for a specific onboarding step + + Args: + user_id: User ID + step_name: Name of the step (e.g., "user_registered") + + Returns: + Step data dictionary or None if not found + """ + try: + progress = await self.get_user_onboarding_progress(user_id) + + if not progress: + logger.warning("No progress data returned", + user_id=user_id) + return None + + logger.debug("Retrieved progress data", + user_id=user_id, + steps_count=len(progress.get("steps", [])), + current_step=progress.get("current_step")) + + # Find the specific step + for step in progress.get("steps", []): + if step.get("step_name") == step_name: + step_data = step.get("data", {}) + logger.info("Found step data", + user_id=user_id, + step_name=step_name, + data_keys=list(step_data.keys()) if step_data else [], + has_subscription_plan="subscription_plan" in step_data) + return step_data + + logger.warning("Step not found in progress", + user_id=user_id, + step_name=step_name, + available_steps=[s.get("step_name") for s in progress.get("steps", [])]) + return None + + except Exception as e: + logger.error("Failed to get step data", + user_id=user_id, + step_name=step_name, + error=str(e)) + return None + + async def get_subscription_plan_from_registration(self, user_id: str) -> str: + """ + Get the subscription plan selected during user registration + + Args: + user_id: User ID + + Returns: + Plan name (e.g., "starter", "professional", "enterprise") or "starter" as default + """ + try: + step_data = await self.get_user_step_data(user_id, "user_registered") + + if step_data and "subscription_plan" in step_data: + plan = step_data["subscription_plan"] + logger.info("Retrieved subscription plan from registration", + user_id=user_id, + plan=plan) + return plan + else: + logger.info("No subscription plan in registration data, using default", + user_id=user_id, + default_plan="starter") + return "starter" + + except Exception as e: + logger.warning("Failed to retrieve subscription plan, using default", + user_id=user_id, + error=str(e), + default_plan="starter") + return "starter" \ No newline at end of file