Files
bakery-ia/services/auth/app/services/auth_service.py
2026-01-13 22:22:38 +01:00

970 lines
45 KiB
Python

"""
Enhanced Authentication Service
Updated to use repository pattern with dependency injection and improved error handling
"""
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from fastapi import HTTPException, status
import structlog
from app.repositories import UserRepository, TokenRepository
from app.schemas.auth import UserRegistration, UserLogin, TokenResponse, UserResponse
from app.models.users import User
from app.models.tokens import RefreshToken
from app.core.security import SecurityManager
from app.utils.subscription_fetcher import SubscriptionFetcher
from shared.messaging import UnifiedEventPublisher, EVENT_TYPES
from shared.database.unit_of_work import UnitOfWork
from shared.database.transactions import transactional
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
# Legacy compatibility alias
AuthService = None # Will be set at the end of the file
class EnhancedAuthService:
"""Enhanced authentication service using repository pattern"""
def __init__(self, database_manager, event_publisher=None):
"""Initialize service with database manager and optional event publisher"""
self.database_manager = database_manager
self.event_publisher = event_publisher
async def register_user(
self,
user_data: UserRegistration
) -> TokenResponse:
"""Register a new user using repository pattern"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories
user_repo = uow.register_repository("users", UserRepository, User)
token_repo = uow.register_repository("tokens", TokenRepository, RefreshToken)
# Check if user already exists
existing_user = await user_repo.get_by_email(user_data.email)
if existing_user:
raise DuplicateRecordError("User with this email already exists")
# Validate password strength
if not SecurityManager.validate_password(user_data.password):
raise ValueError("Password does not meet security requirements")
# Create user data
# Default to admin role for first-time registrations during onboarding flow
# Users creating their own bakery should have admin privileges
user_role = user_data.role if user_data.role else "admin"
hashed_password = SecurityManager.hash_password(user_data.password)
create_data = {
"email": user_data.email,
"full_name": user_data.full_name,
"hashed_password": hashed_password,
"is_active": True,
"is_verified": False,
"role": user_role
}
# Create user using repository
new_user = await user_repo.create_user(create_data)
logger.debug("User created with repository pattern",
user_id=new_user.id,
email=user_data.email,
role=user_role)
# Create tokens with different payloads
access_token_data = {
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"is_verified": new_user.is_verified,
"is_active": new_user.is_active,
"role": new_user.role,
"type": "access"
}
refresh_token_data = {
"user_id": str(new_user.id),
"email": new_user.email,
"type": "refresh"
}
# Generate tokens
access_token = SecurityManager.create_access_token(user_data=access_token_data)
refresh_token_value = SecurityManager.create_refresh_token(user_data=refresh_token_data)
# Store refresh token using repository
token_data = {
"user_id": str(new_user.id),
"token": refresh_token_value,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
"is_revoked": False
}
await token_repo.create_token(token_data)
# Record GDPR consent if provided
if (user_data.terms_accepted or user_data.privacy_accepted or
user_data.marketing_consent or user_data.analytics_consent):
try:
from app.models.consent import UserConsent, ConsentHistory
ip_address = None # Would need to pass from request context
user_agent = None # Would need to pass from request context
consent = UserConsent(
user_id=new_user.id,
terms_accepted=user_data.terms_accepted if user_data.terms_accepted is not None else True,
privacy_accepted=user_data.privacy_accepted if user_data.privacy_accepted is not None else True,
marketing_consent=user_data.marketing_consent if user_data.marketing_consent is not None else False,
analytics_consent=user_data.analytics_consent if user_data.analytics_consent is not None else False,
consent_version="1.0",
consent_method="registration",
ip_address=ip_address,
user_agent=user_agent,
consented_at=datetime.now(timezone.utc)
)
db_session.add(consent)
await db_session.flush()
# Create consent history entry
history = ConsentHistory(
user_id=new_user.id,
consent_id=consent.id,
action="granted",
consent_snapshot={
"terms_accepted": consent.terms_accepted,
"privacy_accepted": consent.privacy_accepted,
"marketing_consent": consent.marketing_consent,
"analytics_consent": consent.analytics_consent,
"consent_version": "1.0",
"consent_method": "registration"
},
ip_address=ip_address,
user_agent=user_agent,
consent_method="registration",
created_at=datetime.now(timezone.utc)
)
db_session.add(history)
logger.info("User consent recorded during registration",
user_id=new_user.id,
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent)
except Exception as e:
logger.error("Failed to record user consent during registration",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if consent can't be recorded
raise
# Payment customer creation via tenant service
# The auth service calls the tenant service to create payment customer
# This maintains proper separation of concerns while providing seamless user experience
try:
# Call tenant service to create payment customer
from shared.clients.tenant_client import TenantServiceClient
from app.core.config import settings
tenant_client = TenantServiceClient(settings)
# Prepare user data for tenant service
user_data_for_tenant = {
"user_id": str(new_user.id),
"email": user_data.email,
"full_name": user_data.full_name,
"name": user_data.full_name
}
# Call tenant service to create payment customer
payment_result = await tenant_client.create_payment_customer(
user_data_for_tenant,
user_data.payment_method_id
)
if payment_result and payment_result.get("success"):
# Store payment customer ID from tenant service response
new_user.payment_customer_id = payment_result.get("payment_customer_id")
logger.info("Payment customer created successfully via tenant service",
user_id=new_user.id,
payment_customer_id=new_user.payment_customer_id,
payment_method_id=user_data.payment_method_id)
else:
logger.warning("Payment customer creation via tenant service returned no success",
user_id=new_user.id,
result=payment_result)
except Exception as e:
logger.error("Payment customer creation via tenant service failed",
user_id=new_user.id,
error=str(e))
# Don't fail registration if payment customer creation fails
# This allows users to register even if payment system is temporarily unavailable
new_user.payment_customer_id = None
# Store payment method ID if provided (will be used by tenant service)
if user_data.payment_method_id:
new_user.default_payment_method_id = user_data.payment_method_id
logger.info("Payment method ID stored for later use by tenant service",
user_id=new_user.id,
payment_method_id=user_data.payment_method_id)
# Store subscription plan selection in onboarding progress BEFORE committing
# This ensures it's part of the same transaction
if user_data.subscription_plan or user_data.payment_method_id or user_data.billing_cycle or user_data.coupon_code:
try:
from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress
# Use upsert_user_step instead of save_step_data to avoid double commits
onboarding_repo = OnboardingRepository(db_session)
plan_data = {
"subscription_plan": user_data.subscription_plan or "starter",
"subscription_tier": user_data.subscription_plan or "starter", # Store tier for enterprise onboarding logic
"billing_cycle": user_data.billing_cycle or "monthly",
"coupon_code": user_data.coupon_code,
"payment_method_id": user_data.payment_method_id,
"payment_customer_id": new_user.payment_customer_id, # Now created via tenant service
"saved_at": datetime.now(timezone.utc).isoformat()
}
# Create the onboarding step record with plan data
# Note: We use completed=True to mark user_registered as complete
# auto_commit=False to let UnitOfWork handle the commit
await onboarding_repo.upsert_user_step(
user_id=str(new_user.id),
step_name="user_registered",
completed=True,
step_data=plan_data,
auto_commit=False
)
logger.info("Subscription plan and parameters saved to onboarding progress",
user_id=new_user.id,
plan=user_data.subscription_plan,
billing_cycle=user_data.billing_cycle,
coupon_code=user_data.coupon_code,
payment_method_id=user_data.payment_method_id,
payment_customer_id=new_user.payment_customer_id)
except Exception as e:
logger.error("Failed to save subscription plan and parameters to onboarding progress",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if onboarding data can't be saved
raise
# Commit transaction (includes user, tokens, consent, and onboarding data)
await uow.commit()
# Publish registration event (non-blocking)
if self.event_publisher:
try:
await self.event_publisher.publish_business_event(
event_type="auth.user.registered",
tenant_id="system", # User registration is system-wide initially
data={
"user_id": str(new_user.id),
"email": new_user.email,
"full_name": new_user.full_name,
"role": new_user.role,
"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: %s", str(e))
logger.info("User registered successfully using repository pattern",
user_id=new_user.id,
email=user_data.email)
from app.schemas.auth import UserData
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
token_type="bearer",
expires_in=1800,
user=UserData(
id=str(new_user.id),
email=new_user.email,
full_name=new_user.full_name,
is_active=new_user.is_active,
is_verified=new_user.is_verified,
created_at=new_user.created_at.isoformat() if new_user.created_at else datetime.now(timezone.utc).isoformat(),
role=new_user.role
)
)
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Registration failed using repository pattern",
email=user_data.email,
error=str(e))
raise DatabaseError(f"Registration failed: {str(e)}")
async def login_user(
self,
login_data: UserLogin
) -> TokenResponse:
"""Login user using repository pattern"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories
user_repo = uow.register_repository("users", UserRepository, User)
token_repo = uow.register_repository("tokens", TokenRepository, RefreshToken)
# Authenticate user using repository
user = await user_repo.authenticate_user(login_data.email, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated"
)
# Revoke existing refresh tokens using repository
await token_repo.revoke_all_user_tokens(str(user.id))
logger.debug("Existing tokens revoked using repository pattern",
user_id=user.id)
# NEW: Fetch subscription data for JWT enrichment
# This happens ONCE at login, not per-request
from app.core.config import settings
subscription_fetcher = SubscriptionFetcher(
tenant_service_url=settings.TENANT_SERVICE_URL # Now properly configurable
)
# Get service token for inter-service communication
service_token = await self._get_service_token()
subscription_context = await subscription_fetcher.get_user_subscription_context(
user_id=str(user.id),
service_token=service_token
)
logger.debug("Fetched subscription context for JWT enrichment",
user_id=user.id,
subscription_tier=subscription_context.get("subscription", {}).get("tier"))
# Create tokens with different payloads
subscription_data = subscription_context.get("subscription") or {}
access_token_data = {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_verified": user.is_verified,
"is_active": user.is_active,
"role": user.role,
"type": "access",
# NEW: Add subscription data to JWT payload
"tenant_id": subscription_context.get("tenant_id"),
"tenant_role": subscription_context.get("tenant_role"),
"subscription": subscription_data,
"subscription_tier": subscription_data.get("tier", "starter"), # Add direct field for gateway
"subscription_from_jwt": True, # Flag for gateway to use JWT data
"tenant_access": subscription_context.get("tenant_access")
}
refresh_token_data = {
"user_id": str(user.id),
"email": user.email,
"type": "refresh",
"jti": str(uuid.uuid4())
}
# Generate tokens
access_token = SecurityManager.create_access_token(user_data=access_token_data)
refresh_token_value = SecurityManager.create_refresh_token(user_data=refresh_token_data)
# Store refresh token using repository
token_data = {
"user_id": str(user.id),
"token": refresh_token_value,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
"is_revoked": False
}
await token_repo.create_token(token_data)
# Update last login using repository
await user_repo.update_last_login(str(user.id))
# Commit transaction
await uow.commit()
# Publish login event (non-blocking)
if self.event_publisher:
try:
await self.event_publisher.publish_business_event(
event_type="auth.user.login",
tenant_id="system",
data={
"user_id": str(user.id),
"email": user.email,
"login_at": datetime.now(timezone.utc).isoformat()
}
)
except Exception as e:
logger.warning("Failed to publish login event: %s", str(e))
logger.info("User logged in successfully using repository pattern",
user_id=user.id,
email=login_data.email)
from app.schemas.auth import UserData
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_value,
token_type="bearer",
expires_in=1800,
user=UserData(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
created_at=user.created_at.isoformat() if user.created_at else datetime.now(timezone.utc).isoformat(),
role=user.role
)
)
except HTTPException:
raise
except Exception as e:
logger.error("Login failed using repository pattern",
email=login_data.email,
error=str(e))
raise DatabaseError(f"Login failed: {str(e)}")
async def logout_user(self, user_id: str, refresh_token: str) -> bool:
"""Logout user using repository pattern"""
try:
async with self.database_manager.get_session() as session:
token_repo = TokenRepository(RefreshToken, session)
# Revoke specific refresh token using repository
success = await token_repo.revoke_token(user_id, refresh_token)
if success:
logger.info("User logged out successfully using repository pattern",
user_id=user_id)
return True
return False
except Exception as e:
logger.error("Logout failed using repository pattern",
user_id=user_id,
error=str(e))
return False
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token using repository pattern"""
try:
# Verify refresh token
payload = SecurityManager.decode_token(refresh_token)
user_id = payload.get("user_id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
# Validate refresh token using repository
is_valid = await token_repo.validate_refresh_token(refresh_token, user_id)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token"
)
# Get user using repository
user = await user_repo.get_by_id(user_id)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# NEW: Fetch FRESH subscription data for token refresh
# This ensures subscription changes propagate within token expiry period
subscription_fetcher = SubscriptionFetcher(
tenant_service_url=settings.TENANT_SERVICE_URL # Now properly configurable
)
service_token = await self._get_service_token()
subscription_context = await subscription_fetcher.get_user_subscription_context(
user_id=str(user.id),
service_token=service_token
)
logger.debug("Fetched fresh subscription context for token refresh",
user_id=user.id,
subscription_tier=subscription_context.get("subscription", {}).get("tier"))
# Create new access token with updated subscription data
access_token_data = {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"is_verified": user.is_verified,
"is_active": user.is_active,
"role": user.role,
"type": "access",
# NEW: Add fresh subscription data to JWT payload
"tenant_id": subscription_context.get("tenant_id"),
"tenant_role": subscription_context.get("tenant_role"),
"subscription": subscription_context.get("subscription"),
"tenant_access": subscription_context.get("tenant_access")
}
new_access_token = SecurityManager.create_access_token(user_data=access_token_data)
logger.debug("Access token refreshed successfully using repository pattern",
user_id=user_id)
return {
"access_token": new_access_token,
"token_type": "bearer",
"expires_in": 1800
}
except HTTPException:
raise
except Exception as e:
logger.error("Token refresh failed using repository pattern: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token refresh failed"
)
async def verify_user_token(self, token: str) -> Dict[str, Any]:
"""Verify access token and return user info"""
try:
payload = SecurityManager.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# Handle service tokens (used for inter-service communication)
if payload.get("type") == "service":
logger.debug("Service token verified successfully",
service=payload.get("service"),
tenant_id=payload.get("tenant_id"))
return {
"valid": True,
"user_id": payload.get("user_id", f"{payload.get('service')}-service"),
"email": payload.get("email", f"{payload.get('service')}-service@internal"),
"role": payload.get("role", "admin"),
"exp": payload.get("exp"),
"service": payload.get("service"),
"tenant_id": payload.get("tenant_id")
}
# Handle regular user tokens
return payload
except Exception as e:
logger.error("Token verification error using repository pattern: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
async def get_user_profile(self, user_id: str) -> Optional[UserResponse]:
"""Get user profile using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
user = await user_repo.get_by_id(user_id)
if not user:
return None
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_verified=user.is_verified,
created_at=user.created_at,
role=user.role
)
except Exception as e:
logger.error("Failed to get user profile using repository pattern",
user_id=user_id,
error=str(e))
return None
async def update_user_profile(
self,
user_id: str,
update_data: Dict[str, Any]
) -> Optional[UserResponse]:
"""Update user profile using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
updated_user = await user_repo.update(user_id, update_data)
if not updated_user:
return None
logger.info("User profile updated using repository pattern",
user_id=user_id,
updated_fields=list(update_data.keys()))
return UserResponse(
id=str(updated_user.id),
email=updated_user.email,
full_name=updated_user.full_name,
is_active=updated_user.is_active,
is_verified=updated_user.is_verified,
created_at=updated_user.created_at,
role=updated_user.role
)
except Exception as e:
logger.error("Failed to update user profile using repository pattern",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to update profile: {str(e)}")
async def change_password(
self,
user_id: str,
old_password: str,
new_password: str
) -> bool:
"""Change user password using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
# Get user and verify old password
user = await user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not SecurityManager.verify_password(old_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid old password"
)
# Validate new password strength
if not SecurityManager.validate_password(new_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password does not meet security requirements"
)
# Hash new password and update
new_hashed_password = SecurityManager.hash_password(new_password)
await user_repo.update(user_id, {"hashed_password": new_hashed_password})
# Revoke all existing tokens for security
await token_repo.revoke_all_user_tokens(user_id)
logger.info("Password changed successfully using repository pattern",
user_id=user_id)
return True
except HTTPException:
raise
except Exception as e:
logger.error("Failed to change password using repository pattern",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to change password: {str(e)}")
async def verify_user_email(self, user_id: str, verification_token: str) -> bool:
"""Verify user email using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
# In a real implementation, you'd verify the verification_token
# For now, just mark user as verified
updated_user = await user_repo.update(user_id, {"is_verified": True})
if updated_user:
logger.info("User email verified using repository pattern",
user_id=user_id)
return True
return False
except Exception as e:
logger.error("Failed to verify email using repository pattern",
user_id=user_id,
error=str(e))
return False
async def deactivate_user(self, user_id: str, admin_user_id: str) -> bool:
"""Deactivate user account using repository pattern"""
try:
async with self.database_manager.get_session() as session:
user_repo = UserRepository(User, session)
token_repo = TokenRepository(RefreshToken, session)
# Update user status
updated_user = await user_repo.update(user_id, {"is_active": False})
if not updated_user:
return False
# Revoke all tokens
await token_repo.revoke_all_user_tokens(user_id)
logger.info("User deactivated using repository pattern",
user_id=user_id,
admin_user_id=admin_user_id)
return True
except Exception as e:
logger.error("Failed to deactivate user using repository pattern",
user_id=user_id,
error=str(e))
return False
async def _get_service_token(self, tenant_id: Optional[str] = None) -> str:
"""
Get service token for inter-service communication.
This is used to fetch subscription data from tenant service.
Args:
tenant_id: Optional tenant ID for tenant-scoped service operations
Returns:
JWT service token
"""
try:
# Create a proper service token with JWT using SecurityManager
service_token = SecurityManager.create_service_token("auth-service", tenant_id)
logger.debug("Generated service token for tenant service communication", tenant_id=tenant_id)
return service_token
except Exception as e:
logger.error(f"Failed to get service token: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to authenticate with tenant service"
)
async def create_subscription_via_tenant_service(
self,
user_id: str,
plan_id: str,
payment_method_id: str,
billing_cycle: str,
coupon_code: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Create a tenant-independent subscription via tenant service
This method calls the tenant service to create a subscription during user registration
that is not linked to any tenant. The subscription will be linked to a tenant
during the onboarding flow.
Args:
user_id: User ID
plan_id: Subscription plan ID
payment_method_id: Payment method ID
billing_cycle: Billing cycle (monthly/yearly)
coupon_code: Optional coupon code
Returns:
Dict with subscription creation results including:
- success: boolean
- subscription_id: string
- customer_id: string
- status: string
- plan: string
- billing_cycle: string
Returns None if creation fails
"""
try:
from shared.clients.tenant_client import TenantServiceClient
from shared.config.base import BaseServiceSettings
# Get the base settings to create tenant client
tenant_client = TenantServiceClient(BaseServiceSettings())
# Get user data for tenant service
user_data = await self.get_user_data_for_tenant_service(user_id)
logger.info("Creating tenant-independent subscription via tenant service",
user_id=user_id,
plan_id=plan_id)
# Call tenant service using the new dedicated method
result = await tenant_client.create_subscription_for_registration(
user_data=user_data,
plan_id=plan_id,
payment_method_id=payment_method_id,
billing_cycle=billing_cycle,
coupon_code=coupon_code
)
if result:
logger.info("Tenant-independent subscription created successfully via tenant service",
user_id=user_id,
subscription_id=result.get('subscription_id'))
return result
else:
logger.error("Tenant-independent subscription creation failed via tenant service",
user_id=user_id)
return None
except Exception as e:
logger.error("Failed to create tenant-independent subscription via tenant service",
user_id=user_id,
error=str(e))
return None
async def get_user_data_for_tenant_service(self, user_id: str) -> Dict[str, Any]:
"""
Get user data formatted for tenant service calls
Args:
user_id: User ID
Returns:
Dict with user data including email, name, etc.
"""
try:
# Get user from database
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
user_repo = uow.register_repository("users", UserRepository, User)
user = await user_repo.get_by_id(user_id)
if not user:
raise ValueError(f"User {user_id} not found")
return {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"name": user.full_name
}
except Exception as e:
logger.error("Failed to get user data for tenant service",
user_id=user_id,
error=str(e))
raise
async def save_subscription_to_onboarding_progress(
self,
user_id: str,
subscription_id: str,
registration_data: UserRegistration
) -> None:
"""
Save subscription data to the user's onboarding progress
This method stores subscription information in the onboarding progress
so it can be retrieved later during the tenant creation step.
Args:
user_id: User ID
subscription_id: Subscription ID created by tenant service
registration_data: Original registration data including plan, payment method, etc.
"""
try:
from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress
# Prepare subscription data to store
subscription_data = {
"subscription_id": subscription_id,
"plan_id": registration_data.subscription_plan,
"payment_method_id": registration_data.payment_method_id,
"billing_cycle": registration_data.billing_cycle or "monthly",
"coupon_code": registration_data.coupon_code,
"created_at": datetime.now(timezone.utc).isoformat()
}
logger.info("Saving subscription data to onboarding progress",
user_id=user_id,
subscription_id=subscription_id)
# Save to onboarding progress
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
onboarding_repo = uow.register_repository(
"onboarding",
OnboardingRepository,
UserOnboardingProgress
)
# Save or update the subscription step data
await onboarding_repo.save_step_data(
user_id=user_id,
step_name="subscription",
step_data=subscription_data,
auto_commit=False
)
# Commit the transaction
await uow.commit()
logger.info("Subscription data saved successfully to onboarding progress",
user_id=user_id,
subscription_id=subscription_id)
except Exception as e:
logger.error("Failed to save subscription data to onboarding progress",
user_id=user_id,
subscription_id=subscription_id,
error=str(e))
# Don't raise - we don't want to fail the registration if this fails
# The subscription was already created, so the user can still proceed
# Legacy compatibility - alias EnhancedAuthService as AuthService
AuthService = EnhancedAuthService
class EnhancedUserService(EnhancedAuthService):
"""User service alias for backward compatibility"""
pass